/* * test_artifact.c — Tests for persistent artifact export/import. */ #include "test_framework.h" #include "store/store.h" #include "pipeline/artifact.h" #include "pipeline/pipeline.h" #include "foundation/compat.h" #include "foundation/compat_fs.h" #include "foundation/log.h" #include #include /* ── Helpers ─────────────────────────────────────────────────────── */ static char g_tmpdir[1024]; static char g_repo[1024]; static char g_db[1024]; enum { ART_TEST_LOG_BUF = 32768 }; static char g_log_capture[ART_TEST_LOG_BUF]; static CBMLogLevel g_prev_log_level; static void setup_artifact_test(void) { snprintf(g_tmpdir, sizeof(g_tmpdir), "%s/cbm_test_artifact_XXXXXX", cbm_tmpdir()); cbm_mkdtemp(g_tmpdir); snprintf(g_repo, sizeof(g_repo), "%s/repo", g_tmpdir); cbm_mkdir_p(g_repo, 0755); snprintf(g_db, sizeof(g_db), "%s/test.db", g_tmpdir); } /* Create a minimal but valid DB with some nodes and edges. */ static void create_test_db(const char *path) { cbm_store_t *s = cbm_store_open_path(path); if (!s) { return; } cbm_store_exec(s, "INSERT OR IGNORE INTO projects(name, indexed_at, root_path) " "VALUES('test-proj', '2026-01-01', '/tmp/test');"); cbm_store_exec(s, "INSERT INTO nodes(project, label, name, qualified_name, file_path) " "VALUES('test-proj', 'Function', 'foo', 'test-proj.foo', 'main.c');"); cbm_store_exec(s, "INSERT INTO nodes(project, label, name, qualified_name, file_path) " "VALUES('test-proj', 'Function', 'bar', 'test-proj.bar', 'main.c');"); cbm_store_exec(s, "INSERT INTO edges(project, source_id, target_id, type) " "VALUES('test-proj', 1, 2, 'CALLS');"); cbm_store_close(s); } static void cleanup_dir(const char *path) { char cmd[2048]; snprintf(cmd, sizeof(cmd), "rm -rf '%s'", path); (void)system(cmd); } static void write_text_file(const char *path, const char *text) { FILE *fp = fopen(path, "w"); if (!fp) { return; } fputs(text, fp); fclose(fp); } static void capture_log_sink(const char *line) { size_t used = strlen(g_log_capture); size_t avail = sizeof(g_log_capture) - used; if (avail <= 1) { return; } int n = snprintf(g_log_capture + used, avail, "%s\n", line); if (n < 0 || (size_t)n >= avail) { g_log_capture[sizeof(g_log_capture) - 1] = '\0'; } } static void capture_logs_start(void) { g_log_capture[0] = '\0'; g_prev_log_level = cbm_log_get_level(); cbm_log_set_level(CBM_LOG_DEBUG); cbm_log_set_sink(capture_log_sink); } static const char *capture_logs_end(void) { cbm_log_set_sink(NULL); cbm_log_set_level(g_prev_log_level); return g_log_capture; } /* ── Tests ───────────────────────────────────────────────────────── */ /* Rewrite the "original_size" number in an artifact.json in place, adding * `delta` to it. Returns false if the field / a digit run isn't found. */ static bool bump_artifact_original_size(const char *meta_path, long delta) { FILE *fp = fopen(meta_path, "rb"); if (!fp) { return false; } char buf[4096]; size_t n = fread(buf, 1, sizeof(buf) - 1, fp); fclose(fp); buf[n] = '\0'; char *key = strstr(buf, "\"original_size\""); if (!key) { return false; } char *colon = strchr(key, ':'); if (!colon) { return false; } char *ds = colon + 1; while (*ds == ' ' || *ds == '\t') { ds++; } char *de = ds; while (*de >= '0' && *de <= '9') { de++; } if (de == ds) { return false; } long val = strtol(ds, NULL, 10) + delta; char out[4096]; int pre = (int)(ds - buf); snprintf(out, sizeof(out), "%.*s%ld%s", pre, buf, val, de); fp = fopen(meta_path, "wb"); if (!fp) { return false; } fwrite(out, 1, strlen(out), fp); fclose(fp); return true; } /* The decompressed size is driven by the zstd frame's own content-size header, * not the separately-stored original_size field (which travels in plaintext * artifact.json and is trivially editable). A mismatch between the two must be * rejected — this is the check that keeps the destination allocation and the * decoder capacity pinned to the same verified size, so a doctored size can * never make the decoder write past the buffer. */ TEST(artifact_import_rejects_size_mismatch) { setup_artifact_test(); create_test_db(g_db); ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); char meta[1024]; snprintf(meta, sizeof(meta), "%s/.codebase-memory/artifact.json", g_repo); ASSERT_TRUE( bump_artifact_original_size(meta, 4096)); /* claim 4 KiB more than the frame holds */ char import_db[1024]; snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); int rc = cbm_artifact_import(g_repo, import_db); ASSERT_NEQ(rc, 0); /* must reject the mismatch, not import on the doctored size */ cleanup_dir(g_tmpdir); PASS(); } TEST(artifact_export_fast_roundtrip) { setup_artifact_test(); create_test_db(g_db); /* Export with fast quality (zstd -3, no index stripping) */ int rc = cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST); ASSERT_EQ(rc, 0); /* Verify artifact files exist */ char zst[1024]; snprintf(zst, sizeof(zst), "%s/.codebase-memory/graph.db.zst", g_repo); struct stat st; ASSERT_EQ(stat(zst, &st), 0); ASSERT_GT((int)st.st_size, 0); char meta[1024]; snprintf(meta, sizeof(meta), "%s/.codebase-memory/artifact.json", g_repo); ASSERT_EQ(stat(meta, &st), 0); /* Import to a new path */ char import_db[1024]; snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); rc = cbm_artifact_import(g_repo, import_db); ASSERT_EQ(rc, 0); /* Verify imported DB has correct data */ cbm_store_t *s = cbm_store_open_path(import_db); ASSERT_NOT_NULL(s); int nodes = cbm_store_count_nodes(s, "test-proj"); int edges = cbm_store_count_edges(s, "test-proj"); ASSERT_EQ(nodes, 2); ASSERT_EQ(edges, 1); cbm_store_close(s); cleanup_dir(g_tmpdir); PASS(); } TEST(artifact_export_best_roundtrip) { setup_artifact_test(); create_test_db(g_db); /* Export with best quality (zstd -9, index stripping + VACUUM) */ int rc = cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_BEST); ASSERT_EQ(rc, 0); /* Source DB should be untouched (VACUUM INTO doesn't modify source) */ cbm_store_t *src = cbm_store_open_path(g_db); ASSERT_NOT_NULL(src); ASSERT_EQ(cbm_store_count_nodes(src, "test-proj"), 2); cbm_store_close(src); /* Import and verify */ char import_db[1024]; snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); rc = cbm_artifact_import(g_repo, import_db); ASSERT_EQ(rc, 0); cbm_store_t *s = cbm_store_open_path(import_db); ASSERT_NOT_NULL(s); ASSERT_EQ(cbm_store_count_nodes(s, "test-proj"), 2); ASSERT_EQ(cbm_store_count_edges(s, "test-proj"), 1); cbm_store_close(s); cleanup_dir(g_tmpdir); PASS(); } TEST(artifact_exists_check) { setup_artifact_test(); create_test_db(g_db); /* No artifact yet */ ASSERT_FALSE(cbm_artifact_exists(g_repo)); /* Export creates the artifact */ cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST); ASSERT_TRUE(cbm_artifact_exists(g_repo)); cleanup_dir(g_tmpdir); PASS(); } TEST(artifact_commit_hash) { setup_artifact_test(); create_test_db(g_db); cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST); /* commit hash may be empty if repo is not a git repo, but should not crash */ char *commit = cbm_artifact_commit(g_repo); /* For a non-git directory, commit will be NULL (git rev-parse HEAD fails) */ free(commit); cleanup_dir(g_tmpdir); PASS(); } TEST(artifact_schema_version_mismatch) { setup_artifact_test(); create_test_db(g_db); cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST); /* Overwrite artifact.json with incompatible schema version */ char meta[1024]; snprintf(meta, sizeof(meta), "%s/.codebase-memory/artifact.json", g_repo); FILE *fp = fopen(meta, "w"); ASSERT_NOT_NULL(fp); fprintf(fp, "{\"schema_version\": 999, \"original_size\": 1000}"); fclose(fp); /* exists should return false for incompatible version */ ASSERT_FALSE(cbm_artifact_exists(g_repo)); /* Import should fail */ char import_db[1024]; snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); int rc = cbm_artifact_import(g_repo, import_db); ASSERT_NEQ(rc, 0); cleanup_dir(g_tmpdir); PASS(); } TEST(artifact_import_missing) { setup_artifact_test(); /* Import from repo without artifact should fail gracefully */ char import_db[1024]; snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); int rc = cbm_artifact_import(g_repo, import_db); ASSERT_NEQ(rc, 0); cleanup_dir(g_tmpdir); PASS(); } TEST(artifact_gitattributes_created) { setup_artifact_test(); create_test_db(g_db); cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST); char ga[1024]; snprintf(ga, sizeof(ga), "%s/.codebase-memory/.gitattributes", g_repo); struct stat st; ASSERT_EQ(stat(ga, &st), 0); /* Attribute ORDER is load-bearing: gitattributes apply left to right and * the `binary` macro expands to `-diff -merge -text`, so a trailing * `binary` unsets a preceding `merge=ours` (git check-attr merge reports * "unset" and concurrent artifact refreshes produce binary conflicts * instead of auto-resolving). The driver must come after the macro. */ FILE *gaf = fopen(ga, "r"); ASSERT_NOT_NULL(gaf); char content[512] = {0}; size_t rd = fread(content, 1, sizeof(content) - 1, gaf); (void)fclose(gaf); ASSERT_TRUE(rd > 0); ASSERT_NOT_NULL(strstr(content, CBM_ARTIFACT_FILENAME " binary merge=ours")); ASSERT_TRUE(strstr(content, "merge=ours binary") == NULL); cleanup_dir(g_tmpdir); PASS(); } TEST(artifact_export_rename_failure_logs_specific_error) { setup_artifact_test(); create_test_db(g_db); char art_dir[1024]; snprintf(art_dir, sizeof(art_dir), "%s/.codebase-memory", g_repo); cbm_mkdir_p(art_dir, 0755); char zst[1024]; snprintf(zst, sizeof(zst), "%s/graph.db.zst", art_dir); cbm_mkdir_p(zst, 0755); capture_logs_start(); int rc = cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST); const char *logs = capture_logs_end(); ASSERT_NEQ(rc, 0); ASSERT_FALSE(cbm_artifact_exists(g_repo)); ASSERT_NOT_NULL(cbm_artifact_export_last_error()); ASSERT(strstr(cbm_artifact_export_last_error(), "write_artifact") != NULL); ASSERT(strstr(cbm_artifact_export_last_error(), "rename_temp") != NULL); ASSERT(strstr(logs, "msg=artifact.export") != NULL); ASSERT(strstr(logs, "stage=write_artifact") != NULL); ASSERT(strstr(logs, "err=rename_temp") != NULL); cleanup_dir(g_tmpdir); PASS(); } TEST(pipeline_persistence_export_failure_returns_error) { setup_artifact_test(); char src[1024]; snprintf(src, sizeof(src), "%s/main.c", g_repo); write_text_file(src, "int main(void) { return 0; }\n"); char art_dir[1024]; snprintf(art_dir, sizeof(art_dir), "%s/.codebase-memory", g_repo); cbm_mkdir_p(art_dir, 0755); char zst[1024]; snprintf(zst, sizeof(zst), "%s/graph.db.zst", art_dir); cbm_mkdir_p(zst, 0755); cbm_pipeline_t *p = cbm_pipeline_new(g_repo, g_db, CBM_MODE_FAST); ASSERT_NOT_NULL(p); cbm_pipeline_set_persistence(p, true); capture_logs_start(); int rc = cbm_pipeline_run(p); const char *logs = capture_logs_end(); cbm_pipeline_free(p); ASSERT_NEQ(rc, 0); ASSERT_FALSE(cbm_artifact_exists(g_repo)); ASSERT(strstr(logs, "msg=pipeline.err") != NULL); ASSERT(strstr(logs, "phase=artifact_export") != NULL); cleanup_dir(g_tmpdir); PASS(); } TEST(artifact_null_safety) { ASSERT_NEQ(cbm_artifact_export(NULL, "/tmp", "p", 0), 0); ASSERT_NEQ(cbm_artifact_export("/tmp/x.db", NULL, "p", 0), 0); ASSERT_NEQ(cbm_artifact_import(NULL, "/tmp/x.db"), 0); ASSERT_NEQ(cbm_artifact_import("/tmp", NULL), 0); ASSERT_FALSE(cbm_artifact_exists(NULL)); ASSERT_NULL(cbm_artifact_commit(NULL)); PASS(); } /* ── git shell-out path safety ──────────────────────────────────────────────── * * artifact.c shells out to git via cbm_popen with the repo path interpolated into * the command. It previously used single quotes (`git -C '%s'`) with NO validation * — but cmd.exe does not honor single quotes, so on Windows a repo path with a space * broke argument grouping, and an embedded quote/metacharacter could break out of the * intended argument entirely. The hardening validates the path and switches to double * quotes; cbm_artifact_repo_path_is_shell_safe() is the guard. Rejecting quotes and * shell/cmd.exe metacharacters is the contract; spaces must stay allowed (double * quotes handle them) — that is the concrete regression the single-quote form caused. */ TEST(artifact_repo_path_shell_safe_accepts_plain_and_spaced) { ASSERT_TRUE(cbm_artifact_repo_path_is_shell_safe("/home/user/repo")); ASSERT_TRUE(cbm_artifact_repo_path_is_shell_safe("C:/Users/me/repo")); ASSERT_TRUE(cbm_artifact_repo_path_is_shell_safe("/home/user/my repo")); /* space OK */ PASS(); } TEST(artifact_repo_path_shell_safe_rejects_injection) { ASSERT_FALSE(cbm_artifact_repo_path_is_shell_safe(NULL)); ASSERT_FALSE(cbm_artifact_repo_path_is_shell_safe("it's")); /* single quote */ ASSERT_FALSE(cbm_artifact_repo_path_is_shell_safe("a\"b")); /* double quote */ ASSERT_FALSE(cbm_artifact_repo_path_is_shell_safe("x; rm -rf /")); /* command sep */ ASSERT_FALSE(cbm_artifact_repo_path_is_shell_safe("$(whoami)")); /* substitution */ ASSERT_FALSE(cbm_artifact_repo_path_is_shell_safe("a`id`b")); /* backtick */ ASSERT_FALSE(cbm_artifact_repo_path_is_shell_safe("a|b")); /* pipe */ PASS(); } TEST(artifact_repo_path_shell_safe_rejects_cmd_metachars_on_windows) { #ifdef _WIN32 /* cmd.exe expands %VAR%, delayed !VAR!, and escapes with ^ even inside double * quotes — git_context.c rejects these on Windows and this must match. */ ASSERT_FALSE(cbm_artifact_repo_path_is_shell_safe("C:/a%USERPROFILE%b")); ASSERT_FALSE(cbm_artifact_repo_path_is_shell_safe("C:/a!b")); ASSERT_FALSE(cbm_artifact_repo_path_is_shell_safe("C:/a^b")); #else /* POSIX shells treat % ! ^ literally inside double quotes — allowed. */ ASSERT_TRUE(cbm_artifact_repo_path_is_shell_safe("/a%b")); ASSERT_TRUE(cbm_artifact_repo_path_is_shell_safe("/a^b")); #endif PASS(); } /* #895: the FAST export path (watcher/incremental auto-update) read the * raw main-file bytes of a live WAL-mode store — committed rows still in * the -wal were missing and mid-checkpoint reads produced torn snapshots * that imported as page-corrupted caches. Export must snapshot * consistently (VACUUM INTO) on BOTH quality levels. */ TEST(artifact_fast_export_snapshots_live_wal_store) { setup_artifact_test(); enum { WAL_NODES = 60 }; /* Live store: rows committed but NOT checkpointed into the main file — * exactly the state the watcher export runs against. */ cbm_store_t *s = cbm_store_open_path(g_db); ASSERT_NOT_NULL(s); cbm_store_upsert_project(s, "test-proj", "/tmp/test"); for (int i = 0; i < WAL_NODES; i++) { char name[64]; char qn[128]; snprintf(name, sizeof(name), "walnode_%03d", i); snprintf(qn, sizeof(qn), "test-proj.mod.%s", name); cbm_node_t n = {.project = "test-proj", .label = "Function", .name = name, .qualified_name = qn, .file_path = "mod.py", .start_line = i + 1, .end_line = i + 2}; ASSERT_TRUE(cbm_store_upsert_node(s, &n) > 0); } /* Export WHILE the writer connection is still open. */ ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); cbm_store_close(s); char import_db[1024]; snprintf(import_db, sizeof(import_db), "%s/imported_wal.db", g_tmpdir); ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); cbm_store_t *imp = cbm_store_open_path(import_db); ASSERT_NOT_NULL(imp); /* Torn snapshot = the WAL-resident rows are missing. */ ASSERT_EQ(cbm_store_count_nodes(imp, "test-proj"), WAL_NODES); cbm_store_close(imp); PASS(); } /* #895 (import half): page-level corruption must be refused at import. * The shallow integrity check only sanity-checks the projects table; the * deep variant runs PRAGMA quick_check and catches corrupt pages. */ TEST(store_deep_integrity_detects_page_corruption) { setup_artifact_test(); enum { DEEP_NODES = 800, PAGE = 4096, ZERO_PAGES = 10 }; char db2[1024]; snprintf(db2, sizeof(db2), "%s/deep.db", g_tmpdir); cbm_store_t *s = cbm_store_open_path(db2); ASSERT_NOT_NULL(s); cbm_store_upsert_project(s, "deep", "/tmp/deep"); for (int i = 0; i < DEEP_NODES; i++) { char name[64]; char qn[192]; snprintf(name, sizeof(name), "deep_probe_%04d", i); snprintf(qn, sizeof(qn), "deep.rather.long.module.path.for.page.fill.%s_pad_pad_pad", name); cbm_node_t n = {.project = "deep", .label = "Function", .name = name, .qualified_name = qn, .file_path = "deep.py", .start_line = i + 1, .end_line = i + 2}; ASSERT_TRUE(cbm_store_upsert_node(s, &n) > 0); } cbm_store_close(s); /* Healthy file passes the deep check. */ cbm_store_t *ok = cbm_store_open_path(db2); ASSERT_NOT_NULL(ok); ASSERT_TRUE(cbm_store_check_integrity_deep(ok)); cbm_store_close(ok); /* Zero a mid-file band and the deep check must refuse. */ FILE *f = fopen(db2, "rb+"); ASSERT_NOT_NULL(f); (void)fseek(f, 0, SEEK_END); long pages = ftell(f) / PAGE; ASSERT_TRUE(pages > ZERO_PAGES + 6); char zero[PAGE]; memset(zero, 0, sizeof(zero)); (void)fseek(f, (pages / 2) * (long)PAGE, SEEK_SET); for (int i = 0; i < ZERO_PAGES; i++) { ASSERT_EQ(fwrite(zero, 1, PAGE, f), (size_t)PAGE); } (void)fclose(f); cbm_store_t *bad = cbm_store_open_path(db2); ASSERT_NOT_NULL(bad); ASSERT_FALSE(cbm_store_check_integrity_deep(bad)); cbm_store_close(bad); PASS(); } SUITE(artifact) { RUN_TEST(artifact_fast_export_snapshots_live_wal_store); RUN_TEST(store_deep_integrity_detects_page_corruption); RUN_TEST(artifact_repo_path_shell_safe_accepts_plain_and_spaced); RUN_TEST(artifact_repo_path_shell_safe_rejects_injection); RUN_TEST(artifact_repo_path_shell_safe_rejects_cmd_metachars_on_windows); RUN_TEST(artifact_export_fast_roundtrip); RUN_TEST(artifact_export_best_roundtrip); RUN_TEST(artifact_exists_check); RUN_TEST(artifact_commit_hash); RUN_TEST(artifact_schema_version_mismatch); RUN_TEST(artifact_import_missing); RUN_TEST(artifact_gitattributes_created); RUN_TEST(artifact_export_rename_failure_logs_specific_error); RUN_TEST(pipeline_persistence_export_failure_returns_error); RUN_TEST(artifact_import_rejects_size_mismatch); RUN_TEST(artifact_null_safety); }