diff --git a/src/cli/cli.c b/src/cli/cli.c index b868fdca25..239ce74879 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -23,6 +23,7 @@ #include "foundation/constants.h" #include "foundation/log.h" #include "foundation/sha256.h" +#include "foundation/str_util.h" #include "cli/client_adapter.h" #include "mcp/mcp.h" // cbm_mcp_tool_input_schema — CLI flag parser + per-tool --help #include "mcp/index_supervisor.h" @@ -62,7 +63,6 @@ enum { SQL_PARAM_1 = 1, /* sqlite3_bind parameter index 1 */ SQL_PARAM_2 = 2, SEMVER_PARTS = 3, /* major.minor.patch */ - DB_EXT_LEN = 3, /* strlen(".db") */ MIN_ARGC_CMD = 3, /* minimum argc for subcommand with arg */ /* sqlite3_bind parameter index 2 */ /* 10 MB cap factor */ @@ -7207,8 +7207,7 @@ int cbm_list_indexes(const char *home_dir) { int count = 0; cbm_dirent_t *ent; while ((ent = cbm_readdir(d)) != NULL) { - size_t len = strlen(ent->name); - if (len > DB_EXT_LEN && strcmp(ent->name + len - DB_EXT_LEN, ".db") == 0) { + if (cbm_is_project_index_db(ent->name)) { printf(" %s/%s\n", cache_dir, ent->name); count++; } @@ -7231,8 +7230,7 @@ int cbm_remove_indexes(const char *home_dir) { int count = 0; cbm_dirent_t *ent; while ((ent = cbm_readdir(d)) != NULL) { - size_t len = strlen(ent->name); - if (len > DB_EXT_LEN && strcmp(ent->name + len - DB_EXT_LEN, ".db") == 0) { + if (cbm_is_project_index_db(ent->name)) { char path[CLI_BUF_1K]; snprintf(path, sizeof(path), "%s/%s", cache_dir, ent->name); /* Also remove .db.tmp if present */ @@ -7268,7 +7266,7 @@ cbm_config_t *cbm_config_open(const char *cache_dir) { } char dbpath[CLI_BUF_1K]; - snprintf(dbpath, sizeof(dbpath), "%s/_config.db", cache_dir); + snprintf(dbpath, sizeof(dbpath), "%s/" CBM_CONFIG_DB_FILENAME, cache_dir); /* Ensure directory exists */ mkdirp(cache_dir, DIR_PERMS); @@ -10423,7 +10421,7 @@ static int cbm_install_agent_configs_with_previous(const char *home, const char return result; } -/* Count .db files in the cache directory. */ +/* Count project index .db files in the cache directory (internal stores excluded). */ static int count_db_indexes(const char *home) { const char *cache_dir = get_cache_dir(home); if (!cache_dir) { @@ -10436,8 +10434,7 @@ static int count_db_indexes(const char *home) { int count = 0; cbm_dirent_t *ent; while ((ent = cbm_readdir(d)) != NULL) { - size_t len = strlen(ent->name); - if (len > DB_EXT_LEN && strcmp(ent->name + len - DB_EXT_LEN, ".db") == 0) { + if (cbm_is_project_index_db(ent->name)) { count++; } } diff --git a/src/cli/cli.h b/src/cli/cli.h index 5522edef06..8cfe77fe3a 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -404,7 +404,8 @@ unsigned char *cbm_extract_binary_from_zip(const unsigned char *data, int data_l * Prints each file path to stdout. Returns count of .db files found. */ int cbm_list_indexes(const char *home_dir); -/* Remove all .db files in the cache directory. Returns count removed. */ +/* Remove every project index .db (and its sidecars) in the cache directory. + * Internal stores (_config.db, _cross_repo.db) are kept. Returns count removed. */ int cbm_remove_indexes(const char *home_dir); /* ── Config store (persistent key-value, backed by _config.db) ── */ diff --git a/src/foundation/str_util.c b/src/foundation/str_util.c index 9f65c079a9..00c6b4e685 100644 --- a/src/foundation/str_util.c +++ b/src/foundation/str_util.c @@ -310,6 +310,24 @@ bool cbm_validate_project_name(const char *name) { return true; } +bool cbm_is_internal_cache_db(const char *filename) { + return filename && (strcmp(filename, CBM_CONFIG_DB_FILENAME) == 0 || + strcmp(filename, CBM_CROSS_REPO_DB_FILENAME) == 0); +} + +bool cbm_is_project_index_db(const char *filename) { + static const char db_ext[] = ".db"; + const size_t ext_len = sizeof(db_ext) - 1; + if (!filename) { + return false; + } + size_t len = strlen(filename); + if (len <= ext_len || strcmp(filename + len - ext_len, db_ext) != 0) { + return false; + } + return !cbm_is_internal_cache_db(filename); +} + int cbm_utf8_trim_partial(char *buf) { if (!buf) { return 0; diff --git a/src/foundation/str_util.h b/src/foundation/str_util.h index afaeb6ba01..b4d8d8013e 100644 --- a/src/foundation/str_util.h +++ b/src/foundation/str_util.h @@ -73,6 +73,22 @@ bool cbm_validate_shell_path_arg(const char *path); * Returns true if safe, false if the name could escape the cache directory. */ bool cbm_validate_project_name(const char *name); +/* Internal stores that live next to the project indexes in the cache + * directory. They are exact filenames: a project name may itself begin with + * "_" or contain "config", so prefix/substring filters would hide real + * projects. */ +#define CBM_CONFIG_DB_FILENAME "_config.db" +#define CBM_CROSS_REPO_DB_FILENAME "_cross_repo.db" + +/* True when a cache-directory entry is one of the internal stores above. */ +bool cbm_is_internal_cache_db(const char *filename); + +/* True when a cache-directory entry is a project index: ".db" with a + * non-empty stem and not an internal store. The one predicate every + * enumeration of the cache directory's .db files uses (list, count, + * remove, cross-repo). */ +bool cbm_is_project_index_db(const char *filename); + /* Safe snprintf append: clamps offset to prevent buffer overflow on truncation. * When snprintf truncates, it returns what it WOULD have written, which can make * offset > bufsize. Next call: bufsize - offset wraps unsigned → huge → overflow. diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index 9ce0e50fbc..a62b6b6f20 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -1094,15 +1094,13 @@ static int collect_all_projects(char ***out, cr_run_context_t *ctx) { failed = true; break; } - size_t len = strlen(ent->name); - if (len < CR_COL_4 || strcmp(ent->name + len - CR_DB_EXT_LEN, ".db") != 0) { - continue; - } - /* Internal stores are exact filenames. Substring filtering would hide - * legitimate projects such as orders_config_service or api-wal. */ - if (strcmp(ent->name, "_cross_repo.db") == 0 || strcmp(ent->name, "_config.db") == 0) { + /* Internal stores are exact filenames (cbm_is_internal_cache_db). + * Substring filtering would hide legitimate projects such as + * orders_config_service or api-wal. */ + if (!cbm_is_project_index_db(ent->name)) { continue; } + size_t len = strlen(ent->name); if (count >= CR_MAX_PROJECTS) { failed = true; break; diff --git a/tests/test_cli.c b/tests/test_cli.c index 4f95e9c43a..f2211087cb 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -2285,6 +2285,71 @@ TEST(cli_remove_indexes_deletes_orphan_sqlite_sidecars_issue2054) { PASS(); } +/* The cache directory also holds internal stores next to the project + * indexes: the user's settings (_config.db) and the cross-repo store + * (_cross_repo.db). Index enumeration treated every *.db as a project, so + * install --reset-indexes / uninstall deleted the user's config along with + * the indexes, and list/count over-reported. Internal stores are exact + * filenames: a real project whose name starts with "_" is still an index. */ +TEST(cli_index_enumeration_skips_internal_cache_dbs) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-internal-dbs-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + cbm_setenv("HOME", tmpdir, 1); + char cache_dir[512]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + test_mkdirp(cache_dir); + + cbm_config_t *cfg = cbm_config_open(cache_dir); + int set_rc = cfg ? cbm_config_set(cfg, "auto_index", "true") : -1; + cbm_config_close(cfg); + + char config_path[640]; + char cross_path[640]; + char proj_path[640]; + char underscore_proj_path[640]; + snprintf(config_path, sizeof(config_path), "%s/_config.db", cache_dir); + snprintf(cross_path, sizeof(cross_path), "%s/_cross_repo.db", cache_dir); + snprintf(proj_path, sizeof(proj_path), "%s/proj.db", cache_dir); + snprintf(underscore_proj_path, sizeof(underscore_proj_path), "%s/_work-api.db", cache_dir); + write_test_file(cross_path, "cross"); + write_test_file(proj_path, "db"); + write_test_file(underscore_proj_path, "db"); + + int listed = cbm_list_indexes(tmpdir); + int removed = cbm_remove_indexes(tmpdir); + + struct stat st; + bool config_kept = stat(config_path, &st) == 0; + bool cross_kept = stat(cross_path, &st) == 0; + bool proj_absent = stat(proj_path, &st) != 0; + bool underscore_proj_absent = stat(underscore_proj_path, &st) != 0; + char value[32] = ""; + cfg = cbm_config_open(cache_dir); + if (cfg) { + snprintf(value, sizeof(value), "%s", cbm_config_get(cfg, "auto_index", "")); + cbm_config_close(cfg); + } + cli_activation_restore_env(old_home, old_cache); + test_rmdir_r(tmpdir); + + ASSERT_EQ(set_rc, 0); + ASSERT_EQ(listed, 2); + ASSERT_EQ(removed, 2); + ASSERT_TRUE(config_kept); + ASSERT_TRUE(cross_kept); + ASSERT_TRUE(proj_absent); + ASSERT_TRUE(underscore_proj_absent); + ASSERT_STR_EQ(value, "true"); + PASS(); +} + TEST(cli_install_config_only_waits_for_cohort_drain) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-daemon-install-config-race-XXXXXX"); @@ -16264,6 +16329,7 @@ SUITE(cli) { RUN_TEST(cli_activation_commands_reject_malformed_and_unknown_flags); RUN_TEST(cli_install_reset_deletion_waits_for_final_activation_guard); RUN_TEST(cli_remove_indexes_deletes_orphan_sqlite_sidecars_issue2054); + RUN_TEST(cli_index_enumeration_skips_internal_cache_dbs); RUN_TEST(cli_install_config_only_waits_for_cohort_drain); RUN_TEST(cli_install_config_and_path_finish_before_guard_release); RUN_TEST(cli_install_config_failure_keeps_published_binary);