diff --git a/CHANGELOG.md b/CHANGELOG.md index 02bad5aa..ba2baab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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] - `dotenv run --no-override` now expands variable references with the same precedence as `load_dotenv(override=False)`, so a value like `${BASE}/suffix` uses the existing `BASE` from the environment instead of the one from the `.env` file by [@ROTl24] in [#698] +- Unreadable `.env` files no longer raise `PermissionError` during discovery or load; `find_dotenv` skips them and continues walking parent directories by [@00200200] in [#712] ## [1.2.3] - 2026-08-16 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 3123690a..ff580e7b 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -60,8 +60,21 @@ def __init__( @contextmanager def _get_stream(self) -> Iterator[IO[str]]: if self.dotenv_path and _is_file_or_fifo(self.dotenv_path): - with open(self.dotenv_path, encoding=self.encoding) as stream: - yield stream + if os.access(self.dotenv_path, os.R_OK): + try: + with open(self.dotenv_path, encoding=self.encoding) as stream: + yield stream + return + except PermissionError: + # Race with permissions changing between the access check and + # open, or sandboxes that block open despite os.access. + pass + if self.verbose: + logger.info( + "python-dotenv could not read configuration file %s.", + self.dotenv_path, + ) + yield io.StringIO("") elif self.stream is not None: yield self.stream else: @@ -376,7 +389,7 @@ def _is_debugger(): for dirname in _walk_to_root(path): check_path = os.path.join(dirname, filename) - if _is_file_or_fifo(check_path): + if _is_readable_file_or_fifo(check_path): return check_path if raise_error_if_not_found: @@ -485,3 +498,13 @@ def _is_file_or_fifo(path: StrPath) -> bool: return False return stat.S_ISFIFO(st.st_mode) + + +def _is_readable_file_or_fifo(path: StrPath) -> bool: + """ + Return True if `path` is a readable regular file or FIFO. + + Unreadable paths are treated as absent so discovery can continue walking + parent directories (e.g. firejail-blocked `.env` files). + """ + return _is_file_or_fifo(path) and os.access(path, os.R_OK) diff --git a/tests/test_main.py b/tests/test_main.py index 6f9d4c5c..46a8e2e8 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -412,6 +412,53 @@ def test_find_dotenv_found(tmp_path): assert result == str(dotenv_path) +@pytest.mark.skipif( + sys.platform == "win32", + reason="Unix permission bits are required to make a file unreadable.", +) +@pytest.mark.skipif( + sys.platform != "win32" and os.geteuid() == 0, + reason="Root user can access files even with 000 permissions.", +) +def test_find_dotenv_skips_unreadable_and_continues(tmp_path): + """Unreadable .env files should be skipped so a parent can be found (#576).""" + leaf = prepare_file_hierarchy(tmp_path) + os.chdir(leaf) + + parent_env = tmp_path / ".env" + parent_env.write_text("PARENT=1\n") + + child_dir = tmp_path / "child1" + child_env = child_dir / ".env" + child_env.write_text("CHILD=1\n") + child_env.chmod(0o000) + try: + result = dotenv.find_dotenv(usecwd=True) + assert result == str(parent_env) + finally: + child_env.chmod(0o600) + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="Unix permission bits are required to make a file unreadable.", +) +@pytest.mark.skipif( + sys.platform != "win32" and os.geteuid() == 0, + reason="Root user can access files even with 000 permissions.", +) +def test_load_dotenv_unreadable_file_does_not_raise(tmp_path): + """Explicit unreadable paths should be treated as missing, not crash (#576).""" + dotenv_path = tmp_path / ".env" + dotenv_path.write_text("A=1\n") + dotenv_path.chmod(0o000) + try: + assert dotenv.load_dotenv(dotenv_path) is False + assert dotenv.dotenv_values(dotenv_path) == {} + finally: + dotenv_path.chmod(0o600) + + @pytest.mark.skipif( sys.platform == "win32", reason="This test assumes case-sensitive variable names" )