fix(artifact): reconcile imported hashes against git on bootstrap (1/3) - #868
fix(artifact): reconcile imported hashes against git on bootstrap (1/3)#868moofone wants to merge 4 commits into
Conversation
Artifact bootstrap byte-copies a teammate's DB into the local cache, but the imported file_hashes rows carry the exporter's mtime_ns. A fresh clone stamps every file with checkout time, so classify_files (mtime_ns + size only) marks ~every file changed and the first incremental run re-parses the whole repo -- the exact run artifact bootstrap exists to make cheap. Add cbm_artifact_reconcile_hashes(), called from try_artifact_bootstrap right after a successful cbm_artifact_import. It re-stamps the hash rows of files git reports unchanged between the artifact's commit and the local working tree with local stat() values, so the existing, untouched classify_files logic then classifies correctly. Zero changes to the incremental pipeline itself. Trust gate (so a stale/corrupt artifact can never mark a genuinely changed file unchanged -> graph corruption): cbm_artifact_export now writes an optional "reconcile_basis":"git-clean-head" marker into the existing artifact.json ONLY when it can prove the DB matches a clean checked-out tree at `commit` -- commit is a validated hex OID, the working tree has no changes outside .codebase-memory, and every file_hashes row's on-disk mtime+size matches. Reconciliation requires the marker and skips (returns -1) on any doubt: no git, untrusted/missing marker, non-hex/unknown commit, shallow clone, or any popen or parse uncertainty. A skip leaves rows foreign and falls back to today's slow-safe full incremental. No schema_version bump (older binaries ignore the new optional field). Security: commit is hex-validated before command construction and repo_path is shell-validated via cbm_validate_shell_arg (same pattern as git_context.c / watcher.c); git output is parsed as NUL-delimited (-z) directly, never via line-oriented parsing, so paths with newlines/quotes are handled. New popen call site added to scripts/security-allowlist.txt. Reuses two pieces of existing dead/unused code: cbm_artifact_commit() (was never called in production) and cbm_store_upsert_file_hash_batch() (existed but was never called in production -- reconciliation persists all restamped rows in one transaction). Tests (tests/test_artifact.c, following the existing suite's structure): export sets the marker on a clean tree and drops it when dirty; reconcile restamps unchanged rows and leaves changed rows foreign; reconcile skips (-1, rows untouched) on untrusted metadata, unknown commit, and no-git, plus null-arg safety. Signed-off-by: Greg Tiller <tiller@dal.ca>
|
Thanks for splitting this out from #867. Triage: performance/correctness bug, review first in the #868 -> #869 -> #870 stack. Because this touches artifact import and the security allowlist file scripts/security-allowlist.txt, review will be a bit cautious: git trust boundaries, path handling, unchanged-file detection, and the necessity/scope of the allowlist update all need to be checked. The focused test coverage in this PR is the right shape. |
git diff/ls-files are blind to gitignored files, but a gitignored file can still be indexed (.cbmignore negation un-skipping a generated dir, DeusData#500) and thus carry a file_hashes row. Without a tracked-set gate such a row would be restamped as "unchanged" even though git cannot vouch for its content, leaving stale graph data after bootstrap. Reconciliation now also captures git ls-tree -r -z --name-only <commit> and restamps only rows tracked at the artifact commit AND absent from the changed set; everything git cannot vouch for stays foreign and is re-parsed. Same validation funnel (hex-validated commit, shell-validated repo path, NUL- delimited parse with truncation guard); allowlist entry text extended to mention ls-tree. Adds regression test artifact_reconcile_skips_untracked_rows. Refs DeusData#867 Signed-off-by: Greg Tiller <tiller@dal.ca>
|
Thanks for the triage focus areas — here's a map of each to the code, plus one hardening commit pushed in response. New commit Concern → where it's handled: Git trust boundaries
Path handling
Unchanged-file detection
Allowlist necessity/scope
Also filed #885 for the cross-path bootstrap limitation noted in #867's deferred list (pre-existing, caps this PR's team-sharing benefit until fixed). Full ASan/UBSan suite green with the new commit. Happy to adjust the trust-marker convention if you'd prefer a different shape for point (a). |
CI lint (clang-format-20 --dry-run --Werror) flagged comment alignment and line-break style in the P1 additions. Formatting only; no code changes. Refs DeusData#867 Signed-off-by: Greg Tiller <tiller@dal.ca>
|
Thanks — this is an excellent writeup and the design (trust-gated reconcile, skip-on-doubt, -z parsing, shell-validated paths) reads exactly right. Two things block the merge:
One more nit while you're in there: Happy to merge once green — parts 2/3 (#869, #870) will follow after this one lands. |
|
Thanks @moofone — reviewed the whole P1→P2→P3 stack (#868/#869/#870) together. The security posture is clean across all three (careful git-shell hygiene: hex-validated OIDs,
Really appreciate the depth here — let's get the harness green + rebased and we'll take it from there. |
|
Reviewed all three PRs in this stack together. First, an apology that matters more than the review: you asked for design sign-off in #867 on 5 July and have been waiting roughly four weeks. You did exactly the right thing — raised the trust-model question before building on it — and we left you without an answer while main moved underneath you. That is our failure, not yours. This is one of the strongest external stacks we have received. Concretely: a real, measured problem (bootstrap re-parsing nearly everything it just imported), a tracking issue asking for sign-off before landing, and security hygiene on every git shell-out that is better than a lot of our own code — The stack structure, confirmed by commit-OID comparison: these literally stack. #869 contains all of #868's commits, and #870 contains all of both. Since all three target Two decisions are the maintainer's, and both are genuinely yours to have asked about. 1. The 2. The debounce policy and env surface (#870). I have put both in front of the maintainer explicitly. Please do not spend time rebasing until those are answered — the stack's fate hangs on the first one, and it would be unfair to have you resolve four weeks of drift for a design that might not land. Meanwhile, three things worth knowing. Your CI red is not your production code. Main moved under you, and it changes #869. Commit #870 needs re-targeting: the incremental re-export moved to One test note for #869 when it does get reworked: And an offer. Given all three conflict with a heavily-reworked main, if you would rather not carry that rebase, we can distill the approved design points onto main ourselves with Answers on #867 coming. |
What & why
After artifact bootstrap,
try_artifact_bootstrapbyte-copies a teammate's DB into the local cache, but the importedfile_hashesrows carry the exporter'smtime_ns. A fresh clone stamps every file with checkout time, soclassify_files(mtime+size only) marks ~every file changed and the first incremental run re-parses the whole repo — the exact run artifact bootstrap exists to make cheap.Change
Add
cbm_artifact_reconcile_hashes()insrc/pipeline/artifact.c(+artifact.h), called fromtry_artifact_bootstrapimmediately after a successfulcbm_artifact_import. It re-stamps thefile_hashesrows of files git reports unchanged between the artifact's commit and the local working tree, using localstat()values. The existing, untouchedclassify_fileslogic then classifies correctly. Zero changes to the incremental pipeline itself.Reuses two pieces of existing dead code:
cbm_artifact_commit()(never called in production) andcbm_store_upsert_file_hash_batch()(existed, never called in production — reconciliation persists all restamped rows in one transaction).Trust gate (the design point flagged in #867)
cbm_artifact_exportnow writes an optional"reconcile_basis":"git-clean-head"field to the existingartifact.json, only when export can prove the DB matches a clean checked-out tree atcommit:commitis a valid hex OID (40/64-char) fromgit rev-parse HEAD;.codebase-memory/(:(exclude)pathspec);file_hashesrow's on-diskmtime_ns + sizematches the stored stamp (belt-and-suspenders that catches a stale/swapped DB even when the tree looks clean).Reconciliation requires the marker and the commit to be present + hex-valid + resolvable locally, and skips (returns -1) on any doubt: no git, untrusted/missing marker, non-hex/unknown commit, shallow clone, or any popen/parse uncertainty. A skip leaves rows foreign → today's slow-safe full incremental. No
schema_versionbump (older binaries ignore the unknown field).Security
commitis hex-validated before any command construction;repo_pathis shell-validated viacbm_validate_shell_arg(same pattern asgit_context.c/watcher.c); git output is parsed as NUL-delimited (-z) directly (never line-oriented), so paths with newlines/quotes are handled. A non-empty-zbuffer not ending in NUL is treated as corrupt → skip. Newcbm_popencall site added toscripts/security-allowlist.txtwith justification.Tests (
tests/test_artifact.c, following the existing suite)artifact_export_marks_clean_basis— marker set on a clean export, dropped when the tree is dirty.artifact_reconcile_restamps_unchanged— full-index → export → clone → modify 2 + add 1 → import → reconcile restamps unchanged rows to local mtime, leaves changed rows foreign (restamped == 3).artifact_reconcile_skips_untrusted_metadata— strip the marker →-1, rows untouched (regression guard for old/manual/dirty/stale artifacts).artifact_reconcile_skips_unknown_commit— valid-hex-but-absent commit →-1.artifact_reconcile_skips_without_git— no.git→-1.cbm_artifact_reconcile_hashes.All fixtures generated at runtime in tmpdirs; no committed fixtures.
scripts/test.sh(ASan+UBSan) green locally — full suite passes.Benchmark (S2 = bootstrap + first incremental, K = 7 changed)
changedchangedCandidate S2 wall is 4.8 % of baseline at N=8000 (~21× faster). After S2, candidate node/edge counts exactly equal a from-scratch full index of the same tree (500/2000/8000). Full matrix + raw CSV kept outside the repo.
Checklist
scripts/test.sh(ASan/UBSan) green.popen/cbm_popensite justified + added toscripts/security-allowlist.txt.scripts/lint.sh(clang-tidy/cppcheck/clang-format) — not installed locally; verified ≤100-col + style by hand, CI is the gate.Seeking maintainer sign-off on the
artifact.jsontrust-marker reuse (point (a) in #867). Happy to rework if a different convention is preferred.