diff --git a/diffgraph/git_snapshot.py b/diffgraph/git_snapshot.py index fc21d5a..da2a377 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,33 @@ 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:] + + if os.sep == "\\": + pathspec = pathspec.replace("\\", "/") + pathspec = posixpath.normpath(pathspec) + 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..37b250d 100644 --- a/tests/test_git_snapshot.py +++ b/tests/test_git_snapshot.py @@ -267,6 +267,35 @@ 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_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)