From c4fdc1ed8cd7df2ee4800d9ec30426102192bf64 Mon Sep 17 00:00:00 2001 From: mvalentsev Date: Sun, 10 May 2026 21:40:15 +0500 Subject: [PATCH] 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. --- mempalace/__init__.py | 13 +++++++++---- tests/test_init.py | 37 +++++++++++++++++++++++++------------ 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/mempalace/__init__.py b/mempalace/__init__.py index a2ce132..9db4124 100644 --- a/mempalace/__init__.py +++ b/mempalace/__init__.py @@ -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() diff --git a/tests/test_init.py b/tests/test_init.py index 643c067..cc47f8c 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -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}"