From e8a5c36913f0db5596e11284484ad74015ccaea4 Mon Sep 17 00:00:00 2001 From: Kavin Shah Date: Sun, 13 Sep 2026 21:27:46 -0700 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + src/dotenv/cli.py | 18 ++++-------------- tests/test_cli.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f08f58..bf959c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- `dotenv set` / `dotenv unset` now follow symlinks instead of replacing the link with a regular file ([#541]) - An unquoted empty value followed by an inline comment (e.g. `KEY= # comment`) is now parsed as an empty string instead of the comment text by [@Noethix55555] in [#663] ## [1.2.3] - 2026-08-16 diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 79613e28..e66cd658 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -114,17 +114,12 @@ 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"] - success, key, value = set_key(file, key, value, quote, export) + success, key, value = set_key(file, key, value, quote, export, follow_symlinks=True) if success: click.echo(f"{key}={value}") else: @@ -152,15 +147,10 @@ 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"] - success, key = unset_key(file, key, quote) + success, key = unset_key(file, key, quote, follow_symlinks=True) if success: click.echo(f"Successfully removed {key}") else: diff --git a/tests/test_cli.py b/tests/test_cli.py index d4e3ad4d..b55dfb25 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ import os +import sys from pathlib import Path from typing import Optional @@ -87,6 +88,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")