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:
mvalentsev 2026-05-10 21:40:15 +05:00
parent 765478d37a
commit c4fdc1ed8c
2 changed files with 34 additions and 16 deletions

View File

@ -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()

View File

@ -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}"