-
Notifications
You must be signed in to change notification settings - Fork 41
fix(pstack): make the upstream merge lever refuse drift and report unsafe rows #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| #!/usr/bin/env python3 | ||
| """Prove scripts/upstream-merge.py refuses unsafe runs and applies safe ones. | ||
|
|
||
| Builds throwaway worktrees at the audited port commit, feeds the real audit | ||
| and relabeled variants through the merge, and asserts what changed on disk. | ||
|
|
||
| python3 scripts/upstream-audit.py --port <sha> --upstream <sha> > audit.json | ||
| python3 scripts/upstream-merge-probe.py audit.json | ||
| """ | ||
| import copy | ||
| import json | ||
| import os | ||
| import shutil | ||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
|
|
||
| ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | ||
| MERGE = os.path.join(ROOT, "scripts", "upstream-merge.py") | ||
|
|
||
|
|
||
| def sh(*args, cwd, check=True): | ||
| return subprocess.run(args, cwd=cwd, capture_output=True, text=True, check=check) | ||
|
|
||
|
|
||
| def worktree(commit): | ||
| path = tempfile.mkdtemp(prefix="merge-probe-") | ||
| os.rmdir(path) | ||
| sh("git", "worktree", "add", "--detach", "-q", path, commit, cwd=ROOT) | ||
| return path | ||
|
|
||
|
|
||
| def run(audit, tree): | ||
| audit_path = os.path.join(tree, "probe-audit.json") | ||
| json.dump(audit, open(audit_path, "w")) | ||
| result = sh("python3", MERGE, audit_path, cwd=tree, check=False) | ||
| os.remove(audit_path) | ||
| return result.returncode, result.stdout + result.stderr | ||
|
|
||
|
|
||
| def changed(tree): | ||
| return sh("git", "status", "--porcelain", "--untracked-files=all", cwd=tree).stdout.strip() | ||
|
|
||
|
|
||
| def check(name, condition, detail=""): | ||
| print(f"{'ok' if condition else 'FAIL'}: {name}{'' if condition else ' ' + detail}") | ||
| return condition | ||
|
|
||
|
|
||
| def main(audit_path): | ||
| audit = json.load(open(audit_path)) | ||
| port = audit["port_commit"] | ||
| results = [] | ||
| trees = [] | ||
| try: | ||
| tree = worktree(port); trees.append(tree) | ||
| code, out = run(audit, tree) | ||
| results.append(check("real range merges (exit 1 for hand review)", code == 1 and "verbatim 24, clean merge 32, needs review 32, removed 2" in out, out.splitlines()[0] if out else "")) | ||
|
|
||
| tree = worktree(f"{port}~1"); trees.append(tree) | ||
| code, out = run(audit, tree) | ||
| results.append(check("stale HEAD refused, nothing written", code == 2 and changed(tree) == "", out)) | ||
|
|
||
| tree = worktree(port); trees.append(tree) | ||
| dirty = next(c["port_path"] for c in audit["changes"] if c["comparison"] == "unchanged-since-base") | ||
| open(os.path.join(tree, dirty), "a").write("\nlocal edit\n") | ||
| code, out = run(audit, tree) | ||
| results.append(check("dirty mapped path refused, edit kept", code == 2 and "local edit" in open(os.path.join(tree, dirty)).read(), out)) | ||
|
|
||
| tree = worktree(port); trees.append(tree) | ||
| variant = copy.deepcopy(audit) | ||
| variant["changes"] = [c for c in variant["changes"] if c["change"] != "modify"] | ||
| for c in variant["changes"]: | ||
| c["comparison"] = "port-diverged-review" | ||
| code, out = run(variant, tree) | ||
| results.append(check("diverged add/delete reported, nothing written", code == 1 and changed(tree) == "" and out.count("review upstream") == len(variant["changes"]), out)) | ||
|
|
||
| tree = worktree(port); trees.append(tree) | ||
| variant = copy.deepcopy(audit) | ||
| row = copy.deepcopy(next(c for c in variant["changes"] if c["change"] == "modify" and c["port_path"])) | ||
| row["comparison"] = "absent-from-port-review-exclusion" | ||
| row["port_path"] = "plugins/pstack/skills/make-bot-ui/SKILL.md" | ||
| variant["changes"] = [row] | ||
| code, out = run(variant, tree) | ||
| results.append(check("excluded path reported without crash or directory", code == 1 and "excluded path" in out and not os.path.exists(os.path.join(tree, "plugins/pstack/skills/make-bot-ui")), out)) | ||
|
|
||
| tree = worktree(port); trees.append(tree) | ||
| variant = copy.deepcopy(audit) | ||
| conflicted = next(c for c in variant["changes"] if c["comparison"] == "port-diverged-review" and c["change"] == "modify") | ||
| variant["changes"] = [conflicted] | ||
| sibling = os.path.join(tree, conflicted["port_path"] + ".upstream-base") | ||
| open(sibling, "w").write("keep me") | ||
| code, out = run(variant, tree) | ||
| results.append(check("sibling temp-name file untouched", os.path.exists(sibling) and open(sibling).read() == "keep me", out)) | ||
|
|
||
| tree = worktree(port); trees.append(tree) | ||
| variant = copy.deepcopy(audit) | ||
| row = copy.deepcopy(next(c for c in variant["changes"] if c["comparison"] == "unchanged-since-base")) | ||
| row["target"]["mode"] = "100755" | ||
| variant["changes"] = [row] | ||
| code, out = run(variant, tree) | ||
| results.append(check("executable bit applied from target mode", code == 0 and os.access(os.path.join(tree, row["port_path"]), os.X_OK), out)) | ||
|
|
||
| tree = worktree(port); trees.append(tree) | ||
| variant = copy.deepcopy(audit) | ||
| row = copy.deepcopy(next(c for c in variant["changes"] if c["change"] == "add")) | ||
| row["comparison"] = "already-matches-target" | ||
| variant["changes"] = [row] | ||
| code, out = run(variant, tree) | ||
| results.append(check("already-matching addition is a no-op", code == 0 and changed(tree) == "", out)) | ||
| tree = worktree(port); trees.append(tree) | ||
| variant = copy.deepcopy(audit) | ||
| variant["changes"] = [c for c in variant["changes"] if c["port_path"] is None] | ||
| open(os.path.join(tree, "audit.json"), "w").write("{}") | ||
| code, out = run(variant, tree) | ||
| results.append(check("all-unmapped audit is not refused by the untracked audit file", code == 0 and "unmapped" in out, out)) | ||
|
|
||
| tree = worktree(port); trees.append(tree) | ||
| variant = copy.deepcopy(audit) | ||
| row = copy.deepcopy(next(c for c in variant["changes"] if c["comparison"] == "port-diverged-review" and c["change"] == "modify")) | ||
| row["target"]["mode"] = "100755" | ||
| variant["changes"] = [row] | ||
| binary = os.path.join(tree, row["port_path"]) | ||
| open(binary, "wb").write(b"\x00\x01 binary local copy\n") | ||
| sh("git", "update-index", "--assume-unchanged", row["port_path"], cwd=tree) | ||
| code, out = run(variant, tree) | ||
| results.append(check("binary merge-file failure reported, no mode change", "merge-file failed" in out and not os.access(binary, os.X_OK), out)) | ||
| finally: | ||
| for tree in trees: | ||
| sh("git", "worktree", "remove", "--force", tree, cwd=ROOT, check=False) | ||
| shutil.rmtree(tree, ignore_errors=True) | ||
| return 0 if all(results) else 1 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main(sys.argv[1])) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,12 @@ | ||
| #!/usr/bin/env python3 | ||
| """Apply an upstream pstack range to the port tree mechanically. | ||
|
|
||
| Reads the JSON that scripts/upstream-audit.py prints. For each mapped | ||
| modification it either checks out the upstream blob (the port still matches | ||
| the old upstream blob) or runs a three-way `git merge-file` in place, leaving | ||
| conflict markers for the hand pass. Additions are copied; deletions are | ||
| removed. Unmapped paths are listed and left alone. | ||
| Reads the JSON that scripts/upstream-audit.py prints. For each mapped path it | ||
| either checks out the upstream blob (the port still matches the old upstream | ||
| blob), runs a three-way `git merge-file` in place and leaves conflict markers | ||
| for the hand pass, or reports the row for review without touching the tree. | ||
| Refuses to run unless HEAD is the audited port commit and the mapped paths are | ||
| clean, so an audit never overwrites work done after it was taken. | ||
|
|
||
| python3 scripts/upstream-audit.py --port <sha> --upstream <sha> > audit.json | ||
| python3 scripts/upstream-merge.py audit.json | ||
|
|
@@ -14,55 +15,102 @@ | |
| import os | ||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
|
|
||
| REGULAR_MODES = {"100644", "100755"} | ||
|
|
||
|
|
||
| def git(*args, **kwargs): | ||
| return subprocess.run(["git", *args], capture_output=True, check=True, **kwargs).stdout | ||
|
|
||
|
|
||
| def blob(rev, path): | ||
| return subprocess.run(["git", "show", f"{rev}:{path}"], capture_output=True, check=True).stdout | ||
| return git("show", f"{rev}:{path}") | ||
|
|
||
|
|
||
| def apply_mode(path, mode): | ||
| executable = mode == "100755" | ||
| current = os.stat(path).st_mode | ||
| os.chmod(path, (current | 0o111) if executable else (current & ~0o111)) | ||
|
|
||
|
|
||
| def refuse_drift(audit, ports): | ||
| head = git("rev-parse", "HEAD").decode().strip() | ||
| if head != audit["port_commit"]: | ||
| return f"HEAD {head[:12]} is not the audited port commit {audit['port_commit'][:12]}" | ||
| if not ports: | ||
| return None | ||
| dirty = git("status", "--porcelain", "--untracked-files=all", "--", *ports).decode().strip() | ||
| if dirty: | ||
| return "mapped paths have local changes:\n" + dirty | ||
| return None | ||
|
|
||
|
|
||
| def merge_three_way(port, old, new): | ||
| with tempfile.TemporaryDirectory(prefix="upstream-merge-") as tmp: | ||
| base_path, new_path = os.path.join(tmp, "base"), os.path.join(tmp, "target") | ||
| open(base_path, "wb").write(old) | ||
| open(new_path, "wb").write(new) | ||
| return subprocess.run( | ||
| ["git", "merge-file", "-L", "port", "-L", "upstream-base", "-L", "upstream-target", port, base_path, new_path] | ||
| ).returncode | ||
|
|
||
|
|
||
| def main(audit_path): | ||
| audit = json.load(open(audit_path)) | ||
| base, target = audit["upstream_base"], audit["upstream_target"] | ||
| verbatim, clean, conflicted, removed, skipped = [], [], [], [], [] | ||
| mapped = [c for c in audit["changes"] if c["port_path"] is not None] | ||
| drift = refuse_drift(audit, [c["port_path"] for c in mapped]) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When an audit contains only unmapped upstream changes,
openswebot[bot] marked this conversation as resolved.
|
||
| if drift: | ||
| print(f"refusing to run: {drift}") | ||
| return 2 | ||
| verbatim, clean, review, removed, skipped = [], [], [], [], [] | ||
| for change in audit["changes"]: | ||
| up, port = change["upstream_path"], change["port_path"] | ||
| up, port, comparison = change["upstream_path"], change["port_path"], change["comparison"] | ||
| if port is None: | ||
| skipped.append(up) | ||
| continue | ||
| comparison = change["comparison"] | ||
| if comparison == "already-matches-target": | ||
| continue | ||
| if comparison == "absent-from-port-review-exclusion": | ||
| review.append((port, f"upstream {change['change']}d an excluded path")) | ||
| continue | ||
| if change["change"] == "delete": | ||
| if comparison not in ("unchanged-since-base", "already-matches-target"): | ||
| conflicted.append((port, f"upstream deleted a port-edited file ({comparison})")) | ||
| if comparison != "unchanged-since-base": | ||
| review.append((port, f"upstream deleted a port-edited file ({comparison})")) | ||
| continue | ||
| if os.path.exists(port): | ||
| os.remove(port) | ||
| os.remove(port) | ||
| removed.append(port) | ||
| continue | ||
| mode = change["target"]["mode"] | ||
| if mode not in REGULAR_MODES: | ||
| review.append((port, f"upstream entry mode {mode} is not a regular file")) | ||
| continue | ||
| new = blob(target, up) | ||
| if change["change"] == "add" and comparison != "upstream-addition": | ||
| conflicted.append((port, f"upstream added a path the port already has ({comparison})")) | ||
| review.append((port, f"upstream added a path the port already has ({comparison})")) | ||
| continue | ||
| if change["change"] == "add" or comparison == "unchanged-since-base": | ||
| os.makedirs(os.path.dirname(port) or ".", exist_ok=True) | ||
| open(port, "wb").write(new) | ||
| apply_mode(port, mode) | ||
| verbatim.append(port) | ||
| continue | ||
| old = blob(base, up) | ||
| tmp_base, tmp_new = port + ".upstream-base", port + ".upstream-target" | ||
| open(tmp_base, "wb").write(old) | ||
| open(tmp_new, "wb").write(new) | ||
| result = subprocess.run( | ||
| ["git", "merge-file", "-L", "port", "-L", "upstream-base", "-L", "upstream-target", port, tmp_base, tmp_new] | ||
| ) | ||
| os.remove(tmp_base) | ||
| os.remove(tmp_new) | ||
| (clean if result.returncode == 0 else conflicted).append((port, result.returncode)) | ||
| print(f"verbatim {len(verbatim)}, clean merge {len(clean)}, conflicted {len(conflicted)}, removed {len(removed)}, unmapped {len(skipped)}") | ||
| for port, why in conflicted: | ||
| print(f"conflict {why} {port}") | ||
| hunks = merge_three_way(port, blob(base, up), new) | ||
| if hunks < 0 or hunks > 127: | ||
| review.append((port, f"git merge-file failed with status {hunks}")) | ||
| continue | ||
| apply_mode(port, mode) | ||
| if hunks: | ||
| review.append((port, f"{hunks} conflict hunks")) | ||
| else: | ||
| clean.append(port) | ||
| print(f"verbatim {len(verbatim)}, clean merge {len(clean)}, needs review {len(review)}, removed {len(removed)}, unmapped {len(skipped)}") | ||
| for port, why in review: | ||
| print(f"review {why}: {port}") | ||
| for path in skipped: | ||
| print(f"unmapped {path}") | ||
| return 1 if conflicted else 0 | ||
| return 1 if review else 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 Ignored files bypass the drift guard
git status --porcelain --untracked-files=allomits ignored paths, so this can report a mapped addition as clean even when a local ignored file (or symlink) already occupies its destination. The lateropen(port, "wb")then truncates that local path. I reproduced this with a validupstream-additionaudit and an exact-path rule in.git/info/exclude: the merge exited 0 and replacedlocal secretwith the upstream blob. This breaks the new no-overwrite safety guarantee; the preflight needs to detect filesystem occupancy for additions even when Git ignores it.(Refers to line 41)
Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current head removed the occupancy guard from the intermediate revision. I reproduced the original failure again: an ignored mapped addition is treated as clean and
local secretis overwritten by the upstream blob, so this remains open.