diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 90179f211..bf2e1a23a 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -2001,6 +2001,14 @@ static bool quarantine_step_allowed(cbm_mcp_server_t *srv, const char *step) { * only recoverable generation. */ static bool quarantine_corrupt_store(cbm_mcp_server_t *srv, const char *project, const char *path, char *backup_out, size_t backup_out_size) { + /* Never quarantine an empty path: SQLite opens "" as an anonymous temp db, + * and "%s.corrupt.*" of an empty prefix would land as relative litter in + * the process cwd instead of beside the store. */ + if (!path || path[0] == '\0') { + cbm_log_error("store.auto_clean_failed", "project", project, "path", path ? path : "", + "reason", "empty store path"); + return false; + } char backup[CBM_SZ_2K]; char pending[CBM_SZ_2K]; if (!reserve_unique_corrupt_pending(path, pending, sizeof(pending), backup, sizeof(backup))) { @@ -2090,10 +2098,18 @@ static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *pr } /* Open project's .db file — query-only open (no SQLITE_OPEN_CREATE) to - * prevent ghost .db file creation for unknown/unindexed projects. */ + * prevent ghost .db file creation for unknown/unindexed projects. + * An invalid project name yields an EMPTY path from project_db_path(); + * SQLite would open "" as an anonymous temporary database that then fails + * the integrity check and gets quarantined as relative .corrupt litter in + * the process cwd. Skip the direct open and fall through to the #704 + * fallback scan: it matches by internal name, so a legacy db can still + * resolve, and a genuine typo stays not-found. */ char path[CBM_SZ_1K]; project_db_path(project, path, sizeof(path)); - srv->store = cbm_store_open_path_query(path); + if (path[0] != '\0') { + srv->store = cbm_store_open_path_query(path); + } if (srv->store) { /* Check DB integrity — back up (never silently delete) a corrupt DB */ if (!cbm_store_check_integrity(srv->store)) { diff --git a/tests/test_mcp.c b/tests/test_mcp.c index e61fa0ffa..ed9450903 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -5325,6 +5325,75 @@ TEST(tool_cross_repo_honors_source_name_override) { PASS(); } +static int mcp_count_corrupt_named_entries(const char *dir_path) { + int count = 0; + cbm_dir_t *dir = cbm_opendir(dir_path); + if (!dir) { + return -1; + } + cbm_dirent_t *entry; + while ((entry = cbm_readdir(dir)) != NULL) { + if (strstr(entry->name, ".corrupt")) { + count++; + } + } + cbm_closedir(dir); + return count; +} + +/* #1425: an invalid project name makes project_db_path() return an empty + * path; SQLite would open "" as an anonymous temp db, fail integrity, and + * quarantine it as relative .corrupt litter in the process cwd. The resolver + * must skip the direct open (no recovery lease attempted), return the + * ordinary not-found error, and leave no .corrupt artifacts in the cwd or + * the cache dir. */ +TEST(tool_query_invalid_project_name_no_corrupt_litter) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-badname-cache-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + char scratch[256]; + snprintf(scratch, sizeof(scratch), "%s/cbm-mcp-badname-cwd-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(scratch)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char old_cwd[CBM_SZ_4K]; + ASSERT_NOT_NULL(cbm_getcwd(old_cwd, sizeof(old_cwd))); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t probe = {0}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); + + ASSERT_EQ(cbm_chdir(scratch), 0); + char *resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"bad name\",\"name_pattern\":\".*\"}"); + ASSERT_EQ(cbm_chdir(old_cwd), 0); + ASSERT_NOT_NULL(resp); + bool not_found = strstr(resp, "not found") != NULL; + free(resp); + cbm_mcp_server_free(srv); + + int cwd_litter = mcp_count_corrupt_named_entries(scratch); + int cache_litter = mcp_count_corrupt_named_entries(cache); + + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(scratch); + cbm_rmdir(cache); + + ASSERT_TRUE(not_found); + ASSERT_EQ(probe.begin_count, 0); + ASSERT_EQ(probe.try_begin_count, 0); + ASSERT_TRUE(cwd_litter >= 0); + ASSERT_TRUE(cache_litter >= 0); + ASSERT_EQ(cwd_litter + cache_litter, 0); + PASS(); +} + /* Corrupt-store quarantine renames/unlinks the project DB and sidecars, so it * is a mutation even when resolve_store() was reached by a query tool. Generic * queries use a blocking guard for that recovery, while manage_adr reads must @@ -10494,6 +10563,7 @@ SUITE(mcp_mutation_guard) { RUN_TEST(tool_cross_repo_missing_inputs_fail_without_creating_ghost_databases); RUN_TEST(tool_cross_repo_dedupes_targets_before_scanning_and_counting); RUN_TEST(tool_cross_repo_honors_source_name_override); + RUN_TEST(tool_query_invalid_project_name_no_corrupt_litter); RUN_TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested); RUN_TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal); RUN_TEST(tool_manage_adr_corrupt_store_busy_is_retryable);