diff --git a/CHANGELOG.md b/CHANGELOG.md index fa81cc6e..3220fe92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed - Strip a leading UTF-8 BOM from `.env` file contents so the first variable is no longer silently lost when the file is saved with BOM (e.g. by some JetBrains IDEs on Windows) by [@h1whelan] in [#640] +- Escape backslashes in `set_key` so values containing backslashes (e.g. Windows paths, regexes) round-trip instead of being corrupted, and fix the single-quoted-value parser so a trailing backslash no longer breaks the file by [@eeshsaxena] in [#673] ## [1.2.2] - 2026-03-01 @@ -435,6 +436,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#497]: https://github.com/theskumar/python-dotenv/pull/497 [#161]: https://github.com/theskumar/python-dotenv/issues/161 [#640]: https://github.com/theskumar/python-dotenv/pull/640 +[#673]: https://github.com/theskumar/python-dotenv/pull/673 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -455,6 +457,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@eaf]: https://github.com/eaf [@earlbread]: https://github.com/earlbread [@eekstunt]: https://github.com/eekstunt +[@eeshsaxena]: https://github.com/eeshsaxena [@eggplants]: https://github.com/eggplants [@ekohl]: https://github.com/ekohl [@elbehery95]: https://github.com/elbehery95 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 3c4608d5..9c205f5a 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -216,7 +216,11 @@ def set_key( ) if quote: - value_out = "'{}'".format(value_to_set.replace("'", "\\'")) + # The single-quoted-value parser treats backslash as an escape + # character, so backslashes must be escaped before single quotes + # (order matters) for the written value to round-trip unchanged. + escaped = value_to_set.replace("\\", "\\\\").replace("'", "\\'") + value_out = "'{}'".format(escaped) else: value_out = value_to_set if export: diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 66773604..3e781cb5 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -22,7 +22,9 @@ def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]: _single_quoted_key = make_regex(r"'([^']+)'") _unquoted_key = make_regex(r"([^=\#\s]+)") _equal_sign = make_regex(r"(=[^\S\r\n]*)") -_single_quoted_value = make_regex(r"'((?:\\'|[^'])*)'") +# Treat every ``\`` as a single escaped unit (including ``\\``), so a +# value ending in an escaped backslash does not merge with the closing quote. +_single_quoted_value = make_regex(r"'((?:\\[\s\S]|[^'\\])*)'") _double_quoted_value = make_regex(r'"((?:\\"|[^"])*)"') _unquoted_value = make_regex(r"([^\r\n]*)") _comment = make_regex(r"(?:[^\S\r\n]*#[^\r\n]*)?") diff --git a/tests/test_main.py b/tests/test_main.py index 1c33c808..7e13fe1b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -54,6 +54,22 @@ def test_set_key(dotenv_path, before, key, value, expected, after): mock_warning.assert_not_called() +@pytest.mark.parametrize( + "value", + [ + r"C:\Users", # Windows path + r"\d+", # regex + "a\\\\b", # embedded double backslash + "C:\\", # trailing backslash (previously corrupted the file) + "x\\'y", # backslash before a quote + ], +) +def test_set_key_backslash_round_trip(dotenv_path, value): + dotenv.set_key(dotenv_path, "a", value) + + assert dotenv.get_key(dotenv_path, "a") == value + + def test_set_key_encoding(dotenv_path): encoding = "latin-1" diff --git a/tests/test_parser.py b/tests/test_parser.py index 4ec5a5af..bade44be 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -273,6 +273,28 @@ ) ], ), + ( + "a='b\\\\c'", # escaped backslash decodes to a single backslash + [ + Binding( + key="a", + value="b\\c", + original=Original(string="a='b\\\\c'", line=1), + error=False, + ) + ], + ), + ( + "a='b\\\\'", # trailing escaped backslash must not consume the quote + [ + Binding( + key="a", + value="b\\", + original=Original(string="a='b\\\\'", line=1), + error=False, + ) + ], + ), ( 'a="b\\"c"', [