From 547f355eaa19f63c2cabf512de92e2f9cfac5452 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 05:55:41 +0000 Subject: [PATCH 1/6] build(deps): bump actions/attest-build-provenance from 4.1.1 to 4.2.2 Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 4.1.1 to 4.2.2. - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/0f67c3f4856b2e3261c31976d6725780e5e4c373...4d101475d8b20a2381f78447822ac1eab6504dd8) --- updated-dependencies: - dependency-name: actions/attest-build-provenance dependency-version: 4.2.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/_build.yml | 10 +++++----- .github/workflows/release.yml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index c7031bd71..47b5b17b5 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -97,7 +97,7 @@ jobs: - name: Attest release candidate provenance if: ${{ inputs.attest }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: | release-candidates/${{ matrix.goos }}-${{ matrix.goarch }}/candidate-provenance.tsv @@ -155,7 +155,7 @@ jobs: - name: Attest release candidate provenance if: ${{ inputs.attest }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: | release-candidates/windows-amd64/candidate-provenance.tsv @@ -217,7 +217,7 @@ jobs: - name: Attest release candidate provenance if: ${{ inputs.attest }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: | release-candidates/windows-arm64/candidate-provenance.tsv @@ -280,7 +280,7 @@ jobs: - name: Attest release candidate provenance if: ${{ inputs.attest }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: | release-candidates/linux-${{ matrix.arch }}-portable/candidate-provenance.tsv @@ -432,7 +432,7 @@ jobs: - name: Attest selected containers and decision evidence if: ${{ inputs.attest }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: | release-containers/**/*.tar.gz diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0dba1720f..97fcee460 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -202,7 +202,7 @@ jobs: run: scripts/ci/append-legacy-alias-checksums.sh checksums.txt - name: Attest checksum provenance - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: checksums.txt From 7d896fee54f8a46b356d495feca3ec4082380d6b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 05:45:38 +0000 Subject: [PATCH 2/6] chore(deps): Bump github/codeql-action/upload-sarif Bumps [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) from 4.37.6 to 4.37.9. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...cdf488f595d80d6e07e03d4674febd5ab45fa938) --- updated-dependencies: - dependency-name: github/codeql-action/upload-sarif dependency-version: 4.37.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index e82043310..a208a62ca 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -27,6 +27,6 @@ jobs: publish_results: true - name: Upload SARIF results - uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 with: sarif_file: results.sarif From 614009481d9f7c938318be09c6f08679d9d700b9 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 28 Aug 2026 20:42:58 +0200 Subject: [PATCH 3/6] feat(artifact): reconcile imported hashes against git on bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An imported artifact carries the EXPORTER's mtime/size in every file_hashes row, so the first incremental run after a bootstrap sees every file as changed and re-parses ~the whole repository — most of the time the artifact was supposed to save. Reconcile the rows instead. After a successful import, ask the local git which files are unchanged between the artifact's commit and the working tree, and re-stamp exactly those rows with local stat() values. Everything git cannot vouch for stays foreign and is re-parsed. Export writes an optional "reconcile_basis":"git-clean-head" marker into the existing artifact.json, and only when the tree is provably clean outside .codebase-memory/ AND every hash row still matches disk. Unknown fields are ignored by older binaries, so no schema_version bump. Eligibility is deliberately narrow, and the polarity is skip-on-doubt: a row is restamped only if it is tracked at the artifact commit (git diff/ls-files are blind to ignored files — a .cbmignore negation can index a gitignored file, #500) and absent from the changed set. Any uncertainty — no git, missing/non-hex commit, shallow clone, truncated -z output, allocation failure — skips reconciliation entirely and falls back to today's behavior. Reconciliation can never fail the import. Windows correctness: every git argument uses DOUBLE quotes, per the rule already stated in artifact.c. cbm_popen runs cmd.exe /c on Windows, which does not honor single quotes, so a single-quoted ":(exclude)" pathspec made the clean-tree probe report dirty and the marker was never written there. The commit-existence probe uses `cat-file -t ` rather than `cat-file -e ^{commit}` because ^ is cmd.exe's escape character. Fail closed on allocation failure: both cbm_ht_create() results are checked, and every insertion is verified to have landed (cbm_ht_set discards the underlying insert's status, so OOM drops entries silently). A dropped entry in the changed set would make a genuinely modified file read as unchanged and be restamped — a silently stale graph. This is the one place where the membership-set primitive has unsafe polarity; classify_files uses it where a dropped entry only means "re-parse". Symlinks are refused rather than followed when stamping: git tracks a symlink's link text, not its target's content, so "unchanged" from git says nothing about the bytes the indexer parses. Uses the established lstat + S_ISLNK / reparse-point idiom (discover.c, pass_pkgmap.c, pass_envscan.c), which on Windows also keeps non-ASCII paths off the ANSI CRT. The trust trade-off is documented at both the import site and in artifact.h: foreign mtimes currently force a re-parse that auto-scrubs a poisoned artifact at an unpredictable clone time, so today's exposure is transient and self-healing. Restamping makes it durable and gates it on a producer-written string — acceptable only because the marker alone never suffices, as every restamped row must additionally be proven unchanged by the local git against a commit present in this clone. Also skip synthetic file_hashes rows. The pipeline stores semantic-input digests under .codebase-memory/.semantic-input/ that have no file on disk; stat()ing them made the export-side check fail unconditionally, so the marker was never written on ANY platform and the whole feature was inert. find_deleted_files skips the same class. Both halves of the trust check now ignore the .codebase-memory/ subtree, matching the clean-tree pathspec. Distilled from #868 by moofone; #869 and #870 are not included. Co-authored-by: moofone <35233167+moofone@users.noreply.github.com> Signed-off-by: Martin Vogel --- src/mcp/mcp.c | 23 +- src/pipeline/artifact.c | 605 +++++++++++++++++++++++++++++++++++++++- src/pipeline/artifact.h | 38 +++ tests/test_artifact.c | 410 +++++++++++++++++++++++++++ 4 files changed, 1065 insertions(+), 11 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 5e64fabdf..eeaeaece0 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -7698,7 +7698,28 @@ static void try_artifact_bootstrap(const char *project_name, const char *repo_pa project_db_path(project_name, db_buf, sizeof(db_buf)); if (cbm_file_size(db_buf) < 0 && cbm_artifact_exists(repo_path)) { cbm_log_info("index.artifact_bootstrap", "project", project_name); - cbm_artifact_import(repo_path, db_buf); + /* An imported artifact is trusted for graph CONTENT as-is — nothing + * verifies that its nodes/edges describe the code they claim to. What + * has been limiting the blast radius is mechanical, not a check: every + * imported row carries the EXPORTER's mtime, so the first incremental + * run re-parses ~everything and auto-scrubs a poisoned artifact at a + * clone time the producer cannot predict. That exposure is transient + * and self-healing. + * + * cbm_artifact_reconcile_hashes deliberately trades part of that away + * for the speed the artifact is supposed to deliver (#885): rows it + * restamps are no longer re-parsed, so poisoned nodes for those files + * persist until the file changes — a DURABLE exposure gated on a + * producer-written marker. It stays acceptable only because the marker + * alone never suffices: each restamped row must additionally be proven + * unchanged by the LOCAL git against a commit present in this clone. + * Read the trade-off note in artifact.h before widening it. + * + * Best-effort: a -1 return leaves every row foreign and falls back to + * today's behavior, so a failure here can never fail the import. */ + if (cbm_artifact_import(repo_path, db_buf) == 0) { + (void)cbm_artifact_reconcile_hashes(repo_path, db_buf, project_name); + } } } diff --git a/src/pipeline/artifact.c b/src/pipeline/artifact.c index 94fcf7263..3a6bd485c 100644 --- a/src/pipeline/artifact.c +++ b/src/pipeline/artifact.c @@ -10,8 +10,11 @@ enum { ART_DIR_PERMS = 0755, ART_ZSTD_FAST = 3, ART_ZSTD_BEST = 9, - ART_RATIO_SCALE = 10, /* multiply ratio by 10 for integer logging */ - ART_NUL = 1, /* NUL terminator byte */ + ART_RATIO_SCALE = 10, /* multiply ratio by 10 for integer logging */ + ART_NUL = 1, /* NUL terminator byte */ + ART_NS_PER_SEC = 1000000000, /* nanoseconds per second (mtime_ns helper) */ + ART_OID_SHA1_LEN = 40, /* hex length of a SHA-1 git object id */ + ART_OID_SHA256_LEN = 64, /* hex length of a SHA-256 git object id */ }; #define ART_BYTES_PER_MB ((size_t)1024 * 1024) @@ -28,7 +31,8 @@ enum { #include "foundation/compat_fs.h" #include "foundation/compat.h" #include "foundation/log.h" -#include "foundation/str_util.h" /* cbm_validate_shell_arg — git shell-out hardening */ +#include "foundation/str_util.h" /* cbm_validate_shell_arg — git shell-out hardening */ +#include "foundation/hash_table.h" /* CBMHashTable — reconcile membership sets */ #include "zstd_store.h" @@ -46,6 +50,7 @@ enum { #include #ifdef _WIN32 #include +#include "foundation/win_utf8.h" /* cbm_path_to_wide */ #endif /* ── Helpers ──────────────────────────────────────────────────────── */ @@ -267,6 +272,314 @@ static void iso_timestamp(char *buf, size_t bufsz) { (void)strftime(buf, bufsz, "%Y-%m-%dT%H:%M:%SZ", &tm); } +/* ── Git + trust helpers for bootstrap reconciliation ───────────── + * + * SHELL QUOTING RULE (see cbm_artifact_repo_path_is_shell_safe above): every + * git command built here is run through cbm_popen, which on Windows executes + * `cmd.exe /c `. cmd.exe does NOT honor single quotes — it passes them + * through to git as literal characters — so a single-quoted argument silently + * becomes part of the value and the command misbehaves rather than failing + * loudly. DOUBLE quotes are honored by both POSIX sh and cmd.exe and are the + * only form used below. For the same reason no argument here contains `^`: + * that is cmd.exe's escape character. */ + +/* Non-NULL sentinel value stored in a membership hash set (key presence is all + * that matters; the value is never dereferenced). */ +static int g_reconcile_sentinel; + +/* Validate s is a hex git object id: 40 chars (SHA-1) or 64 chars (SHA-256). + * Repo-controlled strings reach this check, so it is a hard gate before any + * commit value is interpolated into a git command string. */ +static bool is_hex_oid(const char *s) { + if (!s) { + return false; + } + size_t len = strlen(s); + if (len != ART_OID_SHA1_LEN && len != ART_OID_SHA256_LEN) { + return false; + } + for (size_t i = 0; i < len; i++) { + char c = s[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + return false; + } + } + return true; +} + +/* Portable mtime in nanoseconds. MUST stay bit-identical to + * pipeline_incremental.c's stat_mtime_ns: that is the function the incremental + * classifier compares a restamped row against, and a differing encoding would + * make every restamped row look changed. */ +static int64_t art_stat_mtime_ns(const struct stat *st) { +#ifdef __APPLE__ + return ((int64_t)st->st_mtimespec.tv_sec * ART_NS_PER_SEC) + (int64_t)st->st_mtimespec.tv_nsec; +#elif defined(_WIN32) + return (int64_t)st->st_mtime * ART_NS_PER_SEC; +#else + return ((int64_t)st->st_mtim.tv_sec * ART_NS_PER_SEC) + (int64_t)st->st_mtim.tv_nsec; +#endif +} + +/* Stat a path, refusing symlinks. Returns 0 on success, CBM_NOT_FOUND to skip. + * Mirrors discover.c's safe_stat / pass_pkgmap.c's pkgmap_safe_stat. + * + * Symlinks are refused rather than followed because git tracks a symlink's + * LINK TEXT, not its target's content: "unchanged" from git means the link + * still points at the same name, which says nothing about the bytes the + * indexer would actually parse. Following it would stamp the target's mtime + * into the row and let a changed target read as unchanged. Discovery already + * skips symlinks (discover.c safe_stat), so in practice no such row exists — + * this keeps the invariant true even if that ever changes. + * + * On Windows the wide stat also keeps non-ASCII repo paths off the ANSI CRT + * (the cbm_fopen rule applied to stat). */ +static int reconcile_stat_no_symlink(const char *abs_path, struct stat *st) { +#ifdef _WIN32 + wchar_t *wpath = cbm_path_to_wide(abs_path); + if (!wpath) { + return CBM_NOT_FOUND; + } + DWORD attr = GetFileAttributesW(wpath); + if (attr != INVALID_FILE_ATTRIBUTES && (attr & FILE_ATTRIBUTE_REPARSE_POINT)) { + free(wpath); + return CBM_NOT_FOUND; /* junction / symlink — same escape hatch */ + } + struct _stat64 wst; + int ret = _wstat64(wpath, &wst); + free(wpath); + if (ret != 0) { + return CBM_NOT_FOUND; + } + st->st_mode = wst.st_mode; + st->st_size = wst.st_size; + st->st_mtime = wst.st_mtime; + return 0; +#else + if (lstat(abs_path, st) != 0) { + return CBM_NOT_FOUND; + } + if (S_ISLNK(st->st_mode)) { + return CBM_NOT_FOUND; + } + return 0; +#endif +} + +/* Build `git -C "" 2>` with a shell-validated repo path. + * repo_path is the only untrusted component; args is a trusted literal or a + * hex-validated commit string (never arbitrary data). Returns false on + * shell-arg validation failure or truncation. */ +static bool build_git_cmd(char *buf, size_t bufsz, const char *repo_path, const char *args) { + if (!cbm_artifact_repo_path_is_shell_safe(repo_path)) { + return false; + } + int n = snprintf(buf, bufsz, "git -C \"%s\" %s 2>" ARTIFACT_NULL_DEV, repo_path, args); + return n >= 0 && (size_t)n < bufsz; +} + +/* Run a git command; return true iff it exits 0. Output is drained (not kept) + * so `diff --quiet` semantics work and large outputs don't SIGPIPE. */ +static bool git_run_ok(const char *repo_path, const char *args) { + char cmd[CBM_SZ_2K]; + if (!build_git_cmd(cmd, sizeof(cmd), repo_path, args)) { + return false; + } + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return false; + } + char drain[CBM_SZ_4K]; + while (fread(drain, 1, sizeof(drain), fp) > 0) {} + return cbm_pclose(fp) == 0; +} + +/* Run a git command and capture the FULL stdout (NUL bytes preserved) into a + * growing malloc'd buffer. Empty output is success with *out_len = 0. Returns + * 0 on success, CBM_NOT_FOUND on popen / non-zero-exit / OOM. Caller frees *out. */ +static int git_capture_full(const char *repo_path, const char *args, char **out, size_t *out_len) { + *out = NULL; + *out_len = 0; + char cmd[CBM_SZ_2K]; + if (!build_git_cmd(cmd, sizeof(cmd), repo_path, args)) { + return CBM_NOT_FOUND; + } + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return CBM_NOT_FOUND; + } + size_t cap = CBM_SZ_4K; + size_t len = 0; + char *buf = malloc(cap); + if (!buf) { + (void)cbm_pclose(fp); + return CBM_NOT_FOUND; + } + size_t n; + while ((n = fread(buf + len, 1, cap - len, fp)) > 0) { + len += n; + if (len == cap) { + size_t ncap = cap * PAIR_LEN; + char *tmp = realloc(buf, ncap); + if (!tmp) { + free(buf); + (void)cbm_pclose(fp); + return CBM_NOT_FOUND; + } + buf = tmp; + cap = ncap; + } + } + int rc = cbm_pclose(fp); + if (rc != 0) { + free(buf); + return CBM_NOT_FOUND; + } + *out = buf; + *out_len = len; + return 0; +} + +/* True iff `commit` names an object that exists locally AND is a commit + * (shallow/partial-clone guard). + * + * Deliberately `cat-file -t ` rather than `cat-file -e ^{commit}`: + * the peel suffix contains `^`, which is cmd.exe's escape character. Quoting + * cannot be relied on to neutralise it across both shells, and a mangled + * argument here would silently disable reconciliation on Windows. `-t` needs + * no metacharacters at all — the oid is already hex-validated. */ +static bool git_commit_exists(const char *repo_path, const char *commit) { + char args[CBM_SZ_128]; + int n = snprintf(args, sizeof(args), "cat-file -t %s", commit); + if (n < 0 || (size_t)n >= sizeof(args)) { + return false; + } + char *type = NULL; + size_t type_len = 0; + if (git_capture_full(repo_path, args, &type, &type_len) != 0) { + return false; + } + static const char kCommit[] = "commit"; + size_t want = sizeof(kCommit) - ART_NUL; + bool is_commit = type_len >= want && memcmp(type, kCommit, want) == 0 && + (type_len == want || type[want] == '\n' || type[want] == '\r'); + free(type); + return is_commit; +} + +/* True iff the working tree has no tracked/staged/untracked changes outside + * .codebase-memory/. The export itself writes .codebase-memory/, so a blanket + * dirty check would always fail; excluding that subtree is what makes the + * "clean export" invariant checkable. + * + * The `:(exclude)` pathspec is DOUBLE-quoted: its parentheses are shell + * grouping metacharacters under POSIX sh and cmd.exe alike, and double quotes + * are the one form both honor. (Single quotes here were the Windows bug: under + * cmd.exe git received a literal leading `'`, no path ever matched the + * pathspec, `diff --quiet` reported dirty, and the clean-basis marker was + * never written on Windows.) */ +static bool tree_clean_for_reconcile(const char *repo_path) { + /* Tracked (staged + unstaged) changes vs HEAD, excluding .codebase-memory. */ + if (!git_run_ok(repo_path, "diff --quiet HEAD -- . \":(exclude).codebase-memory\"")) { + return false; + } + /* Untracked (non-ignored) files outside .codebase-memory. */ + static const char *const ls_args = + "ls-files -z --others --exclude-standard -- . \":(exclude).codebase-memory\""; + char *untracked = NULL; + size_t un_len = 0; + if (git_capture_full(repo_path, ls_args, &untracked, &un_len) != 0) { + return false; + } + bool clean = (un_len == 0); + free(untracked); + return clean; +} + +/* True for file_hashes rows that are not repository files. The pipeline stores + * synthetic "semantic input" digests under .codebase-memory/.semantic-input/ + * (pipeline_internal.h's CBM_SEMANTIC_INPUT_PREFIX), and the artifact itself + * lives in the same directory. Neither is a plain file the indexer parsed, so + * neither can be stat()ed or vouched for by git — find_deleted_files skips the + * same class for the same reason. + * + * Testing the whole .codebase-memory/ subtree (rather than the semantic-input + * prefix alone) keeps this in lockstep with the ":(exclude).codebase-memory" + * pathspec used by the clean-tree probe: both halves of the trust check must + * agree on what counts as a repository file, or export can never mark a basis + * it would then refuse to act on. */ +static bool reconcile_is_synthetic_row(const char *rel_path) { + static const char prefix[] = CBM_ARTIFACT_DIR "/"; + return rel_path && strncmp(rel_path, prefix, sizeof(prefix) - ART_NUL) == 0; +} + +/* True iff every file_hashes row for project has an on-disk file whose + * mtime_ns + size matches the stored stamp. Any stat failure, path overflow, + * or mismatch makes the artifact untrusted for reconciliation — this is the + * belt-and-suspenders that catches a stale/swapped DB even when the tree looks + * clean. */ +static bool db_hashes_match_disk(const char *repo_path, const char *db_path, const char *project) { + cbm_store_t *s = cbm_store_open_path(db_path); + if (!s) { + return false; + } + cbm_file_hash_t *hashes = NULL; + int count = 0; + bool match = true; + if (cbm_store_get_file_hashes(s, project, &hashes, &count) != CBM_STORE_OK) { + cbm_store_close(s); + return false; + } + for (int i = 0; i < count; i++) { + if (reconcile_is_synthetic_row(hashes[i].rel_path)) { + continue; + } + char abs[CBM_SZ_4K]; + int n = snprintf(abs, sizeof(abs), "%s/%s", repo_path, hashes[i].rel_path); + if (n < 0 || n >= (int)sizeof(abs)) { + match = false; + break; + } + struct stat st; + if (reconcile_stat_no_symlink(abs, &st) != 0 || + art_stat_mtime_ns(&st) != hashes[i].mtime_ns || (int64_t)st.st_size != hashes[i].size) { + match = false; + break; + } + } + cbm_store_free_file_hashes(hashes, count); + cbm_store_close(s); + return match; +} + +/* Read the optional reconcile_basis marker from artifact.json. True only when it + * is exactly "git-clean-head" (the sole trusted basis this code emits). */ +static bool read_metadata_reconcile_trusted(const char *repo_path) { + char meta_path[CBM_SZ_4K]; + if (!artifact_path(meta_path, sizeof(meta_path), repo_path, CBM_ARTIFACT_META)) { + return false; + } + size_t len = 0; + char *json = read_file_alloc(meta_path, &len); + if (!json) { + return false; + } + yyjson_doc *doc = yyjson_read(json, len, 0); + free(json); + if (!doc) { + return false; + } + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *val = yyjson_obj_get(root, "reconcile_basis"); + bool trusted = false; + if (val) { + const char *s = yyjson_get_str(val); + trusted = (s && strcmp(s, "git-clean-head") == 0); + } + yyjson_doc_free(doc); + return trusted; +} + /* ── Metadata read/write ─────────────────────────────────────────── */ /* Read schema_version from artifact.json. Returns -1 if missing/invalid. */ @@ -318,11 +631,9 @@ static size_t read_metadata_original_size(const char *repo_path) { } /* Write artifact.json metadata. */ -static int write_metadata(const char *repo_path, const char *project_name, int nodes, int edges, - size_t original_size, size_t compressed_size, int compression_level) { - char commit[CBM_SZ_64] = ""; - git_head_hash(repo_path, commit, sizeof(commit)); - +static int write_metadata(const char *repo_path, const char *project_name, const char *commit, + int nodes, int edges, size_t original_size, size_t compressed_size, + int compression_level, bool reconcile_trusted) { char ts[CBM_SZ_64]; iso_timestamp(ts, sizeof(ts)); @@ -339,6 +650,12 @@ static int write_metadata(const char *repo_path, const char *project_name, int n yyjson_mut_obj_add_uint(doc, root, "original_size", (uint64_t)original_size); yyjson_mut_obj_add_uint(doc, root, "compressed_size", (uint64_t)compressed_size); yyjson_mut_obj_add_int(doc, root, "compression_level", compression_level); + /* Optional clean-basis marker: present only when export verified the DB + * matches a clean checked-out tree at `commit` (see cbm_artifact_export). + * Older binaries ignore this unknown field, so no schema_version bump. */ + if (reconcile_trusted) { + yyjson_mut_obj_add_str(doc, root, "reconcile_basis", "git-clean-head"); + } size_t json_len = 0; char *json = yyjson_mut_write(doc, YYJSON_WRITE_PRETTY, &json_len); @@ -628,9 +945,19 @@ int cbm_artifact_export(const char *db_path, const char *repo_path, const char * cbm_store_close(count_store); } + /* Compute the optional clean-basis trust marker. An imported DB can be + * fast-reconciled against git only if export can prove it was built from a + * clean checked-out tree at a known commit. Any doubt omits the marker and + * bootstrap falls back to today's slow-safe full re-parse. */ + char commit[CBM_SZ_64] = ""; + bool has_commit = git_head_hash(repo_path, commit, sizeof(commit)); + bool reconcile_trusted = has_commit && is_hex_oid(commit) && + tree_clean_for_reconcile(repo_path) && + db_hashes_match_disk(repo_path, db_path, project_name); + /* Write metadata */ - if (write_metadata(repo_path, project_name, nodes, edges, db_size, (size_t)clen, - compression_level) != 0) { + if (write_metadata(repo_path, project_name, commit, nodes, edges, db_size, (size_t)clen, + compression_level, reconcile_trusted) != 0) { cbm_unlink(zst_path); return CBM_NOT_FOUND; } @@ -840,3 +1167,261 @@ char *cbm_artifact_commit(const char *repo_path) { yyjson_doc_free(doc); return result; } + +/* ── Bootstrap reconciliation ─────────────────────────────────────── */ + +/* The three git outputs plus the two membership sets built from them. + * The sets BORROW their keys from the buffers, so the tables must be freed + * before the buffers — reconcile_sets_free() is the single place that + * ordering is expressed. */ +typedef struct { + char *diff_out; + size_t diff_len; + char *ls_out; + size_t ls_len; + char *tracked_out; + size_t tracked_len; + CBMHashTable *changed; /* paths modified since the artifact commit */ + CBMHashTable *tracked; /* paths tracked AT the artifact commit */ +} reconcile_sets_t; + +static void reconcile_sets_free(reconcile_sets_t *s) { + /* Tables FIRST: every key points into the buffers freed just below. */ + cbm_ht_free(s->changed); + cbm_ht_free(s->tracked); + s->changed = NULL; + s->tracked = NULL; + free(s->diff_out); + free(s->ls_out); + free(s->tracked_out); + s->diff_out = NULL; + s->ls_out = NULL; + s->tracked_out = NULL; +} + +/* Add every NUL-delimited entry in buf (length len) to the membership set ht. + * git -z output NUL-terminates every entry including the last, so each non-empty + * entry buf+i is already a C string terminated by its trailing NUL. Keys are + * borrowed from buf (the table does not copy keys), so buf must outlive ht. + * Empty entries (consecutive NULs) are skipped. + * + * Returns false if ANY entry failed to land. cbm_ht_set discards the return of + * the underlying insert (hash_table.c), so an allocation failure drops an entry + * SILENTLY — and a dropped entry in `changed` means a genuinely modified file + * reads as unchanged and gets restamped, i.e. a silently stale graph. Presence + * is therefore verified per entry and any failure aborts the whole + * reconciliation (fail closed). */ +static bool reconcile_add_nul_entries(CBMHashTable *ht, const char *buf, size_t len) { + if (!ht) { + return false; + } + if (!buf || len == 0) { + return true; + } + size_t i = 0; + while (i < len) { + const char *entry = buf + i; + size_t j = i; + while (j < len && buf[j] != '\0') { + j++; + } + if (j > i) { + cbm_ht_set(ht, entry, &g_reconcile_sentinel); + if (!cbm_ht_has(ht, entry)) { + return false; + } + } + i = (j < len) ? j + 1 : len; + } + return true; +} + +/* Capture the git outputs and build the changed/tracked membership sets. + * Returns true only if every step succeeded; on false the caller must still + * call reconcile_sets_free (partial state is freed correctly). */ +static bool reconcile_sets_build(const char *repo_path, const char *commit, + reconcile_sets_t *sets) { + /* diff: content changed between the artifact commit and the working tree. + * ls-files --others: untracked files (git cannot vouch for them). + * ls-tree: the paths tracked AT the artifact commit — the eligibility set. + * + * NUL-delimited (-z) output is parsed directly; line-oriented parsing + * cannot handle paths containing newlines or quotes. The tracked set + * exists because git diff/ls-files are blind to files git IGNORES: a + * gitignored-yet-indexed file (a .cbmignore negation un-skipping a + * generated dir, #500) would otherwise be restamped as "unchanged" while + * its content is not under git's control at all. */ + char diff_args[CBM_SZ_256]; + char lstree_args[CBM_SZ_256]; + int dn = + snprintf(diff_args, sizeof(diff_args), "diff -z --name-only --no-renames %s --", commit); + int ln = snprintf(lstree_args, sizeof(lstree_args), "ls-tree -r -z --name-only %s --", commit); + if (dn < 0 || (size_t)dn >= sizeof(diff_args) || ln < 0 || (size_t)ln >= sizeof(lstree_args)) { + return false; + } + if (git_capture_full(repo_path, diff_args, &sets->diff_out, &sets->diff_len) != 0) { + return false; + } + if (git_capture_full(repo_path, "ls-files -z --others --exclude-standard --", &sets->ls_out, + &sets->ls_len) != 0) { + return false; + } + if (git_capture_full(repo_path, lstree_args, &sets->tracked_out, &sets->tracked_len) != 0) { + return false; + } + + /* Parse-invariant guard: git -z NUL-terminates every entry including the + * last; a non-empty buffer not ending in NUL means truncated/corrupt output + * → untrusted → skip rather than risk misclassifying a changed file as + * unchanged (graph corruption). */ + if ((sets->diff_len > 0 && sets->diff_out[sets->diff_len - ART_NUL] != '\0') || + (sets->ls_len > 0 && sets->ls_out[sets->ls_len - ART_NUL] != '\0') || + (sets->tracked_len > 0 && sets->tracked_out[sets->tracked_len - ART_NUL] != '\0')) { + return false; + } + + /* cbm_ht_create returns NULL on OOM. Checking BOTH is load-bearing: with a + * NULL `changed` and a live `tracked`, every tracked row would look + * unchanged and get restamped — including genuinely modified files. Note + * the polarity difference from classify_files, which uses the same + * primitive where a dropped entry merely means "re-parse" (safe); here it + * means "unchanged" (unsafe). Fail closed. */ + sets->changed = cbm_ht_create(CBM_SZ_64); + sets->tracked = cbm_ht_create(CBM_SZ_64); + if (!sets->changed || !sets->tracked) { + return false; + } + return reconcile_add_nul_entries(sets->changed, sets->diff_out, sets->diff_len) && + reconcile_add_nul_entries(sets->changed, sets->ls_out, sets->ls_len) && + reconcile_add_nul_entries(sets->tracked, sets->tracked_out, sets->tracked_len); +} + +/* Restamp every row that is tracked at the artifact commit and not in the + * changed set, with local stat() values. Returns the number of rows restamped, + * or CBM_NOT_FOUND if the store could not be read/written. */ +static int reconcile_restamp_rows(const char *repo_path, const char *cache_db_path, + const char *project_name, const reconcile_sets_t *sets, + int *out_skipped) { + *out_skipped = 0; + cbm_store_t *store = cbm_store_open_path(cache_db_path); + if (!store) { + return CBM_NOT_FOUND; + } + cbm_file_hash_t *stored = NULL; + int stored_count = 0; + if (cbm_store_get_file_hashes(store, project_name, &stored, &stored_count) != CBM_STORE_OK) { + cbm_store_close(store); + return CBM_NOT_FOUND; + } + if (stored_count <= 0) { + cbm_store_free_file_hashes(stored, stored_count); + cbm_store_close(store); + return 0; + } + cbm_file_hash_t *batch = malloc((size_t)stored_count * sizeof(*batch)); + if (!batch) { + cbm_store_free_file_hashes(stored, stored_count); + cbm_store_close(store); + return CBM_NOT_FOUND; + } + + int batch_n = 0; + int skipped = 0; + for (int i = 0; i < stored_count; i++) { + if (reconcile_is_synthetic_row(stored[i].rel_path)) { + continue; /* synthetic semantic input — not a repository file */ + } + if (!cbm_ht_get(sets->tracked, stored[i].rel_path) || + cbm_ht_get(sets->changed, stored[i].rel_path)) { + continue; /* untracked or changed → stays foreign → re-parsed */ + } + char abs[CBM_SZ_4K]; + int n = snprintf(abs, sizeof(abs), "%s/%s", repo_path, stored[i].rel_path); + if (n < 0 || n >= (int)sizeof(abs)) { + skipped++; + continue; + } + struct stat st; + if (reconcile_stat_no_symlink(abs, &st) != 0) { + skipped++; + continue; /* missing locally → find_deleted_files purges the row */ + } + /* Borrow project from the caller and rel_path/sha256 from the row; all + * outlive the batch upsert below, which is issued before `stored` is + * freed. */ + batch[batch_n].project = project_name; + batch[batch_n].rel_path = stored[i].rel_path; + batch[batch_n].sha256 = stored[i].sha256; + batch[batch_n].mtime_ns = art_stat_mtime_ns(&st); + batch[batch_n].size = (int64_t)st.st_size; + batch_n++; + } + + int restamped = 0; + if (batch_n > 0) { + restamped = (cbm_store_upsert_file_hash_batch(store, batch, batch_n) == CBM_STORE_OK) + ? batch_n + : CBM_NOT_FOUND; + } + free(batch); + cbm_store_free_file_hashes(stored, stored_count); + cbm_store_close(store); + *out_skipped = skipped; + return restamped; +} + +/* See artifact.h for the contract and the trust trade-off this implements. + * + * Windows/autocrlf note: on-disk bytes may differ from the exporter's while git + * reports "unchanged"; line numbers and parse results are equivalent, so + * re-stamping by git's diff is correct. */ +int cbm_artifact_reconcile_hashes(const char *repo_path, const char *cache_db_path, + const char *project_name) { + if (!repo_path || !cache_db_path || !project_name) { + return CBM_NOT_FOUND; + } + + /* 1. The producer-written clean-basis marker must be present. This alone is + * NOT trust (see artifact.h) — it only opens the door to the git checks + * below, each of which is evaluated against the LOCAL repository. */ + if (!read_metadata_reconcile_trusted(repo_path)) { + return CBM_NOT_FOUND; + } + char *commit = cbm_artifact_commit(repo_path); + if (!commit || !is_hex_oid(commit)) { + /* Hard gate: repo-controlled metadata never reaches a git command + * unless it is pure hex. */ + free(commit); + return CBM_NOT_FOUND; + } + + /* 2. That commit must exist locally as a commit (shallow-clone guard). */ + if (!git_commit_exists(repo_path, commit)) { + free(commit); + return CBM_NOT_FOUND; + } + + /* 3. Build the changed / tracked-at-commit sets from git. */ + reconcile_sets_t sets = {0}; + bool built = reconcile_sets_build(repo_path, commit, &sets); + free(commit); + if (!built) { + reconcile_sets_free(&sets); + cbm_log_info("artifact.reconcile_skipped", "reason", "git_sets_unavailable"); + return CBM_NOT_FOUND; + } + + /* 4. Restamp the eligible rows. */ + int skipped = 0; + int restamped = reconcile_restamp_rows(repo_path, cache_db_path, project_name, &sets, &skipped); + int changed_count = (int)cbm_ht_count(sets.changed); + reconcile_sets_free(&sets); + + if (restamped < 0) { + cbm_log_info("artifact.reconcile_skipped", "reason", "store_unavailable"); + return CBM_NOT_FOUND; + } + cbm_log_info("artifact.reconcile", "restamped", itoa_buf(restamped), "changed", + itoa_buf(changed_count), "skipped", itoa_buf(skipped)); + return restamped; +} diff --git a/src/pipeline/artifact.h b/src/pipeline/artifact.h index 3b2d9074e..47a1100a0 100644 --- a/src/pipeline/artifact.h +++ b/src/pipeline/artifact.h @@ -61,4 +61,42 @@ char *cbm_artifact_commit(const char *repo_path); * were the pre-existing bug). Exposed so the shell-safety contract is unit-tested. */ bool cbm_artifact_repo_path_is_shell_safe(const char *repo_path); +/* After importing a teammate's artifact, re-stamp the file_hashes rows whose + * content git proves unchanged between the artifact's commit and the local + * working tree, using local stat() values. Without this every imported row + * carries the EXPORTER's mtime, so the first incremental run re-parses + * ~every file and the artifact saves almost nothing (see #885). + * + * TRUST TRADE-OFF — read before widening this. + * Today an imported artifact is already trusted for graph CONTENT; nothing + * verifies that the nodes/edges inside it describe the code they claim to. + * What limits the damage is an accident of mechanics, not a check: the + * foreign mtimes force a full re-parse, so a poisoned artifact is + * auto-scrubbed the first time anyone indexes — the exposure is TRANSIENT + * and SELF-HEALING, and it ends at a clone time the attacker cannot predict. + * + * Reconciliation deliberately removes that scrub for rows it restamps. The + * exposure becomes DURABLE: poisoned nodes survive until the file's content + * changes. And the gate that decides it is a string the artifact PRODUCER + * wrote ("reconcile_basis"), so it is only as trustworthy as whoever could + * write the artifact — the same party who could poison the graph. + * + * What keeps that acceptable is that the marker alone is never sufficient. + * Every restamped row must ALSO be independently confirmed by the local git: + * tracked at a commit that exists in THIS clone, and reported unchanged + * against the local working tree. A row git cannot vouch for stays foreign + * and is re-parsed. So the marker can only ever suppress re-parsing of files + * whose bytes the local repository itself certifies. + * + * Anyone extending this must preserve the polarity: on ANY doubt, skip the + * row (leave it foreign -> re-parse). A dropped entry must never be able to + * read as "unchanged". + * + * Returns the number of rows re-stamped, or -1 when reconciliation was skipped + * (NULL args / no git / untrusted metadata / unknown or non-hex commit / + * shallow clone / allocation failure / any parse uncertainty). + * Best-effort: never fails the import. */ +int cbm_artifact_reconcile_hashes(const char *repo_path, const char *cache_db_path, + const char *project_name); + #endif /* CBM_ARTIFACT_H */ diff --git a/tests/test_artifact.c b/tests/test_artifact.c index 4975179e7..9dd7584cd 100644 --- a/tests/test_artifact.c +++ b/tests/test_artifact.c @@ -11,6 +11,8 @@ #include #include +#include +#include /* ── Helpers ─────────────────────────────────────────────────────── */ @@ -397,6 +399,9 @@ TEST(artifact_null_safety) { ASSERT_NEQ(cbm_artifact_import("/tmp", NULL), 0); ASSERT_FALSE(cbm_artifact_exists(NULL)); ASSERT_NULL(cbm_artifact_commit(NULL)); + ASSERT_EQ(cbm_artifact_reconcile_hashes(NULL, "/tmp/x.db", "p"), -1); + ASSERT_EQ(cbm_artifact_reconcile_hashes("/tmp", NULL, "p"), -1); + ASSERT_EQ(cbm_artifact_reconcile_hashes("/tmp", "/tmp/x.db", NULL), -1); PASS(); } @@ -543,6 +548,404 @@ TEST(store_deep_integrity_detects_page_corruption) { PASS(); } + +/* ── Bootstrap reconciliation ───────────────────────────────────────────────── + * + * Ported from the contributor's stack (#868). Every git command below uses + * DOUBLE quotes, matching the production rule stated in artifact.c: cmd.exe + * does not honor single quotes, so `git -C '%s'` breaks on Windows. The same + * rule that fixes production applies to this harness. */ + +/* printf-formatted system() wrapper; returns the exit status. */ +static int runf(const char *fmt, ...) { + char cmd[4096]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(cmd, sizeof(cmd), fmt, ap); + va_end(ap); + if (n < 0 || (size_t)n >= sizeof(cmd)) { + return -1; + } + return system(cmd); +} + +static void git_init(const char *repo) { + runf("git -C \"%s\" init -q", repo); + runf("git -C \"%s\" config user.email t@t.com", repo); + runf("git -C \"%s\" config user.name t", repo); + runf("git -C \"%s\" config commit.gpgsign false", repo); +} + +/* Portable local mtime_ns for assertions (mirrors artifact.c's art_stat_mtime_ns + * and pipeline_incremental.c's stat_mtime_ns — all three must agree or a + * restamped row would never match the incremental classifier). */ +static int64_t t_mtime_ns(const char *path) { + struct stat st; + if (stat(path, &st) != 0) { + return -1; + } +#ifdef __APPLE__ + return (int64_t)st.st_mtimespec.tv_sec * 1000000000LL + (int64_t)st.st_mtimespec.tv_nsec; +#elif defined(_WIN32) + return (int64_t)st.st_mtime * 1000000000LL; +#else + return (int64_t)st.st_mtim.tv_sec * 1000000000LL + (int64_t)st.st_mtim.tv_nsec; +#endif +} + +/* True iff /.codebase-memory/artifact.json contains substr. */ +static bool meta_contains(const char *repo, const char *substr) { + char path[1024]; + snprintf(path, sizeof(path), "%s/.codebase-memory/artifact.json", repo); + FILE *fp = fopen(path, "r"); + if (!fp) { + return false; + } + char buf[4096]; + size_t n = fread(buf, 1, sizeof(buf) - 1, fp); + fclose(fp); + buf[n] = '\0'; + return strstr(buf, substr) != NULL; +} + +static int64_t row_mtime(cbm_file_hash_t *rows, int n, const char *rel) { + for (int i = 0; i < n; i++) { + if (strcmp(rows[i].rel_path, rel) == 0) { + return rows[i].mtime_ns; + } + } + return -1; +} + +/* Overwrite one file_hashes row's mtime with a caller-chosen sentinel. + * Tests assert against this sentinel rather than against "a different + * wall-clock time": on Windows mtime_ns has ONE-SECOND resolution, so two + * writes in the same second are indistinguishable and an inequality + * assertion would be a coin flip (O9 — a verdict must not depend on + * filesystem timestamp granularity). */ +static bool stamp_row_mtime(const char *db, const char *proj, const char *rel, int64_t sentinel, + const char *sha) { + cbm_store_t *s = cbm_store_open_path(db); + if (!s) { + return false; + } + int rc = cbm_store_upsert_file_hash(s, proj, rel, sha ? sha : "", sentinel, 1); + cbm_store_close(s); + return rc == CBM_STORE_OK; +} + +/* Build a git repo at g_repo with 5 .rs files, full-index + export (clean tree + * -> reconcile_basis marker set), and commit .codebase-memory so a clone receives + * the artifact. Returns the derived project name (caller frees) or NULL. */ +static char *build_trusted_artifact_repo(void) { + git_init(g_repo); + const char *names[] = {"a.rs", "b.rs", "c.rs", "d.rs", "e.rs"}; + for (int i = 0; i < 5; i++) { + char p[1024]; + snprintf(p, sizeof(p), "%s/%s", g_repo, names[i]); + char body[128]; + snprintf(body, sizeof(body), "pub fn f%d() {}\n", i); + write_text_file(p, body); + } + if (runf("git -C \"%s\" add -A && git -C \"%s\" commit -qm init", g_repo, g_repo) != 0) { + return NULL; + } + cbm_pipeline_t *p = cbm_pipeline_new(g_repo, g_db, CBM_MODE_FAST); + if (!p) { + return NULL; + } + cbm_pipeline_set_persistence(p, true); + int rc = cbm_pipeline_run(p); + cbm_pipeline_free(p); + if (rc != 0) { + return NULL; + } + /* Clean source tree at export -> marker must be present. */ + if (!meta_contains(g_repo, "\"reconcile_basis\"")) { + return NULL; + } + if (runf("git -C \"%s\" add -A && git -C \"%s\" commit -qm artifact", g_repo, g_repo) != 0) { + return NULL; + } + return cbm_project_name_from_path(g_repo); +} + +/* Clones g_repo (A) into /work/repo (B) so both share the basename "repo" + * and thus the derived project name — required for artifact bootstrap. */ +static bool clone_to_b(char *repoB_out, size_t repoB_sz) { + char work[1024]; + snprintf(work, sizeof(work), "%s/work", g_tmpdir); + cbm_mkdir_p(work, 0755); + snprintf(repoB_out, repoB_sz, "%s/repo", work); + return runf("git clone -q \"%s\" \"%s\"", g_repo, repoB_out) == 0; +} + +TEST(artifact_export_marks_clean_basis) { + setup_artifact_test(); + git_init(g_repo); + char src[1024]; + snprintf(src, sizeof(src), "%s/a.rs", g_repo); + write_text_file(src, "pub fn f0() {}\n"); + ASSERT_EQ(runf("git -C \"%s\" add -A && git -C \"%s\" commit -qm init", g_repo, g_repo), 0); + + char *proj = cbm_project_name_from_path(g_repo); + ASSERT_NOT_NULL(proj); + + /* Full index + export on a clean source tree -> clean-basis marker set. + * This assert is the one that failed on Windows CI for the original patch: + * the single-quoted `:(exclude)` pathspec made tree_clean_for_reconcile + * always report dirty under cmd.exe, so the marker was never written. It is + * a PRODUCTION assert, not a harness artifact. */ + cbm_pipeline_t *p = cbm_pipeline_new(g_repo, g_db, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_set_persistence(p, true); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); + ASSERT(meta_contains(g_repo, "\"reconcile_basis\"")); + + /* Dirty the source tree (uncommitted edit). Re-export must omit the marker: + * the tree is non-clean outside .codebase-memory (and the a.rs hash row no + * longer matches disk). */ + write_text_file(src, "pub fn dirty() {}\n"); + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, proj, CBM_ARTIFACT_FAST), 0); + ASSERT_FALSE(meta_contains(g_repo, "\"reconcile_basis\"")); + + free(proj); + cleanup_dir(g_tmpdir); + PASS(); +} + +TEST(artifact_reconcile_restamps_unchanged) { + setup_artifact_test(); + char *proj = build_trusted_artifact_repo(); + ASSERT_NOT_NULL(proj); + + char repoB[1024]; + ASSERT(clone_to_b(repoB, sizeof(repoB))); + + /* B: modify 2 files + add 1, commit. (Clone gave B fresh mtimes.) */ + char m1[1152], m2[1152], add[1152]; + snprintf(m1, sizeof(m1), "%s/a.rs", repoB); + snprintf(m2, sizeof(m2), "%s/b.rs", repoB); + snprintf(add, sizeof(add), "%s/added.rs", repoB); + write_text_file(m1, "pub fn f0() { /* changed */ }\n"); + write_text_file(m2, "pub fn f1() { /* changed */ }\n"); + write_text_file(add, "pub fn new_fn() {}\n"); + ASSERT_EQ(runf("git -C \"%s\" add -A && git -C \"%s\" commit -qm edits", repoB, repoB), 0); + + /* Import A's artifact into a fresh cache DB for B. */ + char dbB[1152]; + snprintf(dbB, sizeof(dbB), "%s/b.db", g_tmpdir); + ASSERT_EQ(cbm_artifact_import(repoB, dbB), 0); + + /* Pin a.rs's row to a sentinel so "changed rows stay foreign" is a + * deterministic assertion instead of an mtime-granularity race. */ + const int64_t foreign_mtime = 12345; + ASSERT(stamp_row_mtime(dbB, proj, "a.rs", foreign_mtime, "")); + + /* Reconcile: 5 rows; a.rs+b.rs changed (left foreign), 3 unchanged restamped. */ + int restamped = cbm_artifact_reconcile_hashes(repoB, dbB, proj); + ASSERT_EQ(restamped, 3); + + /* Unchanged row (c.rs) now carries B's local mtime; the changed row (a.rs) + * still carries its foreign stamp — untouched. */ + cbm_store_t *s = cbm_store_open_path(dbB); + ASSERT_NOT_NULL(s); + cbm_file_hash_t *rows = NULL; + int n = 0; + ASSERT_EQ(cbm_store_get_file_hashes(s, proj, &rows, &n), 0); + char c_path[1152]; + snprintf(c_path, sizeof(c_path), "%s/c.rs", repoB); + ASSERT_EQ(row_mtime(rows, n, "c.rs"), t_mtime_ns(c_path)); + ASSERT_EQ(row_mtime(rows, n, "a.rs"), foreign_mtime); + cbm_store_free_file_hashes(rows, n); + cbm_store_close(s); + + free(proj); + cleanup_dir(g_tmpdir); + PASS(); +} + +TEST(artifact_reconcile_skips_untracked_rows) { + setup_artifact_test(); + char *proj = build_trusted_artifact_repo(); + ASSERT_NOT_NULL(proj); + char repoB[1024]; + ASSERT(clone_to_b(repoB, sizeof(repoB))); + + /* B: a gitignored-yet-indexed file (the .cbmignore-negation shape, #500). + * git diff/ls-files are both blind to it, so without the tracked-at-commit + * gate it would be restamped as "unchanged" even though git cannot vouch + * for its content. */ + char gen[1152], gi[1152]; + snprintf(gen, sizeof(gen), "%s/gen.rs", repoB); + snprintf(gi, sizeof(gi), "%s/.gitignore", repoB); + write_text_file(gen, "pub fn generated_local() {}\n"); + write_text_file(gi, "gen.rs\n"); + ASSERT_EQ(runf("git -C \"%s\" add .gitignore && git -C \"%s\" commit -qm ignore", repoB, repoB), + 0); + + char dbB[1152]; + snprintf(dbB, sizeof(dbB), "%s/b.db", g_tmpdir); + ASSERT_EQ(cbm_artifact_import(repoB, dbB), 0); + + /* Simulate the exporter having indexed gen.rs: insert a foreign-mtime row. */ + const int64_t foreign_mtime = 12345; + ASSERT(stamp_row_mtime(dbB, proj, "gen.rs", foreign_mtime, "")); + + /* Reconcile: the 5 tracked unchanged rows restamp; gen.rs must not. */ + ASSERT_EQ(cbm_artifact_reconcile_hashes(repoB, dbB, proj), 5); + + cbm_store_t *s = cbm_store_open_path(dbB); + ASSERT_NOT_NULL(s); + cbm_file_hash_t *rows = NULL; + int n = 0; + ASSERT_EQ(cbm_store_get_file_hashes(s, proj, &rows, &n), CBM_STORE_OK); + ASSERT_EQ(row_mtime(rows, n, "gen.rs"), foreign_mtime); + char c_path[1152]; + snprintf(c_path, sizeof(c_path), "%s/c.rs", repoB); + ASSERT_EQ(row_mtime(rows, n, "c.rs"), t_mtime_ns(c_path)); + cbm_store_free_file_hashes(rows, n); + cbm_store_close(s); + + free(proj); + cleanup_dir(g_tmpdir); + PASS(); +} + +TEST(artifact_reconcile_skips_untrusted_metadata) { + setup_artifact_test(); + char *proj = build_trusted_artifact_repo(); + ASSERT_NOT_NULL(proj); + char repoB[1024]; + ASSERT(clone_to_b(repoB, sizeof(repoB))); + + char dbB[1152]; + snprintf(dbB, sizeof(dbB), "%s/b.db", g_tmpdir); + ASSERT_EQ(cbm_artifact_import(repoB, dbB), 0); + + /* Snapshot one foreign mtime, then strip the trust marker. */ + cbm_store_t *s = cbm_store_open_path(dbB); + ASSERT_NOT_NULL(s); + cbm_file_hash_t *rows = NULL; + int n = 0; + ASSERT_EQ(cbm_store_get_file_hashes(s, proj, &rows, &n), CBM_STORE_OK); + int64_t before = row_mtime(rows, n, "c.rs"); + cbm_store_free_file_hashes(rows, n); + cbm_store_close(s); + + char meta[1152]; + snprintf(meta, sizeof(meta), "%s/.codebase-memory/artifact.json", repoB); + /* Rewrite artifact.json without reconcile_basis (schema_version preserved). */ + FILE *fp = fopen(meta, "w"); + ASSERT_NOT_NULL(fp); + fprintf(fp, "{\"schema_version\":2,\"commit\":\"deadbeef\"," + "\"original_size\":1000,\"indexed_at\":\"2026-01-01T00:00:00Z\"}"); + fclose(fp); + + ASSERT_EQ(cbm_artifact_reconcile_hashes(repoB, dbB, proj), -1); + + /* Rows untouched: still the foreign value captured before. */ + s = cbm_store_open_path(dbB); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_get_file_hashes(s, proj, &rows, &n), CBM_STORE_OK); + ASSERT_EQ(row_mtime(rows, n, "c.rs"), before); + cbm_store_free_file_hashes(rows, n); + cbm_store_close(s); + + free(proj); + cleanup_dir(g_tmpdir); + PASS(); +} + +TEST(artifact_reconcile_skips_unknown_commit) { + setup_artifact_test(); + char *proj = build_trusted_artifact_repo(); + ASSERT_NOT_NULL(proj); + char repoB[1024]; + ASSERT(clone_to_b(repoB, sizeof(repoB))); + + char dbB[1152]; + snprintf(dbB, sizeof(dbB), "%s/b.db", g_tmpdir); + ASSERT_EQ(cbm_artifact_import(repoB, dbB), 0); + + /* Rewrite artifact.json: keep the marker but point commit at a hex-valid + * SHA that does not exist locally -> the object-existence gate fails. */ + char meta[1152]; + snprintf(meta, sizeof(meta), "%s/.codebase-memory/artifact.json", repoB); + FILE *fp = fopen(meta, "w"); + ASSERT_NOT_NULL(fp); + fprintf(fp, + "{\"schema_version\":2,\"commit\":\"%s\"," + "\"original_size\":1000,\"reconcile_basis\":\"git-clean-head\"}", + "1111111111111111111111111111111111111111"); + fclose(fp); + + ASSERT_EQ(cbm_artifact_reconcile_hashes(repoB, dbB, proj), -1); + + free(proj); + cleanup_dir(g_tmpdir); + PASS(); +} + +TEST(artifact_reconcile_skips_non_hex_commit) { + setup_artifact_test(); + char *proj = build_trusted_artifact_repo(); + ASSERT_NOT_NULL(proj); + char repoB[1024]; + ASSERT(clone_to_b(repoB, sizeof(repoB))); + + char dbB[1152]; + snprintf(dbB, sizeof(dbB), "%s/b.db", g_tmpdir); + ASSERT_EQ(cbm_artifact_import(repoB, dbB), 0); + + /* A commit field carrying shell metacharacters must never reach a command + * string: is_hex_oid is the hard gate in front of every interpolation. */ + char meta[1152]; + snprintf(meta, sizeof(meta), "%s/.codebase-memory/artifact.json", repoB); + FILE *fp = fopen(meta, "w"); + ASSERT_NOT_NULL(fp); + fprintf(fp, "{\"schema_version\":2,\"commit\":\"a$(touch pwned)b\"," + "\"original_size\":1000,\"reconcile_basis\":\"git-clean-head\"}"); + fclose(fp); + + ASSERT_EQ(cbm_artifact_reconcile_hashes(repoB, dbB, proj), -1); + + char pwned[1152]; + snprintf(pwned, sizeof(pwned), "%s/pwned", repoB); + struct stat st; + ASSERT_NEQ(stat(pwned, &st), 0); + + free(proj); + cleanup_dir(g_tmpdir); + PASS(); +} + +TEST(artifact_reconcile_skips_without_git) { + setup_artifact_test(); + char *proj = build_trusted_artifact_repo(); + ASSERT_NOT_NULL(proj); + char repoB[1024]; + ASSERT(clone_to_b(repoB, sizeof(repoB))); + + char dbB[1152]; + snprintf(dbB, sizeof(dbB), "%s/b.db", g_tmpdir); + ASSERT_EQ(cbm_artifact_import(repoB, dbB), 0); + + /* Disable the repo by renaming .git (portable; `rm -rf` is not a cmd.exe + * builtin). Marker + commit still present, but every git call now fails. */ + char gitdir[1152], disabled[1152]; + snprintf(gitdir, sizeof(gitdir), "%s/.git", repoB); + snprintf(disabled, sizeof(disabled), "%s/.git-disabled", repoB); + ASSERT_EQ(cbm_rename_replace(gitdir, disabled), 0); + + ASSERT_EQ(cbm_artifact_reconcile_hashes(repoB, dbB, proj), -1); + + free(proj); + cleanup_dir(g_tmpdir); + PASS(); +} + SUITE(artifact) { RUN_TEST(artifact_fast_export_snapshots_live_wal_store); RUN_TEST(store_deep_integrity_detects_page_corruption); @@ -560,4 +963,11 @@ SUITE(artifact) { RUN_TEST(pipeline_persistence_export_failure_returns_error); RUN_TEST(artifact_import_rejects_size_mismatch); RUN_TEST(artifact_null_safety); + RUN_TEST(artifact_export_marks_clean_basis); + RUN_TEST(artifact_reconcile_restamps_unchanged); + RUN_TEST(artifact_reconcile_skips_untracked_rows); + RUN_TEST(artifact_reconcile_skips_untrusted_metadata); + RUN_TEST(artifact_reconcile_skips_unknown_commit); + RUN_TEST(artifact_reconcile_skips_non_hex_commit); + RUN_TEST(artifact_reconcile_skips_without_git); } From a9d635f2993879edecb73f754ac59dfc0758e7ec Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 29 Aug 2026 17:47:21 +0200 Subject: [PATCH 4/6] fix(test): set git identity on the artifact test clone clone_to_b() cloned the source repo but never configured user.email or user.name on the result. A clone does not inherit the source repo's LOCAL config, and CI runners have no global identity, so every commit made in the clone exited 128 -- surfacing as: FAIL tests/test_artifact.c:734: runf("git -C ... add -A && git -C ... commit -qm edits") == 32768, expected 0 32768 is 128 << 8. It passed locally only because developer machines carry a global git identity; the Linux CI shards do not. git_init() already configures identity for the source repo (:574-575); the clone needs the same treatment. This is the defect originally diagnosed on #868 as "git identity in clone_to_b" -- correct for the POSIX legs, and separate from the cmd.exe quoting bug that caused the Windows failure. Signed-off-by: Martin Vogel --- tests/test_artifact.c | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_artifact.c b/tests/test_artifact.c index 9dd7584cd..7d444b757 100644 --- a/tests/test_artifact.c +++ b/tests/test_artifact.c @@ -677,7 +677,14 @@ static bool clone_to_b(char *repoB_out, size_t repoB_sz) { snprintf(work, sizeof(work), "%s/work", g_tmpdir); cbm_mkdir_p(work, 0755); snprintf(repoB_out, repoB_sz, "%s/repo", work); - return runf("git clone -q \"%s\" \"%s\"", g_repo, repoB_out) == 0; + if (runf("git clone -q \"%s\" \"%s\"", g_repo, repoB_out) != 0) { + return false; + } + /* A clone does NOT inherit the source repo's local user config, and CI + * runners have no global identity -- `git commit` then exits 128. Set it + * on the clone the same way git_init() does for the source repo. */ + return runf("git -C \"%s\" config user.email t@t.com", repoB_out) == 0 && + runf("git -C \"%s\" config user.name t", repoB_out) == 0; } TEST(artifact_export_marks_clean_basis) { From 4cbc9e1ca2bee240b83341626b21680d69c4924d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 29 Aug 2026 20:33:15 +0200 Subject: [PATCH 5/6] fix(artifact): stamp the clean-basis trust check the way the rows were written The reconcile_basis marker was never written on Windows, so all six build_trusted_artifact_repo() tests plus artifact_export_marks_clean_basis failed there (16 passed, 7 failed) while every Linux and macOS leg was green. Root cause is a stamp-encoding mismatch, not shell quoting. file_hashes rows are stamped by the semantic manifest via cbm_path_info_utf8(), which on Windows derives mtime_ns from ftLastWriteTime -- 100-nanosecond FILETIME ticks. db_hashes_match_disk() re-stat()ed those same files through _wstat64, whose st_mtime carries WHOLE SECONDS, and compared the two. The values can only agree when a file's write time lands exactly on a second boundary, so the trust check rejected the very first row on every export, on Windows and only on Windows. On POSIX both paths read st_mtim nanoseconds and agree, which is why the split was clean along platform lines. The disk side now reads through cbm_path_info_utf8 -- the same function that wrote the rows -- so there is no encoding left to keep in sync. Its is_regular/is_symlink guard preserves exactly the symlink refusal reconcile_stat_no_symlink provided. reconcile_restamp_rows deliberately keeps art_stat_mtime_ns: a restamped row is read by the incremental classifier and by check_index_coverage, both of which stat() and encode whole seconds on Windows. The asymmetry is now pinned with a comment at both sites so neither is "unified" into breaking the other. Diagnostics, which is why this took two wrong diagnoses to find: - Export's four clean-basis preconditions were one short-circuiting && chain collapsing to a single bool. They are now evaluated by reconcile_basis_trusted, which records the first failing gate (head_unresolved / head_not_hex_oid / tree_not_clean / db_hashes_differ_from_disk, the last carrying the offending path and BOTH stamps) and logs it as artifact.reconcile_basis_omitted. The reason is recorded rather than recomputed on demand: export's own ensure_gitattributes leaves an untracked .gitattributes behind, so any later evaluation answers tree_not_clean whatever the real cause was. - build_trusted_artifact_repo() had six `return NULL` exits and reported none of them; CI could not tell a failed `git commit` from a missing marker. Every exit now names its step and carries the number that decided it -- git exit status, pipeline rc, or export's recorded blocker -- surfaced through BUILD_TRUSTED_REPO_OR_FAIL at the call site so file:line still points at the failing test. Verified locally on macOS by emulating the Windows encoding: truncating the disk-side mtime to whole seconds reproduces the CI signature exactly (16 passed, 7 failed, same seven sites), and the new diagnostic names it as `db_hashes_differ_from_disk path=a.rs reason=mtime_differs stored_mtime_ns=... disk_mtime_ns=...`. Restored, the artifact/pipeline/incremental/mcp/index_format/ daemon_bootstrap suites are green and make lint-ci passes. Co-authored-by: moofone <35233167+moofone@users.noreply.github.com> Signed-off-by: Martin Vogel --- src/pipeline/artifact.c | 120 +++++++++++++++++++++++++++++++++++++--- src/pipeline/artifact.h | 12 ++++ tests/test_artifact.c | 105 +++++++++++++++++++++++++++++------ 3 files changed, 212 insertions(+), 25 deletions(-) diff --git a/src/pipeline/artifact.c b/src/pipeline/artifact.c index 3a6bd485c..df73708b9 100644 --- a/src/pipeline/artifact.c +++ b/src/pipeline/artifact.c @@ -69,6 +69,18 @@ static const char *itoa_buf(int v) { return bufs[i]; } +/* Same rotating-buffer trick for 64-bit values. mtime_ns does not fit an int, + * and a diagnostic that truncates the number it is there to explain is worse + * than no diagnostic. */ +static const char *i64_buf(int64_t v) { + static _Thread_local char bufs[ART_RING][CBM_SZ_32]; + static _Thread_local int idx = 0; + int i = idx; + idx = (idx + ART_NUL) & ART_RING_MASK; + snprintf(bufs[i], sizeof(bufs[i]), "%lld", (long long)v); + return bufs[i]; +} + const char *cbm_artifact_export_last_error(void) { return g_export_error[0] ? g_export_error : NULL; } @@ -517,8 +529,27 @@ static bool reconcile_is_synthetic_row(const char *rel_path) { * mtime_ns + size matches the stored stamp. Any stat failure, path overflow, * or mismatch makes the artifact untrusted for reconciliation — this is the * belt-and-suspenders that catches a stale/swapped DB even when the tree looks - * clean. */ -static bool db_hashes_match_disk(const char *repo_path, const char *db_path, const char *project) { + * clean. + * + * The disk side MUST be read with cbm_path_info_utf8, because that is the + * function that WROTE the rows being compared: the semantic manifest stamps + * every row via pipeline_incremental.c's semantic_manifest_hash_file, which + * calls cbm_path_info_utf8. On POSIX any lstat-based helper agrees with it, + * but on Windows cbm_path_info_utf8 derives mtime_ns from ftLastWriteTime + * (100-ns FILETIME ticks) while a _wstat64 st_mtime carries WHOLE SECONDS — + * so a seconds-truncated re-stat can only ever equal the stored value when a + * file's write time lands exactly on a second boundary. It essentially never + * does, which made this check fail for every row on Windows and only on + * Windows, suppressing the reconcile_basis marker there. Compare like with + * like: same function on both sides, no encoding to keep in sync. + * + * cbm_path_info_utf8 reports symlinks/reparse points rather than following + * them, so the is_symlink/is_regular guard below preserves exactly the + * refusal reconcile_stat_no_symlink provides (git tracks a symlink's link + * text, not its target's bytes). */ +static bool db_hashes_match_disk(const char *repo_path, const char *db_path, const char *project, + char *detail, size_t detail_sz) { + snprintf(detail, detail_sz, "store_unreadable"); cbm_store_t *s = cbm_store_open_path(db_path); if (!s) { return false; @@ -527,9 +558,11 @@ static bool db_hashes_match_disk(const char *repo_path, const char *db_path, con int count = 0; bool match = true; if (cbm_store_get_file_hashes(s, project, &hashes, &count) != CBM_STORE_OK) { + snprintf(detail, detail_sz, "file_hashes_unreadable project=%s", project); cbm_store_close(s); return false; } + detail[0] = '\0'; for (int i = 0; i < count; i++) { if (reconcile_is_synthetic_row(hashes[i].rel_path)) { continue; @@ -538,12 +571,29 @@ static bool db_hashes_match_disk(const char *repo_path, const char *db_path, con int n = snprintf(abs, sizeof(abs), "%s/%s", repo_path, hashes[i].rel_path); if (n < 0 || n >= (int)sizeof(abs)) { match = false; + snprintf(detail, detail_sz, "path=%s reason=path_too_long", hashes[i].rel_path); break; } - struct stat st; - if (reconcile_stat_no_symlink(abs, &st) != 0 || - art_stat_mtime_ns(&st) != hashes[i].mtime_ns || (int64_t)st.st_size != hashes[i].size) { + cbm_path_info_t info; + if (cbm_path_info_utf8(abs, &info) != 0 || !info.is_regular || info.is_symlink) { match = false; + snprintf(detail, detail_sz, "path=%s reason=not_a_readable_regular_file", + hashes[i].rel_path); + break; + } + if (info.mtime_ns != hashes[i].mtime_ns || info.size != hashes[i].size) { + match = false; + /* BOTH stamps are reported: a stale/swapped DB and a stamp-encoding + * mismatch look identical at the boolean, and the numbers are the + * only thing that separates them on a platform we cannot attach a + * debugger to. */ + snprintf(detail, detail_sz, + "path=%s reason=%s stored_mtime_ns=%s disk_mtime_ns=%s stored_size=%s " + "disk_size=%s", + hashes[i].rel_path, + info.size != hashes[i].size ? "size_differs" : "mtime_differs", + i64_buf(hashes[i].mtime_ns), i64_buf(info.mtime_ns), i64_buf(hashes[i].size), + i64_buf(info.size)); break; } } @@ -580,6 +630,49 @@ static bool read_metadata_reconcile_trusted(const char *repo_path) { return trusted; } +/* Description of why the last export on THIS thread withheld the clean-basis + * marker; empty when it wrote one. See cbm_artifact_reconcile_basis_last_blocker. */ +static _Thread_local char g_basis_blocker[CBM_SZ_512]; + +const char *cbm_artifact_reconcile_basis_last_blocker(void) { + return g_basis_blocker[0] ? g_basis_blocker : NULL; +} + +/* Evaluate export's four clean-basis preconditions, recording the first one + * that fails in g_basis_blocker. Returns true iff all four hold. + * + * Split out of cbm_artifact_export deliberately: as one short-circuiting && + * chain the four gates collapsed into a single bool, and a marker that went + * missing on one platform gave no way to tell "HEAD unresolved" from "tree + * dirty" from "DB does not match disk" — which is exactly how a Windows-only + * stamp-encoding bug hid behind a suspected quoting bug for two diagnoses. + * The reason is recorded rather than merely logged so a caller can quote it + * verbatim: recomputing it after the fact reports a DIFFERENT gate, because + * export's own ensure_gitattributes leaves an untracked .gitattributes behind + * and every later evaluation then answers tree_not_clean. */ +static bool reconcile_basis_trusted(const char *repo_path, const char *db_path, + const char *project_name, const char *commit, bool has_commit) { + g_basis_blocker[0] = '\0'; + if (!has_commit) { + snprintf(g_basis_blocker, sizeof(g_basis_blocker), "head_unresolved"); + return false; + } + if (!is_hex_oid(commit)) { + snprintf(g_basis_blocker, sizeof(g_basis_blocker), "head_not_hex_oid"); + return false; + } + if (!tree_clean_for_reconcile(repo_path)) { + snprintf(g_basis_blocker, sizeof(g_basis_blocker), "tree_not_clean"); + return false; + } + char detail[CBM_SZ_256] = ""; + if (!db_hashes_match_disk(repo_path, db_path, project_name, detail, sizeof(detail))) { + snprintf(g_basis_blocker, sizeof(g_basis_blocker), "db_hashes_differ_from_disk %s", detail); + return false; + } + return true; +} + /* ── Metadata read/write ─────────────────────────────────────────── */ /* Read schema_version from artifact.json. Returns -1 if missing/invalid. */ @@ -951,9 +1044,11 @@ int cbm_artifact_export(const char *db_path, const char *repo_path, const char * * bootstrap falls back to today's slow-safe full re-parse. */ char commit[CBM_SZ_64] = ""; bool has_commit = git_head_hash(repo_path, commit, sizeof(commit)); - bool reconcile_trusted = has_commit && is_hex_oid(commit) && - tree_clean_for_reconcile(repo_path) && - db_hashes_match_disk(repo_path, db_path, project_name); + bool reconcile_trusted = + reconcile_basis_trusted(repo_path, db_path, project_name, commit, has_commit); + if (!reconcile_trusted) { + cbm_log_info("artifact.reconcile_basis_omitted", "reason", g_basis_blocker); + } /* Write metadata */ if (write_metadata(repo_path, project_name, commit, nodes, edges, db_size, (size_t)clen, @@ -1352,6 +1447,15 @@ static int reconcile_restamp_rows(const char *repo_path, const char *cache_db_pa batch[batch_n].project = project_name; batch[batch_n].rel_path = stored[i].rel_path; batch[batch_n].sha256 = stored[i].sha256; + /* art_stat_mtime_ns, NOT cbm_path_info_utf8 (which db_hashes_match_disk + * uses): the two disagree on Windows and each side must match ITS OWN + * consumer. A restamped row exists to be read by the incremental + * classifier (pipeline_incremental.c classify_files) and by + * check_index_coverage (mcp.c coverage_path_freshness) — both stat() + * the file and encode whole seconds on Windows. Stamping a 100-ns + * FILETIME value here would make every restamped row read as changed + * there and reconcile would buy nothing. Do not unify these two call + * sites without changing those consumers first. */ batch[batch_n].mtime_ns = art_stat_mtime_ns(&st); batch[batch_n].size = (int64_t)st.st_size; batch_n++; diff --git a/src/pipeline/artifact.h b/src/pipeline/artifact.h index 47a1100a0..15f448671 100644 --- a/src/pipeline/artifact.h +++ b/src/pipeline/artifact.h @@ -39,6 +39,18 @@ int cbm_artifact_export(const char *db_path, const char *repo_path, const char * * Returns NULL if no export error is recorded. */ const char *cbm_artifact_export_last_error(void); +/* Why the most recent cbm_artifact_export on THIS thread did NOT write the + * optional "reconcile_basis" marker, or NULL when it did write one. The string + * names the failing precondition (head_unresolved / head_not_hex_oid / + * tree_not_clean / db_hashes_differ_from_disk) and, for the last of those, the + * offending row with both stamps. + * + * Must be read from the thread that called export and BEFORE anything else + * exports on it. It cannot be reconstructed by re-running export: export's own + * ensure_gitattributes leaves an untracked .gitattributes behind, so a second + * evaluation always answers tree_not_clean regardless of the real cause. */ +const char *cbm_artifact_reconcile_basis_last_blocker(void); + /* Import artifact from .codebase-memory/graph.db.zst to cache_db_path. * Decompresses, runs integrity check, recreates indexes. * Returns 0 on success, -1 on error. */ diff --git a/tests/test_artifact.c b/tests/test_artifact.c index 7d444b757..7657b3faa 100644 --- a/tests/test_artifact.c +++ b/tests/test_artifact.c @@ -634,10 +634,48 @@ static bool stamp_row_mtime(const char *db, const char *proj, const char *rel, i return rc == CBM_STORE_OK; } +/* Why the last build_trusted_artifact_repo() call returned NULL. Read by the + * BUILD_TRUSTED_REPO_OR_FAIL() call sites. + * + * A setup helper whose only failure signal is "NULL" is untestable on a + * platform nobody can attach to: six distinct exits (git commit, pipeline + * construction, pipeline run, marker absence, artifact commit, project-name + * derivation) all reported the same thing, and CI could not tell a failed + * `git commit` from a missing reconcile_basis marker. Every exit now names + * itself and carries the number that decided it. */ +static char g_build_repo_err[4096]; + +static void build_repo_errf(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + vsnprintf(g_build_repo_err, sizeof(g_build_repo_err), fmt, ap); + va_end(ap); +} + +/* Report which of export's four clean-basis preconditions denied the marker. + * Read straight from export's recorded blocker rather than recomputed: export's + * own ensure_gitattributes leaves an untracked .gitattributes behind, so any + * later evaluation answers tree_not_clean whatever the real cause was. Must run + * on the thread that ran the pipeline (cbm_pipeline_run exports inline). */ +static void describe_missing_marker(const char *repo) { + const char *blocker = cbm_artifact_reconcile_basis_last_blocker(); + char meta[1152]; + snprintf(meta, sizeof(meta), "%s/.codebase-memory/artifact.json", repo); + struct stat st; + build_repo_errf( + "step=reconcile_basis: marker absent from %s (artifact.json %s); export blocked by: %s", + meta, stat(meta, &st) == 0 ? "present" : "MISSING", + blocker ? blocker + : ""); +} + /* Build a git repo at g_repo with 5 .rs files, full-index + export (clean tree * -> reconcile_basis marker set), and commit .codebase-memory so a clone receives - * the artifact. Returns the derived project name (caller frees) or NULL. */ + * the artifact. Returns the derived project name (caller frees) or NULL, with + * g_build_repo_err naming the failing step. */ static char *build_trusted_artifact_repo(void) { + g_build_repo_err[0] = '\0'; git_init(g_repo); const char *names[] = {"a.rs", "b.rs", "c.rs", "d.rs", "e.rs"}; for (int i = 0; i < 5; i++) { @@ -647,29 +685,58 @@ static char *build_trusted_artifact_repo(void) { snprintf(body, sizeof(body), "pub fn f%d() {}\n", i); write_text_file(p, body); } - if (runf("git -C \"%s\" add -A && git -C \"%s\" commit -qm init", g_repo, g_repo) != 0) { + int git_rc = runf("git -C \"%s\" add -A && git -C \"%s\" commit -qm init", g_repo, g_repo); + if (git_rc != 0) { + build_repo_errf("step=commit_sources: `git add -A && git commit -qm init` in %s " + "exited status=%d", + g_repo, git_rc); return NULL; } cbm_pipeline_t *p = cbm_pipeline_new(g_repo, g_db, CBM_MODE_FAST); if (!p) { + build_repo_errf("step=pipeline_new: cbm_pipeline_new(repo=%s, db=%s) returned NULL", g_repo, + g_db); return NULL; } cbm_pipeline_set_persistence(p, true); int rc = cbm_pipeline_run(p); cbm_pipeline_free(p); if (rc != 0) { + build_repo_errf("step=pipeline_run: cbm_pipeline_run(repo=%s) rc=%d", g_repo, rc); + return NULL; + } + char *project = cbm_project_name_from_path(g_repo); + if (!project) { + build_repo_errf("step=project_name: cbm_project_name_from_path(%s) returned NULL", g_repo); return NULL; } /* Clean source tree at export -> marker must be present. */ if (!meta_contains(g_repo, "\"reconcile_basis\"")) { + describe_missing_marker(g_repo); + free(project); return NULL; } - if (runf("git -C \"%s\" add -A && git -C \"%s\" commit -qm artifact", g_repo, g_repo) != 0) { + git_rc = runf("git -C \"%s\" add -A && git -C \"%s\" commit -qm artifact", g_repo, g_repo); + if (git_rc != 0) { + build_repo_errf("step=commit_artifact: `git add -A && git commit -qm artifact` in %s " + "exited status=%d", + g_repo, git_rc); + free(project); return NULL; } - return cbm_project_name_from_path(g_repo); + return project; } +/* Call-site form: keeps file:line on the failing TEST while printing the step + * that actually failed inside the helper. */ +#define BUILD_TRUSTED_REPO_OR_FAIL(var) \ + do { \ + (var) = build_trusted_artifact_repo(); \ + if (!(var)) { \ + FAIL(g_build_repo_err); \ + } \ + } while (0) + /* Clones g_repo (A) into /work/repo (B) so both share the basename "repo" * and thus the derived project name — required for artifact bootstrap. */ static bool clone_to_b(char *repoB_out, size_t repoB_sz) { @@ -708,7 +775,11 @@ TEST(artifact_export_marks_clean_basis) { cbm_pipeline_set_persistence(p, true); ASSERT_EQ(cbm_pipeline_run(p), 0); cbm_pipeline_free(p); - ASSERT(meta_contains(g_repo, "\"reconcile_basis\"")); + if (!meta_contains(g_repo, "\"reconcile_basis\"")) { + describe_missing_marker(g_repo); + free(proj); + FAIL(g_build_repo_err); + } /* Dirty the source tree (uncommitted edit). Re-export must omit the marker: * the tree is non-clean outside .codebase-memory (and the a.rs hash row no @@ -724,8 +795,8 @@ TEST(artifact_export_marks_clean_basis) { TEST(artifact_reconcile_restamps_unchanged) { setup_artifact_test(); - char *proj = build_trusted_artifact_repo(); - ASSERT_NOT_NULL(proj); + char *proj = NULL; + BUILD_TRUSTED_REPO_OR_FAIL(proj); char repoB[1024]; ASSERT(clone_to_b(repoB, sizeof(repoB))); @@ -775,8 +846,8 @@ TEST(artifact_reconcile_restamps_unchanged) { TEST(artifact_reconcile_skips_untracked_rows) { setup_artifact_test(); - char *proj = build_trusted_artifact_repo(); - ASSERT_NOT_NULL(proj); + char *proj = NULL; + BUILD_TRUSTED_REPO_OR_FAIL(proj); char repoB[1024]; ASSERT(clone_to_b(repoB, sizeof(repoB))); @@ -822,8 +893,8 @@ TEST(artifact_reconcile_skips_untracked_rows) { TEST(artifact_reconcile_skips_untrusted_metadata) { setup_artifact_test(); - char *proj = build_trusted_artifact_repo(); - ASSERT_NOT_NULL(proj); + char *proj = NULL; + BUILD_TRUSTED_REPO_OR_FAIL(proj); char repoB[1024]; ASSERT(clone_to_b(repoB, sizeof(repoB))); @@ -867,8 +938,8 @@ TEST(artifact_reconcile_skips_untrusted_metadata) { TEST(artifact_reconcile_skips_unknown_commit) { setup_artifact_test(); - char *proj = build_trusted_artifact_repo(); - ASSERT_NOT_NULL(proj); + char *proj = NULL; + BUILD_TRUSTED_REPO_OR_FAIL(proj); char repoB[1024]; ASSERT(clone_to_b(repoB, sizeof(repoB))); @@ -897,8 +968,8 @@ TEST(artifact_reconcile_skips_unknown_commit) { TEST(artifact_reconcile_skips_non_hex_commit) { setup_artifact_test(); - char *proj = build_trusted_artifact_repo(); - ASSERT_NOT_NULL(proj); + char *proj = NULL; + BUILD_TRUSTED_REPO_OR_FAIL(proj); char repoB[1024]; ASSERT(clone_to_b(repoB, sizeof(repoB))); @@ -930,8 +1001,8 @@ TEST(artifact_reconcile_skips_non_hex_commit) { TEST(artifact_reconcile_skips_without_git) { setup_artifact_test(); - char *proj = build_trusted_artifact_repo(); - ASSERT_NOT_NULL(proj); + char *proj = NULL; + BUILD_TRUSTED_REPO_OR_FAIL(proj); char repoB[1024]; ASSERT(clone_to_b(repoB, sizeof(repoB))); From 288d41551e26a9b220431899d6e0423309555d3c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 30 Aug 2026 16:39:49 +0200 Subject: [PATCH 6/6] chore(deps): bump github/codeql-action init+analyze to v4.37.9 together Dependabot split this bump across two PRs -- #1898 bumps init, #1900 bumps analyze -- but codeql-action requires every step in a workflow to run the same version. Either PR alone produces a mismatch and analyze fails with: Loaded a configuration file for version '4.37.4', but running version '4.37.9' so neither can be green on its own, and merging either would leave main's CodeQL job broken for every subsequent PR. Bump both pins in one commit. Pin verified: cdf488f595d80d6e07e03d4674febd5ab45fa938 is the commit that the annotated tag v4.37.9 dereferences to in github/codeql-action. Supersedes #1898 and #1900. Signed-off-by: Martin Vogel --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c1e5b17e9..dbaa52a6c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,7 +29,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y zlib1g-dev - name: Initialize CodeQL - uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: c-cpp build-mode: manual @@ -38,6 +38,6 @@ jobs: run: scripts/build.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: "/language:c-cpp"