From 76924e24c1c6bddacea40a03979fdc4db8d48d8e Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Thu, 24 Sep 2026 09:03:38 +0530 Subject: [PATCH 1/2] fix(git): reject relative pathspecs outside repository --- diffgraph/git_snapshot.py | 42 +++++++++++++++++++++++++++++++++----- tests/test_git_snapshot.py | 22 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/diffgraph/git_snapshot.py b/diffgraph/git_snapshot.py index fc21d5a..9d467c8 100644 --- a/diffgraph/git_snapshot.py +++ b/diffgraph/git_snapshot.py @@ -440,10 +440,10 @@ def _root_relative_pathspecs( ) -> Optional[List[str]]: """Translate caller-relative pathspecs for a Git process run at ``root``. - An absolute scope outside the repository cannot be passed to Git safely. - Reject it rather than normalising it to ``../...`` and relying on a - command failure, so callers receive an actionable warning and never risk - falling back to a broader query. + A scope outside the repository cannot be passed to Git safely. Reject it + rather than normalising it to ``../...`` and relying on a command failure, + so callers receive an actionable warning and never risk falling back to a + broader query. This applies to relative scopes from a subdirectory too. """ if not pathspecs: @@ -474,7 +474,15 @@ def _root_relative_pathspecs( os.path.relpath(absolute_path, canonical_root).replace(os.sep, "/") ) else: - scoped.append(_prefix_pathspec(pathspec, prefix)) + prefixed = _prefix_pathspec(pathspec, prefix) + if _pathspec_escapes_repository(prefixed): + warnings.append(ResolutionWarning( + "pathspec_outside_repository", + "Relative pathspec escapes the repository and was not resolved", + pathspec, + )) + return None + scoped.append(prefixed) return scoped @@ -498,6 +506,30 @@ def prefixed(pattern: str) -> str: return prefixed(pathspec) +def _pathspec_escapes_repository(pathspec: str) -> bool: + """Return whether a root-relative pathspec traverses above the repository. + + ``_prefix_pathspec`` preserves Git's long and short pathspec magic. Strip + only that prefix before checking the path portion so exclusions and glob + pathspecs cannot turn a caller-relative ``../...`` scope into a Git + command rooted outside the requested repository. + """ + + if pathspec.startswith(":/"): + return False + if pathspec.startswith(":("): + end = pathspec.find(")") + if end != -1: + magic = pathspec[2:end].split(",") + if "top" in magic: + return False + pathspec = pathspec[end + 1 :] + elif pathspec.startswith((":!", ":^")): + pathspec = pathspec[2:] + + return pathspec == ".." or pathspec.startswith("../") + + def _run( command: Sequence[str], cwd: str, diff --git a/tests/test_git_snapshot.py b/tests/test_git_snapshot.py index ea8b87f..0a88f27 100644 --- a/tests/test_git_snapshot.py +++ b/tests/test_git_snapshot.py @@ -267,6 +267,28 @@ def test_absolute_pathspec_outside_repository_is_a_scoped_warning(tmp_path): ] +def test_relative_pathspec_outside_repository_is_a_scoped_warning(tmp_path): + """A subdirectory caller must not pass an escaping scope to Git's root.""" + repo = make_repo(tmp_path) + write(repo, "inside/tracked.txt", b"old\n") + commit_all(repo) + write(repo, "inside/tracked.txt", b"new\n") + + caller = repo / "inside" + pathspec = "../../outside" + staged = resolve_staged(str(caller), [pathspec]) + unstaged = resolve_unstaged(str(caller), [pathspec]) + ranged = resolve_commit_range( + str(caller), "HEAD", "HEAD", pathspecs=[pathspec] + ) + + for result in (staged, unstaged, ranged): + assert result.entries == () + assert [(warning.code, warning.path) for warning in result.warnings] == [ + ("pathspec_outside_repository", pathspec) + ] + + def test_absolute_pathspec_via_symlink_alias_is_in_repository(tmp_path): """A symlinked repository path resolves to the canonical repository scope.""" repo = make_repo(tmp_path) From 5769ca5edba461e678a6fa12f126f1e6910a8ce6 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Thu, 24 Sep 2026 13:32:34 +0530 Subject: [PATCH 2/2] fix(git): detect Windows escaping pathspecs --- diffgraph/git_snapshot.py | 3 +++ tests/test_git_snapshot.py | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/diffgraph/git_snapshot.py b/diffgraph/git_snapshot.py index 9d467c8..da2a377 100644 --- a/diffgraph/git_snapshot.py +++ b/diffgraph/git_snapshot.py @@ -527,6 +527,9 @@ def _pathspec_escapes_repository(pathspec: str) -> bool: elif pathspec.startswith((":!", ":^")): pathspec = pathspec[2:] + if os.sep == "\\": + pathspec = pathspec.replace("\\", "/") + pathspec = posixpath.normpath(pathspec) return pathspec == ".." or pathspec.startswith("../") diff --git a/tests/test_git_snapshot.py b/tests/test_git_snapshot.py index 0a88f27..37b250d 100644 --- a/tests/test_git_snapshot.py +++ b/tests/test_git_snapshot.py @@ -289,6 +289,13 @@ def test_relative_pathspec_outside_repository_is_a_scoped_warning(tmp_path): ] +def test_windows_relative_pathspec_outside_repository_is_detected(monkeypatch): + """Windows separators must not bypass the repository-boundary check.""" + monkeypatch.setattr(git_snapshot.os, "sep", "\\") + + assert git_snapshot._pathspec_escapes_repository("inside/..\\..\\outside") + + def test_absolute_pathspec_via_symlink_alias_is_in_repository(tmp_path): """A symlinked repository path resolves to the canonical repository scope.""" repo = make_repo(tmp_path)