diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f25414..d3405752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `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] - `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] - `find_dotenv` and the IPython `%dotenv` magic now expand a leading `~` in the file name by [@theskumar] in [#714] +- `dotenv set` / `dotenv unset` now follow symlinks instead of replacing the link with a regular file ([#541]) ## [1.2.4] - 2026-10-01 diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index c06c7068..b9d767ef 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -116,18 +116,15 @@ def list_values(ctx: click.Context, output_format: str) -> None: @click.argument("key", required=True) @click.argument("value", required=True) def set_value(ctx: click.Context, key: Any, value: Any) -> None: - """ - Store the given key/value. - - This doesn't follow symlinks, to avoid accidentally modifying a file at a - potentially untrusted path. - """ + """Store the given key/value.""" file = ctx.obj["FILE"] quote = ctx.obj["QUOTE"] export = ctx.obj["EXPORT"] try: - success, key, value = set_key(file, key, value, quote, export) + success, key, value = set_key( + file, key, value, quote, export, follow_symlinks=True + ) except OSError as exc: print(f"Error writing env file: {exc}", file=sys.stderr) sys.exit(2) @@ -157,16 +154,11 @@ def get(ctx: click.Context, key: Any) -> None: @click.pass_context @click.argument("key", required=True) def unset(ctx: click.Context, key: Any) -> None: - """ - Removes the given key. - - This doesn't follow symlinks, to avoid accidentally modifying a file at a - potentially untrusted path. - """ + """Removes the given key.""" file = ctx.obj["FILE"] quote = ctx.obj["QUOTE"] try: - success, key = unset_key(file, key, quote) + success, key = unset_key(file, key, quote, follow_symlinks=True) except OSError as exc: print(f"Error writing env file: {exc}", file=sys.stderr) sys.exit(2) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8123c9dd..5eaecc5c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -105,6 +105,38 @@ def test_get_not_a_file(cli): assert "Error opening env file" in result.output +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks need extra privileges on Windows" +) +def test_set_follows_symlink(cli, tmp_path): + target = tmp_path / "real.env" + target.write_text("a=x\n") + link = tmp_path / ".env" + link.symlink_to(target) + + result = cli.invoke(dotenv_cli, ["--file", str(link), "set", "a", "y"]) + + assert result.exit_code == 0 + assert link.is_symlink() + assert target.read_text() == "a='y'\n" + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks need extra privileges on Windows" +) +def test_unset_follows_symlink(cli, tmp_path): + target = tmp_path / "real.env" + target.write_text("a=b\n") + link = tmp_path / ".env" + link.symlink_to(target) + + result = cli.invoke(dotenv_cli, ["--file", str(link), "unset", "a"]) + + assert result.exit_code == 0 + assert link.is_symlink() + assert target.read_text() == "" + + def test_unset_existing_value(cli, dotenv_path): dotenv_path.write_text("a=b")