fix(init): normalize path comparison for sys.path strip (#1423)
Previous commit compared sys.path entries to PYTHONPATH tokens by byte-exact string equality, which let leaks slip through on Windows (case-insensitive paths) and across trailing-separator or whitespace quirks (a leading or trailing space in PYTHONPATH would not match the trimmed sys.path entry the interpreter actually populated). Both sides of the comparison now run through os.path.normcase + os.path.normpath so case differences and trailing separators no longer defeat the filter, and PYTHONPATH tokens are trimmed before the match. Test extended to 6 parametrized cases (single, multi-path, trailing separator, leading pathsep, empty, unset) with explicit ids and stderr included in assertion diagnostics. Synthetic leak placeholder replaces /tmp so the test is portable across CI runners.
This commit is contained in:
parent
765478d37a
commit
c4fdc1ed8c
|
|
@ -9,13 +9,18 @@ def _strip_leaked_pythonpath() -> None:
|
|||
# Venvs inherit PYTHONPATH; on multi-Python systems it can cause
|
||||
# transitive imports to load compiled extensions (pydantic_core,
|
||||
# chromadb_rust_bindings) from the wrong ABI. Drop the env var
|
||||
# and remove the sys.path entries the interpreter populated from
|
||||
# it before the consumer imports anything that ships compiled code.
|
||||
# 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.
|
||||
leaked = os.environ.pop("PYTHONPATH", None)
|
||||
if not leaked:
|
||||
return
|
||||
leaked_entries = {p for p in leaked.split(os.pathsep) if p}
|
||||
sys.path[:] = [p for p in sys.path if p not in leaked_entries]
|
||||
|
||||
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]
|
||||
|
||||
|
||||
_strip_leaked_pythonpath()
|
||||
|
|
|
|||
|
|
@ -7,18 +7,25 @@ import sys
|
|||
import pytest
|
||||
|
||||
|
||||
_LEAK_PREFIX = "/__mempalace_leak_test_sentinel__"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pythonpath",
|
||||
[
|
||||
"/tmp/some/leaked/path",
|
||||
f"/tmp/leak-a{os.pathsep}/tmp/leak-b",
|
||||
f"{_LEAK_PREFIX}/single",
|
||||
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"],
|
||||
)
|
||||
def test_init_strips_leaked_pythonpath(pythonpath):
|
||||
"""Package init must clear PYTHONPATH (env) AND remove its entries
|
||||
from sys.path so compiled-extension imports cannot resolve from a
|
||||
leaked location."""
|
||||
"""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."""
|
||||
env = os.environ.copy()
|
||||
if pythonpath is None:
|
||||
env.pop("PYTHONPATH", None)
|
||||
|
|
@ -28,8 +35,11 @@ def test_init_strips_leaked_pythonpath(pythonpath):
|
|||
"import mempalace, os, sys; "
|
||||
f"leaked = {pythonpath!r}; "
|
||||
"print('ENV:', repr(os.environ.get('PYTHONPATH'))); "
|
||||
"entries = leaked.split(os.pathsep) if leaked else []; "
|
||||
"leaked_in_path = any(e in sys.path for e in entries); "
|
||||
"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)"
|
||||
)
|
||||
result = subprocess.run(
|
||||
|
|
@ -37,10 +47,13 @@ def test_init_strips_leaked_pythonpath(pythonpath):
|
|||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
check=False,
|
||||
)
|
||||
diag = (
|
||||
f"input={pythonpath!r}; rc={result.returncode}; "
|
||||
f"stdout={result.stdout!r}; stderr={result.stderr!r}"
|
||||
)
|
||||
assert result.returncode == 0, f"subprocess failed: {diag}"
|
||||
out = result.stdout
|
||||
assert "ENV: None" in out, f"PYTHONPATH not cleared (input={pythonpath!r}): {out!r}"
|
||||
assert (
|
||||
"SYSPATH_LEAK: False" in out
|
||||
), f"sys.path retains leaked entry (input={pythonpath!r}): {out!r}"
|
||||
assert "ENV: None" in out, f"PYTHONPATH not cleared: {diag}"
|
||||
assert "SYSPATH_LEAK: False" in out, f"sys.path retains leaked entry: {diag}"
|
||||
|
|
|
|||
Loading…
Reference in New Issue