Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions diffgraph/git_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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


Expand All @@ -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("../")
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _run(
command: Sequence[str],
cwd: str,
Expand Down
29 changes: 29 additions & 0 deletions tests/test_git_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading