mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
660 lines
31 KiB
Python
660 lines
31 KiB
Python
"""Exercise the snapshot resolver with real local Git objects, not mocked answers."""
|
|||
|
|||
import subprocess
|
|||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from openultrasast.push.snapshot import SnapshotAdapter
|
||
|
|
|
||
|
|
|
||
|
|
def git(repo: Path, *args: str, input: str | None = None) -> str:
|
||
|
|
result = subprocess.run(["git", "-C", str(repo), *args], input=input, text=True, capture_output=True, check=True)
|
||
|
|
return result.stdout.strip()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(params=["sha1", "sha256"])
|
||
|
|
def repo(tmp_path: Path, request: pytest.FixtureRequest) -> Path:
|
||
|
|
git(tmp_path, "init", "--object-format=" + request.param)
|
||
|
|
git(tmp_path, "config", "user.name", "Fixture")
|
||
|
|
git(tmp_path, "config", "user.email", "[email protected]")
|
||
|
|
return tmp_path
|
||
|
|
|
||
|
|
|
||
|
|
def commit(repo: Path, label: str, *parents: str) -> str:
|
||
|
|
blob = git(repo, "hash-object", "-w", "--stdin", input=label)
|
||
|
|
assert git(repo, "cat-file", "-s", blob) == str(len(label))
|
||
|
|
tree = git(repo, "mktree", input=f"100644 blob {blob}\tfile.js\n")
|
||
|
|
args = ["commit-tree", tree]
|
||
|
|
for parent in parents:
|
||
|
|
args.extend(["-p", parent])
|
||
|
|
return git(repo, *args, input=label)
|
||
|
|
|
||
|
|
|
||
|
|
def line(head: str, base: str, name: str = "main") -> str:
|
||
|
|
return f"refs/heads/{name} {head} refs/heads/{name} {base}\n"
|
||
|
|
|
||
|
|
|
||
|
|
def test_preserves_every_ref_and_distinct_comparisons(repo: Path) -> None:
|
||
|
|
base = commit(repo, "base")
|
||
|
|
other = commit(repo, "other", base)
|
||
|
|
head = commit(repo, "head", base)
|
||
|
|
result = SnapshotAdapter(repo).resolve_updates(line(head, base) + line(head, other, "force") + line(head, base, "alias"))
|
||
|
|
assert result.targets == (head,)
|
||
|
|
assert [(c.head_oid, c.base_oid, c.refs) for c in result.comparisons] == [
|
||
(head, base, ("refs/heads/main", "refs/heads/alias")),
|
|||
|
|
(head, other, ("refs/heads/force",)),
|
||
|
|
]
|
||
assert [r.disposition for r in result.updates] == ["ready"] * 3
|
|||
|
|
assert len(result.updates) == 3
|
||
|
|
|
||
|
|
|
||
|
|
def test_configured_new_branch_uses_unique_merge_base(repo: Path) -> None:
|
||
|
|
base = commit(repo, "base")
|
||
|
|
left, right = commit(repo, "left", base), commit(repo, "right", base)
|
||
|
|
git(repo, "update-ref", "refs/heads/baseline", right)
|
||
|
|
merge = commit(repo, "merge", left, right)
|
||
|
|
result = SnapshotAdapter(repo, comparison_base="refs/heads/baseline").resolve_updates(line(merge, "0" * len(base)))
|
||
|
|
assert result.comparisons[0].base_oid == right
|
||
|
|
assert result.comparisons[0].head_oid == merge
|
||
|
|
assert result.updates[0].base_reason == "configured_unique_merge_base:" + right
|
||
|
|
assert result.updates[0].disposition == "ready"
|
||
|
|
|
||
|
|
|
||
|
|
def test_no_implicit_head_or_fetch_for_missing_base(repo: Path) -> None:
|
||
|
|
head = commit(repo, "head")
|
||
|
|
git(repo, "update-ref", "HEAD", head)
|
||
|
|
for configured in (None, "refs/remotes/origin/absent"):
|
||
|
|
result = SnapshotAdapter(repo, comparison_base=configured).resolve_updates(line(head, "0" * len(head)))
|
||
|
|
assert result.updates[0].disposition == "missing_base"
|
||
|
|
assert result.comparisons[0].base_oid is None
|
||
|
|
result = SnapshotAdapter(repo).resolve_updates(line(head, "1" * len(head)))
|
||
|
|
assert result.updates[0].disposition == "missing_base"
|
||
|
|
assert result.comparisons[0].base_reason == "supplied_remote_tip"
|
||
|
|
|
||
|
|
|
||
|
|
def test_unrelated_and_ambiguous_new_branch_bases(repo: Path) -> None:
|
||
|
|
root = commit(repo, "root")
|
||
|
|
unrelated = commit(repo, "unrelated")
|
||
|
|
result = SnapshotAdapter(repo, comparison_base=unrelated).resolve_updates(line(root, "0" * len(root)))
|
||
|
|
assert result.updates[0].disposition == "no_merge_base"
|
||
|
|
left, right = commit(repo, "left", root), commit(repo, "right", root)
|
||
|
|
one, two = commit(repo, "one", left, right), commit(repo, "two", right, left)
|
||
|
|
result = SnapshotAdapter(repo, comparison_base=one).resolve_updates(line(two, "0" * len(root)))
|
||
|
|
assert result.updates[0].disposition == "ambiguous_base"
|
||
|
|
assert result.comparisons[0].base_oid is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_tags_peel_to_commit_and_retain_input_identity(repo: Path) -> None:
|
||
|
|
base, head = commit(repo, "base"), commit(repo, "head")
|
||
|
|
git(repo, "tag", "-a", "old", base, "-m", "old")
|
||
|
|
git(repo, "tag", "-a", "new", head, "-m", "new")
|
||
|
|
old_tag, new_tag = git(repo, "rev-parse", "old"), git(repo, "rev-parse", "new")
|
||
|
|
data = f"refs/tags/new {new_tag} refs/tags/new {old_tag}\n" + line(head, base)
|
||
|
|
result = SnapshotAdapter(repo).resolve_updates(data)
|
||
|
|
assert result.targets == (head,)
|
||
|
|
assert result.comparisons[0].base_oid == base
|
||
|
|
assert result.comparisons[0].refs == ("refs/tags/new", "refs/heads/main")
|
||
|
|
assert result.updates[0].update.local_oid == new_tag
|
||
|
|
assert result.updates[0].update.remote_oid == old_tag
|
||
|
|
assert type(result).from_payload(result.to_payload()) == result
|
||
|
|
|
||
|
|
|
||
|
|
def test_deletion_only_has_no_analysis(repo: Path) -> None:
|
||
|
|
old = commit(repo, "old")
|
||
|
|
result = SnapshotAdapter(repo).resolve_updates(f"(delete) {'0' * len(old)} refs/heads/old {old}\n")
|
||
|
|
assert result.targets == result.comparisons == ()
|
||
|
|
assert result.updates[0].disposition == "deleted"
|
||
|
|
assert SnapshotAdapter(repo).resolve_updates("").updates == ()
|
||
|
|
|
||
|
|
|
||
|
|
def test_missing_and_unsupported_objects_are_explicit(repo: Path) -> None:
|
||
|
|
head = commit(repo, "head")
|
||
|
|
blob = git(repo, "hash-object", "-w", "--stdin", input="unsupported source")
|
||
|
|
git(repo, "tag", "-a", "blob-tag", blob, "-m", "unsupported")
|
||
|
|
tag = git(repo, "rev-parse", "blob-tag")
|
||
|
|
result = SnapshotAdapter(repo).resolve_updates(line("1" * len(head), head) + line(blob, head, "blob") + line(tag, head, "tag"))
|
||
|
|
assert [r.disposition for r in result.updates] == ["missing_target", "unsupported_target", "unsupported_target"]
|
||
|
|
assert result.targets == result.comparisons == ()
|
||
|
|
result = SnapshotAdapter(repo).resolve_updates(line(head, blob))
|
||
|
|
assert result.updates[0].disposition == "unsupported_base"
|
||
|
|
|
||
|
|
|
||
|
|
def test_bad_protocol_never_silently_skips_lines(repo: Path) -> None:
|
||
|
|
from openultrasast.push.snapshot import SnapshotInputError
|
||
|
|||
head = commit(repo, "head")
|
|||
for invalid in (
|
|||
|
|
"\n",
|
||
|
|
"only three fields\n",
|
||
|
|
line(head[:-1], head),
|
||
|
|
line(head, head, "bad..ref"),
|
||
|
|
line("0" * len(head), head),
|
||
|
|
f"(delete) {head} refs/heads/main {head}\n",
|
||
|
|
):
|
||
with pytest.raises(SnapshotInputError):
|
|||
|
|
SnapshotAdapter(repo).resolve_updates(line(head, head) + invalid)
|
||
|
|
|
||
|
|
|
||
|
|
def test_replacements_and_inherited_git_environment_do_not_change_identity(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
|
|
root = commit(repo, "root")
|
||
|
|
original = commit(repo, "original", root)
|
||
|
|
replacement = commit(repo, "replacement")
|
||
|
|
git(repo, "replace", original, replacement)
|
||
|
|
monkeypatch.setenv("GIT_DIR", str(repo / "nonexistent"))
|
||
|
|
monkeypatch.setenv("GIT_OBJECT_DIRECTORY", str(repo / "nonexistent"))
|
||
|
|
result = SnapshotAdapter(repo, comparison_base=root).resolve_updates(line(original, "0" * len(root)))
|
||
|
|
assert result.updates[0].disposition == "ready"
|
||
|
|
assert result.comparisons[0].base_oid == root
|
||
|
|
assert result.comparisons[0].head_oid == original
|
||
|
|
|
||
|
|
|
||
|
|
def test_missing_promisor_object_cannot_fetch_even_when_repo_allows_file(repo: Path, tmp_path: Path) -> None:
|
||
|
|
source = repo / "source"
|
||
|
|
source.mkdir()
|
||
|
|
fmt = git(repo, "rev-parse", "--show-object-format")
|
||
|
|
git(source, "init", "--object-format=" + fmt)
|
||
|
|
git(source, "config", "user.name", "Fixture")
|
||
|
|
git(source, "config", "user.email", "[email protected]")
|
||
|
|
missing = commit(source, "must not fetch")
|
||
|
|
git(source, "update-ref", "refs/heads/main", missing)
|
||
|
|
git(repo, "config", "remote.origin.url", str(source))
|
||
|
|
git(repo, "config", "remote.origin.promisor", "true")
|
||
|
|
git(repo, "config", "remote.origin.partialclonefilter", "blob:none")
|
||
|
|
git(repo, "config", "protocol.file.allow", "always")
|
||
|
|
before = sorted(p.relative_to(repo / ".git/objects") for p in (repo / ".git/objects").rglob("*") if p.is_file())
|
||
|
|
result = SnapshotAdapter(repo).resolve_updates(line(missing, "0" * len(missing)))
|
||
|
|
after = sorted(p.relative_to(repo / ".git/objects") for p in (repo / ".git/objects").rglob("*") if p.is_file())
|
||
|
|
assert result.updates[0].disposition == "missing_target"
|
||
|
|
assert before == after
|
||
|
|
|
||
|
|
|
||
|
|
def test_missing_tag_referent_is_missing_not_unsupported(repo: Path) -> None:
|
||
|
|
head = commit(repo, "head")
|
||
|
|
git(repo, "tag", "-a", "dangling", head, "-m", "dangling")
|
||
|
|
tag = git(repo, "rev-parse", "dangling")
|
||
|
|
(repo / ".git" / "objects" / head[:2] / head[2:]).unlink()
|
||
|
|
result = SnapshotAdapter(repo).resolve_updates(f"refs/tags/dangling {tag} refs/tags/dangling {'0' * len(head)}\n")
|
||
|
|
assert result.updates[0].disposition == "missing_target"
|
||
|
|
|
||
|
|
|
||
|
|
def test_legacy_grafts_cannot_rewrite_comparison_ancestry(repo: Path) -> None:
|
||
|
|
root = commit(repo, "root")
|
||
|
|
head = commit(repo, "head", root)
|
||
|
|
(repo / ".git/info/grafts").write_text(head + "\n")
|
||
|
|
result = SnapshotAdapter(repo, comparison_base=root).resolve_updates(line(head, "0" * len(head)))
|
||
|
|
assert result.comparisons[0].base_oid == root
|
||
|
|
|
||
|
|
|
||
|
|
def test_dirty_non_head_and_duplicate_update_records_are_preserved(repo: Path) -> None:
|
||
|
|
base = commit(repo, "base")
|
||
|
|
pushed = commit(repo, "pushed", base)
|
||
|
|
checked = commit(repo, "checked", base)
|
||
|
|
git(repo, "update-ref", "HEAD", checked)
|
||
|
|
(repo / "file.js").write_text("dirty file")
|
||
|
|
git(repo, "add", "file.js")
|
||
|
|
before_index = (repo / ".git/index").read_bytes()
|
||
|
|
result = SnapshotAdapter(repo).resolve_updates(line(pushed, base) * 2)
|
||
|
|
assert result.targets == (pushed,)
|
||
|
|
assert len(result.updates) == 2
|
||
|
|
assert len(result.comparisons) == 1
|
||
|
|
assert (repo / ".git/index").read_bytes() == before_index
|
||
|
|
assert (repo / "file.js").read_text() == "dirty file"
|
||
|
|
assert git(repo, "rev-parse", "HEAD") == checked
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("name", ["unicode\u00a0inside", "unicode-trailing\u00a0", "unicode\u2028separator"])
|
||
|
|
def test_git_valid_unicode_ref_names_are_preserved(repo: Path, name: str) -> None:
|
||
|
|
base = commit(repo, "base")
|
||
|
|
head = commit(repo, "head", base)
|
||
|
|
ref = "refs/heads/" + name
|
||
|
|
git(repo, "check-ref-format", ref)
|
||
|
|
git(repo, "update-ref", ref, head)
|
||
|
|
result = SnapshotAdapter(repo).resolve_updates(line(head, base, name))
|
||
|
|
assert result.updates[0].update.local_ref == ref
|
||
|
|
assert result.updates[0].update.remote_ref == ref
|
||
|
|
assert result.comparisons[0].refs == (ref,)
|
||
|
|
assert result.updates[0].disposition == "ready"
|
||
|
|||
|
|
|
||
|
|
def test_materialize_reads_non_head_objects_and_cleans(repo: Path, tmp_path: Path) -> None:
|
||
|
|
pushed = commit(repo, "<?php echo $_GET['x'];")
|
||
|
|
checked = commit(repo, "dirty baseline")
|
||
|
|
git(repo, "update-ref", "HEAD", checked)
|
||
|
|
(repo / "file.js").write_bytes(b"staged")
|
||
|
|
git(repo, "add", "file.js")
|
||
|
|
(repo / "file.js").write_bytes(b"dirty")
|
||
|
|
before = (repo / ".git/index").read_bytes()
|
||
|
|
with SnapshotAdapter(repo).materialize(pushed) as snapshot:
|
||
|
|
root = snapshot.root
|
||
|
|
assert (root / "file.js").read_bytes() == b"<?php echo $_GET['x'];"
|
||
|
|
assert snapshot.manifest.complete
|
||
|
|
assert snapshot.manifest.bytes_read == 22
|
||
|
|
assert snapshot.manifest.commit_oid == pushed
|
||
|
|
assert not root.exists()
|
||
|
|
assert (repo / ".git/index").read_bytes() == before
|
||
|
|
assert (repo / "file.js").read_bytes() == b"dirty"
|
||
|
|
assert git(repo, "rev-parse", "HEAD") == checked
|
||
|
|
|
||
|
|
|
||
|
|
def binary_commit(repo: Path, entries: list[tuple[bytes, bytes, bytes]]) -> str:
|
||
|
|
records = []
|
||
|
|
for mode, path, data in entries:
|
||
|
|
if mode == b"160000":
|
||
|
|
oid = data
|
||
|
|
kind = b"commit"
|
||
|
|
else:
|
||
oid = subprocess.run(
|
|||
|
|
["git", "-C", str(repo), "hash-object", "-w", "--stdin"], input=data, capture_output=True, check=True
|
||
|
|
).stdout.rstrip(b"\n")
|
||
kind = b"blob"
|
|||
|
|
assert int(git(repo, "cat-file", "-s", oid.decode())) == len(data)
|
||
|
|
records.append(mode + b" " + kind + b" " + oid + b"\t" + path + b"\0")
|
||
tree = (
|
|||
|
|
subprocess.run(["git", "-C", str(repo), "mktree", "-z"], input=b"".join(records), capture_output=True, check=True)
|
||
|
|
.stdout.decode()
|
||
|
|
.strip()
|
||
|
|
)
|
||
return git(repo, "commit-tree", tree, input="binary fixture")
|
|||
|
|
|
||
|
|
|
||
|
|
def test_snapshot_exact_binary_paths_and_no_filters(repo: Path) -> None:
|
||
|
|
import os
|
||
|
|||
|
|
entries = [
|
||
|
|
(b"100644", path, data)
|
||
|
|
for path, data in [
|
||
|
|
(b"handler.php", b"<?php echo $_GET['x'];\n"),
|
||
|
|
(b"api.js", b"app.post('/x', (req,res)=>res.send(req.body.x));\n"),
|
||
|
|
(b"newline\nname\t.js", b"\x00\xffbinary\r\n"),
|
||
|
|
("\u03b1.js".encode(), b"unicode"),
|
||
|
|
(b"\xff.js", b"invalid UTF8 name"),
|
||
|
|
(b" ", b"space"),
|
||
|
|
(b".gitattributes", b"* filter=trap\n"),
|
||
|
|
]
|
||
|
|
]
|
||
oid = binary_commit(repo, entries)
|
|||
|
|
sentinel = repo / "FILTER_RAN"
|
||
|
|
git(repo, "config", "filter.trap.smudge", "touch " + str(sentinel))
|
||
|
|
with SnapshotAdapter(repo).materialize(oid) as snapshot:
|
||
|
|
assert snapshot.manifest.complete
|
||
|
|
assert snapshot.manifest.bytes_read == sum(len(data) for _, _, data in entries)
|
||
|
|
assert {f.path_bytes for f in snapshot.manifest.files} == {path for _, path, _ in entries}
|
||
|
|
for _, path, data in entries:
|
||
|
|
assert (snapshot.root / os.fsdecode(path)).read_bytes() == data
|
||
|
|
assert type(snapshot.manifest).from_payload(snapshot.manifest.to_payload()) == snapshot.manifest
|
||
|
|
assert not sentinel.exists()
|
||
|
|
|
||
|
|
|
||
|
|
def test_snapshot_omits_symlinks_gitlinks_and_lfs(repo: Path) -> None:
|
||
|
|
target = commit(repo, "submodule")
|
||
oid = binary_commit(
|
|||
|
|
repo,
|
||
|
|
[
|
||
|
|
(b"120000", b"outside", b"/etc/passwd"),
|
||
|
|
(b"160000", b"submodule", target.encode()),
|
||
|
|
(b"100644", b"large.js", b"version https://git-lfs.github.com/spec/v1\noid sha256:" + b"0" * 64 + b"\nsize 3\n"),
|
||
|
|
],
|
||
|
|
)
|
||
with SnapshotAdapter(repo).materialize(oid) as snapshot:
|
|||
|
|
assert not snapshot.manifest.complete
|
||
|
|
assert snapshot.manifest.tree_complete
|
||
|
|
assert {b.reason for b in snapshot.manifest.boundaries} == {
|
||
"symlink_not_materialized",
|
|||
|
|
"gitlink_not_materialized",
|
||
|
|
"lfs_content_unavailable",
|
||
|
|
}
|
||
assert list(snapshot.root.iterdir()) == []
|
|||
|
|
|
||
|
|
|
||
@pytest.mark.parametrize(
|
|||
|
|
"limit,reason",
|
||
|
|
[
|
||
|
|
("max_files", "file_count_limit"),
|
||
|
|
("max_tree_bytes", "git_output_limit"),
|
||
|
|
("max_blob_bytes", "source_byte_limit"),
|
||
|
|
("max_total_bytes", "source_byte_limit"),
|
||
|
|
("max_path_bytes", "unsupported_path"),
|
||
|
|
],
|
||
|
|
)
|
||
def test_snapshot_limits_are_explicit(repo: Path, limit: str, reason: str) -> None:
|
|||
|
|
from openultrasast.push.snapshot import SnapshotLimits
|
||
|
|||
oid = binary_commit(repo, [(b"100644", b"one.js", b"123"), (b"100644", b"two.php", b"456")])
|
|||
|
|
with SnapshotAdapter(repo).materialize(oid, limits=SnapshotLimits(**{limit: 1})) as snapshot:
|
||
|
|
root = snapshot.root
|
||
|
|
assert not snapshot.manifest.complete
|
||
|
|
assert reason in {b.reason for b in snapshot.manifest.boundaries}
|
||
|
|
assert snapshot.manifest.bytes_read <= 1 if limit == "max_total_bytes" else True
|
||
|
|
assert not root.exists()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("failure", ["cancel", "deadline", "missing_blob", "consumer"])
|
||
|
|
def test_snapshot_failure_preserves_live_state_and_cleans(repo: Path, failure: str) -> None:
|
||
|
|
import time
|
||
|
|
|
||
|
|
from openultrasast.model.contracts import ExecutionBudget
|
||
|
|
from openultrasast.push.snapshot import SnapshotInputError
|
||
|
|||
oid = commit(repo, "object bytes")
|
|||
|
|
git(repo, "update-ref", "HEAD", oid)
|
||
|
|
(repo / "file.js").write_bytes(b"staged")
|
||
|
|
git(repo, "add", "file.js")
|
||
|
|
(repo / "file.js").write_bytes(b"dirty")
|
||
|
|
hooks = repo / ".git/hooks/pre-push"
|
||
|
|
hooks.write_bytes(b"existing hook")
|
||
|
|
if failure == "missing_blob":
|
||
|
|
blob = git(repo, "rev-parse", oid + ":file.js")
|
||
|
|
(repo / ".git/objects" / blob[:2] / blob[2:]).unlink()
|
||
|
|
before = {p.relative_to(repo): p.read_bytes() for p in repo.rglob("*") if p.is_file()}
|
||
|
|
calls = 0
|
||
|
|||
def cancel() -> bool:
|
|||
|
|
nonlocal calls
|
||
|
|
calls += 1
|
||
|
|
return failure == "cancel" and calls >= 5
|
||
|
|||
budget = ExecutionBudget(time.monotonic() + (-1 if failure == "deadline" else 10), 2.0)
|
|||
|
|
root = None
|
||
|
|
try:
|
||
|
|
with SnapshotAdapter(repo).materialize(oid, budget=budget, cancelled=cancel) as snapshot:
|
||
|
|
root = snapshot.root
|
||
|
|
if failure == "consumer":
|
||
|
|
raise RuntimeError("consumer cancelled")
|
||
|
|
assert not snapshot.manifest.complete
|
||
|
|
assert snapshot.manifest.boundaries
|
||
|
|
except RuntimeError:
|
||
|
|
assert failure == "consumer"
|
||
|
|
except SnapshotInputError as error:
|
||
|
|
assert failure == "deadline" and str(error) == "deadline_exhausted"
|
||
|
|
assert (root is None and failure == "deadline") or (root is not None and not root.exists())
|
||
|
|
after = {p.relative_to(repo): p.read_bytes() for p in repo.rglob("*") if p.is_file()}
|
||
|
|
assert before == after
|
||
|
|
|
||
|
|
|
||
|
|
def test_snapshot_rejects_live_scratch_parent(repo: Path) -> None:
|
||
|
|
from openultrasast.push.snapshot import SnapshotInputError
|
||
|
|||
oid = commit(repo, "input")
|
|||
|
|
for parent in (repo, repo / ".git", repo / ".git/objects"):
|
||
|
|
with pytest.raises(SnapshotInputError, match="outside"), SnapshotAdapter(repo).materialize(oid, scratch_parent=parent):
|
||
|
|
pytest.fail("unsafe scratch accepted")
|
||
|
|
|
||
|
|
|
||
|
|
def test_snapshot_missing_tree_and_unsupported_object_are_not_clean(repo: Path) -> None:
|
||
|
|
oid = commit(repo, "input")
|
||
|
|
blob = git(repo, "rev-parse", oid + ":file.js")
|
||
|
|
with SnapshotAdapter(repo).materialize(blob) as snapshot:
|
||
|
|
assert not snapshot.manifest.complete
|
||
|
|
assert not snapshot.manifest.tree_complete
|
||
|
|
tree = git(repo, "rev-parse", oid + "^{tree}")
|
||
|
|
(repo / ".git/objects" / tree[:2] / tree[2:]).unlink()
|
||
|
|
with SnapshotAdapter(repo).materialize(oid) as snapshot:
|
||
|
|
assert not snapshot.manifest.complete
|
||
|
|
assert not snapshot.manifest.tree_complete
|
||
|
|
assert not snapshot.manifest.files
|
||
|
|
|
||
|
|
|
||
|
|
def test_snapshot_refuses_oversize_blob_before_reading_it(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
|
|
from openultrasast.push.snapshot import SnapshotLimits
|
||
|
|||
oid = commit(repo, "large payload")
|
|||
|
|
adapter = SnapshotAdapter(repo)
|
||
|
|
original = adapter._bounded_git
|
||
|
|
commands = []
|
||
|
|||
def capture(args, *rest):
|
|||
|
|
commands.append(args)
|
||
|
|
return original(args, *rest)
|
||
|
|||
monkeypatch.setattr(adapter, "_bounded_git", capture)
|
|||
|
|
with adapter.materialize(oid, limits=SnapshotLimits(max_blob_bytes=1)) as snapshot:
|
||
|
|
assert not snapshot.manifest.complete
|
||
|
|
assert not any(args[:2] == ("cat-file", "blob") for args in commands)
|
||
|
|
|
||
|
|
|
||
|
|
def test_cleanup_does_not_follow_consumer_symlinks(repo: Path, tmp_path_factory: pytest.TempPathFactory) -> None:
|
||
|
|
outside = tmp_path_factory.mktemp("outside")
|
||
|
|
(outside / "keep").write_bytes(b"preserve")
|
||
|
|
oid = commit(repo, "source")
|
||
|
|
with SnapshotAdapter(repo).materialize(oid) as snapshot:
|
||
|
|
(snapshot.root / "external").symlink_to(outside, target_is_directory=True)
|
||
|
|
assert (outside / "keep").read_bytes() == b"preserve"
|
||
|
|
with SnapshotAdapter(repo).materialize(oid) as snapshot:
|
||
|
|
(snapshot.root / "file.js").unlink()
|
||
|
|
snapshot.root.rmdir()
|
||
|
|
snapshot.root.symlink_to(outside, target_is_directory=True)
|
||
|
|
assert (outside / "keep").read_bytes() == b"preserve"
|
||
|
|
assert not snapshot.root.exists()
|
||
|
|
|
||
|
|
|
||
|
|
def test_snapshot_deadline_kills_slow_git_and_cleans(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
|
||
|
|
from openultrasast.model.contracts import ExecutionBudget
|
||
|
|||
oid = commit(repo, "input")
|
|||
|
|
adapter = SnapshotAdapter(repo)
|
||
|
|
real_popen = subprocess.Popen
|
||
|
|
processes = []
|
||
|
|||
def slow_git(command, **kwargs):
|
|||
|
|
process = real_popen([sys.executable, "-c", "import time; time.sleep(30)"], **kwargs)
|
||
|
|
processes.append(process)
|
||
|
|
return process
|
||
|
|||
monkeypatch.setattr(subprocess, "Popen", slow_git)
|
|||
|
|
start = time.monotonic()
|
||
|
|
with adapter.materialize(oid, budget=ExecutionBudget(start + 0.1, 1.0)) as snapshot:
|
||
|
|
assert {b.reason for b in snapshot.manifest.boundaries} == {"deadline_exhausted"}
|
||
|
|
assert not snapshot.manifest.complete
|
||
|
|
assert time.monotonic() - start < 1.5
|
||
|
|
assert processes and all(process.poll() is not None for process in processes)
|
||
|
|
assert not snapshot.root.exists()
|
||
|
|
|
||
|
|
|
||
|
|
def test_snapshot_manifest_rejects_forged_census(repo: Path) -> None:
|
||
|
|
from dataclasses import replace
|
||
|
|||
oid = commit(repo, "input")
|
|||
|
|
with SnapshotAdapter(repo).materialize(oid) as snapshot:
|
||
|
|
manifest = snapshot.manifest
|
||
|
|
file = manifest.files[0]
|
||
|
|
for changes in ({"bytes_read": -1}, {"files": (file, file)}, {"tree_complete": False}):
|
||
|
|
with pytest.raises(ValueError):
|
||
|
|
replace(manifest, **changes)
|
||
|
|
for changes in ({"path_hex": "zz"}, {"size_bytes": -1}, {"sha256": "bad"}, {"path_hex": "00"}):
|
||
|
|
with pytest.raises(ValueError):
|
||
|
|
replace(file, **changes)
|
||
|
|
|
||
|
|
|
||
|
|
def test_snapshot_subdirectory_adapter_cannot_scratch_in_live_parent(repo: Path) -> None:
|
||
|
|
from openultrasast.push.snapshot import SnapshotInputError
|
||
|
|||
oid = commit(repo, "input")
|
|||
|
|
nested = repo / "nested"
|
||
|
|
nested.mkdir()
|
||
|
|
with pytest.raises(SnapshotInputError, match="outside"), SnapshotAdapter(nested).materialize(oid, scratch_parent=repo):
|
||
|
|
pytest.fail("live parent accepted")
|
||
|
|
|
||
|
|
|
||
|
|
def test_snapshot_write_failure_cleans_partial_scratch(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
|
|
oid = commit(repo, "input")
|
||
|
|
original = Path.open
|
||
|
|||
def broken(path, mode="r", *args, **kwargs):
|
|||
|
|
if mode == "xb":
|
||
|
|
raise OSError("scratch_write_failed")
|
||
|
|
return original(path, mode, *args, **kwargs)
|
||
|
|||
monkeypatch.setattr(Path, "open", broken)
|
|||
|
|
with SnapshotAdapter(repo).materialize(oid) as snapshot:
|
||
|
|
assert not snapshot.manifest.complete
|
||
|
|
assert any(b.reason == "scratch_write_failed" for b in snapshot.manifest.boundaries)
|
||
|
|
assert not snapshot.root.exists()
|
||
|
|||
|
|
|
||
|
|
def comparison(base: str | None, head: str):
|
||
|
|
from openultrasast.push.contracts import PushComparison
|
||
|
|
|
||
|
|
return PushComparison(head, base, "supplied_remote_tip", ("refs/heads/main",))
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"extension,prefix,guard,sink",
|
||
|
|
[
|
||
|
|
(b"php", b"<?php\n", b" require_permission($user);\n", b" execute($input);\n"),
|
||
|
|
(b"js", b"", b" requirePermission(user);\n", b" execute(input);\n"),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_change_context_function_rename_and_removed_guard(repo: Path, extension, prefix, guard, sink) -> None:
|
||
|
|
path = b"handler." + extension
|
||
|
|
old = prefix + b"function oldHandler() {\n" + guard + sink + b"}\n"
|
||
|
|
new = prefix + b"function newHandler() {\n" + sink + b"}\n"
|
||
|
|
base = binary_commit(repo, [(b"100644", path, old)])
|
||
|
|
head = binary_commit(repo, [(b"100644", path, new)])
|
||
|
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=())
|
||
|
|
assert result.base_revision == base and result.head_revision == head
|
||
|
|
assert result.path_encoding == "filesystem-bytes-hex"
|
||
|
|
assert result.changed_paths == (path.hex(),)
|
||
|
|
base_guard = old.split(b"\n").index(guard.rstrip(b"\n")) + 1
|
||
|
|
assert any(s.side == "base" and s.start_line <= base_guard <= s.end_line for s in result.spans)
|
||
|
|
base_sink = old.split(b"\n").index(sink.rstrip(b"\n")) + 1
|
||
|
|
head_sink = new.split(b"\n").index(sink.rstrip(b"\n")) + 1
|
||
|
|
assert any(
|
||
|
|
m.base_start_line <= base_sink <= m.base_end_line and m.head_start_line + base_sink - m.base_start_line == head_sink
|
||
|
|
for m in result.line_correspondences
|
||
|
|
)
|
||
|
|
assert not any(s.side == "head" and s.start_line <= head_sink <= s.end_line for s in result.spans)
|
||
|
|
assert result.relationships == () # Snapshot evidence cannot invent semantic call/guard edges.
|
||
|
|
assert type(result).from_payload(result.to_payload()) == result
|
||
|
|
|
||
|
|
|
||
|
|
def test_change_context_nul_paths_renames_deletions_and_declarations(repo: Path) -> None:
|
||
|
|
old_path, new_path = b"old\n\xff.js", b"new\t\xfe.js"
|
||
|
|
payload = b"function unchanged() {\n uniqueOperation();\n}\n"
|
||
|
|
base = binary_commit(
|
||
|
|
repo, [(b"100644", old_path, payload), (b"100644", b" ", b"deleted\n"), (b"100644", b"package.json", b'{"mode":"old"}\n')]
|
||
|
|
)
|
||
|
|
head = binary_commit(repo, [(b"100644", new_path, payload), (b"100644", b"package.json", b'{"mode":"new"}\n')])
|
||
|
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=(b"package.json",))
|
||
|
|
assert {(r.base_path, r.head_path) for r in result.renames} == {(old_path.hex(), new_path.hex())}
|
||
|
|
assert result.deleted_paths == (b" ".hex(),)
|
||
|
|
assert result.declaration_paths == (b"package.json".hex(),)
|
||
|
|
assert any(m.base_path == old_path.hex() and m.head_path == new_path.hex() for m in result.line_correspondences)
|
||
|
|
assert any(s.path == b" ".hex() and s.side == "base" for s in result.spans)
|
||
|
|
|
||
|
|
|
||
|
|
def test_change_context_unknown_base_binary_and_ambiguous_lines_are_unresolved(repo: Path) -> None:
|
||
|
|
base = binary_commit(repo, [(b"100644", b"api.js", b"old();\nsink();\nsink();\n"), (b"100644", b"binary", b"\x00old")])
|
||
|
|
head = binary_commit(repo, [(b"100644", b"api.js", b"new();\nsink();\nsink();\n"), (b"100644", b"binary", b"\x00new")])
|
||
|
|
missing = SnapshotAdapter(repo).compare(comparison(None, head))
|
||
|
|
assert "comparison_base_unavailable" in missing.unresolved_boundaries
|
||
|
|
result = SnapshotAdapter(repo).compare(comparison(base, head))
|
||
|
|
assert "declaration_paths_unavailable" in result.unresolved_boundaries
|
||
|
|
assert any("ambiguous_line_correspondence" in b for b in result.unresolved_boundaries)
|
||
|
|
assert any("binary_line_correspondence_unavailable" in b for b in result.unresolved_boundaries)
|
||
|
|
assert not result.line_correspondences
|
||
|
|
|
||
|
|
|
||
|
|
def test_change_context_is_bounded_and_never_executes_diff_helpers(repo: Path) -> None:
|
||
|
|
from openultrasast.push.snapshot import SnapshotLimits
|
||
|
|
|
||
|
|
marker = repo / "DIFF_RAN"
|
||
|
|
git(repo, "config", "diff.external", "touch " + str(marker))
|
||
|
|
git(repo, "config", "diff.trap.textconv", "touch " + str(marker))
|
||
|
|
(repo / ".gitattributes").write_text("* diff=trap\n")
|
||
|
|
base, head = commit(repo, "old\n"), commit(repo, "new\n")
|
||
|
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=())
|
||
|
|
assert result.spans
|
||
|
|
assert not marker.exists()
|
||
|
|
limited = SnapshotAdapter(repo).compare(comparison(base, head), limits=SnapshotLimits(max_blob_bytes=1))
|
||
|
|
assert any("source_byte_limit" in b for b in limited.unresolved_boundaries)
|
||
|
|
cancelled = SnapshotAdapter(repo).compare(comparison(base, head), cancelled=lambda: True)
|
||
|
|
assert "cancelled" in cancelled.unresolved_boundaries
|
||
|
|
|
||
|
|
|
||
|
|
def test_change_context_moved_repeated_operations_do_not_claim_identity(repo: Path) -> None:
|
||
|
|
base = binary_commit(repo, [(b"100644", b"api.js", b"function a() {\n sink();\n}\nfunction b() {\n sink();\n}\n")])
|
||
|
|
head = binary_commit(repo, [(b"100644", b"api.js", b"function b() {\n sink();\n}\nfunction renamed() {\n sink();\n}\n")])
|
||
|
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=())
|
||
|
|
assert any("ambiguous_line_correspondence" in b for b in result.unresolved_boundaries)
|
||
|
|
assert not any(m.base_start_line in (2, 5) for m in result.line_correspondences)
|
||
|
|
assert result.decode_path(result.changed_paths[0]) == "api.js"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("limit", ["max_total_bytes", "max_diff_bytes", "max_mapping_lines", "max_files"])
|
||
|
|
def test_change_context_total_limits_are_explicit(repo: Path, limit: str) -> None:
|
||
|
|
from openultrasast.push.snapshot import SnapshotLimits
|
||
|
|
|
||
|
|
base = binary_commit(repo, [(b"100644", b"a.js", b"old\n"), (b"100644", b"b.js", b"old2\n")])
|
||
|
|
head = binary_commit(repo, [(b"100644", b"a.js", b"new\n"), (b"100644", b"b.js", b"new2\n")])
|
||
|
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=(), limits=SnapshotLimits(**{limit: 1}))
|
||
|
|
assert result.unresolved_boundaries
|
||
|
|
assert not result.line_correspondences
|
||
|
|
|
||
|
|
|
||
|
|
def test_change_context_missing_blob_and_invalid_revisions_are_not_clean(repo: Path) -> None:
|
||
|
|
base, head = commit(repo, "old\n"), commit(repo, "new\n")
|
||
|
|
blob = git(repo, "rev-parse", base + ":file.js")
|
||
|
|
(repo / ".git/objects" / blob[:2] / blob[2:]).unlink()
|
||
|
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=())
|
||
|
|
assert result.unresolved_boundaries
|
||
|
|
assert not result.spans and not result.line_correspondences
|
||
|
|
result = SnapshotAdapter(repo).compare(comparison("HEAD", head), declaration_paths=())
|
||
|
|
assert result.unresolved_boundaries == ("comparison_requires_immutable_commit_oid",)
|
||
|
|
|
||
|
|
|
||
|
|
def test_change_context_lf_lines_and_final_newline_changes(repo: Path) -> None:
|
||
|
|
# CR, vertical tab and Unicode line separators are source bytes, not LF lines.
|
||
|
|
before = b"first\x0b\xe2\x80\xa8\rline\noperation();\n"
|
||
|
|
after = b"changed\x0b\xe2\x80\xa8\rline\noperation();\n"
|
||
|
|
base = binary_commit(repo, [(b"100644", b"api.js", before)])
|
||
|
|
head = binary_commit(repo, [(b"100644", b"api.js", after)])
|
||
|
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=())
|
||
|
|
assert [(s.start_line, s.end_line) for s in result.spans] == [(1, 1), (1, 1)]
|
||
|
|
assert [(m.base_start_line, m.head_start_line) for m in result.line_correspondences] == [(2, 2)]
|
||
|
|
no_newline = binary_commit(repo, [(b"100644", b"api.js", after[:-1])])
|
||
|
|
result = SnapshotAdapter(repo).compare(comparison(head, no_newline), declaration_paths=())
|
||
|
|
assert any(s.side == "head" and s.start_line == 2 for s in result.spans)
|
||
|
|
assert not any(m.head_start_line == 2 for m in result.line_correspondences)
|
||
|
|
|
||
|
|
|
||
|
|
def test_change_context_zero_changes_are_real_equal_objects(repo: Path) -> None:
|
||
|
|
oid = commit(repo, "source read by Git\n")
|
||
|
|
result = SnapshotAdapter(repo).compare(comparison(oid, oid), declaration_paths=())
|
||
|
|
assert result.changed_paths == result.spans == result.unresolved_boundaries == ()
|
||
|
|
|
||
|
|
|
||
|
|
def test_change_context_generic_hex_contract_validation(repo: Path) -> None:
|
||
|
|
import os
|
||
|
|
from dataclasses import replace
|
||
|
|
|
||
|
|
oid = binary_commit(repo, [(b"100644", b" \xff", b"a\n")])
|
||
|
|
new = binary_commit(repo, [(b"100644", b" \xff", b"b\n")])
|
||
|
|
context = SnapshotAdapter(repo).compare(comparison(oid, new), declaration_paths=())
|
||
|
|
assert os.fsencode(context.decode_path(context.changed_paths[0])) == b" \xff"
|
||
|
|
for invalid in ("zz", "00", "FF", "20 ff"):
|
||
|
|
with pytest.raises(ValueError):
|
||
|
|
replace(context, changed_paths=(invalid,))
|
||
|
|
|
||
|
|
|
||
|
|
def test_change_context_unmatched_move_is_not_proven_new_behavior(repo: Path) -> None:
|
||
|
|
base = binary_commit(repo, [(b"100644", b"old.js", b"function old() {\n guard();\n sink();\n}\n")])
|
||
|
|
head = binary_commit(repo, [(b"100644", b"new.js", b"function renamed() {\n sink();\n newCall();\n}\n")])
|
||
|
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=())
|
||
|
|
assert not result.renames
|
||
|
|
assert "unmatched_added_deleted_path_correspondence" in result.unresolved_boundaries
|
||
|
|
assert {span.side for span in result.spans} == {"base", "head"}
|
||
|
|
|
||
|
|
|
||
|
|
def test_change_context_ambiguous_identical_file_renames_preserve_gap(repo: Path) -> None:
|
||
|
|
payload = b"function original() {\n operation();\n}\n"
|
||
|
|
base = binary_commit(repo, [(b"100644", b"a.js", payload), (b"100644", b"b.js", payload)])
|
||
|
|
head = binary_commit(repo, [(b"100644", b"c.js", payload), (b"100644", b"d.js", payload)])
|
||
|
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=())
|
||
|
|
assert result.renames # Git's attribution retained, with its ambiguity alongside.
|
||
|
|
assert not result.line_correspondences
|
||
|
|
assert any("ambiguous_path_rename_correspondence" in b for b in result.unresolved_boundaries)
|