Skip to content
Open
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
21 changes: 20 additions & 1 deletion src/graph_engineering/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,12 +306,31 @@ def assessment_source(repo: Path) -> Mapping[str, str]:
records: list[Mapping[str, Any]] = []
total_bytes = 0
for relative in paths:
path = (repo / relative).resolve()
unresolved = repo / relative
if unresolved.is_symlink():
# Git stores a symlink (mode 120000) as a blob holding the target
# path text; snapshot exactly that and never follow the link.
payload = os.readlink(unresolved).encode("utf-8", errors="surrogateescape")
total_bytes += len(payload)
records.append(
{
"path": relative,
"sha256": hashlib.sha256(payload).hexdigest(),
"tracked": relative in tracked,
}
)
continue
path = unresolved.resolve()
if path != repo and not path.is_relative_to(repo):
raise ProjectPolicyError(
"ASSESSMENT_SOURCE_ESCAPE",
f"source path escapes repository: {relative}",
)
if path.is_dir():
# Gitlink (submodule) entries appear in `git ls-files` as tracked
# paths but are directories on disk; their content belongs to the
# sub-repository, not this snapshot.
continue
try:
payload = path.read_bytes()
except OSError as exc:
Expand Down
22 changes: 22 additions & 0 deletions src/graph_engineering/session_ux.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import hashlib
import json
import os
import re
import sqlite3
import stat
Expand Down Expand Up @@ -109,6 +110,21 @@ def _source_identity(repo: Path) -> dict[str, str]:
f"source path {relative!r} escapes repository",
)
path = repo / relative
if path.is_symlink():
# Git stores a symlink (mode 120000) as a blob holding the target
# path text; snapshot exactly that and never follow the link.
# Following would either escape the repository (absolute/external
# targets abort assess) or double-count in-repo content.
payload = os.readlink(path).encode("utf-8", errors="surrogateescape")
total_bytes += len(payload)
records.append(
{
"path": relative,
"sha256": hashlib.sha256(payload).hexdigest(),
"tracked": relative in tracked,
}
)
continue
try:
if not path.resolve().is_relative_to(repo.resolve()):
raise SessionUxError(
Expand All @@ -119,6 +135,12 @@ def _source_identity(repo: Path) -> dict[str, str]:
raise SessionUxError(
"ASSESSMENT_SOURCE_READ", f"cannot resolve source path {relative!r}"
) from exc
if path.is_dir():
# Gitlink (submodule) entries appear in `git ls-files` as tracked
# paths but are directories on disk; their content belongs to the
# sub-repository, not this snapshot. Reading one as a file raises
# IsADirectoryError and aborted assess on any repo with submodules.
continue
try:
size = path.stat().st_size
if size > MAX_SOURCE_FILE_BYTES:
Expand Down
36 changes: 36 additions & 0 deletions tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -974,3 +974,39 @@ def test_run_scope_registry_is_atomic_repo_bound_and_resume_exact(tmp_path: Path
resume=False,
)
assert registry.matches(other)[0]["run_id"] == "other-repository"


def test_assess_repo_skips_gitlink_submodule_entries(tmp_path: Path):
# intent: `git ls-files` lists gitlink (mode 160000) submodule entries as
# tracked paths; they are directories on disk and reading one as a file
# raised ASSESSMENT_SOURCE_READ, aborting assess on any repo with
# submodules (found on dayprotocol/day: contracts/evm/lib/layerzero-v2).
repo = bare_repo(tmp_path)
head_sha = git(repo, "rev-parse", "HEAD")
git(
repo,
"update-index",
"--add",
"--cacheinfo",
f"160000,{head_sha},vendor/subrepo",
)
git(repo, "commit", "-qm", "add gitlink")
(repo / "vendor" / "subrepo").mkdir(parents=True)
result = assess_repo(repo)
assert result["repo_digest"]


def test_assess_repo_snapshots_symlink_targets_without_following(tmp_path: Path):
# intent: a tracked symlink (mode 120000) whose target lies outside the
# repository aborted assess with ASSESSMENT_SOURCE_ESCAPE because the
# snapshot resolved (followed) it. Git stores the target path text as the
# blob; the snapshot must hash exactly that and never follow the link
# (found on dayprotocol/day: contracts/xel-v2-ref -> /root/projects/XEL/…).
repo = bare_repo(tmp_path)
outside = tmp_path / "outside-target"
outside.mkdir()
(repo / "external-ref").symlink_to(outside)
git(repo, "add", "--", "external-ref")
git(repo, "commit", "-qm", "add external symlink")
result = assess_repo(repo)
assert result["repo_digest"]