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:
parent
c4fdc1ed8c
commit
d09cd9dab1
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Reference in New Issue