Skip to content

Commit bc8841b

Browse files
committed
fix: follow symlinks in dotenv set/unset
rewrite() already knows how to do this, we just were not passing follow_symlinks through from the CLI so a linked .env would get replaced by a regular file. Fixes #541.
1 parent 0b28805 commit bc8841b

3 files changed

Lines changed: 39 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1414
- `set_key` and `unset_key` no longer leave a `.tmp_*` file behind on Windows when writing a read-only `.env` fails, and the error raised is the one from the failed write rather than from cleaning up the temporary file by [@MohammedAlkindi] in [#686]
1515
- `load_dotenv`, `dotenv_values`, `get_key`, `set_key`, `unset_key` and the CLI `--file` option now expand a leading `~` to the user's home directory by [@veeceey] in [#615]
1616
- `find_dotenv` and the IPython `%dotenv` magic now expand a leading `~` in the file name by [@theskumar] in [#714]
17+
- `dotenv set` / `dotenv unset` now follow symlinks instead of replacing the link with a regular file ([#541])
1718

1819
## [1.2.4] - 2026-10-01
1920

‎src/dotenv/cli.py‎

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -116,18 +116,15 @@ def list_values(ctx: click.Context, output_format: str) -> None:
116116
@click.argument("key", required=True)
117117
@click.argument("value", required=True)
118118
def set_value(ctx: click.Context, key: Any, value: Any) -> None:
119-
"""
120-
Store the given key/value.
121-
122-
This doesn't follow symlinks, to avoid accidentally modifying a file at a
123-
potentially untrusted path.
124-
"""
119+
"""Store the given key/value."""
125120

126121
file = ctx.obj["FILE"]
127122
quote = ctx.obj["QUOTE"]
128123
export = ctx.obj["EXPORT"]
129124
try:
130-
success, key, value = set_key(file, key, value, quote, export)
125+
success, key, value = set_key(
126+
file, key, value, quote, export, follow_symlinks=True
127+
)
131128
except OSError as exc:
132129
print(f"Error writing env file: {exc}", file=sys.stderr)
133130
sys.exit(2)
@@ -157,16 +154,11 @@ def get(ctx: click.Context, key: Any) -> None:
157154
@click.pass_context
158155
@click.argument("key", required=True)
159156
def unset(ctx: click.Context, key: Any) -> None:
160-
"""
161-
Removes the given key.
162-
163-
This doesn't follow symlinks, to avoid accidentally modifying a file at a
164-
potentially untrusted path.
165-
"""
157+
"""Removes the given key."""
166158
file = ctx.obj["FILE"]
167159
quote = ctx.obj["QUOTE"]
168160
try:
169-
success, key = unset_key(file, key, quote)
161+
success, key = unset_key(file, key, quote, follow_symlinks=True)
170162
except OSError as exc:
171163
print(f"Error writing env file: {exc}", file=sys.stderr)
172164
sys.exit(2)

‎tests/test_cli.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,38 @@ def test_get_not_a_file(cli):
105105
assert "Error opening env file" in result.output
106106

107107

108+
@pytest.mark.skipif(
109+
sys.platform == "win32", reason="symlinks need extra privileges on Windows"
110+
)
111+
def test_set_follows_symlink(cli, tmp_path):
112+
target = tmp_path / "real.env"
113+
target.write_text("a=x\n")
114+
link = tmp_path / ".env"
115+
link.symlink_to(target)
116+
117+
result = cli.invoke(dotenv_cli, ["--file", str(link), "set", "a", "y"])
118+
119+
assert result.exit_code == 0
120+
assert link.is_symlink()
121+
assert target.read_text() == "a='y'\n"
122+
123+
124+
@pytest.mark.skipif(
125+
sys.platform == "win32", reason="symlinks need extra privileges on Windows"
126+
)
127+
def test_unset_follows_symlink(cli, tmp_path):
128+
target = tmp_path / "real.env"
129+
target.write_text("a=b\n")
130+
link = tmp_path / ".env"
131+
link.symlink_to(target)
132+
133+
result = cli.invoke(dotenv_cli, ["--file", str(link), "unset", "a"])
134+
135+
assert result.exit_code == 0
136+
assert link.is_symlink()
137+
assert target.read_text() == ""
138+
139+
108140
def test_unset_existing_value(cli, dotenv_path):
109141
dotenv_path.write_text("a=b")
110142

0 commit comments

Comments
 (0)