Files
deusdata--codebase-memory-mcp/tests/repro/repro_issue557.c
T
wehub-resource-sync 41cb1c0170
OpenSSF Scorecard / scorecard (push) Failing after 0s
DCO / dco (push) Failing after 0s
CodeQL SAST / analyze (push) Failing after 1s
Deploy Pages / deploy (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:28:05 +08:00

286 lines
13 KiB
C

/*
* repro_issue557.c -- Reproduce-first case for OPEN bug #557.
*
* Issue: #557 -- "cbm v0.8.1 silently deletes project DBs on 'corrupt'
* detection -- data loss with no recovery"
*
* DESTROYING CODE PATH:
* src/mcp/mcp.c resolve_store() lines 796-810
*
* The sequence is:
* 1. resolve_store() opens the project DB with cbm_store_open_path_query().
* 2. It calls cbm_store_check_integrity() (src/store/store.c:664).
* That function returns false when the projects table contains a row
* whose root_path does not start with '/', 'A'-'Z', or 'a'-'z' (the
* numeric-string corruption pattern -- e.g. "826" -- observed in the
* binary and confirmed in the issue report).
* 3. On false, resolve_store() calls cbm_unlink(path) at mcp.c:803,
* then cbm_unlink(wal_path) and cbm_unlink(shm_path) -- with NO rename,
* NO backup, NO recovery path. The user's indexed project is gone.
*
* ROOT CAUSE:
* "Delete on first suspicion" design in resolve_store(). The unlink is
* unconditional and irreversible. Any false-positive integrity signal
* (WAL/SHM leftover after SIGKILL, schema-version drift between standard
* and UI binary variants, or a root_path value that happens not to match
* the narrow whitelist) causes permanent data loss.
*
* EXPECTED (correct) behaviour:
* After cbm_store_check_integrity() returns false and resolve_store()
* executes its cleanup path, EITHER:
* (a) the original DB file must still exist at db_path (zero deletion), OR
* (b) a backup file must exist at a nearby path (e.g. "<db_path>.corrupt"
* or "<db_path>.bak") so the user can recover the data.
* The original DB must NOT be silently destroyed with no recovery path.
*
* ACTUAL (buggy) behaviour on v0.8.1:
* cbm_unlink(path) at mcp.c:803 destroys the DB file. After resolve_store()
* returns, access(db_path, F_OK) returns -1 (ENOENT) and no backup file
* exists -- total data loss.
*
* WHY RED on current code:
* The final ASSERT_TRUE checks that EITHER db_still_exists OR backup_exists.
* On buggy code cbm_unlink() runs with no rename, so both conditions are
* false and ASSERT_TRUE fires -- RED.
*
* TRIGGER:
* We construct the scenario directly at the store API level (no full index
* needed -- the integrity check runs before any graph data is consulted):
*
* 1. Set CBM_CACHE_DIR to a temp directory so the DB lands in a controlled
* location and does not pollute the real cache.
* 2. Create the DB via cbm_store_open_path() (creates schema + tables).
* 3. Insert one projects row with root_path = "826" -- the exact numeric
* string from the binary evidence in the issue report. This passes the
* "> 5 rows" check (only 1 row) but trips the bad_root_path check in
* cbm_store_check_integrity() because '8' is not '/', 'A'-'Z', or 'a'-'z'.
* 4. Close the store, verify the DB file exists (precondition).
* 5. Call cbm_mcp_handle_tool(srv, "search_graph", ...) with the project
* name. search_graph resolves the project store via resolve_store(),
* which opens the DB, runs the integrity check, detects bad_root_path,
* and executes the destroying cbm_unlink() at mcp.c:803.
* 6. Assert survival: DB file still exists OR a backup exists.
*
* NOTE on determinism:
* The "826" root_path value is a deterministically planted value -- not
* dependent on kill timing or WAL state. cbm_store_check_integrity() is
* a pure SQL query; its result for root_path="826" is guaranteed to be
* false on any build. The trigger is 100% reproducible.
*
* FIX LOCATION (not implemented here):
* src/mcp/mcp.c resolve_store() around line 803:
* Replace cbm_unlink(path) with a rename to a timestamped .corrupt path,
* then log a prominent error so the user knows where the preserved file is.
*/
#include <foundation/compat.h>
#include "test_framework.h"
#include <store/store.h>
#include <mcp/mcp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
/* Project name used throughout: must pass cbm_validate_project_name().
* Kept short and slug-safe so it is valid on every platform. */
#define REPRO557_PROJECT "cbm-repro557-test"
/* ── Helper: check whether a file exists ────────────────────────────── */
static int file_exists(const char *path) {
struct stat st;
return (stat(path, &st) == 0) ? 1 : 0;
}
/* ── Test ─────────────────────────────────────────────────────────────
*
* repro_issue557_corrupt_db_not_silently_deleted
*
* Precondition (must be GREEN to prove the setup is correct):
* The DB file exists at db_path after we create and populate it.
* If this fires RED, the temp dir or store creation failed -- not #557.
*
* The failing assertion (RED on buggy code):
* After resolve_store() detects bad_root_path and runs its cleanup path,
* EITHER the DB file still exists OR a backup file exists.
* On buggy code: neither exists -- ASSERT_TRUE fires.
* ─────────────────────────────────────────────────────────────────── */
TEST(repro_issue557_corrupt_db_not_silently_deleted) {
/* ── Step 1: redirect CBM_CACHE_DIR to a temp dir ─────────────────
*
* cbm_resolve_cache_dir() checks the CBM_CACHE_DIR env var first.
* Pointing it at a fresh temp dir ensures:
* - the test DB is isolated from the user's real cache
* - we know the exact db_path before the MCP call
*
* The static buffer in cbm_resolve_cache_dir() is updated on the
* next call because it re-reads CBM_CACHE_DIR each time. We must
* also call cbm_mkdir on the directory before opening the store.
*/
char tmp_cache[512];
snprintf(tmp_cache, sizeof(tmp_cache), "/tmp/cbm_repro557_XXXXXX");
if (!cbm_mkdtemp(tmp_cache)) {
/* mkdtemp failed -- cannot run the test */
ASSERT_NOT_NULL(NULL); /* marks setup failure clearly */
}
/* Set the env var so all subsequent cbm_resolve_cache_dir() calls
* return tmp_cache. setenv is POSIX; Windows uses _putenv_s. */
#if defined(_WIN32)
char ev[600];
snprintf(ev, sizeof(ev), "CBM_CACHE_DIR=%s", tmp_cache);
_putenv(ev);
#else
setenv("CBM_CACHE_DIR", tmp_cache, 1 /* overwrite */);
#endif
/* ── Step 2: build the DB path we will inspect ────────────────────
*
* project_db_path() in mcp.c computes: <cache_dir>/<project>.db
* Mirror the same formula here so db_path matches exactly.
*/
char db_path[700];
snprintf(db_path, sizeof(db_path), "%s/%s.db", tmp_cache, REPRO557_PROJECT);
/* ── Step 3: create the DB via cbm_store_open_path() ──────────────
*
* cbm_store_open_path() calls store_open_internal() with
* SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, then runs init_schema()
* to create all tables including `projects`. This gives us a
* fully-structured DB at db_path.
*/
cbm_store_t *setup_store = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(setup_store); /* precondition: store creation must work */
/* ── Step 4: insert a project row with a bad root_path ────────────
*
* root_path = "826" is the exact numeric string from the binary
* evidence in the issue report and confirmed by the integrity check
* SQL in cbm_store_check_integrity():
*
* SELECT root_path FROM projects
* WHERE root_path != ''
* AND NOT (substr(root_path,1,1) = '/'
* OR substr(...) BETWEEN 'A' AND 'Z'
* OR substr(...) BETWEEN 'a' AND 'z')
* LIMIT 1;
*
* '8' does not satisfy any of the three path-start conditions, so
* the query returns the row and cbm_store_check_integrity() returns
* false -- which is the exact trigger for the destroying path.
*
* cbm_store_upsert_project() is the store's own public API for
* writing project rows (used by the pipeline on every full index).
*/
int rc = cbm_store_upsert_project(setup_store, REPRO557_PROJECT, "826");
ASSERT_EQ(rc, CBM_STORE_OK); /* precondition: row must be written */
cbm_store_close(setup_store);
setup_store = NULL;
/* ── Step 5: verify the DB exists before triggering the MCP path ──
*
* This is the precondition that confirms setup succeeded.
* If this fires RED, something in Steps 2-4 broke -- not #557.
*/
ASSERT_TRUE(file_exists(db_path)); /* precondition: DB must exist now */
/* ── Step 6: drive resolve_store() via cbm_mcp_handle_tool ────────
*
* search_graph is the lightest query tool that reaches resolve_store().
* The tool handler calls resolve_store(srv, project) which:
* 1. Calls cbm_store_open_path_query(path) -- opens read-write/no-create.
* The DB was created in step 3 so SQLITE_OPEN_READWRITE succeeds.
* 2. Calls cbm_store_check_integrity() -- returns false (root_path="826").
* 3. Closes the store and calls cbm_unlink(path) at mcp.c:803.
* Then cbm_unlink(wal_path) and cbm_unlink(shm_path).
* 4. Returns NULL (resolve_store() returns NULL on corrupt detection).
*
* We do not assert anything about the search_graph response -- the
* response is irrelevant (it will be an error about the project not
* being found). What matters is the side-effect on db_path.
*/
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv); /* precondition: server must initialise */
char args[512];
snprintf(args, sizeof(args),
"{\"project\":\"%s\","
"\"query\":\"Function\","
"\"limit\":1}",
REPRO557_PROJECT);
char *resp = cbm_mcp_handle_tool(srv, "search_graph", args);
/* Response may be NULL or an error string -- we do not assert on it.
* The side-effect (unlink) is what we are testing. */
if (resp) {
free(resp);
}
cbm_mcp_server_free(srv);
/* ── Step 7: PRIMARY ASSERTION -- the DB must survive ─────────────
*
* Correct behaviour: the DB is quarantined (renamed to a backup path)
* rather than silently destroyed. We accept either:
* (a) the original DB still exists at db_path (zero deletion), or
* (b) a backup file exists at a conventional backup path.
*
* Two conventional backup suffixes from the suggested fix in #557:
* "<db_path>.corrupt" -- timestamped or plain rename
* "<db_path>.bak" -- simpler alternative
*
* WHY RED on buggy code:
* cbm_unlink(path) at mcp.c:803 removes the file.
* No rename to .corrupt or .bak is performed.
* db_still_exists == 0 and backup_exists == 0.
* ASSERT_TRUE(0) fires -- RED.
*/
int db_still_exists = file_exists(db_path);
char backup_corrupt[720], backup_bak[720];
snprintf(backup_corrupt, sizeof(backup_corrupt), "%s.corrupt", db_path);
snprintf(backup_bak, sizeof(backup_bak), "%s.bak", db_path);
int backup_exists = file_exists(backup_corrupt) || file_exists(backup_bak);
/* Clean up temp dir (best effort -- before the assertion so the dir
* is removed even when the assertion fails and longjmp unwinds). */
unlink(db_path);
unlink(backup_corrupt);
unlink(backup_bak);
char wal[730], shm[730];
snprintf(wal, sizeof(wal), "%s-wal", db_path);
snprintf(shm, sizeof(shm), "%s-shm", db_path);
unlink(wal);
unlink(shm);
rmdir(tmp_cache);
#if defined(_WIN32)
_putenv("CBM_CACHE_DIR=");
#else
unsetenv("CBM_CACHE_DIR");
#endif
/*
* THE KEY ASSERTION -- must be RED on unpatched code:
*
* db_still_exists -- 1 if the DB was preserved in-place (zero-delete fix)
* backup_exists -- 1 if a .corrupt or .bak rename was made (quarantine fix)
*
* On buggy code: both are 0 because cbm_unlink() ran with no backup.
* On fixed code: at least one is 1.
*/
ASSERT_TRUE(db_still_exists || backup_exists);
PASS();
}
/* ── Suite ─────────────────────────────────────────────────────────── */
SUITE(repro_issue557) {
RUN_TEST(repro_issue557_corrupt_db_not_silently_deleted);
}