fix(init): preserve cwd marker; decouple test (#1423)

When PYTHONPATH is `.`, both the leaked entry and the implicit
empty-string CWD marker on sys.path normalize to `.`. The previous
filter would remove the CWD marker too, silently changing import
resolution. The sys.path filter now preserves any empty-string entry
unconditionally.

Also drop the asymmetric `.strip()` on PYTHONPATH tokens. CPython
does not strip whitespace from PYTHONPATH before populating sys.path,
so stripping on one side only could let whitespace-padded leaks
through.

Test extended with a `dot` parametrize case and a dedicated
test_init_preserves_cwd_marker_when_pythonpath_collides. Test child
code now asserts on the sentinel-prefix substring directly instead of
re-implementing the production normalization, so future changes to
_norm cannot silently mask test regressions.
This commit is contained in:
mvalentsev 2026-05-10 21:54:17 +05:00
parent c4fdc1ed8c
commit d09cd9dab1
2 changed files with 37 additions and 13 deletions

View File

@ -12,6 +12,8 @@ def _strip_leaked_pythonpath() -> None:
# and remove sys.path entries the interpreter populated from it.
# Comparison normalizes case + separators so Windows paths and
# trailing-separator quirks do not slip through string equality.
# The empty-string CWD marker on sys.path is preserved regardless,
# so PYTHONPATH=. does not collapse the implicit current directory.
leaked = os.environ.pop("PYTHONPATH", None)
if not leaked:
return
@ -19,8 +21,8 @@ def _strip_leaked_pythonpath() -> None:
def _norm(path: str) -> str:
return os.path.normcase(os.path.normpath(path))
leaked_entries = {_norm(p.strip()) for p in leaked.split(os.pathsep) if p.strip()}
sys.path[:] = [p for p in sys.path if _norm(p) not in leaked_entries]
leaked_entries = {_norm(p) for p in leaked.split(os.pathsep) if p}
sys.path[:] = [p for p in sys.path if not p or _norm(p) not in leaked_entries]
_strip_leaked_pythonpath()

View File

@ -17,15 +17,18 @@ _LEAK_PREFIX = "/__mempalace_leak_test_sentinel__"
f"{_LEAK_PREFIX}/a{os.pathsep}{_LEAK_PREFIX}/b",
f"{_LEAK_PREFIX}/with-trailing{os.sep}",
f"{os.pathsep}{_LEAK_PREFIX}/leading-sep",
".",
"",
None,
],
ids=["single", "multi", "trailing-sep", "leading-pathsep", "empty", "unset"],
ids=["single", "multi", "trailing-sep", "leading-pathsep", "dot", "empty", "unset"],
)
def test_init_strips_leaked_pythonpath(pythonpath):
"""Package init must clear PYTHONPATH (env) AND remove all of its
leaked entries from sys.path, normalizing case and trailing
separators so Windows and POSIX behave alike."""
sentinel-prefixed entries from sys.path. Asserts on the sentinel
substring directly so the test does not couple to the production
normalization logic. The dot/empty/unset cases additionally
exercise the early-return / collision paths without crashing."""
env = os.environ.copy()
if pythonpath is None:
env.pop("PYTHONPATH", None)
@ -33,14 +36,9 @@ def test_init_strips_leaked_pythonpath(pythonpath):
env["PYTHONPATH"] = pythonpath
code = (
"import mempalace, os, sys; "
f"leaked = {pythonpath!r}; "
f"prefix = {_LEAK_PREFIX!r}; "
"print('ENV:', repr(os.environ.get('PYTHONPATH'))); "
"tokens = leaked.split(os.pathsep) if leaked else []; "
"entries = [t.strip() for t in tokens if t.strip()]; "
"norm = lambda p: os.path.normcase(os.path.normpath(p)); "
"leaked_norm = {norm(e) for e in entries}; "
"leaked_in_path = any(norm(p) in leaked_norm for p in sys.path); "
"print('SYSPATH_LEAK:', leaked_in_path)"
"print('SENTINEL_IN_PATH:', any(prefix in (p or '') for p in sys.path))"
)
result = subprocess.run(
[sys.executable, "-c", code],
@ -56,4 +54,28 @@ def test_init_strips_leaked_pythonpath(pythonpath):
assert result.returncode == 0, f"subprocess failed: {diag}"
out = result.stdout
assert "ENV: None" in out, f"PYTHONPATH not cleared: {diag}"
assert "SYSPATH_LEAK: False" in out, f"sys.path retains leaked entry: {diag}"
assert "SENTINEL_IN_PATH: False" in out, f"sentinel-prefix leak: {diag}"
def test_init_preserves_cwd_marker_when_pythonpath_collides():
"""PYTHONPATH='.' normalizes to the same value as the empty-string
CWD marker on sys.path. The strip must remove '.' from sys.path
without collapsing the implicit current-directory entry."""
env = os.environ.copy()
env["PYTHONPATH"] = "."
code = (
"import mempalace, sys; "
"print('CWD_IN_PATH:', '' in sys.path); "
"print('DOT_IN_PATH:', '.' in sys.path)"
)
result = subprocess.run(
[sys.executable, "-c", code],
env=env,
capture_output=True,
text=True,
check=False,
)
diag = f"rc={result.returncode}; stdout={result.stdout!r}; " f"stderr={result.stderr!r}"
assert result.returncode == 0, f"subprocess failed: {diag}"
assert "CWD_IN_PATH: True" in result.stdout, f"cwd marker dropped: {diag}"
assert "DOT_IN_PATH: False" in result.stdout, f"dot leak survived: {diag}"