mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-09 09:11:00 +08:00
Merge branch 'main' into fix/issue-1682-dotted-ts-imports
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+22
-1
@@ -7706,7 +7706,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+699
-10
@@ -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 <unistd.h>
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include "foundation/win_utf8.h" /* cbm_path_to_wide */
|
||||
#endif
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────────────────── */
|
||||
@@ -64,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;
|
||||
}
|
||||
@@ -267,6 +284,395 @@ 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>`. 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 "<repo>" <args> 2><null>` 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 <oid>` rather than `cat-file -e <oid>^{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.
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
snprintf(detail, detail_sz, "path=%s reason=path_too_long", hashes[i].rel_path);
|
||||
break;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
@@ -318,11 +724,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 +743,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 +1038,21 @@ 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 =
|
||||
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, 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 +1262,270 @@ 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;
|
||||
/* 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++;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
@@ -61,4 +73,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 */
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <stdio.h>
|
||||
#include <stdarg.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ── 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,482 @@ 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 <repo>/.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;
|
||||
}
|
||||
|
||||
/* 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
|
||||
: "<nothing — export believed it wrote the marker, so the file read here is not "
|
||||
"the file export wrote>");
|
||||
}
|
||||
|
||||
/* 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, 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++) {
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
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 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 <tmp>/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);
|
||||
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) {
|
||||
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);
|
||||
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
|
||||
* 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 = NULL;
|
||||
BUILD_TRUSTED_REPO_OR_FAIL(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 = NULL;
|
||||
BUILD_TRUSTED_REPO_OR_FAIL(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 = NULL;
|
||||
BUILD_TRUSTED_REPO_OR_FAIL(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 = NULL;
|
||||
BUILD_TRUSTED_REPO_OR_FAIL(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 = NULL;
|
||||
BUILD_TRUSTED_REPO_OR_FAIL(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 = NULL;
|
||||
BUILD_TRUSTED_REPO_OR_FAIL(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 +1041,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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user