From 34e177a322641c9421b4da0277bd76dd8622f282 Mon Sep 17 00:00:00 2001 From: hotragn Date: Tue, 18 Aug 2026 16:16:26 -0400 Subject: [PATCH] fix(paths): reject every path anchor in repo-relative guards, not just absolute ones `is_absolute()` is not a containment check on Windows. `PureWindowsPath` reports "/tmp/wiki" as relative because it carries no drive, and "C:wiki" as relative because it carries no root. Both still discard the left-hand side when joined, so a guard built on `is_absolute()` accepts inputs that escape the repo: PureWindowsPath("C:/repo") / "/tmp/wiki" -> C:/tmp/wiki PureWindowsPath("D:/repo") / "C:wiki" -> C:wiki Two guards depended on that check: - `SourceRuntimeContext.ignored_directories`, which promises "source runtime ignored directories must be repo-relative" - `require_default_almanac_root`, which promises "Almanac root must be a repo-relative path" `tests/test_filesystem_source_runtime.py` already asserted that "/tmp/wiki" is rejected, and that assertion failed on Windows. This restores it. Both guards now share `core.paths.is_rooted`, which rejects any anchor. On POSIX a root implies an absolute path and `drive` is always empty, so the predicate is exactly equivalent to the old check and macOS/Linux behaviour is unchanged. The new tests use explicit `PureWindowsPath`/`PurePosixPath` inputs rather than the platform-native `Path`, so both platforms' semantics are pinned on any runner and this stays verifiable on the current Linux-only CI. --- src/codealmanac/core/paths.py | 11 +++- .../services/repositories/roots.py | 4 +- src/codealmanac/services/sources/requests.py | 3 +- tests/test_core_paths.py | 50 +++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 tests/test_core_paths.py diff --git a/src/codealmanac/core/paths.py b/src/codealmanac/core/paths.py index 71363a9a..5ecec5d9 100644 --- a/src/codealmanac/core/paths.py +++ b/src/codealmanac/core/paths.py @@ -1,4 +1,13 @@ -from pathlib import Path +from pathlib import Path, PurePath + + +def is_rooted(path: PurePath) -> bool: + # `is_absolute()` is not a containment check on Windows. `PureWindowsPath` + # treats "/tmp/x" as relative because it carries no drive, and "C:x" as + # relative because it carries no root, yet joining either one discards the + # left-hand side: `C:/repo` / `/tmp/x` is `C:/tmp/x`. A repo-relative guard + # therefore has to reject every anchor, not just fully qualified paths. + return path.is_absolute() or bool(path.drive) or bool(path.root) def home_dir() -> Path: diff --git a/src/codealmanac/services/repositories/roots.py b/src/codealmanac/services/repositories/roots.py index 0f4d67ad..14095297 100644 --- a/src/codealmanac/services/repositories/roots.py +++ b/src/codealmanac/services/repositories/roots.py @@ -1,7 +1,7 @@ from pathlib import Path from codealmanac.core.models import CodeAlmanacModel -from codealmanac.core.paths import normalize_path +from codealmanac.core.paths import is_rooted, normalize_path DEFAULT_ALMANAC_ROOT = Path("almanac") ALMANAC_ROOT_MARKER_FILE = "topics.yaml" @@ -18,7 +18,7 @@ def require_default_almanac_root(value: Path | str | None) -> Path: if value is None: return DEFAULT_ALMANAC_ROOT path = Path(value) - if path.is_absolute(): + if is_rooted(path): raise ValueError("Almanac root must be a repo-relative path") if len(path.parts) == 0: raise ValueError("Almanac root must name a directory") diff --git a/src/codealmanac/services/sources/requests.py b/src/codealmanac/services/sources/requests.py index 524edd5e..597bd450 100644 --- a/src/codealmanac/services/sources/requests.py +++ b/src/codealmanac/services/sources/requests.py @@ -3,6 +3,7 @@ from pydantic import Field, field_validator from codealmanac.core.models import CodeAlmanacModel +from codealmanac.core.paths import is_rooted from codealmanac.services.sources.models import SourceRef, TranscriptApp @@ -57,7 +58,7 @@ class InspectSourceRuntimeRequest(CodeAlmanacModel): def normalize_ignored_directory(path: Path) -> Path: - if path.is_absolute(): + if is_rooted(path): raise ValueError("source runtime ignored directories must be repo-relative") if len(path.parts) == 0: raise ValueError("source runtime ignored directories must name a directory") diff --git a/tests/test_core_paths.py b/tests/test_core_paths.py new file mode 100644 index 00000000..1316fbbc --- /dev/null +++ b/tests/test_core_paths.py @@ -0,0 +1,50 @@ +from pathlib import Path, PurePosixPath, PureWindowsPath + +import pytest +from pydantic import ValidationError + +from codealmanac.core.paths import is_rooted +from codealmanac.services.repositories.roots import require_default_almanac_root +from codealmanac.services.sources.requests import SourceRuntimeContext + + +@pytest.mark.parametrize( + "raw", + ("/tmp/wiki", "C:/abs/wiki", "C:wiki", "//server/share/wiki"), +) +def test_windows_anchored_paths_are_rooted(raw: str): + assert is_rooted(PureWindowsPath(raw)) is True + + +@pytest.mark.parametrize("raw", ("wiki", "docs/wiki", "../wiki", "./wiki")) +def test_windows_relative_paths_are_not_rooted(raw: str): + assert is_rooted(PureWindowsPath(raw)) is False + + +@pytest.mark.parametrize("raw", ("/tmp/wiki", "/")) +def test_posix_absolute_paths_are_rooted(raw: str): + assert is_rooted(PurePosixPath(raw)) is True + + +@pytest.mark.parametrize("raw", ("wiki", "docs/wiki", "../wiki", "C:wiki")) +def test_posix_relative_paths_are_not_rooted(raw: str): + # "C:wiki" is an ordinary relative filename on POSIX, so the guard must not + # borrow Windows drive semantics on platforms that have no drives. + assert is_rooted(PurePosixPath(raw)) is False + + +def test_rooted_paths_discard_the_repo_root_when_joined(): + repo = PureWindowsPath("C:/repo") + + assert repo / PureWindowsPath("/tmp/wiki") == PureWindowsPath("C:/tmp/wiki") + assert repo / PureWindowsPath("D:/wiki") == PureWindowsPath("D:/wiki") + + +def test_source_runtime_context_rejects_rooted_ignored_directory(): + with pytest.raises(ValidationError): + SourceRuntimeContext(ignored_directories=(Path("/tmp/wiki"),)) + + +def test_almanac_root_rejects_rooted_path(): + with pytest.raises(ValueError): + require_default_almanac_root(Path("/tmp/almanac"))