diff --git a/internal/cbm/service_patterns.c b/internal/cbm/service_patterns.c index 053fe9b2d..2f246a627 100644 --- a/internal/cbm/service_patterns.c +++ b/internal/cbm/service_patterns.c @@ -649,8 +649,9 @@ static bool has_filesystem_extension(const char *path) { static const char *const hard_file_exts[] = { ".cfg", ".conf", ".credentials", ".crt", ".db", ".env", - ".ini", ".key", ".pem", ".pid", ".properties", ".service", - ".sock", ".socket", ".sqlite", ".toml", NULL}; + ".ini", ".key", ".log", ".md", ".pdf", ".pem", + ".pid", ".properties", ".rst", ".service", ".sock", ".socket", + ".sqlite", ".toml", ".txt", NULL}; for (int i = 0; hard_file_exts[i]; i++) { if (path_ext_matches(ext, hard_file_exts[i])) { return true; @@ -716,6 +717,11 @@ bool cbm_service_pattern_is_http_route_literal(const char *literal, const char * if (!path || !path[0]) { return false; } + /* Routes never contain whitespace; reject command/description strings + * (e.g. "/autorun test task description") that start with '/'. */ + if (strpbrk(path, " \t\r\n")) { + return false; + } if (strncmp(path, "http://", 7) == 0 || strncmp(path, "https://", 8) == 0) { return true; } @@ -725,6 +731,16 @@ bool cbm_service_pattern_is_http_route_literal(const char *literal, const char * if (path[0] != '/') { return false; } + /* Reject CLI slash-command syntax ("/ar:allow", "/gh:pr") without blocking + * ordinary route parameters in later segments ("/teams/:team/users/:id"). */ + const char *first_slash = strchr(path + 1, '/'); + size_t first_segment_len = first_slash ? (size_t)(first_slash - (path + 1)) : strlen(path + 1); + if (first_segment_len > 0) { + const char *colon = memchr(path + 1, ':', first_segment_len); + if (colon && path[1] != ':') { + return false; + } + } if (callee_is_delimiter_or_filesystem_builder(callee_name)) { return false; } diff --git a/internal/cbm/service_patterns.h b/internal/cbm/service_patterns.h index 8a87c63fa..0c2a9ba92 100644 --- a/internal/cbm/service_patterns.h +++ b/internal/cbm/service_patterns.h @@ -11,6 +11,8 @@ #ifndef CBM_SERVICE_PATTERNS_H #define CBM_SERVICE_PATTERNS_H +#include + /* Edge type returned by pattern match. */ typedef enum { CBM_SVC_NONE = 0, /* Not a service pattern — use normal CALLS */ @@ -51,6 +53,15 @@ const char *cbm_service_pattern_http_method(const char *callee_name); * Returns NULL if not a known route registration method. */ const char *cbm_service_pattern_route_method(const char *callee_name); +/* Classify a string literal as a genuine HTTP route path. Returns true for + * real routes ("/api/orders", "/users/:id", "https://..."); false for file + * paths ("/tmp/foo.md"), CLI slash-commands ("/ar:allow"), description strings + * (contain whitespace), and other non-route args that extraction may surface. + * Used to gate Route-node creation/emit sites so non-routes don't pollute the + * graph. callee_name is used to reject string-builder callees (str.split, + * os.path.join, ...). May be NULL. */ +bool cbm_service_pattern_is_http_route_literal(const char *literal, const char *callee_name); + /* Get the broker name for an async QN (e.g., "pubsub" from a Pub/Sub QN). * Returns NULL if not an async pattern. */ const char *cbm_service_pattern_broker(const char *resolved_qn); diff --git a/internal/cbm/sqlite_writer.c b/internal/cbm/sqlite_writer.c index e566a04b1..703d14647 100644 --- a/internal/cbm/sqlite_writer.c +++ b/internal/cbm/sqlite_writer.c @@ -449,8 +449,17 @@ typedef struct { PageRef *leaves; int leaf_count; int leaf_cap; + bool ok; } PageBuilder; +static bool write_bytes_at(FILE *fp, long offset, const void *data, size_t len) { + return fp && data && fseek(fp, offset, SEEK_SET) == 0 && fwrite(data, SKIP_ONE, len, fp) == len; +} + +static bool write_page_at(FILE *fp, uint32_t page_num, const uint8_t page[CBM_PAGE_SIZE]) { + return write_bytes_at(fp, (long)(page_num - SKIP_ONE) * CBM_PAGE_SIZE, page, CBM_PAGE_SIZE); +} + static void pb_init(PageBuilder *pb, FILE *fp, uint32_t start_page, bool is_index) { pb->fp = fp; pb->next_page = start_page; @@ -464,6 +473,7 @@ static void pb_init(PageBuilder *pb, FILE *fp, uint32_t start_page, bool is_inde pb->leaves = NULL; pb->leaf_count = 0; pb->leaf_cap = 0; + pb->ok = true; } static void pb_free(PageBuilder *pb) { @@ -473,6 +483,9 @@ static void pb_free(PageBuilder *pb) { } free(pb->leaves); } + pb->leaves = NULL; + pb->leaf_count = 0; + pb->leaf_cap = 0; } // Flush current leaf page to file @@ -480,6 +493,9 @@ static void pb_flush_leaf(PageBuilder *pb) { if (pb->cell_count == 0) { return; } + if (!pb->ok) { + return; + } int hdr = pb->page1_offset; // Write leaf page header @@ -493,8 +509,10 @@ static void pb_flush_leaf(PageBuilder *pb) { pb->next_page = cbm_skip_pending_byte(pb->next_page); uint32_t page_num = pb->next_page; long offset = (long)(page_num - SKIP_ONE) * CBM_PAGE_SIZE; - (void)fseek(pb->fp, offset, SEEK_SET); - (void)fwrite(pb->page, SKIP_ONE, CBM_PAGE_SIZE, pb->fp); + if (!write_bytes_at(pb->fp, offset, pb->page, CBM_PAGE_SIZE)) { + pb->ok = false; + return; + } // Record this leaf for interior page building if (pb->leaf_count >= pb->leaf_cap) { @@ -502,8 +520,8 @@ static void pb_flush_leaf(PageBuilder *pb) { pb->leaf_cap = old_cap == 0 ? INITIAL_LEAF_CAP : old_cap * GROWTH_FACTOR; void *tmp = realloc(pb->leaves, (size_t)pb->leaf_cap * sizeof(PageRef)); if (!tmp) { - free(pb->leaves); - pb->leaves = NULL; + pb_free(pb); + pb->ok = false; return; } pb->leaves = (PageRef *)tmp; @@ -534,6 +552,10 @@ static bool pb_cell_fits(PageBuilder *pb, int cell_len) { // For table leaves: varint(payload_len) + varint(rowid) + payload // For index leaves: varint(payload_len) + payload static void pb_add_cell(PageBuilder *pb, const uint8_t *cell, int cell_len) { + if (!pb->ok || !cell || cell_len <= 0 || !pb_cell_fits(pb, cell_len)) { + pb->ok = false; + return; + } // Write cell content (grows down) pb->content_offset -= cell_len; memcpy(pb->page + pb->content_offset, cell, cell_len); @@ -568,8 +590,14 @@ static int build_interior_cell(const PageRef *child, bool is_index, uint8_t *cel put_u32(cell_buf, child->page_num); return BTREE_PTR_SIZE + put_varint(cell_buf + BTREE_PTR_SIZE, child->max_key); } + if (!child->sep_cell || child->sep_cell_len <= 0) { + return 0; + } int clen = BTREE_PTR_SIZE + child->sep_cell_len; uint8_t *data = (uint8_t *)malloc(clen); + if (!data) { + return 0; + } put_u32(data, child->page_num); memcpy(data + 4, child->sep_cell, child->sep_cell_len); *out_heap = data; @@ -591,16 +619,23 @@ static int write_interior_page(PageBuilder *pb, uint8_t *page, int cell_count, i page[HDR_FRAGBYTES_OFF] = 0; put_u32(page + HDR_RIGHTCHILD_OFF, right_child_page); - (void)fseek(pb->fp, (long)(pnum - SKIP_ONE) * CBM_PAGE_SIZE, SEEK_SET); - (void)fwrite(page, SKIP_ONE, CBM_PAGE_SIZE, pb->fp); + if (!write_page_at(pb->fp, pnum, page)) { + pb->ok = false; + return CBM_NOT_FOUND; + } if (parent_count >= *parent_cap) { int old_pcap = *parent_cap; *parent_cap = old_pcap == 0 ? INITIAL_PARENT_CAP : old_pcap * GROWTH_FACTOR; PageRef *tmp = (PageRef *)realloc(*parents, *parent_cap * sizeof(PageRef)); if (!tmp) { + for (int j = 0; j < parent_count; j++) { + free((*parents)[j].sep_cell); + } free(*parents); *parents = NULL; + *parent_cap = 0; + pb->ok = false; return CBM_NOT_FOUND; } *parents = tmp; @@ -612,6 +647,10 @@ static int write_interior_page(PageBuilder *pb, uint8_t *page, int cell_count, i if (is_index && children[right_child_idx].sep_cell) { int slen = children[right_child_idx].sep_cell_len; (*parents)[parent_count].sep_cell = (uint8_t *)malloc(slen); + if (!(*parents)[parent_count].sep_cell) { + pb->ok = false; + return CBM_NOT_FOUND; + } memcpy((*parents)[parent_count].sep_cell, children[right_child_idx].sep_cell, slen); (*parents)[parent_count].sep_cell_len = slen; } else { @@ -633,13 +672,16 @@ static void free_children(PageRef *children, int child_count, const PageRef *lea // Fill an interior page with cells from children[*idx..child_count-2]. // Updates cell_count, content_offset, ptr_offset, and *idx. -static void fill_interior_page(uint8_t *page, const PageRef *children, int child_count, +static bool fill_interior_page(uint8_t *page, const PageRef *children, int child_count, bool is_index, int *idx, int *cell_count, int *content_offset, int *ptr_offset) { while (*idx < child_count - SKIP_ONE) { uint8_t tbuf[INTERIOR_CELL_BUF]; uint8_t *heap_cell = NULL; int clen = build_interior_cell(&children[*idx], is_index, tbuf, &heap_cell); + if (clen <= 0) { + return false; + } uint8_t *cell_data = heap_cell ? heap_cell : tbuf; int available = *content_offset - *ptr_offset - CELL_PTR_SIZE; @@ -656,6 +698,7 @@ static void fill_interior_page(uint8_t *page, const PageRef *children, int child free(heap_cell); (*idx)++; } + return true; } static uint32_t pb_build_interior(PageBuilder *pb, bool is_index) { @@ -682,8 +725,11 @@ static uint32_t pb_build_interior(PageBuilder *pb, bool is_index) { int content_offset = CBM_PAGE_SIZE; int ptr_offset = BTREE_INTERIOR_HDR; - fill_interior_page(page, children, child_count, is_index, &i, &cell_count, - &content_offset, &ptr_offset); + if (!fill_interior_page(page, children, child_count, is_index, &i, &cell_count, + &content_offset, &ptr_offset)) { + pb->ok = false; + break; + } int right_child_idx = (i < child_count - SKIP_ONE) ? i : child_count - SKIP_ONE; uint32_t right_child_page = 0; @@ -700,11 +746,16 @@ static uint32_t pb_build_interior(PageBuilder *pb, bool is_index) { right_child_page, children, right_child_idx, is_index, &parents, parent_count, &parent_cap); if (parent_count < 0) { + pb->ok = false; break; } } free_children(children, child_count, pb->leaves); + if (!pb->ok) { + free_children(parents, parent_count > 0 ? parent_count : 0, pb->leaves); + return 0; + } children = parents; child_count = parent_count; } @@ -861,6 +912,7 @@ static uint32_t write_overflow_pages(FILE *fp, uint32_t *next_page, const uint8_ int offset = 0; while (offset < data_len) { + *next_page = cbm_skip_pending_byte(*next_page); uint32_t pnum = (*next_page)++; if (first_page == 0) { first_page = pnum; @@ -870,8 +922,9 @@ static uint32_t write_overflow_pages(FILE *fp, uint32_t *next_page, const uint8_ if (prev_next_ptr_offset >= 0) { uint8_t ptr[BTREE_PTR_SIZE]; put_u32(ptr, pnum); - (void)fseek(fp, prev_next_ptr_offset, SEEK_SET); - (void)fwrite(ptr, SKIP_ONE, BTREE_PTR_SIZE, fp); + if (!write_bytes_at(fp, prev_next_ptr_offset, ptr, BTREE_PTR_SIZE)) { + return 0; + } } int chunk = data_len - offset; @@ -886,8 +939,9 @@ static uint32_t write_overflow_pages(FILE *fp, uint32_t *next_page, const uint8_ long page_offset = (long)(pnum - SKIP_ONE) * CBM_PAGE_SIZE; prev_next_ptr_offset = page_offset; - (void)fseek(fp, page_offset, SEEK_SET); - (void)fwrite(page, SKIP_ONE, CBM_PAGE_SIZE, fp); + if (!write_bytes_at(fp, page_offset, page, CBM_PAGE_SIZE)) { + return 0; + } offset += chunk; } @@ -1038,11 +1092,13 @@ static bool pb_ensure_leaf_cap(PageBuilder *pb) { pb->leaf_cap = pb->leaf_cap == 0 ? INITIAL_LEAF_CAP : pb->leaf_cap * GROWTH_FACTOR; void *tmp = realloc(pb->leaves, (size_t)pb->leaf_cap * sizeof(PageRef)); if (!tmp) { - free(pb->leaves); - pb->leaves = NULL; + pb_free(pb); + pb->ok = false; return false; } pb->leaves = (PageRef *)tmp; + memset(&pb->leaves[pb->leaf_count], 0, + ((size_t)pb->leaf_cap - (size_t)pb->leaf_count) * sizeof(PageRef)); return true; } @@ -1081,7 +1137,10 @@ static int get_varint(const uint8_t *buf, uint64_t *out) { // overflow pages: varint(payload_len) + payload[0..local) + u32(first_ovfl). // Returns the (possibly new, malloc'd) cell; frees the original when replaced. static uint8_t *overflowize_index_cell(FILE *fp, uint32_t *next_page, uint8_t *cell, - int *cell_len) { + int *cell_len, bool *ok) { + if (!*ok) { + return cell; + } uint64_t plen = 0; int vlen = get_varint(cell, &plen); if ((int64_t)plen <= INDEX_OVERFLOW_MAX_LOCAL) { @@ -1092,10 +1151,15 @@ static uint8_t *overflowize_index_cell(FILE *fp, uint32_t *next_page, uint8_t *c int local = (k <= INDEX_OVERFLOW_MAX_LOCAL) ? (int)k : INDEX_OVERFLOW_MIN_LOCAL; uint32_t first_ovfl = write_overflow_pages(fp, next_page, cell + vlen + local, (int)plen - local); + if (first_ovfl == 0) { + *ok = false; + return cell; + } int nlen = vlen + local + BTREE_PTR_SIZE; uint8_t *data = (uint8_t *)malloc((size_t)nlen); if (!data) { - return cell; /* fall back to the (broken) inline form on OOM */ + *ok = false; + return cell; } memcpy(data, cell, (size_t)(vlen + local)); put_u32(data + vlen + local, first_ovfl); @@ -1126,6 +1190,7 @@ static void pb_add_table_cell_with_flush(PageBuilder *pb, int64_t rowid, const u uint32_t overflow_page = write_overflow_pages(pb->fp, &pb->next_page, payload + local_len, payload_len - local_len); if (overflow_page == 0) { + pb->ok = false; return; // overflow write failed } @@ -1136,11 +1201,13 @@ static void pb_add_table_cell_with_flush(PageBuilder *pb, int64_t rowid, const u } if (!cell) { + pb->ok = false; return; } if (!pb_cell_fits(pb, cell_len) && pb->cell_count > 0) { if (!pb_ensure_leaf_cap(pb)) { + pb->ok = false; free(cell); return; } @@ -1148,17 +1215,35 @@ static void pb_add_table_cell_with_flush(PageBuilder *pb, int64_t rowid, const u pb->leaves[pb->leaf_count].sep_cell = NULL; pb->leaves[pb->leaf_count].sep_cell_len = 0; pb_flush_leaf(pb); + if (!pb->ok) { + free(cell); + return; + } } + if (!pb_cell_fits(pb, cell_len)) { + pb->ok = false; + free(cell); + return; + } pb_add_cell(pb, cell, cell_len); free(cell); } // Finalize a table PageBuilder: flush last leaf and build interior pages. static uint32_t pb_finalize_table(PageBuilder *pb, uint32_t *next_page, int64_t last_rowid) { + if (!pb->ok) { + pb_free(pb); + return 0; + } if (pb->cell_count > 0) { - pb_ensure_leaf_cap(pb); + if (!pb_ensure_leaf_cap(pb)) { + pb->ok = false; + pb_free(pb); + return 0; + } if (!pb->leaves) { + pb->ok = false; pb_free(pb); return 0; } @@ -1166,6 +1251,10 @@ static uint32_t pb_finalize_table(PageBuilder *pb, uint32_t *next_page, int64_t pb->leaves[pb->leaf_count].sep_cell = NULL; pb->leaves[pb->leaf_count].sep_cell_len = 0; pb_flush_leaf(pb); + if (!pb->ok) { + pb_free(pb); + return 0; + } } *next_page = pb->next_page; @@ -1199,9 +1288,7 @@ static uint32_t write_table_btree(FILE *fp, uint32_t *next_page, const uint8_t * put_u16(page + hdr + HDR_CELLCOUNT_OFF, 0); // 0 cells put_u16(page + hdr + HDR_CONTENT_OFF, (uint16_t)CBM_PAGE_SIZE); // content at end of page page[hdr + HDR_FRAGBYTES_OFF] = 0; // 0 fragmented bytes - (void)fseek(fp, (long)(pnum - SKIP_ONE) * CBM_PAGE_SIZE, SEEK_SET); - (void)fwrite(page, SKIP_ONE, CBM_PAGE_SIZE, fp); - return pnum; + return write_page_at(fp, pnum, page) ? pnum : 0; } PageBuilder pb; @@ -1212,6 +1299,10 @@ static uint32_t write_table_btree(FILE *fp, uint32_t *next_page, const uint8_t * for (int i = 0; i < count; i++) { pb_add_table_cell_with_flush(&pb, rowids[i], records[i], record_lens[i], i > 0 ? rowids[i - SKIP_ONE] : 0); + if (!pb.ok) { + pb_free(&pb); + return 0; + } } return pb_finalize_table(&pb, next_page, rowids[count - SKIP_ONE]); @@ -1224,6 +1315,10 @@ static bool pb_promote_and_flush(PageBuilder *pb, uint8_t **cells, int *cell_len } pb->leaves[pb->leaf_count].max_key = 0; pb->leaves[pb->leaf_count].sep_cell = (uint8_t *)malloc(cell_lens[prev_idx]); + if (!pb->leaves[pb->leaf_count].sep_cell) { + pb->ok = false; + return false; + } memcpy(pb->leaves[pb->leaf_count].sep_cell, cells[prev_idx], cell_lens[prev_idx]); pb->leaves[pb->leaf_count].sep_cell_len = cell_lens[prev_idx]; @@ -1249,9 +1344,7 @@ static uint32_t write_empty_index_leaf(FILE *fp, uint32_t *next_page) { put_u16(page + HDR_CELLCOUNT_OFF, 0); put_u16(page + HDR_CONTENT_OFF, (uint16_t)CBM_PAGE_SIZE); page[HDR_FRAGBYTES_OFF] = 0; - (void)fseek(fp, (long)(pnum - SKIP_ONE) * CBM_PAGE_SIZE, SEEK_SET); - (void)fwrite(page, SKIP_ONE, CBM_PAGE_SIZE, fp); - return pnum; + return write_page_at(fp, pnum, page) ? pnum : 0; } // Write leaf pages for an index, returns root page. @@ -1265,8 +1358,12 @@ static uint32_t write_index_btree(FILE *fp, uint32_t *next_page, uint8_t **cells * every cell added below is within the local-payload limit (see * INDEX_OVERFLOW_MAX_LOCAL). Overflow pages are allocated from *next_page * ahead of the leaf pages, which is fine — page order is arbitrary. */ + bool overflow_ok = true; for (int i = 0; i < count; i++) { - cells[i] = overflowize_index_cell(fp, next_page, cells[i], &cell_lens[i]); + cells[i] = overflowize_index_cell(fp, next_page, cells[i], &cell_lens[i], &overflow_ok); + if (!overflow_ok) { + return 0; + } } PageBuilder pb; @@ -1276,6 +1373,11 @@ static uint32_t write_index_btree(FILE *fp, uint32_t *next_page, uint8_t **cells if (!pb_cell_fits(&pb, cell_lens[i])) { if (pb.cell_count > 0) { if (!pb_promote_and_flush(&pb, cells, cell_lens, i - SKIP_ONE)) { + pb_free(&pb); + return 0; + } + if (!pb.ok) { + pb_free(&pb); return 0; } } @@ -1288,18 +1390,33 @@ static uint32_t write_index_btree(FILE *fp, uint32_t *next_page, uint8_t **cells } } pb_add_cell(&pb, cells[i], cell_lens[i]); + if (!pb.ok) { + pb_free(&pb); + return 0; + } } if (pb.cell_count > 0) { if (!pb_ensure_leaf_cap(&pb)) { + pb.ok = false; + pb_free(&pb); return 0; } pb.leaves[pb.leaf_count].max_key = 0; int last = count - SKIP_ONE; pb.leaves[pb.leaf_count].sep_cell = (uint8_t *)malloc(cell_lens[last]); + if (!pb.leaves[pb.leaf_count].sep_cell) { + pb.ok = false; + pb_free(&pb); + return 0; + } memcpy(pb.leaves[pb.leaf_count].sep_cell, cells[last], cell_lens[last]); pb.leaves[pb.leaf_count].sep_cell_len = cell_lens[last]; pb_flush_leaf(&pb); + if (!pb.ok) { + pb_free(&pb); + return 0; + } } *next_page = pb.next_page; @@ -1723,7 +1840,7 @@ static int write_one_table(write_db_ctx_t *w, uint32_t *root, const void *items, build_record_fn build_rec, get_rowid_fn get_id) { if (count <= 0 || !items) { *root = write_table_btree(w->fp, &w->next_page, NULL, NULL, NULL, 0, false); - return 0; + return *root == 0 ? ERR_WRITE_FAILED : 0; } PageBuilder pb; pb_init(&pb, w->fp, w->next_page, false); @@ -1731,15 +1848,20 @@ static int write_one_table(write_db_ctx_t *w, uint32_t *root, const void *items, int rec_len; uint8_t *rec = build_rec(items, i, &rec_len); if (!rec) { + pb_free(&pb); return ERR_WRITE_FAILED; } int64_t rowid = get_id(items, i); int64_t prev_id = i > 0 ? get_id(items, i - SKIP_ONE) : 0; pb_add_table_cell_with_flush(&pb, rowid, rec, rec_len, prev_id); free(rec); + if (!pb.ok) { + pb_free(&pb); + return ERR_WRITE_FAILED; + } } *root = pb_finalize_table(&pb, &w->next_page, get_id(items, count - SKIP_ONE)); - return 0; + return *root == 0 ? ERR_WRITE_FAILED : 0; } /* Adapter functions for write_one_table (nodes are written via the streaming @@ -1764,21 +1886,30 @@ static int64_t adapt_token_vec_id(const void *items, int i) { } /* Phase 2: Write metadata tables (projects, file_hashes, summaries, sqlite_sequence). */ -static void write_metadata_tables(write_db_ctx_t *w, uint32_t *projects_root, - uint32_t *file_hashes_root, uint32_t *summaries_root, - uint32_t *sqlite_seq_root) { +static int write_metadata_tables(write_db_ctx_t *w, uint32_t *projects_root, + uint32_t *file_hashes_root, uint32_t *summaries_root, + uint32_t *sqlite_seq_root) { int proj_rec_len; uint8_t *proj_rec = build_project_record(w->project, w->indexed_at, w->root_path, &proj_rec_len); + if (!proj_rec) { + return ERR_WRITE_FAILED; + } const uint8_t *proj_recs[] = {proj_rec}; int proj_lens[] = {proj_rec_len}; int64_t proj_rowids[] = {FIRST_ROWID}; *projects_root = write_table_btree(w->fp, &w->next_page, proj_recs, proj_lens, proj_rowids, SKIP_ONE, false); free(proj_rec); + if (*projects_root == 0) { + return ERR_WRITE_FAILED; + } *file_hashes_root = write_table_btree(w->fp, &w->next_page, NULL, NULL, NULL, 0, false); *summaries_root = write_table_btree(w->fp, &w->next_page, NULL, NULL, NULL, 0, false); + if (*file_hashes_root == 0 || *summaries_root == 0) { + return ERR_WRITE_FAILED; + } RecordBuilder r1; RecordBuilder r2; @@ -1788,6 +1919,9 @@ static void write_metadata_tables(write_db_ctx_t *w, uint32_t *projects_root, int seq1_len; uint8_t *seq1 = rec_finalize(&r1, &seq1_len); rec_free(&r1); + if (!seq1) { + return ERR_WRITE_FAILED; + } rec_init(&r2); rec_add_text(&r2, "edges"); @@ -1795,6 +1929,10 @@ static void write_metadata_tables(write_db_ctx_t *w, uint32_t *projects_root, int seq2_len; uint8_t *seq2 = rec_finalize(&r2, &seq2_len); rec_free(&r2); + if (!seq2) { + free(seq1); + return ERR_WRITE_FAILED; + } const uint8_t *seq_recs[] = {seq1, seq2}; int seq_lens[] = {seq1_len, seq2_len}; @@ -1803,6 +1941,7 @@ static void write_metadata_tables(write_db_ctx_t *w, uint32_t *projects_root, write_table_btree(w->fp, &w->next_page, seq_recs, seq_lens, seq_rowids, PAIR_LEN, false); free(seq1); free(seq2); + return *sqlite_seq_root == 0 ? ERR_WRITE_FAILED : 0; } /* Write the SQLite file header on page 1 with master entries. */ @@ -1834,12 +1973,21 @@ static void write_sqlite_file_header(uint8_t *page1, uint32_t total_pages) { /* Build master records, write page 1 B-tree + file header. */ static int write_master_page1(FILE *fp, MasterEntry *master, int master_count, uint32_t next_page) { - const uint8_t **master_records = (const uint8_t **)malloc(master_count * sizeof(uint8_t *)); - int *master_lens = (int *)malloc(master_count * sizeof(int)); - int64_t *master_rowids = (int64_t *)malloc(master_count * sizeof(int64_t)); + const uint8_t **master_records = (const uint8_t **)calloc((size_t)master_count, sizeof(uint8_t *)); + int *master_lens = (int *)calloc((size_t)master_count, sizeof(int)); + int64_t *master_rowids = (int64_t *)calloc((size_t)master_count, sizeof(int64_t)); + int rc = 0; + if (!master_records || !master_lens || !master_rowids) { + rc = ERR_WRITE_FAILED; + goto cleanup; + } for (int i = 0; i < master_count; i++) { master_rowids[i] = i + SKIP_ONE; master_records[i] = build_master_record(&master[i], &master_lens[i]); + if (!master_records[i]) { + rc = ERR_WRITE_FAILED; + goto cleanup; + } } uint8_t page1[CBM_PAGE_SIZE]; @@ -1857,13 +2005,8 @@ static int write_master_page1(FILE *fp, MasterEntry *master, int master_count, u int available = content_off - ptr_off - CELL_PTR_SIZE; if (!cell || cell_len > available) { free(cell); - for (int j = 0; j < master_count; j++) { - free((void *)master_records[j]); - } - free(master_records); - free(master_lens); - free(master_rowids); - return ERR_MASTER_OVERFLOW; + rc = ERR_MASTER_OVERFLOW; + goto cleanup; } content_off -= cell_len; memcpy(page1 + content_off, cell, cell_len); @@ -1880,28 +2023,40 @@ static int write_master_page1(FILE *fp, MasterEntry *master, int master_count, u write_sqlite_file_header(page1, next_page - SKIP_ONE); - (void)fseek(fp, 0, SEEK_SET); - (void)fwrite(page1, SKIP_ONE, CBM_PAGE_SIZE, fp); + if (!write_bytes_at(fp, 0, page1, CBM_PAGE_SIZE)) { + rc = ERR_WRITE_FAILED; + goto cleanup; + } +cleanup: for (int i = 0; i < master_count; i++) { - free((void *)master_records[i]); + if (master_records) { + free((void *)master_records[i]); + } } free(master_records); free(master_lens); free(master_rowids); - return 0; + return rc; } /* Pad file to exact page boundary. */ -static void pad_file_to_page_boundary(FILE *fp, uint32_t next_page) { - (void)fseek(fp, 0, SEEK_END); +static int pad_file_to_page_boundary(FILE *fp, uint32_t next_page) { + if (fseek(fp, 0, SEEK_END) != 0) { + return ERR_WRITE_FAILED; + } long file_size = ftell(fp); + if (file_size < 0) { + return ERR_WRITE_FAILED; + } long expected_size = (long)(next_page - SKIP_ONE) * CBM_PAGE_SIZE; if (file_size < expected_size) { uint8_t zero = 0; - (void)fseek(fp, expected_size - SKIP_ONE, SEEK_SET); - (void)fwrite(&zero, SKIP_ONE, SKIP_ONE, fp); + if (!write_bytes_at(fp, expected_size - SKIP_ONE, &zero, SKIP_ONE)) { + return ERR_WRITE_FAILED; + } } + return 0; } /* Build all 4 node index B-trees. Returns 0 on success, ERR_SORT_FAILED on failure. */ @@ -1916,7 +2071,7 @@ static int build_node_indexes(FILE *fp, uint32_t *next_page, CBMDumpNode *nodes, ncol_file); *qn_root = build_node_index_sorted(fp, next_page, nodes, node_count, nsorts[NSORT_QN].perm, ncol_qn); - if (node_count > 0 && (!*label_root || !*name_root || !*file_root || !*qn_root)) { + if (!*label_root || !*name_root || !*file_root || !*qn_root) { return ERR_SORT_FAILED; } return 0; @@ -1941,30 +2096,61 @@ static int build_edge_indexes(FILE *fp, uint32_t *next_page, CBMDumpEdge *edges, esorts[ESORT_URL_PATH].perm, ecell_url_path); *auto_root = build_edge_index_sorted(fp, next_page, edges, edge_count, esorts[ESORT_SRC_TGT_TYPE].perm, ecell_src_tgt_type); - if (edge_count > 0 && (!*source_root || !*target_root || !*type_root || !*tgt_type_root || - !*src_type_root || !*url_path_root || !*auto_root)) { + if (!*source_root || !*target_root || !*type_root || !*tgt_type_root || !*src_type_root || + !*url_path_root || !*auto_root) { return ERR_SORT_FAILED; } return 0; } /* Launch parallel sort threads for all index permutations. */ -static void parallel_sort_indexes(SortJob *nsorts, int n_node, SortJob *esorts, int n_edge) { +static int maybe_start_sort_job(SortJob *job, cbm_thread_t *threads, int *thread_count) { + if (job->count <= 0) { + return 0; + } + if (cbm_thread_create(&threads[*thread_count], 0, sort_worker, job) == 0) { + (*thread_count)++; + return 0; + } + (void)sort_worker(job); + return job->perm ? 0 : ERR_SORT_FAILED; +} + +static int parallel_sort_indexes(SortJob *nsorts, int n_node, SortJob *esorts, int n_edge) { cbm_thread_t st[TOTAL_SORT_THREADS]; int nt = 0; + int rc = 0; for (int i = 0; i < n_node; i++) { - if (nsorts[i].count > 0) { - cbm_thread_create(&st[nt++], 0, sort_worker, &nsorts[i]); + if (maybe_start_sort_job(&nsorts[i], st, &nt) != 0) { + rc = ERR_SORT_FAILED; } } for (int i = 0; i < n_edge; i++) { - if (esorts[i].count > 0) { - cbm_thread_create(&st[nt++], 0, sort_worker, &esorts[i]); + if (maybe_start_sort_job(&esorts[i], st, &nt) != 0) { + rc = ERR_SORT_FAILED; } } for (int i = 0; i < nt; i++) { cbm_thread_join(&st[i]); } + for (int i = 0; i < n_node; i++) { + if (nsorts[i].count > 0 && !nsorts[i].perm) { + rc = ERR_SORT_FAILED; + } + } + for (int i = 0; i < n_edge; i++) { + if (esorts[i].count > 0 && !esorts[i].perm) { + rc = ERR_SORT_FAILED; + } + } + return rc; +} + +static void free_sort_perms(SortJob *jobs, int count) { + for (int i = 0; i < count; i++) { + free(jobs[i].perm); + jobs[i].perm = NULL; + } } /* Write everything after the nodes table: the edges/vectors/token_vectors data @@ -2009,9 +2195,13 @@ static int write_db_after_nodes(write_db_ctx_t *w, uint32_t nodes_root) { uint32_t file_hashes_root; uint32_t summaries_root; uint32_t sqlite_seq_root; - write_metadata_tables(w, &projects_root, &file_hashes_root, &summaries_root, &sqlite_seq_root); + rc = write_metadata_tables(w, &projects_root, &file_hashes_root, &summaries_root, &sqlite_seq_root); uint32_t next_page = w->next_page; CBM_PROF_END("write_db", "2_metadata_tables", t_meta); + if (rc != 0) { + (void)fclose(fp); + return rc; + } // --- Build indexes (all sorted by key columns before writing) --- @@ -2039,8 +2229,14 @@ static int write_db_after_nodes(write_db_ctx_t *w, uint32_t nodes_root) { }; CBM_PROF_START(t_sort); - parallel_sort_indexes(nsorts, NODE_SORT_THREADS, esorts, EDGE_SORT_THREADS); + rc = parallel_sort_indexes(nsorts, NODE_SORT_THREADS, esorts, EDGE_SORT_THREADS); CBM_PROF_END_N("write_db", "3_parallel_sort_indexes", t_sort, node_count + edge_count); + if (rc != 0) { + free_sort_perms(nsorts, NODE_SORT_THREADS); + free_sort_perms(esorts, EDGE_SORT_THREADS); + (void)fclose(fp); + return rc; + } /* Phase 4-5: Build node + edge index B-trees */ CBM_PROF_START(t_node_idx); @@ -2052,6 +2248,7 @@ static int write_db_after_nodes(write_db_ctx_t *w, uint32_t nodes_root) { &idx_nodes_name_root, &idx_nodes_file_root, &autoindex_nodes_root); CBM_PROF_END_N("write_db", "4_node_indexes_seq", t_node_idx, node_count * NODE_SORT_THREADS); if (nrc != 0) { + free_sort_perms(esorts, EDGE_SORT_THREADS); (void)fclose(fp); return nrc; } @@ -2085,9 +2282,18 @@ static int write_db_after_nodes(write_db_ctx_t *w, uint32_t nodes_root) { int plen = 0; uint8_t *payload = rec_finalize(&r, &plen); rec_free(&r); + if (!payload) { + (void)fclose(fp); + return ERR_WRITE_FAILED; + } int vl = varint_len(plen); int total = vl + plen; uint8_t *cell = (uint8_t *)malloc(total); + if (!cell) { + free(payload); + (void)fclose(fp); + return ERR_WRITE_FAILED; + } int pos = put_varint(cell, plen); memcpy(cell + pos, payload, plen); free(payload); @@ -2102,6 +2308,11 @@ static int write_db_after_nodes(write_db_ctx_t *w, uint32_t nodes_root) { // Autoindex for project_summaries(project TEXT PK) — empty (0 rows) uint32_t autoindex_summaries_root = write_index_btree(fp, &next_page, NULL, NULL, 0); + if (autoindex_projects_root == 0 || autoindex_file_hashes_root == 0 || + autoindex_summaries_root == 0) { + (void)fclose(fp); + return ERR_WRITE_FAILED; + } // --- sqlite_master table (page 1) --- // This must be written last because it references root pages of all other tables/indexes. @@ -2178,7 +2389,11 @@ static int write_db_after_nodes(write_db_ctx_t *w, uint32_t nodes_root) { (void)fclose(fp); return rc2; } - pad_file_to_page_boundary(fp, next_page); + rc2 = pad_file_to_page_boundary(fp, next_page); + if (rc2 != 0) { + (void)fclose(fp); + return rc2; + } (void)fclose(fp); return 0; } @@ -2228,6 +2443,10 @@ int cbm_writer_append_nodes(cbm_db_writer_t *w, const CBMDumpNode *nodes, int co * the one-shot write_one_table loop — so output is byte-identical. */ pb_add_table_cell_with_flush(&w->nodes_pb, nodes[i].id, rec, rec_len, w->last_node_rowid); free(rec); + if (!w->nodes_pb.ok) { + w->err = ERR_WRITE_FAILED; + return w->err; + } w->last_node_rowid = nodes[i].id; w->node_rows_written++; } @@ -2243,13 +2462,23 @@ int cbm_writer_finalize(cbm_db_writer_t *w, const char *project, const char *roo } int err = w->err; uint32_t nodes_root = 0; + bool nodes_pb_done = false; if (err == 0) { if (w->node_rows_written == 0) { pb_free(&w->nodes_pb); + nodes_pb_done = true; nodes_root = write_table_btree(w->wc.fp, &w->wc.next_page, NULL, NULL, NULL, 0, false); } else { nodes_root = pb_finalize_table(&w->nodes_pb, &w->wc.next_page, w->last_node_rowid); + nodes_pb_done = true; } + if (nodes_root == 0) { + err = ERR_WRITE_FAILED; + } + } + if (err != 0 && !nodes_pb_done) { + pb_free(&w->nodes_pb); + nodes_pb_done = true; } w->wc.project = project; w->wc.root_path = root_path; diff --git a/src/cli/cli.c b/src/cli/cli.c index 520f13096..c8ea361ba 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -8,6 +8,7 @@ #include "foundation/compat.h" #include "foundation/platform.h" #include "foundation/constants.h" +#include "foundation/str_util.h" /* CLI buffer size constants. */ enum { @@ -409,7 +410,10 @@ int cbm_replace_binary(const char *path, const unsigned char *data, int len, int #ifdef _WIN32 /* Windows: can't unlink running .exe — rename aside */ char old_path[CLI_BUF_1K]; - snprintf(old_path, sizeof(old_path), "%s.old", path); + int old_len = snprintf(old_path, sizeof(old_path), "%s.old", path); + if (old_len < 0 || (size_t)old_len >= sizeof(old_path)) { + return CLI_ERR; + } (void)cbm_unlink(old_path); if (rename(path, old_path) != 0) { return CLI_ERR; @@ -2454,11 +2458,23 @@ int cbm_remove_indexes(const char *home_dir) { size_t len = strlen(ent->name); if (len > DB_EXT_LEN && strcmp(ent->name + len - DB_EXT_LEN, ".db") == 0) { char path[CLI_BUF_1K]; - snprintf(path, sizeof(path), "%s/%s", cache_dir, ent->name); + int path_len = snprintf(path, sizeof(path), "%s/%s", cache_dir, ent->name); + if (path_len < 0 || (size_t)path_len >= sizeof(path)) { + (void)fprintf(stderr, + "warning: skipping index cleanup entry with overlong path: %s/%s\n", + cache_dir, ent->name); + continue; + } /* Also remove .db.tmp if present */ char tmp_path[CLI_FIELD_1040]; - snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path); - cbm_unlink(tmp_path); + int tmp_len = snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path); + if (tmp_len >= 0 && (size_t)tmp_len < sizeof(tmp_path)) { + cbm_unlink(tmp_path); + } else { + (void)fprintf(stderr, + "warning: skipping overlong temporary sidecar cleanup for %s\n", + path); + } if (cbm_unlink(path) == 0) { count++; } @@ -2673,6 +2689,12 @@ const cbm_config_entry_t CBM_CONFIG_REGISTRY[] = { "The architecture resource ranks every symbol by PageRank importance and returns the top N. " "Use 25 for most projects. Raise to 50-100 for large multi-language codebases where " "important functions may not appear in the first 25. Lower to 10 when tokens are limited."}, + {"context_key_functions_limit", "10", NULL, "Search", + "Max key functions PUSHED in the first-response _context header (0 = use built-in 10)", + "0-100", + "Separate from key_functions_count (the architecture query bound) — this governs only the " + "auto-pushed summary that closes the codebase://architecture pull-only gap. Kept small (10) " + "to keep first-response token cost modest; raise to 20-25 if you want richer upfront context."}, /* ── Tools ── */ {"tool_mode", "streamlined", "CBM_TOOL_MODE", "Tools", "Which set of tools the MCP server exposes: 5 default tools or all 15 individual tools", @@ -2719,6 +2741,18 @@ const cbm_config_entry_t CBM_CONFIG_REGISTRY[] = { "20 iterations converges in ~5ms for 16K-node codebases. Typical convergence is 10-15 iters. " "Raise to 50-100 for very large codebases (>100K nodes). " "Diminishing returns above convergence — set too high wastes CPU at reindex time."}, + {"pagerank_damping", "0.85", NULL, "PageRank", + "Damping factor — fraction of importance that follows edges vs. teleports randomly (the 'bored surfer')", + "0.0-1.0", + "0.85 is the standard Google PageRank value. Higher (0.9) spreads importance further along long " + "call chains; lower (0.7-0.8) keeps importance local to direct callers. Out-of-range and NaN " + "values are clamped to 0.85 at compute time, so an invalid value never crashes indexing."}, + {"pagerank_epsilon", "0.000001", NULL, "PageRank", + "Convergence threshold — PageRank stops early when the L2 change between iterations drops below this", + "0.0-1.0", + "1e-6 default. Lower (1e-8) iterates longer for marginally finer convergence (rarely needed); " + "higher (1e-4) stops sooner with slightly less precise rankings. Must be > 0 — non-positive and " + "NaN values are clamped to 1e-6. Rarely needs tuning; pair with pagerank_max_iter as the hard cap."}, {"rank_scope", "project", NULL, "PageRank", "Whether PageRank importance is computed per-project or across all indexed projects", "project|full", @@ -2811,6 +2845,21 @@ const cbm_config_entry_t CBM_CONFIG_REGISTRY[] = { "100-3600000", "60 seconds for repos with 50K+ files. Lower to 10000 for faster detection in large repos " "if CPU allows. Formula: min(base + file_count/500 * 1000, max)."}, + {"store_idle_timeout_s", "60", NULL, "MCP", + "Seconds an MCP server keeps an idle SQLite project store open", + "1-65536", + "60 seconds balances latency for repeated tool calls with memory release while idle. Lower for " + "memory-constrained hosts; raise for long interactive sessions that repeatedly query one project."}, + {"db_validate_busy_timeout_ms", "1000", NULL, "MCP", + "SQLite busy timeout for read-only cache database validation in MCP startup/discovery paths", + "0-65536", + "1 second avoids hanging JSON-RPC startup on locked databases. Raise on slow network filesystems; " + "set 0 to fail immediately."}, + {"update_check_timeout_s", "5", NULL, "MCP", + "Curl timeout for the optional MCP background latest-release check (0=disabled)", + "0-256", + "Default preserves the historical 5-second bound. Set 0 for offline, hermetic, or privacy-sensitive " + "MCP deployments where the server must not make background network requests."}, /* ── Architecture ── */ {"arch_hotspot_limit", "25", NULL, "Architecture", "Max hotspot functions shown in the classic get_architecture tool's hotspots section", @@ -2819,6 +2868,40 @@ const cbm_config_entry_t CBM_CONFIG_REGISTRY[] = { "They identify the most-invoked code — good candidates for optimization and risk assessment. " "25 is enough for orientation; raise to 100 for exhaustive call-density analysis. " "Only applies to the classic 'get_architecture' tool (tool_mode=classic)."}, + {"architecture_resolution", "1.0", NULL, "Architecture", + "Leiden community-detection resolution for architecture clusters", + "0.0001-10.0", + "1.0 is the standard Leiden default. Higher (2.0-5.0) splits code into more, finer-grained " + "clusters; lower (0.3-0.5) merges related clusters into coarse subsystems. Non-positive and " + "NaN values are clamped to 1.0. Drives the 'clusters' section of get_architecture."}, + /* ── Similarity ── */ + {"similarity_threshold", "0.0", NULL, "Similarity", + "MinHash Jaccard threshold for semantic SIMILAR edges (0.0 = use the built-in 0.95 default)", + "0.0-1.0", + "Two symbols get a SIMILAR edge when their estimated Jaccard similarity is at least this. " + "0.0 (default) uses the built-in 0.95, so only near-duplicates are linked. Lower to 0.7-0.8 to " + "surface more semantic duplicates (refactoring candidates); too low adds noisy edges that inflate " + "PageRank rankings. Effective only when indexing creates similarity edges (full/moderate modes)."}, + {"semantic_threshold", "0.0", NULL, "Similarity", + "Combined semantic score threshold for SEMANTICALLY_RELATED edges (0.0 = built-in 0.75)", + "0.0-1.0", + "Controls the algorithmic semantic-edge pass. Higher values improve precision and reduce edge count; " + "lower values increase recall and runtime output volume. 0.0 preserves the upstream-compatible default."}, + {"httplink_min_confidence", "0.0", NULL, "Similarity", + "Minimum confidence for HTTP route-to-call linking (0.0 = built-in 0.25)", + "0.0-1.0", + "Raises or lowers the fork-only HTTP linker's match threshold. Higher values reduce speculative " + "cross-service HTTP_CALLS edges; 0.0 keeps existing behavior."}, + {"githistory_min_coupling", "0.0", NULL, "Similarity", + "Minimum file co-change coupling score for FILE_CHANGES_WITH edges (0.0 = built-in 0.3)", + "0.0-1.0", + "Controls how strongly two files must co-change before git-history coupling emits an edge. " + "Higher values reduce noisy historical edges; 0.0 keeps existing behavior."}, + {"lsp_confidence_floor", "0.0", NULL, "Similarity", + "Minimum LSP-resolved call confidence accepted by call resolution (0.0 = built-in 0.6)", + "0.0-1.0", + "Applies consistently to sequential and parallel call resolution. Raise to prefer registry matches " + "over uncertain LSP hints; lower only when language-specific LSP coverage is known to be precise."}, /* ── Degree / Sort ── */ {"degree_mode", "weighted", NULL, "Degree", "What 'degree' means for min_degree/max_degree filters and sort_by=degree ranking", @@ -3061,15 +3144,19 @@ static bool prompt_yn(const char *question) { /* Compute SHA-CBM_SZ_256 of a file using platform tools (sha256sum/shasum). * Writes CBM_SZ_64-char hex digest + NUL to out. Returns 0 on success. */ static int sha256_file(const char *path, char *out, size_t out_size) { - if (out_size < SHA256_BUF_SIZE) { + if (!path || !cbm_validate_shell_arg(path) || out_size < SHA256_BUF_SIZE) { return CLI_ERR; } char cmd[CLI_BUF_1K]; + int cmd_len; #ifdef __APPLE__ - snprintf(cmd, sizeof(cmd), "shasum -a CBM_SZ_256 '%s' 2>/dev/null", path); + cmd_len = snprintf(cmd, sizeof(cmd), "shasum -a CBM_SZ_256 '%s' 2>/dev/null", path); #else - snprintf(cmd, sizeof(cmd), "sha256sum '%s' 2>/dev/null", path); + cmd_len = snprintf(cmd, sizeof(cmd), "sha256sum '%s' 2>/dev/null", path); #endif + if (cmd_len < 0 || (size_t)cmd_len >= sizeof(cmd)) { + return CLI_ERR; + } FILE *fp = cbm_popen(cmd, "r"); if (!fp) { return CLI_ERR; @@ -3101,6 +3188,24 @@ static int cbm_download_to_file_quiet(const char *url, const char *dest) { return cbm_exec_no_shell(argv); } +static int cbm_cli_make_temp_file(char *out, size_t out_sz, const char *prefix) { + if (!out || out_sz == 0 || !prefix || !prefix[0]) { + errno = EINVAL; + return CLI_ERR; + } + int n = snprintf(out, out_sz, "%s/%s_XXXXXX", cbm_tmpdir(), prefix); + if (n < 0 || (size_t)n >= out_sz) { + errno = ENAMETOOLONG; + return CLI_ERR; + } + int fd = cbm_mkstemp_s(out, out_sz); + if (fd < 0) { + return CLI_ERR; + } + cbm_close_fd(fd); + return 0; +} + /* ── macOS ad-hoc signing ─────────────────────────────────────── */ #ifdef __APPLE__ @@ -3151,19 +3256,29 @@ static int cbm_kill_other_instances(void) { /* Download checksums.txt and verify the archive integrity. * Returns: 0 = verified OK, 1 = mismatch (FAIL), -1 = could not verify (warning). */ static int verify_download_checksum(const char *archive_path, const char *archive_name) { - char checksum_file[CLI_BUF_256]; - snprintf(checksum_file, sizeof(checksum_file), "%s/cbm-checksums.txt", cbm_tmpdir()); + char checksum_file[CBM_PATH_MAX]; + if (cbm_cli_make_temp_file(checksum_file, sizeof(checksum_file), "cbm-checksums") != 0) { + (void)fprintf(stderr, + "warning: could not create temporary checksum file — skipping verification\n"); + return CLI_ERR; + } char dl_base_buf[CLI_BUF_512]; const char *dl_base = cbm_safe_getenv("CBM_DOWNLOAD_URL", dl_base_buf, sizeof(dl_base_buf), NULL); char checksum_url[CLI_BUF_512]; + int url_len; if (dl_base && dl_base[0]) { - snprintf(checksum_url, sizeof(checksum_url), "%s/checksums.txt", dl_base); + url_len = snprintf(checksum_url, sizeof(checksum_url), "%s/checksums.txt", dl_base); } else { - snprintf(checksum_url, sizeof(checksum_url), "%s", - "https://github.com/DeusData/codebase-memory-mcp/releases/latest/download/" - "checksums.txt"); + url_len = snprintf(checksum_url, sizeof(checksum_url), "%s", + "https://github.com/DeusData/codebase-memory-mcp/releases/latest/download/" + "checksums.txt"); + } + if (url_len < 0 || (size_t)url_len >= sizeof(checksum_url)) { + (void)fprintf(stderr, "warning: checksum URL too long — skipping verification\n"); + cbm_unlink(checksum_file); + return CLI_ERR; } int rc = cbm_download_to_file_quiet(checksum_url, checksum_file); if (rc != 0) { @@ -3174,8 +3289,8 @@ static int verify_download_checksum(const char *archive_path, const char *archiv } FILE *fp = fopen(checksum_file, "r"); - cbm_unlink(checksum_file); if (!fp) { + cbm_unlink(checksum_file); return CLI_ERR; } @@ -3190,6 +3305,7 @@ static int verify_download_checksum(const char *archive_path, const char *archiv } } (void)fclose(fp); + cbm_unlink(checksum_file); if (expected[0] == '\0') { (void)fprintf(stderr, "warning: %s not found in checksums.txt\n", archive_name); @@ -4158,7 +4274,7 @@ static int extract_and_install_binary(extract_install_args_t args) { } /* Build the download URL for the update command. */ -static void build_update_url(char *url, int url_sz, const char *os, const char *arch, +static int build_update_url(char *url, int url_sz, const char *os, const char *arch, const char *ext, bool want_ui) { char base_url_buf[CLI_BUF_512]; const char *base_url = @@ -4171,8 +4287,9 @@ static void build_update_url(char *url, int url_sz, const char *os, const char * * have no such variant. Keep in sync with install.sh / install.js / pypi * _cli.py. */ const char *portable = (strcmp(os, "linux") == 0) ? "-portable" : ""; - snprintf(url, url_sz, "%s/codebase-memory-mcp-%s%s-%s%s.%s", base_url, want_ui ? "ui-" : "", os, - arch, portable, ext); + int n = snprintf(url, (size_t)url_sz, "%s/codebase-memory-mcp-%s%s-%s%s.%s", base_url, + want_ui ? "ui-" : "", os, arch, portable, ext); + return (n >= 0 && n < url_sz) ? 0 : CLI_ERR; } /* Prompt to delete existing indexes. Returns 0 to continue, 1 to abort. */ @@ -4200,8 +4317,11 @@ static int update_clear_indexes(const char *home, bool dry_run) { /* Download, verify checksum, kill old instances, and install binary. Returns 0 on success. */ static int download_verify_install(const char *url, const char *ext, const char *os, const char *arch, bool want_ui, const char *bin_dest) { - char tmp_archive[CLI_BUF_256]; - snprintf(tmp_archive, sizeof(tmp_archive), "%s/cbm-update.%s", cbm_tmpdir(), ext); + char tmp_archive[CBM_PATH_MAX]; + if (cbm_cli_make_temp_file(tmp_archive, sizeof(tmp_archive), "cbm-update") != 0) { + (void)fprintf(stderr, "error: cannot create temporary update archive path\n"); + return CLI_TRUE; + } int rc = cbm_download_to_file(url, tmp_archive); if (rc != 0) { @@ -4384,7 +4504,10 @@ int cbm_cmd_update(int argc, char **argv) { const char *ext = strcmp(os, "windows") == 0 ? "zip" : "tar.gz"; char url[CLI_BUF_512]; - build_update_url(url, sizeof(url), os, arch, ext, want_ui); + if (build_update_url(url, sizeof(url), os, arch, ext, want_ui) != 0) { + (void)fprintf(stderr, "error: update download URL is too long\n"); + return CLI_TRUE; + } if (dry_run) { printf("\nWould download %s binary for %s/%s ...\n", variant_label, os, arch); diff --git a/src/foundation/compat.c b/src/foundation/compat.c index e59accf7e..c530cd4d7 100644 --- a/src/foundation/compat.c +++ b/src/foundation/compat.c @@ -7,6 +7,7 @@ #include "foundation/compat.h" #include "foundation/constants.h" +#include #include #include #ifdef _WIN32 @@ -54,20 +55,55 @@ char *cbm_strcasestr(const char *haystack, const char *needle) { #ifdef _WIN32 #include -char *cbm_mkdtemp(char *tmpl) { - /* Build path in static buffer, then copy back to caller. - * Callers must provide buffers >= CBM_SZ_256 bytes (all test code does). */ - static char buf[CBM_SZ_512]; - if (strncmp(tmpl, "/tmp/", 5) == 0) { + +static int rewrite_tmp_template(char *tmpl, size_t tmpl_sz) { + if (!tmpl || tmpl_sz == 0) { + errno = EINVAL; + return CBM_NOT_FOUND; + } + + size_t len = 0; + while (len < tmpl_sz && tmpl[len]) { + len++; + } + if (len == tmpl_sz || len >= CBM_PATH_MAX) { + errno = ENAMETOOLONG; + return CBM_NOT_FOUND; + } + + char original[CBM_PATH_MAX]; + memcpy(original, tmpl, len + SKIP_ONE); + + enum { TMP_PREFIX_LEN = sizeof("/tmp/") - SKIP_ONE }; + int n; + if (strncmp(original, "/tmp/", TMP_PREFIX_LEN) == 0) { const char *tmp = getenv("TEMP"); if (!tmp) tmp = getenv("TMP"); if (!tmp) tmp = "."; - snprintf(buf, sizeof(buf), "%s\\%s", tmp, tmpl + 5); + n = snprintf(tmpl, tmpl_sz, "%s\\%s", tmp, original + TMP_PREFIX_LEN); } else { - snprintf(buf, sizeof(buf), "%s", tmpl); + n = snprintf(tmpl, tmpl_sz, "%s", original); } + if (n < 0 || (size_t)n >= tmpl_sz) { + errno = ENAMETOOLONG; + return CBM_NOT_FOUND; + } + return 0; +} + +char *cbm_mkdtemp(char *tmpl) { + /* Build path in thread-local storage, then copy back to caller. + * Callers must provide buffers >= CBM_SZ_256 bytes (all test code does). */ + static CBM_TLS char buf[CBM_PATH_MAX]; + int n = snprintf(buf, sizeof(buf), "%s", tmpl ? tmpl : ""); + if (n < 0 || (size_t)n >= sizeof(buf)) { + errno = ENAMETOOLONG; + return NULL; + } + if (rewrite_tmp_template(buf, sizeof(buf)) != 0) + return NULL; if (!_mktemp(buf)) return NULL; if (_mkdir(buf) != 0) @@ -90,25 +126,55 @@ char *cbm_mkdtemp(char *tmpl) { #ifdef _WIN32 int cbm_mkstemp(char *tmpl) { - /* Rewrite /tmp/ to %TEMP%\ like cbm_mkdtemp */ - static char buf[CBM_SZ_512]; - if (strncmp(tmpl, "/tmp/", 5) == 0) { - const char *tmp = getenv("TEMP"); - if (!tmp) - tmp = getenv("TMP"); - if (!tmp) - tmp = "."; - snprintf(buf, sizeof(buf), "%s\\%s", tmp, tmpl + 5); - } else { - snprintf(buf, sizeof(buf), "%s", tmpl); - } - if (!_mktemp(buf)) + /* Legacy ABI: caller owns an unsized template buffer. New shared code should + * use cbm_mkstemp_s() so long paths fail instead of copying back blindly. */ + static CBM_TLS char buf[CBM_PATH_MAX]; + int n = snprintf(buf, sizeof(buf), "%s", tmpl ? tmpl : ""); + if (n < 0 || (size_t)n >= sizeof(buf)) { + errno = ENAMETOOLONG; return CBM_NOT_FOUND; - int fd = _open(buf, _O_CREAT | _O_RDWR | _O_BINARY, _S_IREAD | _S_IWRITE); + } + int fd = cbm_mkstemp_s(buf, sizeof(buf)); if (fd >= 0) strcpy(tmpl, buf); return fd; } + +int cbm_mkstemp_s(char *tmpl, size_t tmpl_sz) { + if (rewrite_tmp_template(tmpl, tmpl_sz) != 0) + return CBM_NOT_FOUND; + + char *pattern = cbm_strdup(tmpl); + if (!pattern) { + errno = ENOMEM; + return CBM_NOT_FOUND; + } + + enum { MKSTEMP_COLLISION_RETRIES = CBM_SZ_32 }; + for (int attempt = 0; attempt < MKSTEMP_COLLISION_RETRIES; attempt++) { + int n = snprintf(tmpl, tmpl_sz, "%s", pattern); + if (n < 0 || (size_t)n >= tmpl_sz) { + free(pattern); + errno = ENAMETOOLONG; + return CBM_NOT_FOUND; + } + if (!_mktemp(tmpl)) { + free(pattern); + return CBM_NOT_FOUND; + } + int fd = _open(tmpl, _O_CREAT | _O_EXCL | _O_RDWR | _O_BINARY, _S_IREAD | _S_IWRITE); + if (fd >= 0) { + free(pattern); + return fd; + } + if (errno != EEXIST) { + break; + } + } + + free(pattern); + return CBM_NOT_FOUND; +} #endif /* ── clock_gettime (Windows lacks it) ─────────────────────────── */ diff --git a/src/foundation/compat.h b/src/foundation/compat.h index 4ac9bf755..456d33c1b 100644 --- a/src/foundation/compat.h +++ b/src/foundation/compat.h @@ -48,9 +48,12 @@ ssize_t cbm_getline(char **lineptr, size_t *n, FILE *stream); /* ── fileno ───────────────────────────────────────────────────── */ #ifdef _WIN32 +#include #define cbm_fileno _fileno +#define cbm_close_fd _close #else #define cbm_fileno fileno +#define cbm_close_fd close #endif /* ── strcasestr (Windows lacks it) ────────────────────────────── */ @@ -109,8 +112,13 @@ char *cbm_mkdtemp(char *tmpl); /* ── mkstemp (Windows lacks it) ──────────────────────────────── */ #ifdef _WIN32 int cbm_mkstemp(char *tmpl); +int cbm_mkstemp_s(char *tmpl, size_t tmpl_sz); #else #define cbm_mkstemp mkstemp +static inline int cbm_mkstemp_s(char *tmpl, size_t tmpl_sz) { + (void)tmpl_sz; + return mkstemp(tmpl); +} #endif /* ── setenv / unsetenv (Windows lacks them) ──────────────────── */ @@ -129,7 +137,6 @@ static inline int cbm_unsetenv(const char *name) { /* ── pipe (Windows uses _pipe) ───────────────────────────────── */ #ifdef _WIN32 -#include #include #define cbm_pipe(fds) _pipe(fds, 4096, _O_BINARY) #else diff --git a/src/foundation/compat_fs.c b/src/foundation/compat_fs.c index efd4f39cf..98ddfd849 100644 --- a/src/foundation/compat_fs.c +++ b/src/foundation/compat_fs.c @@ -5,8 +5,10 @@ * Windows: FindFirstFile/FindNextFile, _popen/_pclose, _mkdir, _unlink. */ #include "foundation/constants.h" +#include "foundation/compat.h" #include "foundation/compat_fs.h" +#include #include #include #include @@ -181,6 +183,40 @@ int cbm_rmdir(const char *path) { return ret; } +int cbm_replace_file_ex(const char *tmp_path, const char *dest_path, int *platform_error) { + if (platform_error) { + *platform_error = 0; + } + if (!tmp_path || !dest_path) { + if (platform_error) { + *platform_error = ERROR_INVALID_PARAMETER; + } + return CBM_NOT_FOUND; + } + wchar_t *wtmp = cbm_utf8_to_wide(tmp_path); + wchar_t *wdest = cbm_utf8_to_wide(dest_path); + if (!wtmp || !wdest) { + if (platform_error) { + *platform_error = ERROR_NOT_ENOUGH_MEMORY; + } + free(wtmp); + free(wdest); + return CBM_NOT_FOUND; + } + BOOL ok = MoveFileExW(wtmp, wdest, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH); + DWORD err = ok ? 0 : GetLastError(); + free(wtmp); + free(wdest); + if (!ok && platform_error) { + *platform_error = (int)err; + } + return ok ? 0 : CBM_NOT_FOUND; +} + +int cbm_replace_file(const char *tmp_path, const char *dest_path) { + return cbm_replace_file_ex(tmp_path, dest_path, NULL); +} + int cbm_exec_no_shell(const char *const *argv) { if (!argv || !argv[0]) { return CBM_NOT_FOUND; @@ -293,6 +329,29 @@ int cbm_rmdir(const char *path) { return rmdir(path); } +int cbm_replace_file_ex(const char *tmp_path, const char *dest_path, int *platform_error) { + if (platform_error) { + *platform_error = 0; + } + if (!tmp_path || !dest_path) { + if (platform_error) { + *platform_error = EINVAL; + } + return CBM_NOT_FOUND; + } + if (rename(tmp_path, dest_path) != 0) { + if (platform_error) { + *platform_error = errno; + } + return CBM_NOT_FOUND; + } + return 0; +} + +int cbm_replace_file(const char *tmp_path, const char *dest_path) { + return cbm_replace_file_ex(tmp_path, dest_path, NULL); +} + int cbm_exec_no_shell(const char *const *argv) { if (!argv || !argv[0]) { return CBM_NOT_FOUND; @@ -331,3 +390,68 @@ int cbm_exec_no_shell(const char *const *argv) { } #endif /* _WIN32 */ + +static void set_file_error(cbm_file_error_t *out, const char *stage, int code) { + if (out) { + out->stage = stage; + out->code = code; + } +} + +int cbm_write_file_atomic(const char *dest_path, const void *data, size_t len, + cbm_file_error_t *out_err) { + set_file_error(out_err, NULL, 0); + if (!dest_path || (!data && len > 0)) { + set_file_error(out_err, "invalid_argument", EINVAL); + return CBM_NOT_FOUND; + } + + char tmp_path[CBM_PATH_MAX]; + int n = snprintf(tmp_path, sizeof(tmp_path), "%s.tmp.XXXXXX", dest_path); + if (n < 0 || (size_t)n >= sizeof(tmp_path)) { + set_file_error(out_err, "path_too_long", 0); + return CBM_NOT_FOUND; + } + + int fd = cbm_mkstemp_s(tmp_path, sizeof(tmp_path)); + if (fd < 0) { + set_file_error(out_err, "open_temp", errno); + return CBM_NOT_FOUND; + } + +#ifdef _WIN32 + FILE *fp = _fdopen(fd, "wb"); +#else + FILE *fp = fdopen(fd, "wb"); +#endif + if (!fp) { + int code = errno; + cbm_close_fd(fd); + cbm_unlink(tmp_path); + set_file_error(out_err, "open_temp", code); + return CBM_NOT_FOUND; + } + + if (len > 0 && fwrite(data, 1, len, fp) != len) { + int code = ferror(fp) ? errno : 0; + (void)fclose(fp); + cbm_unlink(tmp_path); + set_file_error(out_err, "write_temp", code); + return CBM_NOT_FOUND; + } + + if (fclose(fp) != 0) { + int code = errno; + cbm_unlink(tmp_path); + set_file_error(out_err, "close_temp", code); + return CBM_NOT_FOUND; + } + + int replace_error = 0; + if (cbm_replace_file_ex(tmp_path, dest_path, &replace_error) != 0) { + cbm_unlink(tmp_path); + set_file_error(out_err, "rename_temp", replace_error); + return CBM_NOT_FOUND; + } + return 0; +} diff --git a/src/foundation/compat_fs.h b/src/foundation/compat_fs.h index 285ad555b..fb2fb5805 100644 --- a/src/foundation/compat_fs.h +++ b/src/foundation/compat_fs.h @@ -50,6 +50,25 @@ int cbm_unlink(const char *path); /* Delete an empty directory. Returns 0 on success. */ int cbm_rmdir(const char *path); +/* Atomically replace dest_path with tmp_path when the platform supports it. + * tmp_path must already contain the complete new file. Returns 0 on success. + * POSIX: rename(). Windows: MoveFileExW(REPLACE_EXISTING | WRITE_THROUGH). */ +int cbm_replace_file(const char *tmp_path, const char *dest_path); + +/* Same as cbm_replace_file(), but returns the platform-native failure code via + * platform_error: errno on POSIX, GetLastError() on Windows. */ +int cbm_replace_file_ex(const char *tmp_path, const char *dest_path, int *platform_error); + +typedef struct { + const char *stage; /* path_too_long, open_temp, write_temp, close_temp, rename_temp */ + int code; /* errno/GetLastError() when available, or 0 for validation errors */ +} cbm_file_error_t; + +/* Write data to a unique temp sibling, close it, then atomically replace dest_path. + * Binary-safe. Returns 0 on success and fills out_err on failure when provided. */ +int cbm_write_file_atomic(const char *dest_path, const void *data, size_t len, + cbm_file_error_t *out_err); + /* Execute a command without shell interpretation. * argv is a NULL-terminated array: {"cmd", "arg1", "arg2", NULL}. * Returns the process exit code, or -1 on fork/exec failure. diff --git a/src/foundation/compat_thread.c b/src/foundation/compat_thread.c index 19610cef6..b598d3cd1 100644 --- a/src/foundation/compat_thread.c +++ b/src/foundation/compat_thread.c @@ -7,13 +7,16 @@ #include "foundation/constants.h" #include "foundation/compat_thread.h" -#include #include +#include + +#ifndef _WIN32 +#include +#endif /* Default 8MB stack for all threads. macOS ARM64 default is only 512KB, * which is too small for deep pipeline passes (configlink, etc.). */ #define CBM_DEFAULT_STACK_SIZE ((size_t)8 * CBM_SZ_1K * CBM_SZ_1K) -#include /* ── Thread ───────────────────────────────────────────────────── */ diff --git a/src/foundation/diagnostics.c b/src/foundation/diagnostics.c index 317e778de..c23718ad1 100644 --- a/src/foundation/diagnostics.c +++ b/src/foundation/diagnostics.c @@ -117,40 +117,29 @@ static void write_diagnostics(void) { long long qmax = atomic_load(&g_query_stats.max_us); long long qavg = qcount > 0 ? qtime / qcount : 0; - /* Write to .tmp then rename (atomic) */ - char tmp_path[sizeof(g_diag_path) + DIAG_PATH_EXTRA]; - snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", g_diag_path); - - FILE *f = fopen(tmp_path, "w"); - if (!f) { + char json[CBM_SZ_2K]; + int n = snprintf(json, sizeof(json), + "{\n" + " \"uptime_s\": %ld,\n" + " \"rss_bytes\": %zu,\n" + " \"peak_rss_bytes\": %zu,\n" + " \"heap_committed_bytes\": %zu,\n" + " \"peak_committed_bytes\": %zu,\n" + " \"page_faults\": %zu,\n" + " \"fd_count\": %d,\n" + " \"query_count\": %d,\n" + " \"query_errors\": %d,\n" + " \"query_total_us\": %lld,\n" + " \"query_avg_us\": %lld,\n" + " \"query_max_us\": %lld,\n" + " \"pid\": %d\n" + "}\n", + uptime, current_rss, peak_rss, current_commit, peak_commit, page_faults, fds, + qcount, qerrors, qtime, qavg, qmax, (int)getpid()); + if (n < 0 || (size_t)n >= sizeof(json)) { return; } - - if (fprintf(f, - "{\n" - " \"uptime_s\": %ld,\n" - " \"rss_bytes\": %zu,\n" - " \"peak_rss_bytes\": %zu,\n" - " \"heap_committed_bytes\": %zu,\n" - " \"peak_committed_bytes\": %zu,\n" - " \"page_faults\": %zu,\n" - " \"fd_count\": %d,\n" - " \"query_count\": %d,\n" - " \"query_errors\": %d,\n" - " \"query_total_us\": %lld,\n" - " \"query_avg_us\": %lld,\n" - " \"query_max_us\": %lld,\n" - " \"pid\": %d\n" - "}\n", - uptime, current_rss, peak_rss, current_commit, peak_commit, page_faults, fds, - qcount, qerrors, qtime, qavg, qmax, (int)getpid()) < 0) { - (void)fclose(f); - return; - } - if (fclose(f) != 0) { - return; - } - (void)rename(tmp_path, g_diag_path); + (void)cbm_write_file_atomic(g_diag_path, json, (size_t)n, NULL); } static void *diag_thread_fn(void *arg) { @@ -177,8 +166,12 @@ bool cbm_diag_start(void) { g_start_time = time(NULL); atomic_store(&g_diag_stop, 0); - snprintf(g_diag_path, sizeof(g_diag_path), "%s/cbm-diagnostics-%d.json", cbm_tmpdir(), - (int)getpid()); + int path_len = snprintf(g_diag_path, sizeof(g_diag_path), "%s/cbm-diagnostics-%d.json", + cbm_tmpdir(), (int)getpid()); + if (path_len < 0 || (size_t)path_len >= sizeof(g_diag_path)) { + g_diag_path[0] = '\0'; + return false; + } if (cbm_thread_create(&g_diag_thread, 0, diag_thread_fn, NULL) != 0) { return false; @@ -201,6 +194,8 @@ void cbm_diag_stop(void) { /* Clean up file */ cbm_unlink(g_diag_path); char tmp_path[sizeof(g_diag_path) + DIAG_PATH_EXTRA]; - snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", g_diag_path); - cbm_unlink(tmp_path); + int tmp_len = snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", g_diag_path); + if (tmp_len >= 0 && (size_t)tmp_len < sizeof(tmp_path)) { + cbm_unlink(tmp_path); + } } diff --git a/src/graph_buffer/graph_buffer.c b/src/graph_buffer/graph_buffer.c index e5dbc213b..1e0a1b043 100644 --- a/src/graph_buffer/graph_buffer.c +++ b/src/graph_buffer/graph_buffer.c @@ -28,6 +28,7 @@ enum { #include "sqlite_writer.h" #include "foundation/hash_table.h" #include "foundation/compat.h" +#include "foundation/compat_fs.h" #include "foundation/log.h" #include "foundation/dyn_array.h" #include "foundation/profile.h" @@ -1449,7 +1450,19 @@ int cbm_gbuf_dump_to_sqlite(cbm_gbuf_t *gb, const char *path) { if (!gb || !path) { return CBM_NOT_FOUND; } - + char tmp_path[CBM_SZ_1K]; + int tmp_len = snprintf(tmp_path, sizeof(tmp_path), "%s.tmp.XXXXXX", path); + if (tmp_len <= 0 || (size_t)tmp_len >= sizeof(tmp_path)) { + return CBM_NOT_FOUND; + } + char wal_path[CBM_SZ_1K]; + char shm_path[CBM_SZ_1K]; + int wal_len = snprintf(wal_path, sizeof(wal_path), "%s-wal", path); + int shm_len = snprintf(shm_path, sizeof(shm_path), "%s-shm", path); + if (wal_len <= 0 || (size_t)wal_len >= sizeof(wal_path) || shm_len <= 0 || + (size_t)shm_len >= sizeof(shm_path)) { + return CBM_NOT_FOUND; + } CBM_PROF_START(t_count); int live_count = count_live_nodes(gb); CBM_PROF_END_N("dump", "1_count_live_nodes", t_count, live_count); @@ -1470,14 +1483,24 @@ int cbm_gbuf_dump_to_sqlite(cbm_gbuf_t *gb, const char *path) { char indexed_at[CBM_SZ_64]; generate_iso_timestamp(indexed_at, sizeof(indexed_at)); - /* Stream node rows to the DB in partitions. Under memory pressure, free each + int tmp_fd = cbm_mkstemp_s(tmp_path, sizeof(tmp_path)); + if (tmp_fd < 0) { + free(src_nodes); + free(dump_nodes); + free(temp_to_final); + return CBM_NOT_FOUND; + } + cbm_close_fd(tmp_fd); + + /* Stream node rows to the unique temp DB in partitions. Under memory pressure, free each * partition's heavy properties_json once persisted — the heavy column is * write-once and never read again, so this bounds the dump/finalize peak. * The DB output is identical whether or not freeing engages, so non-pressure * runs (and tests) leave the gbuf intact (the budget>0 guard keeps an * uninitialized budget from ever triggering the free). */ - cbm_db_writer_t *w = cbm_writer_open(path); + cbm_db_writer_t *w = cbm_writer_open(tmp_path); if (!w) { + cbm_unlink(tmp_path); free(src_nodes); free(dump_nodes); free(temp_to_final); @@ -1530,11 +1553,9 @@ int cbm_gbuf_dump_to_sqlite(cbm_gbuf_t *gb, const char *path) { rc = frc; } - /* Post-write integrity verification (B1 mitigation): the streaming dump can - * intermittently emit a structurally-corrupt .db (gbuf-data corruption that - * is ASan/TSan-invisible). Detect it with the fast projects-table check - * (O(1), not a full integrity_check) and remove the corrupt DB so neither - * queries nor tests ever read garbage — the next access re-indexes. + /* Post-write integrity verification (B1 mitigation): verify the temp DB + * before the atomic rename. A corrupt or unopenable temp file is deleted; + * the previously published DB, if any, remains in place for readers. * * Use the _full variant so a path-only defect (root_path the check considers * non-absolute, e.g. a relative repo_path like ".") is RETAINED, not deleted @@ -1543,7 +1564,7 @@ int cbm_gbuf_dump_to_sqlite(cbm_gbuf_t *gb, const char *path) { * path is indexed (caught by self-indexing the repo with repo_path="."). * Consistent with #557 resolve_store. */ if (rc == 0) { - cbm_store_t *verify = cbm_store_open_path((const char *)path); + cbm_store_t *verify = cbm_store_open_path((const char *)tmp_path); if (verify) { bool path_only = false; bool intact = cbm_store_check_integrity_full(verify, &path_only); @@ -1553,14 +1574,30 @@ int cbm_gbuf_dump_to_sqlite(cbm_gbuf_t *gb, const char *path) { char edges_str[CBM_SZ_16]; snprintf(nodes_str, sizeof(nodes_str), "%d", node_idx); snprintf(edges_str, sizeof(edges_str), "%d", edge_idx); - cbm_log_error("dump.verify_corrupt", "path", path, "action", - "deleting corrupt db; re-index required", "nodes", nodes_str, "edges", - edges_str); - unlink(path); + cbm_log_error("dump.verify_corrupt", "path", tmp_path, "action", + "deleting corrupt temp db; re-index required", "nodes", nodes_str, + "edges", edges_str); + cbm_unlink(tmp_path); rc = GB_ERR; } + } else { + cbm_log_error("dump.verify_open_failed", "path", tmp_path, "action", + "deleting unverified temp db"); + cbm_unlink(tmp_path); + rc = GB_ERR; } } + if (rc == 0) { + cbm_unlink(wal_path); + cbm_unlink(shm_path); + if (cbm_replace_file(tmp_path, path) != 0) { + cbm_log_error("dump.rename_failed", "tmp", tmp_path, "path", path); + cbm_unlink(tmp_path); + rc = GB_ERR; + } + } else { + cbm_unlink(tmp_path); + } log_dump_summary(node_idx, edge_idx); free_dump_resources(url_paths, edge_idx, dump_edges, dump_nodes, temp_to_final); diff --git a/src/main.c b/src/main.c index 3a644bbd0..64706fb46 100644 --- a/src/main.c +++ b/src/main.c @@ -31,6 +31,7 @@ enum { MAIN_PORT_OFF = 7, /* strlen("--port=") */ MAIN_MAX_PORT = 65536, PARENT_WATCHDOG_STACK_SIZE = 64 * CBM_SZ_1K, /* watchdog only polls — tiny stack suffices */ + PARENT_WATCHDOG_POLL_US = CBM_USEC_PER_SEC / 2, }; #define MAIN_RAM_FRACTION 0.5 @@ -112,10 +113,9 @@ static void signal_handler(int sig) { #ifndef _WIN32 static void *parent_watchdog_thread(void *arg) { pid_t initial_ppid = *(pid_t *)arg; - const unsigned int poll_interval_us = 500000; /* 500ms */ while (!atomic_load(&g_shutdown)) { - cbm_usleep(poll_interval_us); + cbm_usleep(PARENT_WATCHDOG_POLL_US); if (atomic_load(&g_shutdown)) { break; } @@ -327,10 +327,10 @@ static void print_help(void) { printf("\nSupported agents (auto-detected):\n"); printf(" Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode,\n"); printf(" Antigravity, Aider, KiloCode, Kiro\n"); - printf("\nTools: index_repository, search_graph, query_graph, trace_path,\n"); + printf("\nTools: index_repository, search_graph, query_graph, trace_call_path,\n"); printf(" get_code_snippet, get_graph_schema, get_architecture, search_code,\n"); printf(" list_projects, delete_project, index_status, detect_changes,\n"); - printf(" manage_adr, ingest_traces\n"); + printf(" manage_adr, ingest_traces, index_dependencies\n"); } /* ── Main ───────────────────────────────────────────────────────── */ diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index dfe488b93..ddfa8a333 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -96,7 +96,7 @@ static void add_pagerank_val(yyjson_mut_doc *doc, yyjson_mut_val *obj, double v) /* Default result limit for search_graph and search_code. * Prevents unbounded 500K-result responses. Callers can override. * Configurable via config key "search_limit". */ -#define CBM_DEFAULT_SEARCH_LIMIT 50 +#define CBM_MCP_DEFAULT_SEARCH_LIMIT 50 #define CBM_CONFIG_SEARCH_LIMIT "search_limit" /* Default: rank dependency sub-project symbols (proj.dep.*) LAST so a stdlib @@ -123,7 +123,16 @@ static void add_pagerank_val(yyjson_mut_doc *doc, yyjson_mut_val *obj, double v) /* Idle store eviction: close cached project store after this many seconds * of inactivity to free SQLite memory during idle periods. */ -#define STORE_IDLE_TIMEOUT_S 60 +#define CBM_MCP_DEFAULT_STORE_IDLE_TIMEOUT_S 60 +#define CBM_CONFIG_STORE_IDLE_TIMEOUT_S "store_idle_timeout_s" +/* Read-only DB validation should fail promptly when another process holds a + * lock; this path is on startup/project discovery and must not hang MCP. */ +#define CBM_DB_VALIDATE_BUSY_TIMEOUT_MS 1000 +#define CBM_CONFIG_DB_VALIDATE_BUSY_TIMEOUT_MS "db_validate_busy_timeout_ms" +/* Optional background release check. Default preserves the historical 5s curl + * bound; config value 0 disables the network probe for offline/locked-down MCP. */ +#define CBM_MCP_UPDATE_CHECK_TIMEOUT_S 5 +#define CBM_CONFIG_UPDATE_CHECK_TIMEOUT_S "update_check_timeout_s" /* Config key: comma-separated glob patterns to exclude from key_functions. * Set via: config set key_functions_exclude "scripts/,tools/,tests/" */ @@ -133,9 +142,16 @@ static void add_pagerank_val(yyjson_mut_doc *doc, yyjson_mut_val *obj, double v) * header (closes the codebase://architecture pull-only gap). Smaller than the * get_architecture default (25) to keep first-response token cost modest. */ #define CBM_CONTEXT_KEY_FUNCTIONS_LIMIT 10 +/* Config-tunable override for the _context key_functions push bound. + * <=0 falls back to the CBM_CONTEXT_KEY_FUNCTIONS_LIMIT default above. */ +#define CBM_CONFIG_CONTEXT_KEY_FUNCTIONS_LIMIT "context_key_functions_limit" #define CBM_CONFIG_ARCH_HOTSPOT_LIMIT "arch_hotspot_limit" #define CBM_CONFIG_ARCH_RESOLUTION "architecture_resolution" #define CBM_CONFIG_SIMILARITY_THRESHOLD "similarity_threshold" +#define CBM_CONFIG_HTTPLINK_MIN_CONFIDENCE "httplink_min_confidence" +#define CBM_CONFIG_SEMANTIC_THRESHOLD "semantic_threshold" +#define CBM_CONFIG_GITHISTORY_MIN_COUPLING "githistory_min_coupling" +#define CBM_CONFIG_LSP_CONFIDENCE_FLOOR "lsp_confidence_floor" /* Directory permissions: rwxr-xr-x */ #define ADR_DIR_PERMS 0755 @@ -797,6 +813,7 @@ struct cbm_mcp_server { char *current_project; /* which project store is open for (heap) */ time_t store_last_used; /* last time resolve_store was called for a named project */ char update_notice[CBM_SZ_256]; /* one-shot update notice, cleared after first injection */ + cbm_mutex_t update_notice_lock; /* protects update_notice across background check/request thread */ bool update_checked; /* true after background check has been launched */ cbm_thread_t update_tid; /* background update check thread */ bool update_thread_active; /* true if update thread was started and needs joining */ @@ -820,6 +837,36 @@ struct cbm_mcp_server { int64_t active_request_id; /* JSON-RPC id of the in-progress tool call */ }; +static int cbm_mcp_config_int_clamped(cbm_mcp_server_t *srv, const char *key, int default_val, + int min_val, int max_val) { + int value = srv && srv->config ? cbm_config_get_int(srv->config, key, default_val) : default_val; + if (value < min_val) { + value = min_val; + } + if (value > max_val) { + value = max_val; + } + return value; +} + +static int cbm_mcp_store_idle_timeout_s(cbm_mcp_server_t *srv) { + return cbm_mcp_config_int_clamped(srv, CBM_CONFIG_STORE_IDLE_TIMEOUT_S, + CBM_MCP_DEFAULT_STORE_IDLE_TIMEOUT_S, 1, + CBM_SZ_64K); +} + +static int cbm_mcp_db_validate_busy_timeout_ms(cbm_mcp_server_t *srv) { + return cbm_mcp_config_int_clamped(srv, CBM_CONFIG_DB_VALIDATE_BUSY_TIMEOUT_MS, + CBM_DB_VALIDATE_BUSY_TIMEOUT_MS, 0, + CBM_SZ_64K); +} + +static int cbm_mcp_update_check_timeout_s(cbm_mcp_server_t *srv) { + return cbm_mcp_config_int_clamped(srv, CBM_CONFIG_UPDATE_CHECK_TIMEOUT_S, + CBM_MCP_UPDATE_CHECK_TIMEOUT_S, 0, + CBM_SZ_256); +} + /* ── Tool list (needs full struct definition above) ──────────── */ char *cbm_mcp_tools_list(cbm_mcp_server_t *srv) { @@ -916,6 +963,7 @@ cbm_mcp_server_t *cbm_mcp_server_new(const char *store_path) { if (!srv) { return NULL; } + cbm_mutex_init(&srv->update_notice_lock); /* If a store_path is given, open that project directly. * Otherwise, create an in-memory store for test/embedded use. */ @@ -969,6 +1017,7 @@ void cbm_mcp_server_free(cbm_mcp_server_t *srv) { if (srv->autoindex_active) { cbm_thread_join(&srv->autoindex_tid); } + cbm_mutex_destroy(&srv->update_notice_lock); if (srv->owns_store && srv->store) { cbm_store_close(srv->store); } @@ -1018,7 +1067,12 @@ static const char *cache_dir(char *buf, size_t bufsz) { if (!dir) { dir = cbm_tmpdir(); } - snprintf(buf, bufsz, "%s", dir); + int len = snprintf(buf, bufsz, "%s", dir); + if (len <= 0 || (size_t)len >= bufsz) { + if (bufsz > 0) { + buf[0] = '\0'; + } + } return buf; } @@ -1030,7 +1084,12 @@ static const char *project_db_path(const char *project, char *buf, size_t bufsz) } char dir[CBM_SZ_1K]; cache_dir(dir, sizeof(dir)); - snprintf(buf, bufsz, "%s/%s.db", dir, project); + int len = snprintf(buf, bufsz, "%s/%s.db", dir, project); + if (len <= 0 || (size_t)len >= bufsz) { + if (bufsz > 0) { + buf[0] = '\0'; + } + } return buf; } @@ -1042,8 +1101,8 @@ static const char *project_db_path(const char *project, char *buf, size_t bufsz) * matching DB is found. Cost: one access() call per dot in the QN (~5-10). */ static char *extract_project_from_qn(const char *qn) { if (!qn) return NULL; - const char *home = getenv("HOME"); - if (!home) return NULL; + const char *cdir = cbm_resolve_cache_dir(); + if (!cdir) return NULL; /* Scan each dot-separated prefix of the QN and test if a matching DB file * exists. Walk left-to-right so the last hit is the longest (most @@ -1056,13 +1115,11 @@ static char *extract_project_from_qn(const char *qn) { size_t best_end = 0; /* length of the longest matching prefix found */ char db_path[1024]; - const char *home_val = home; for (size_t i = 0; i < qn_len; i++) { if (candidate[i] == '.') { candidate[i] = '\0'; - snprintf(db_path, sizeof(db_path), - "%s/.cache/codebase-memory-mcp/%s.db", home_val, candidate); + snprintf(db_path, sizeof(db_path), "%s/%s.db", cdir, candidate); if (access(db_path, F_OK) == 0) { best_end = i; /* length of this prefix */ } @@ -1131,15 +1188,18 @@ static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { /* Open project's .db file — query-only open (no SQLITE_OPEN_CREATE) to * prevent ghost .db file creation for unknown/unindexed projects. */ char path[CBM_SZ_1K]; - project_db_path(project, path, sizeof(path)); + project_db_path(db_project, path, sizeof(path)); + if (!path[0]) { + return NULL; + } srv->store = cbm_store_open_path_query(path); if (srv->store) { - /* Check DB integrity — auto-clean corrupt databases. A bad project + /* Check DB integrity — auto-clean corrupt cache databases. A bad project * root_path (with an otherwise-fine projects table) is cosmetic: the * indexed nodes/edges are intact and queries key off project name, not * root_path. Retain such DBs instead of deleting them, to avoid the - * data loss reported in #557. Only genuine corruption (e.g. an - * over-accumulated projects table) is auto-deleted. */ + * data loss reported in #557. Only genuine structural corruption is + * removed, and only from the derived CBM cache. */ bool path_only = false; if (!cbm_store_check_integrity_full(srv->store, &path_only)) { if (path_only) { @@ -1151,14 +1211,18 @@ static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { "deleting corrupt db — re-index required"); cbm_store_close(srv->store); srv->store = NULL; - /* Delete the corrupt DB + WAL/SHM files */ - cbm_unlink(path); + /* Delete the corrupt cache DB + WAL/SHM files. */ + (void)cbm_unlink(path); char wal_path[MCP_FIELD_SIZE]; char shm_path[MCP_FIELD_SIZE]; - snprintf(wal_path, sizeof(wal_path), "%s-wal", path); - snprintf(shm_path, sizeof(shm_path), "%s-shm", path); - cbm_unlink(wal_path); - cbm_unlink(shm_path); + int wal_len = snprintf(wal_path, sizeof(wal_path), "%s-wal", path); + int shm_len = snprintf(shm_path, sizeof(shm_path), "%s-shm", path); + if (wal_len > 0 && (size_t)wal_len < sizeof(wal_path)) { + (void)cbm_unlink(wal_path); + } + if (shm_len > 0 && (size_t)shm_len < sizeof(shm_path)) { + (void)cbm_unlink(shm_path); + } return NULL; } } @@ -1219,7 +1283,8 @@ static int collect_db_project_names(const char *dir_path, char *out, size_t out_ if (count > 0 && offset < (int)out_sz - MCP_SEPARATOR) { out[offset++] = ','; } - int wrote = snprintf(out + offset, out_sz - (size_t)offset, "\"%.*s\"", (int)(len - 3), n); + int wrote = snprintf(out + offset, out_sz - (size_t)offset, "\"%.*s\"", + (int)(len - MCP_DB_EXT), n); if (wrote > 0) { offset += wrote; if ((size_t)offset >= out_sz) { @@ -1518,8 +1583,14 @@ static void inject_context_once(yyjson_mut_doc *doc, yyjson_mut_val *root, const char *kf_exclude = srv->config ? cbm_config_get(srv->config, CBM_CONFIG_KEY_FUNCTIONS_EXCLUDE, "") : ""; - char *kf_sql = build_key_functions_sql(kf_exclude, NULL, - CBM_CONTEXT_KEY_FUNCTIONS_LIMIT); + int kf_cfg_limit = srv->config + ? cbm_config_get_int(srv->config, CBM_CONFIG_CONTEXT_KEY_FUNCTIONS_LIMIT, + CBM_CONTEXT_KEY_FUNCTIONS_LIMIT) + : CBM_CONTEXT_KEY_FUNCTIONS_LIMIT; + if (kf_cfg_limit <= 0) { + kf_cfg_limit = CBM_CONTEXT_KEY_FUNCTIONS_LIMIT; + } + char *kf_sql = build_key_functions_sql(kf_exclude, NULL, kf_cfg_limit); if (kf_sql) { sqlite3_stmt *kf_stmt = NULL; if (sqlite3_prepare_v2(db, kf_sql, -1, &kf_stmt, NULL) == SQLITE_OK) { @@ -1727,7 +1798,7 @@ static void fill_project_params(const project_expand_t *pe, cbm_search_params_t * On ANY error: returns false, logs actionable warning to stderr, * does NOT crash, does NOT hang, does NOT modify the file. * Opens read-only with busy_timeout to avoid hanging on locked files. */ -static bool validate_cbm_db(const char *path) { +static bool validate_cbm_db_with_timeout(const char *path, int busy_timeout_ms) { if (!path) return false; struct stat vst; @@ -1764,7 +1835,7 @@ static bool validate_cbm_db(const char *path) { if (db) sqlite3_close(db); return false; } - sqlite3_busy_timeout(db, 1000); /* 1s max — don't hang on locked files */ + sqlite3_busy_timeout(db, busy_timeout_ms); sqlite3_stmt *stmt = NULL; rc = sqlite3_prepare_v2(db, @@ -1808,10 +1879,17 @@ static bool is_project_db_file(const char *name, size_t len) { static void build_project_json_entry(yyjson_mut_doc *doc, yyjson_mut_val *arr, const char *dir_path, const char *name, size_t name_len, int64_t size_bytes) { char project_name[CBM_SZ_1K]; - snprintf(project_name, sizeof(project_name), "%.*s", (int)(name_len - 3), name); + int project_len = + snprintf(project_name, sizeof(project_name), "%.*s", (int)(name_len - MCP_DB_EXT), name); + if (project_len <= 0 || (size_t)project_len >= sizeof(project_name)) { + return; + } char full_path[CBM_SZ_2K]; - snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, name); + int full_path_len = snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, name); + if (full_path_len <= 0 || (size_t)full_path_len >= sizeof(full_path)) { + return; + } cbm_store_t *pstore = cbm_store_open_path(full_path); int nodes = 0; @@ -1845,11 +1923,11 @@ static void build_project_json_entry(yyjson_mut_doc *doc, yyjson_mut_val *arr, c /* list_projects: scan cache directory for .db files. * Each project is a single .db file — no central registry needed. */ static char *handle_list_projects(cbm_mcp_server_t *srv, const char *args) { - (void)srv; (void)args; char dir_path[CBM_SZ_1K]; cache_dir(dir_path, sizeof(dir_path)); + int validate_busy_timeout_ms = cbm_mcp_db_validate_busy_timeout_ms(srv); cbm_dir_t *d = cbm_opendir(dir_path); @@ -1864,21 +1942,18 @@ static char *handle_list_projects(cbm_mcp_server_t *srv, const char *args) { const char *name = entry->name; size_t len = strlen(name); - /* Must end with .db and be at least 4 chars (x.db) */ - if (len < 4 || strcmp(name + len - 3, ".db") != 0) { - continue; - } - - /* Skip temp/internal files and corrupt project names */ - if (strncmp(name, "tmp-", 4) == 0 || strncmp(name, "_", 1) == 0 || - strncmp(name, ":memory:", 8) == 0 || - strcmp(name, "..db") == 0 || strcmp(name, ".db") == 0) { + if (!is_project_db_file(name, len)) { continue; } /* Extract project name = filename without .db suffix */ - char project_name[1024]; - snprintf(project_name, sizeof(project_name), "%.*s", (int)(len - 3), name); + char project_name[CBM_SZ_1K]; + int project_len = + snprintf(project_name, sizeof(project_name), "%.*s", (int)(len - MCP_DB_EXT), + name); + if (project_len <= 0 || (size_t)project_len >= sizeof(project_name)) { + continue; + } /* Skip invalid project names (corrupt entries like ..db) */ if (project_name[0] == '\0' || strcmp(project_name, ".") == 0 || @@ -1887,15 +1962,18 @@ static char *handle_list_projects(cbm_mcp_server_t *srv, const char *args) { } /* Get file metadata */ - char full_path[2048]; - snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, name); + char full_path[CBM_SZ_2K]; + int full_path_len = snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, name); + if (full_path_len <= 0 || (size_t)full_path_len >= sizeof(full_path)) { + continue; + } struct stat st; if (stat(full_path, &st) != 0) { continue; } /* Validate db structure before opening — skip corrupt/non-cbm files */ - if (!validate_cbm_db(full_path)) { + if (!validate_cbm_db_with_timeout(full_path, validate_busy_timeout_ms)) { continue; } @@ -2705,7 +2783,7 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { return cbm_mcp_text_result(errbuf, true); } int cfg_search_limit = cbm_config_get_int(srv->config, CBM_CONFIG_SEARCH_LIMIT, - CBM_DEFAULT_SEARCH_LIMIT); + CBM_MCP_DEFAULT_SEARCH_LIMIT); int limit = cbm_mcp_get_int_arg(args, "limit", cfg_search_limit); /* F4: treat limit<=0 as default */ if (limit <= 0) limit = cfg_search_limit; @@ -3199,11 +3277,17 @@ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) { /* Delete the .db file + WAL/SHM */ char path[CBM_SZ_1K]; project_db_path(name, path, sizeof(path)); + if (!path[0]) { + cbm_pipeline_unlock(); + free(name); + return cbm_mcp_text_result("{\"status\":\"delete_failed\",\"error\":\"project path too long\"}", + true); + } char wal[CBM_SZ_1K]; char shm[CBM_SZ_1K]; - snprintf(wal, sizeof(wal), "%s-wal", path); - snprintf(shm, sizeof(shm), "%s-shm", path); + int wal_len = snprintf(wal, sizeof(wal), "%s-wal", path); + int shm_len = snprintf(shm, sizeof(shm), "%s-shm", path); bool exists = (access(path, F_OK) == 0); const char *status = "not_found"; @@ -3212,8 +3296,12 @@ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) { if (exists) { int rc = cbm_unlink(path); - (void)cbm_unlink(wal); - (void)cbm_unlink(shm); + if (wal_len > 0 && (size_t)wal_len < sizeof(wal)) { + (void)cbm_unlink(wal); + } + if (shm_len > 0 && (size_t)shm_len < sizeof(shm)) { + (void)cbm_unlink(shm); + } if (rc == 0) { status = "deleted"; } else { @@ -4260,6 +4348,26 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { if (sim_thresh > 0.0) { cbm_pipeline_set_similarity_threshold(p, sim_thresh); } + double httplink_min = + cbm_config_get_double(srv->config, CBM_CONFIG_HTTPLINK_MIN_CONFIDENCE, 0.0); + if (httplink_min > 0.0) { + cbm_pipeline_set_httplink_min_confidence(p, httplink_min); + } + double semantic_thresh = + cbm_config_get_double(srv->config, CBM_CONFIG_SEMANTIC_THRESHOLD, 0.0); + if (semantic_thresh > 0.0) { + cbm_pipeline_set_semantic_threshold(p, semantic_thresh); + } + double gh_min = + cbm_config_get_double(srv->config, CBM_CONFIG_GITHISTORY_MIN_COUPLING, 0.0); + if (gh_min > 0.0) { + cbm_pipeline_set_githistory_min_coupling(p, gh_min); + } + double lsp_floor = + cbm_config_get_double(srv->config, CBM_CONFIG_LSP_CONFIDENCE_FLOOR, 0.0); + if (lsp_floor > 0.0) { + cbm_pipeline_set_lsp_confidence_floor(p, lsp_floor); + } } char *project_name = heap_strdup(cbm_pipeline_project_name(p)); @@ -4462,6 +4570,12 @@ static yyjson_doc *enrich_node_properties(yyjson_mut_doc *doc, yyjson_mut_val *o if (!k) { continue; } + /* Search results flatten node properties into the result object for + * token economy, so property keys must not overwrite/collide with + * stable result fields such as source:"project" vs source:"infra". */ + if (yyjson_mut_obj_get(obj, k) != NULL) { + continue; + } if (yyjson_is_str(val)) { yyjson_mut_obj_add_str(doc, obj, k, yyjson_get_str(val)); } else if (yyjson_is_bool(val)) { @@ -5079,29 +5193,32 @@ static int search_result_cmp(const void *a, const void *b) { /* Build the grep command string based on scoped vs recursive mode. * case_sensitive=false adds -i for case-insensitive matching (grep default is sensitive). */ -static void build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bool case_sensitive, +static bool build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bool case_sensitive, bool scoped, const char *file_pattern, const char *tmpfile, const char *filelist, const char *root_path) { // NOLINTNEXTLINE(readability-implicit-bool-conversion) const char *flag = use_regex ? "-E" : "-F"; const char *ci_flag = case_sensitive ? "" : " -i"; + int n; if (scoped) { if (file_pattern) { - snprintf(cmd, cmd_sz, "xargs grep -n%s %s --include='%s' -f '%s' < '%s' 2>/dev/null", - ci_flag, flag, file_pattern, tmpfile, filelist); + n = snprintf(cmd, cmd_sz, + "xargs grep -n%s %s --include='%s' -f '%s' < '%s' 2>/dev/null", + ci_flag, flag, file_pattern, tmpfile, filelist); } else { - snprintf(cmd, cmd_sz, "xargs grep -n%s %s -f '%s' < '%s' 2>/dev/null", ci_flag, flag, - tmpfile, filelist); + n = snprintf(cmd, cmd_sz, "xargs grep -n%s %s -f '%s' < '%s' 2>/dev/null", ci_flag, + flag, tmpfile, filelist); } } else { if (file_pattern) { - snprintf(cmd, cmd_sz, "grep -rn%s %s --include='%s' -f '%s' '%s' 2>/dev/null", - ci_flag, flag, file_pattern, tmpfile, root_path); + n = snprintf(cmd, cmd_sz, "grep -rn%s %s --include='%s' -f '%s' '%s' 2>/dev/null", + ci_flag, flag, file_pattern, tmpfile, root_path); } else { - snprintf(cmd, cmd_sz, "grep -rn%s %s -f '%s' '%s' 2>/dev/null", ci_flag, flag, tmpfile, - root_path); + n = snprintf(cmd, cmd_sz, "grep -rn%s %s -f '%s' '%s' 2>/dev/null", ci_flag, flag, + tmpfile, root_path); } } + return n >= 0 && (size_t)n < cmd_sz; } /* Build deduplicated file list from search results + raw matches. */ @@ -5534,13 +5651,35 @@ static bool validate_search_args(const char *root_path, const char *file_pattern /* Write pattern to a temp file for grep -f. Returns true on success. */ static bool write_pattern_file(char *tmpfile, int tmpfile_sz, const char *pattern) { - snprintf(tmpfile, tmpfile_sz, "%s/cbm_search_%d.pat", cbm_tmpdir(), (int)getpid()); - FILE *tf = fopen(tmpfile, "w"); + if (!tmpfile || tmpfile_sz <= 0 || !pattern) { + return false; + } + int n = snprintf(tmpfile, (size_t)tmpfile_sz, "%s/cbm_search_XXXXXX", cbm_tmpdir()); + if (n < 0 || n >= tmpfile_sz) { + return false; + } + int fd = cbm_mkstemp_s(tmpfile, (size_t)tmpfile_sz); + if (fd < 0) { + return false; + } +#ifdef _WIN32 + FILE *tf = _fdopen(fd, "w"); +#else + FILE *tf = fdopen(fd, "w"); +#endif if (!tf) { + cbm_close_fd(fd); + cbm_unlink(tmpfile); + return false; + } + bool ok = fputs(pattern, tf) >= 0 && fputc('\n', tf) != EOF; + if (fclose(tf) != 0) { + ok = false; + } + if (!ok) { + cbm_unlink(tmpfile); return false; } - (void)fprintf(tf, "%s\n", pattern); - (void)fclose(tf); return true; } @@ -5560,7 +5699,7 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { char *mode_str = cbm_mcp_get_string_arg(args, "mode"); int context_lines = cbm_mcp_get_int_arg(args, "context", 0); int cfg_search_limit_sc = cbm_config_get_int(srv->config, CBM_CONFIG_SEARCH_LIMIT, - CBM_DEFAULT_SEARCH_LIMIT); + CBM_MCP_DEFAULT_SEARCH_LIMIT); int limit = cbm_mcp_get_int_arg(args, "limit", cfg_search_limit_sc); bool use_regex = cbm_mcp_get_bool_arg(args, "regex"); uint64_t search_t0 = cbm_now_ms(); @@ -5705,7 +5844,7 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { } /* ── Phase 1: Grep scan ──────────────────────────────────── */ - char tmpfile[CBM_SZ_256]; + char tmpfile[CBM_PATH_MAX]; if (!write_pattern_file(tmpfile, sizeof(tmpfile), pattern)) { char errmsg[CBM_SZ_256]; snprintf(errmsg, sizeof(errmsg), "search failed: cannot create temp file (%s)", @@ -5732,13 +5871,32 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { * miss files written or modified after the last index run (e.g. in tests * and in active development workflows where new files aren't yet indexed). * Vendored/generated code can be excluded via .gitignore or path_filter. */ - char filelist[256]; - snprintf(filelist, sizeof(filelist), "%s.files", tmpfile); + char filelist[CBM_PATH_MAX]; + int filelist_len = snprintf(filelist, sizeof(filelist), "%s.files", tmpfile); + if (filelist_len < 0 || (size_t)filelist_len >= sizeof(filelist)) { + cbm_unlink(tmpfile); + free(root_path); + free(pattern); + free(project); + free(file_pattern); + return cbm_mcp_text_result( + "{\"error\":\"search failed: temporary file path too long\"," + "\"hint\":\"Use a shorter TMPDIR/TEMP path or project path.\"}", true); + } bool scoped = false; char cmd[4096]; - build_grep_cmd(cmd, sizeof(cmd), use_regex, case_sensitive, scoped, file_pattern, tmpfile, - filelist, root_path); + if (!build_grep_cmd(cmd, sizeof(cmd), use_regex, case_sensitive, scoped, file_pattern, tmpfile, + filelist, root_path)) { + cbm_unlink(tmpfile); + free(root_path); + free(pattern); + free(project); + free(file_pattern); + return cbm_mcp_text_result( + "{\"error\":\"search failed: grep command too long\"," + "\"hint\":\"Use a shorter project path or file_pattern.\"}", true); + } FILE *fp = cbm_popen(cmd, "r"); if (!fp) { @@ -5936,17 +6094,27 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { /* Get changed files via git (-C avoids cd + quoting issues on Windows) */ char cmd[CBM_SZ_2K]; + int cmd_len; #ifdef _WIN32 - snprintf(cmd, sizeof(cmd), - "git -C \"%s\" diff --name-only \"%s\"...HEAD 2>NUL & " - "git -C \"%s\" diff --name-only 2>NUL", - root_path, base_branch, root_path); + cmd_len = snprintf(cmd, sizeof(cmd), + "git -C \"%s\" diff --name-only \"%s\"...HEAD 2>NUL & " + "git -C \"%s\" diff --name-only 2>NUL", + root_path, base_branch, root_path); #else - snprintf(cmd, sizeof(cmd), - "{ git -C '%s' diff --name-only '%s'...HEAD 2>/dev/null; " - "git -C '%s' diff --name-only 2>/dev/null; } | sort -u", - root_path, base_branch, root_path); + cmd_len = snprintf(cmd, sizeof(cmd), + "{ git -C \"%s\" diff --name-only \"%s\"...HEAD 2>/dev/null; " + "git -C \"%s\" diff --name-only 2>/dev/null; } | sort -u", + root_path, base_branch, root_path); #endif + if (cmd_len < 0 || (size_t)cmd_len >= sizeof(cmd)) { + free(root_path); + free(project); + free(base_branch); + free(scope); + return cbm_mcp_text_result( + "{\"error\":\"git diff failed: command too long\"," + "\"hint\":\"Use shorter project paths or branch names.\"}", true); + } FILE *fp = cbm_popen(cmd, "r"); if (!fp) { @@ -6585,9 +6753,16 @@ static bool db_is_stale(const char *db_path, const char *repo_path, int max_age_ } /* Check git HEAD commit time vs DB mtime */ + if (!validate_search_path_arg(repo_path)) return false; char cmd[1024]; - snprintf(cmd, sizeof(cmd), - "git -C '%s' log -1 --format=%%ct HEAD 2>/dev/null", repo_path); +#ifdef _WIN32 + const char *null_dev = "NUL"; +#else + const char *null_dev = "/dev/null"; +#endif + int cmd_len = snprintf(cmd, sizeof(cmd), "git -C \"%s\" log -1 --format=%%ct HEAD 2>%s", + repo_path, null_dev); + if (cmd_len < 0 || (size_t)cmd_len >= sizeof(cmd)) return false; // NOLINTNEXTLINE(bugprone-command-processor,cert-env33-c) FILE *fp = cbm_popen(cmd, "r"); if (!fp) return false; @@ -6615,11 +6790,8 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) { /* Check if project already has a populated DB */ bool needs_index = true; char db_check[1024] = {0}; - const char *home = cbm_get_home_dir(); - if (home) { - snprintf(db_check, sizeof(db_check), "%s/.cache/codebase-memory-mcp/%s.db", home, - srv->session_project); - + project_db_path(srv->session_project, db_check, sizeof(db_check)); + if (db_check[0]) { if (db_has_content(db_check)) { /* DB exists and has nodes — check if stale */ bool reindex_on_startup = srv->config @@ -6687,26 +6859,47 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) { return; } - /* Quick file count check to avoid OOM on massive repos */ - if (!cbm_validate_shell_arg(srv->session_root)) { - cbm_log_warn("autoindex.skip", "reason", "path contains shell metacharacters"); - return; - } - char cmd[CBM_SZ_1K]; - snprintf(cmd, sizeof(cmd), "git -C '%s' ls-files 2>/dev/null | wc -l", srv->session_root); - FILE *fp = cbm_popen(cmd, "r"); - if (fp) { - char line[CBM_SZ_64]; - if (fgets(line, sizeof(line), fp)) { - int count = (int)strtol(line, NULL, CBM_DECIMAL_BASE); + /* Quick file count check to avoid OOM on massive repos. A configured limit + * of 0 means "no limit", matching the public config registry. */ + if (file_limit > 0) { + if (!validate_search_path_arg(srv->session_root)) { + cbm_log_warn("autoindex.skip", "reason", "path contains shell metacharacters"); + return; + } + char cmd[CBM_SZ_1K]; +#ifdef _WIN32 + const char *null_dev = "NUL"; +#else + const char *null_dev = "/dev/null"; +#endif + int cmd_len = snprintf(cmd, sizeof(cmd), "git -C \"%s\" ls-files 2>%s", + srv->session_root, null_dev); + if (cmd_len < 0 || (size_t)cmd_len >= sizeof(cmd)) { + cbm_log_warn("autoindex.skip", "reason", "file_count_command_too_long"); + return; + } + FILE *fp = cbm_popen(cmd, "r"); + if (fp) { + char *line = NULL; + size_t line_cap = 0; + int count = 0; + while (cbm_getline(&line, &line_cap, fp) > 0) { + count++; + if (count > file_limit) { + break; + } + } + free(line); if (count > file_limit) { - cbm_log_warn("autoindex.skip", "reason", "too_many_files", "files", line, "limit", + char count_buf[CBM_SZ_32]; + snprintf(count_buf, sizeof(count_buf), "%d", count); + cbm_log_warn("autoindex.skip", "reason", "too_many_files", "files", count_buf, "limit", CBM_CONFIG_AUTO_INDEX_LIMIT); cbm_pclose(fp); return; } + cbm_pclose(fp); } - cbm_pclose(fp); } /* Launch auto-index in background */ @@ -6722,12 +6915,22 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) { static void *update_check_thread(void *arg) { cbm_mcp_server_t *srv = (cbm_mcp_server_t *)arg; - /* Use curl with 5s timeout to fetch latest release tag */ - FILE *fp = cbm_popen("curl -sf --max-time 5 -H 'Accept: application/vnd.github+json' " - "'" UPDATE_CHECK_URL "' 2>/dev/null", - "r"); + int timeout_s = cbm_mcp_update_check_timeout_s(srv); + if (timeout_s <= 0) { + return NULL; + } + + char cmd[CBM_SZ_512]; + int cmd_len = snprintf(cmd, sizeof(cmd), + "curl -sf --max-time %d -H 'Accept: application/vnd.github+json' " + "'" UPDATE_CHECK_URL "' 2>/dev/null", + timeout_s); + if (cmd_len < 0 || (size_t)cmd_len >= sizeof(cmd)) { + return NULL; + } + + FILE *fp = cbm_popen(cmd, "r"); if (!fp) { - srv->update_checked = true; return NULL; } @@ -6746,7 +6949,6 @@ static void *update_check_thread(void *arg) { /* Parse tag_name from JSON response */ yyjson_doc *doc = yyjson_read(buf, total, 0); if (!doc) { - srv->update_checked = true; return NULL; } @@ -6757,17 +6959,18 @@ static void *update_check_thread(void *arg) { if (tag_str) { const char *current = cbm_cli_get_version(); if (cbm_compare_versions(tag_str, current) > 0) { + cbm_mutex_lock(&srv->update_notice_lock); snprintf(srv->update_notice, sizeof(srv->update_notice), "Update available: %s -> %s -- run: codebase-memory-mcp update | " "Enjoying codebase-memory-mcp? Please leave a star: " "https://github.com/DeusData/codebase-memory-mcp", current, tag_str); + cbm_mutex_unlock(&srv->update_notice_lock); cbm_log_info("update.available", "current", current, "latest", tag_str); } } yyjson_doc_free(doc); - srv->update_checked = true; return NULL; } @@ -6783,7 +6986,16 @@ static void start_update_check(cbm_mcp_server_t *srv) { /* Prepend update notice to a tool result, then clear it (one-shot). */ static char *inject_update_notice(cbm_mcp_server_t *srv, char *result_json) { - if (srv->update_notice[0] == '\0') { + if (!srv || !result_json) { + return result_json; + } + + char notice[sizeof(srv->update_notice)]; + cbm_mutex_lock(&srv->update_notice_lock); + snprintf(notice, sizeof(notice), "%s", srv->update_notice); + cbm_mutex_unlock(&srv->update_notice_lock); + + if (notice[0] == '\0') { return result_json; } @@ -6808,7 +7020,7 @@ static char *inject_update_notice(cbm_mcp_server_t *srv, char *result_json) { /* Prepend a text content item with the update notice */ yyjson_mut_val *notice_item = yyjson_mut_obj(mdoc); yyjson_mut_obj_add_str(mdoc, notice_item, "type", "text"); - yyjson_mut_obj_add_str(mdoc, notice_item, "text", srv->update_notice); + yyjson_mut_obj_add_str(mdoc, notice_item, "text", notice); yyjson_mut_arr_prepend(content, notice_item); } @@ -6818,7 +7030,9 @@ static char *inject_update_notice(cbm_mcp_server_t *srv, char *result_json) { if (new_json) { free(result_json); - srv->update_notice[0] = '\0'; /* clear — one-shot */ + cbm_mutex_lock(&srv->update_notice_lock); + srv->update_notice[0] = '\0'; /* clear — one-shot after successful injection */ + cbm_mutex_unlock(&srv->update_notice_lock); return new_json; } return result_json; @@ -7404,6 +7618,8 @@ static void handle_content_length_frame(cbm_mcp_server_t *srv, FILE *in, FILE *o * Returns: 1 = data ready, 0 = timeout (evicted idle stores), -1 = error/EOF. */ static int poll_for_input_unix(cbm_mcp_server_t *srv, int fd, FILE *in) { struct pollfd pfd = {.fd = fd, .events = POLLIN}; + int idle_timeout_s = cbm_mcp_store_idle_timeout_s(srv); + int poll_timeout_ms = idle_timeout_s * MCP_TIMEOUT_MS; int pr = poll(&pfd, SKIP_ONE, 0); /* Phase 1: non-blocking */ if (pr < 0) { @@ -7417,12 +7633,12 @@ static int poll_for_input_unix(cbm_mcp_server_t *srv, int fd, FILE *in) { int saved_flags = fcntl(fd, F_GETFL); if (saved_flags < 0) { /* fcntl failed — fall through to blocking poll */ - pr = poll(&pfd, SKIP_ONE, STORE_IDLE_TIMEOUT_S * MCP_TIMEOUT_MS); + pr = poll(&pfd, SKIP_ONE, poll_timeout_ms); if (pr < 0) { return CBM_NOT_FOUND; } if (pr == 0) { - cbm_mcp_server_evict_idle(srv, STORE_IDLE_TIMEOUT_S); + cbm_mcp_server_evict_idle(srv, idle_timeout_s); return 0; } return SKIP_ONE; @@ -7438,12 +7654,12 @@ static int poll_for_input_unix(cbm_mcp_server_t *srv, int fd, FILE *in) { } clearerr(in); /* Phase 3: blocking poll */ - pr = poll(&pfd, SKIP_ONE, STORE_IDLE_TIMEOUT_S * MCP_TIMEOUT_MS); + pr = poll(&pfd, SKIP_ONE, poll_timeout_ms); if (pr < 0) { return CBM_NOT_FOUND; } if (pr == 0) { - cbm_mcp_server_evict_idle(srv, STORE_IDLE_TIMEOUT_S); + cbm_mcp_server_evict_idle(srv, idle_timeout_s); return 0; } return SKIP_ONE; @@ -7469,7 +7685,7 @@ int cbm_mcp_server_run(cbm_mcp_server_t *srv, FILE *in, FILE *out) { * buffered FILE*. When a client sends multiple messages in rapid * succession, the first getline() call may drain ALL kernel data into * libc's internal FILE* buffer. Subsequent poll() calls then see an - * empty kernel fd and block for STORE_IDLE_TIMEOUT_S seconds even + * empty kernel fd and block for store_idle_timeout_s seconds even * though the next messages are already in the FILE* buffer. * * Fix (Unix): use a three-phase approach — @@ -7484,12 +7700,13 @@ int cbm_mcp_server_run(cbm_mcp_server_t *srv, FILE *in, FILE *out) { #ifdef _WIN32 /* Windows: WaitForSingleObject on stdin handle */ HANDLE hStdin = (HANDLE)_get_osfhandle(fd); - DWORD wr = WaitForSingleObject(hStdin, STORE_IDLE_TIMEOUT_S * MCP_TIMEOUT_MS); + int idle_timeout_s = cbm_mcp_store_idle_timeout_s(srv); + DWORD wr = WaitForSingleObject(hStdin, (DWORD)(idle_timeout_s * MCP_TIMEOUT_MS)); if (wr == WAIT_FAILED) { break; } if (wr == WAIT_TIMEOUT) { - cbm_mcp_server_evict_idle(srv, STORE_IDLE_TIMEOUT_S); + cbm_mcp_server_evict_idle(srv, idle_timeout_s); continue; } #else diff --git a/src/pipeline/artifact.c b/src/pipeline/artifact.c index 720c9d237..a8bd5839e 100644 --- a/src/pipeline/artifact.c +++ b/src/pipeline/artifact.c @@ -21,6 +21,7 @@ enum { #include "foundation/compat_fs.h" #include "foundation/compat.h" #include "foundation/log.h" +#include "foundation/str_util.h" #include "zstd_store.h" @@ -95,25 +96,6 @@ static int artifact_export_fail(const char *stage, const char *path, const char return CBM_NOT_FOUND; } -typedef struct { - const char *err; - int err_no; -} artifact_file_error_t; - -static void file_error_clear(artifact_file_error_t *out) { - if (out) { - out->err = NULL; - out->err_no = 0; - } -} - -static void file_error_set(artifact_file_error_t *out, const char *err, int err_no) { - if (out) { - out->err = err; - out->err_no = err_no; - } -} - /* Build path: /.codebase-memory/ into caller-owned buf. */ static bool artifact_path(char *buf, size_t bufsz, const char *repo_path, const char *name) { int n = snprintf(buf, bufsz, "%s/%s/%s", repo_path, CBM_ARTIFACT_DIR, name); @@ -148,63 +130,26 @@ static char *read_file_alloc(const char *path, size_t *out_len) { return buf; } -/* Write buffer to file atomically (write to tmp, rename). Returns 0 on success. */ -static int write_file_atomic(const char *path, const char *data, size_t len, - artifact_file_error_t *out_err) { - file_error_clear(out_err); - - char tmp[CBM_SZ_4K]; - int n = snprintf(tmp, sizeof(tmp), "%s.tmp", path); - if (n < 0 || (size_t)n >= sizeof(tmp)) { - file_error_set(out_err, "path_too_long", 0); - return CBM_NOT_FOUND; - } - - FILE *fp = fopen(tmp, "wb"); - if (!fp) { - file_error_set(out_err, "open_temp", errno); - return CBM_NOT_FOUND; - } - - size_t wr = fwrite(data, ART_NUL, len, fp); - if (wr != len) { - int saved_errno = ferror(fp) ? errno : 0; - (void)fclose(fp); - cbm_unlink(tmp); - file_error_set(out_err, "write_temp", saved_errno); - return CBM_NOT_FOUND; - } - - if (fclose(fp) != 0) { - int saved_errno = errno; - cbm_unlink(tmp); - file_error_set(out_err, "close_temp", saved_errno); - return CBM_NOT_FOUND; - } - -#ifdef _WIN32 - /* MoveFileEx replace approach suggested by @Ayush7Ranjan in #492. */ - if (!MoveFileExA(tmp, path, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { - DWORD saved_error = GetLastError(); - cbm_unlink(tmp); - file_error_set(out_err, "rename_temp", (int)saved_error); - return CBM_NOT_FOUND; - } -#else - if (rename(tmp, path) != 0) { - int saved_errno = errno; - cbm_unlink(tmp); - file_error_set(out_err, "rename_temp", saved_errno); - return CBM_NOT_FOUND; - } -#endif - return 0; -} - /* Get current git HEAD hash. buf must be >= CBM_SZ_64. Returns false on error. */ static bool git_head_hash(const char *repo_path, char *buf, size_t bufsz) { + if (!buf || bufsz == 0 || !cbm_validate_shell_arg(repo_path)) { + if (buf && bufsz > 0) { + buf[0] = '\0'; + } + return false; + } char cmd[CBM_SZ_1K]; - snprintf(cmd, sizeof(cmd), "git -C '%s' rev-parse HEAD 2>/dev/null", repo_path); +#ifdef _WIN32 + const char *null_dev = "NUL"; +#else + const char *null_dev = "/dev/null"; +#endif + int cmd_len = snprintf(cmd, sizeof(cmd), "git -C \"%s\" rev-parse HEAD 2>%s", repo_path, + null_dev); + if (cmd_len < 0 || (size_t)cmd_len >= sizeof(cmd)) { + buf[0] = '\0'; + return false; + } FILE *fp = cbm_popen(cmd, "r"); if (!fp) { buf[0] = '\0'; @@ -239,7 +184,9 @@ static void iso_timestamp(char *buf, size_t bufsz) { /* Read schema_version from artifact.json. Returns -1 if missing/invalid. */ static int read_metadata_version(const char *repo_path) { char meta_path[CBM_SZ_4K]; - artifact_path(meta_path, sizeof(meta_path), repo_path, CBM_ARTIFACT_META); + if (!artifact_path(meta_path, sizeof(meta_path), repo_path, CBM_ARTIFACT_META)) { + return CBM_NOT_FOUND; + } size_t len = 0; char *json = read_file_alloc(meta_path, &len); @@ -263,7 +210,9 @@ static int read_metadata_version(const char *repo_path) { /* Read original_size from artifact.json. Returns 0 on error. */ static size_t read_metadata_original_size(const char *repo_path) { char meta_path[CBM_SZ_4K]; - artifact_path(meta_path, sizeof(meta_path), repo_path, CBM_ARTIFACT_META); + if (!artifact_path(meta_path, sizeof(meta_path), repo_path, CBM_ARTIFACT_META)) { + return 0; + } size_t len = 0; char *json = read_file_alloc(meta_path, &len); @@ -319,11 +268,11 @@ static int write_metadata(const char *repo_path, const char *project_name, int n free(json); return artifact_export_fail("write_metadata", repo_path, "path_too_long", 0); } - artifact_file_error_t ioerr; - int rc = write_file_atomic(meta_path, json, json_len, &ioerr); + cbm_file_error_t ioerr; + int rc = cbm_write_file_atomic(meta_path, json, json_len, &ioerr); free(json); if (rc != 0) { - return artifact_export_fail("write_metadata", meta_path, ioerr.err, ioerr.err_no); + return artifact_export_fail("write_metadata", meta_path, ioerr.stage, ioerr.code); } return rc; } @@ -332,7 +281,10 @@ static int write_metadata(const char *repo_path, const char *project_name, int n static void ensure_gitattributes(const char *repo_path) { char ga_path[CBM_SZ_4K]; - artifact_path(ga_path, sizeof(ga_path), repo_path, ".gitattributes"); + if (!artifact_path(ga_path, sizeof(ga_path), repo_path, ".gitattributes")) { + cbm_log_warn("artifact.gitattributes.open path=%s err=%s", repo_path, "path_too_long"); + return; + } /* Atomic create-only-if-absent: O_EXCL closes the TOCTOU window * between checking existence and writing. If the file exists, open @@ -356,9 +308,24 @@ static void ensure_gitattributes(const char *repo_path) { } } - /* Best-effort: configure merge driver */ + /* Best-effort: configure merge driver. The popen fallback uses a shell, so + * reuse the repository-wide argument validator before quoting repo_path. */ + if (!cbm_validate_shell_arg(repo_path)) { + cbm_log_warn("artifact.merge_driver.skip", "reason", "unsafe_repo_path"); + return; + } char cmd[CBM_SZ_1K]; - snprintf(cmd, sizeof(cmd), "git -C '%s' config merge.ours.driver true 2>/dev/null", repo_path); +#ifdef _WIN32 + const char *null_dev = "NUL"; +#else + const char *null_dev = "/dev/null"; +#endif + int cmd_len = snprintf(cmd, sizeof(cmd), "git -C \"%s\" config merge.ours.driver true 2>%s", + repo_path, null_dev); + if (cmd_len < 0 || (size_t)cmd_len >= sizeof(cmd)) { + cbm_log_warn("artifact.merge_driver.skip", "reason", "command_too_long"); + return; + } FILE *p = cbm_popen(cmd, "r"); if (p) { (void)cbm_pclose(p); @@ -384,7 +351,19 @@ static const char *DROP_INDEXES_SQL = "DROP INDEX IF EXISTS idx_nodes_label;" * VACUUM INTO → drop indexes → VACUUM. Returns malloc'd buffer or NULL. */ static char *prepare_stripped_db(const char *db_path, size_t *out_size) { char tmp_path[CBM_SZ_4K]; - snprintf(tmp_path, sizeof(tmp_path), "%s/cbm_artifact_tmp.db", cbm_tmpdir()); + int tmp_len = snprintf(tmp_path, sizeof(tmp_path), "%s/cbm_artifact_tmp_XXXXXX", cbm_tmpdir()); + if (tmp_len < 0 || (size_t)tmp_len >= sizeof(tmp_path)) { + artifact_export_fail("prepare_temp_db", cbm_tmpdir(), "path_too_long", 0); + return NULL; + } + int tmp_fd = cbm_mkstemp_s(tmp_path, sizeof(tmp_path)); + if (tmp_fd < 0) { + artifact_export_fail("prepare_temp_db", tmp_path, "create_temp", errno); + return NULL; + } + cbm_close_fd(tmp_fd); + /* VACUUM INTO requires the destination file not to exist. The unique name + * still prevents cross-process collision after this unlink. */ cbm_unlink(tmp_path); /* VACUUM INTO: clean compacted copy. Use raw sqlite3 to bypass store authorizer @@ -397,10 +376,15 @@ static char *prepare_stripped_db(const char *db_path, size_t *out_size) { return NULL; } - char vacuum_sql[CBM_SZ_4K]; - snprintf(vacuum_sql, sizeof(vacuum_sql), "VACUUM INTO '%s';", tmp_path); + char *vacuum_sql = sqlite3_mprintf("VACUUM INTO %Q;", tmp_path); + if (!vacuum_sql) { + artifact_export_fail("vacuum_into", tmp_path, "alloc_sql", 0); + sqlite3_close(raw_db); + return NULL; + } char *errmsg = NULL; int vrc = sqlite3_exec(raw_db, vacuum_sql, NULL, NULL, &errmsg); + sqlite3_free(vacuum_sql); sqlite3_close(raw_db); if (vrc != SQLITE_OK) { @@ -427,10 +411,14 @@ static char *prepare_stripped_db(const char *db_path, size_t *out_size) { /* Clean up WAL/SHM from temp */ char wal[CBM_SZ_4K]; char shm[CBM_SZ_4K]; - snprintf(wal, sizeof(wal), "%s-wal", tmp_path); - snprintf(shm, sizeof(shm), "%s-shm", tmp_path); - cbm_unlink(wal); - cbm_unlink(shm); + int wal_len = snprintf(wal, sizeof(wal), "%s-wal", tmp_path); + int shm_len = snprintf(shm, sizeof(shm), "%s-shm", tmp_path); + if (wal_len >= 0 && (size_t)wal_len < sizeof(wal)) { + cbm_unlink(wal); + } + if (shm_len >= 0 && (size_t)shm_len < sizeof(shm)) { + cbm_unlink(shm); + } return data; } @@ -500,12 +488,12 @@ int cbm_artifact_export(const char *db_path, const char *repo_path, const char * free(compressed); return artifact_export_fail("write_artifact", repo_path, "path_too_long", 0); } - artifact_file_error_t ioerr; - int wrc = write_file_atomic(zst_path, compressed, (size_t)clen, &ioerr); + cbm_file_error_t ioerr; + int wrc = cbm_write_file_atomic(zst_path, compressed, (size_t)clen, &ioerr); free(compressed); if (wrc != 0) { - return artifact_export_fail("write_artifact", zst_path, ioerr.err, ioerr.err_no); + return artifact_export_fail("write_artifact", zst_path, ioerr.stage, ioerr.code); } /* Get node/edge counts for metadata */ @@ -561,7 +549,10 @@ int cbm_artifact_import(const char *repo_path, const char *cache_db_path) { /* Read compressed artifact */ char zst_path[CBM_SZ_4K]; - artifact_path(zst_path, sizeof(zst_path), repo_path, CBM_ARTIFACT_FILENAME); + if (!artifact_path(zst_path, sizeof(zst_path), repo_path, CBM_ARTIFACT_FILENAME)) { + cbm_log_error("artifact.import", "err", "artifact_path_too_long"); + return CBM_NOT_FOUND; + } size_t clen = 0; char *compressed = read_file_alloc(zst_path, &clen); @@ -586,31 +577,49 @@ int cbm_artifact_import(const char *repo_path, const char *cache_db_path) { return CBM_NOT_FOUND; } - /* Write to temp file, then rename for atomicity */ + /* Write to a unique temp file, then rename for atomicity */ char tmp_path[CBM_SZ_4K]; - snprintf(tmp_path, sizeof(tmp_path), "%s.import_tmp", cache_db_path); + int tmp_len = snprintf(tmp_path, sizeof(tmp_path), "%s.import.XXXXXX", cache_db_path); + if (tmp_len < 0 || (size_t)tmp_len >= sizeof(tmp_path)) { + free(decompressed); + cbm_log_error("artifact.import", "err", "cache_path_too_long"); + return CBM_NOT_FOUND; + } + int tmp_fd = cbm_mkstemp_s(tmp_path, sizeof(tmp_path)); + if (tmp_fd < 0) { + free(decompressed); + cbm_log_error("artifact.import", "err", "create_temp_db", "path", tmp_path); + return CBM_NOT_FOUND; + } + cbm_close_fd(tmp_fd); /* Ensure cache directory exists */ char cache_dir[CBM_SZ_1K]; - snprintf(cache_dir, sizeof(cache_dir), "%s", cache_db_path); + int dir_len = snprintf(cache_dir, sizeof(cache_dir), "%s", cache_db_path); + if (dir_len < 0 || (size_t)dir_len >= sizeof(cache_dir)) { + free(decompressed); + cbm_log_error("artifact.import", "err", "cache_dir_path_too_long"); + return CBM_NOT_FOUND; + } char *last_slash = strrchr(cache_dir, '/'); if (last_slash) { *last_slash = '\0'; cbm_mkdir_p(cache_dir, ART_DIR_PERMS); } - artifact_file_error_t ioerr; - int wrc = write_file_atomic(tmp_path, decompressed, (size_t)dlen, &ioerr); + cbm_file_error_t ioerr; + int wrc = cbm_write_file_atomic(tmp_path, decompressed, (size_t)dlen, &ioerr); free(decompressed); if (wrc != 0) { - if (ioerr.err_no != 0) { - cbm_log_error("artifact.import", "err", "write_temp_db", "detail", ioerr.err, "errno", - itoa_buf(ioerr.err_no), "path", tmp_path); + if (ioerr.code != 0) { + cbm_log_error("artifact.import", "err", "write_temp_db", "detail", ioerr.stage, + "errno", itoa_buf(ioerr.code), "path", tmp_path); } else { - cbm_log_error("artifact.import", "err", "write_temp_db", "detail", ioerr.err, "path", - tmp_path); + cbm_log_error("artifact.import", "err", "write_temp_db", "detail", ioerr.stage, + "path", tmp_path); } + cbm_unlink(tmp_path); return CBM_NOT_FOUND; } @@ -632,9 +641,24 @@ int cbm_artifact_import(const char *repo_path, const char *cache_db_path) { cbm_store_close(store); - /* Atomic rename to final path */ - if (rename(tmp_path, cache_db_path) != 0) { - cbm_log_error("artifact.import", "err", "rename_to_cache"); + /* A stale WAL/SHM can describe the previous cache DB. Remove sidecars only + * after the imported DB has passed integrity and immediately before publish. */ + char final_wal[CBM_SZ_4K]; + char final_shm[CBM_SZ_4K]; + int final_wal_len = snprintf(final_wal, sizeof(final_wal), "%s-wal", cache_db_path); + int final_shm_len = snprintf(final_shm, sizeof(final_shm), "%s-shm", cache_db_path); + if (final_wal_len >= 0 && (size_t)final_wal_len < sizeof(final_wal)) { + cbm_unlink(final_wal); + } + if (final_shm_len >= 0 && (size_t)final_shm_len < sizeof(final_shm)) { + cbm_unlink(final_shm); + } + + /* Preserve the Windows persistence fix from #400/#486: replacing an + * existing cache DB must use MoveFileEx(REPLACE_EXISTING) via compat_fs. */ + int replace_error = 0; + if (cbm_replace_file_ex(tmp_path, cache_db_path, &replace_error) != 0) { + cbm_log_error("artifact.import", "err", "replace_cache", "errno", itoa_buf(replace_error)); cbm_unlink(tmp_path); return CBM_NOT_FOUND; } @@ -642,10 +666,14 @@ int cbm_artifact_import(const char *repo_path, const char *cache_db_path) { /* Clean up any stale WAL/SHM from the temp open */ char wal[CBM_SZ_4K]; char shm[CBM_SZ_4K]; - snprintf(wal, sizeof(wal), "%s-wal", tmp_path); - snprintf(shm, sizeof(shm), "%s-shm", tmp_path); - cbm_unlink(wal); - cbm_unlink(shm); + int wal_len = snprintf(wal, sizeof(wal), "%s-wal", tmp_path); + int shm_len = snprintf(shm, sizeof(shm), "%s-shm", tmp_path); + if (wal_len >= 0 && (size_t)wal_len < sizeof(wal)) { + cbm_unlink(wal); + } + if (shm_len >= 0 && (size_t)shm_len < sizeof(shm)) { + cbm_unlink(shm); + } cbm_log_info("artifact.import", "db", cache_db_path, "size_mb", itoa_buf((int)((size_t)dlen / ART_BYTES_PER_MB))); @@ -661,7 +689,9 @@ bool cbm_artifact_exists(const char *repo_path) { } char zst_path[CBM_SZ_4K]; - artifact_path(zst_path, sizeof(zst_path), repo_path, CBM_ARTIFACT_FILENAME); + if (!artifact_path(zst_path, sizeof(zst_path), repo_path, CBM_ARTIFACT_FILENAME)) { + return false; + } struct stat st; if (stat(zst_path, &st) != 0 || st.st_size == 0) { @@ -681,7 +711,9 @@ char *cbm_artifact_commit(const char *repo_path) { } char meta_path[CBM_SZ_4K]; - artifact_path(meta_path, sizeof(meta_path), repo_path, CBM_ARTIFACT_META); + if (!artifact_path(meta_path, sizeof(meta_path), repo_path, CBM_ARTIFACT_META)) { + return NULL; + } size_t len = 0; char *json = read_file_alloc(meta_path, &len); diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 85facee81..d03e74889 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -45,21 +45,23 @@ * the textual callee_name as the last dot-separated segment. The * pointer returned aliases into `arr` and stays valid as long as the * underlying CBMFileResult is alive. */ -static inline const CBMResolvedCall *cbm_pipeline_find_lsp_resolution( - const CBMResolvedCallArray *arr, const CBMCall *call) { +static inline const CBMResolvedCall *cbm_pipeline_find_lsp_resolution_with_floor( + const CBMResolvedCallArray *arr, const CBMCall *call, double confidence_floor) { if (!arr || arr->count == 0 || !call) { return NULL; } if (!call->enclosing_func_qn || !call->callee_name) { return NULL; } + double floor = + confidence_floor > 0.0 ? confidence_floor : (double)CBM_LSP_CONFIDENCE_FLOOR; const CBMResolvedCall *best = NULL; for (int i = 0; i < arr->count; i++) { const CBMResolvedCall *rc = &arr->items[i]; if (!rc->caller_qn || !rc->callee_qn) { continue; } - if (rc->confidence < CBM_LSP_CONFIDENCE_FLOOR) { + if ((double)rc->confidence < floor) { continue; } if (strcmp(rc->caller_qn, call->enclosing_func_qn) != 0) { @@ -77,6 +79,11 @@ static inline const CBMResolvedCall *cbm_pipeline_find_lsp_resolution( return best; } +static inline const CBMResolvedCall *cbm_pipeline_find_lsp_resolution( + const CBMResolvedCallArray *arr, const CBMCall *call) { + return cbm_pipeline_find_lsp_resolution_with_floor(arr, call, 0.0); +} + /* Resolve an LSP-emitted callee_qn to a graph-buffer node. * * Per-file LSPs (notably py_lsp) sometimes emit `callee_qn` as the raw diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 547811dde..6e24e5ab3 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -186,6 +186,13 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca const cbm_gbuf_node_t *source_node, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count) { const char *method = cbm_service_pattern_route_method(call->callee_name); + /* Reject CLI slash-command args (e.g. "/ar:allow") that start with '/' and + * so pass the caller's first_string_arg[0]=='/' check, but aren't valid + * route paths. Same gate as pass_route_nodes.c — keeps command-syntax + * strings from becoming spurious Route nodes. */ + if (!cbm_service_pattern_is_http_route_literal(call->first_string_arg, call->callee_name)) { + return; + } char route_qn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method ? method : "ANY", @@ -270,6 +277,16 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call, (url_or_topic[0] == '/' || strstr(url_or_topic, "://") != NULL)); bool is_topic = (url_or_topic && url_or_topic[0] != '\0' && svc == CBM_SVC_ASYNC && strlen(url_or_topic) > PAIR_LEN); + /* An HTTP call whose URL isn't a valid route path (e.g. CLI "/ar:ok" from a + * .get(...) accessor that the service-pattern matcher misclassified as + * HTTP) must not become a spurious Route node. Drop the URL so we fall + * through to the plain CALLS edge — the call relationship is preserved + * without fabricating a route. (Async topics aren't route paths, so only + * gate HTTP.) */ + if (svc == CBM_SVC_HTTP && is_url && + !cbm_service_pattern_is_http_route_literal(url_or_topic, call->callee_name)) { + is_url = false; + } if (!is_url && !is_topic) { char esc_callee[CBM_SZ_256]; cbm_json_escape(esc_callee, sizeof(esc_callee), call->callee_name); @@ -366,7 +383,8 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, } /* LSP-resolved calls take precedence over registry-textual matching. */ - const CBMResolvedCall *lsp = cbm_pipeline_find_lsp_resolution(lsp_calls, call); + const CBMResolvedCall *lsp = cbm_pipeline_find_lsp_resolution_with_floor( + lsp_calls, call, ctx->lsp_confidence_floor); if (lsp) { const cbm_gbuf_node_t *target_node = cbm_pipeline_lsp_target_node(ctx->gbuf, ctx->project_name, lsp->callee_qn); diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index a3ce18e1f..dcbbde08f 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -16,6 +16,7 @@ #include "foundation/platform.h" #include "foundation/compat.h" #include "foundation/compat_fs.h" +#include "service_patterns.h" #include #include @@ -283,6 +284,9 @@ static int match_http_routes(cbm_store_t *src_store, const char *src_project, if (!url_path[0]) { continue; } + if (!cbm_service_pattern_is_http_route_literal(url_path, NULL)) { + continue; + } /* Build the expected Route QN in the target project (param-canonicalized * so client url_path matches the server handler regardless of framework diff --git a/src/pipeline/pass_githistory.c b/src/pipeline/pass_githistory.c index da3546c80..500cb0d5f 100644 --- a/src/pipeline/pass_githistory.c +++ b/src/pipeline/pass_githistory.c @@ -315,6 +315,7 @@ typedef struct { cbm_change_coupling_t *out; int out_count; int max_out; + double min_coupling_score; } collect_coupling_ctx_t; static void collect_coupling_cb(const char *pair_key, void *val, void *ud) { @@ -353,7 +354,9 @@ static void collect_coupling_cb(const char *pair_key, void *val, void *ud) { } double score = (double)co_count / (double)min_total; - if (score < MIN_COUPLING_SCORE) { + double min_score = + cctx->min_coupling_score > 0.0 ? cctx->min_coupling_score : MIN_COUPLING_SCORE; + if (score < min_score) { return; } @@ -366,8 +369,10 @@ static void collect_coupling_cb(const char *pair_key, void *val, void *ud) { cc->last_co_change = ts ? *ts : 0; } -int cbm_compute_change_coupling(const cbm_commit_files_t *commits, int commit_count, - cbm_change_coupling_t *out, int max_out) { +int cbm_compute_change_coupling_with_threshold(const cbm_commit_files_t *commits, + int commit_count, + cbm_change_coupling_t *out, int max_out, + double min_coupling_score) { CBMHashTable *file_counts = cbm_ht_create(CBM_SZ_1K); CBMHashTable *pair_counts = cbm_ht_create(CBM_SZ_2K); /* Parallel table mapping pair_key → max commit timestamp seen for that @@ -438,6 +443,7 @@ int cbm_compute_change_coupling(const cbm_commit_files_t *commits, int commit_co .out = out, .out_count = 0, .max_out = max_out, + .min_coupling_score = min_coupling_score, }; cbm_ht_foreach(pair_counts, collect_coupling_cb, &cctx); @@ -451,6 +457,11 @@ int cbm_compute_change_coupling(const cbm_commit_files_t *commits, int commit_co return cctx.out_count; } +int cbm_compute_change_coupling(const cbm_commit_files_t *commits, int commit_count, + cbm_change_coupling_t *out, int max_out) { + return cbm_compute_change_coupling_with_threshold(commits, commit_count, out, max_out, 0.0); +} + /* ── Split pass: compute (I/O-bound) + apply (gbuf writes) ───────── */ /* Pre-computed coupling result buffer for fused post-pass parallelism. */ @@ -459,7 +470,9 @@ int cbm_compute_change_coupling(const cbm_commit_files_t *commits, int commit_co /* Compute change couplings without touching the graph buffer. * Can run on a separate thread while other passes use the gbuf. */ -int cbm_pipeline_githistory_compute(const char *repo_path, cbm_githistory_result_t *result) { +int cbm_pipeline_githistory_compute_with_threshold(const char *repo_path, + cbm_githistory_result_t *result, + double min_coupling_score) { result->couplings = NULL; result->count = 0; result->commit_count = 0; @@ -492,7 +505,8 @@ int cbm_pipeline_githistory_compute(const char *repo_path, cbm_githistory_result } cbm_change_coupling_t *couplings = malloc(MAX_COUPLINGS * sizeof(cbm_change_coupling_t)); - int coupling_count = cbm_compute_change_coupling(cf, commit_count, couplings, MAX_COUPLINGS); + int coupling_count = cbm_compute_change_coupling_with_threshold( + cf, commit_count, couplings, MAX_COUPLINGS, min_coupling_score); /* Per-file temporal aggregation: change_count + last_modified. * Single hash-table pass over the same commit set used for coupling so @@ -543,6 +557,10 @@ int cbm_pipeline_githistory_compute(const char *repo_path, cbm_githistory_result return 0; } +int cbm_pipeline_githistory_compute(const char *repo_path, cbm_githistory_result_t *result) { + return cbm_pipeline_githistory_compute_with_threshold(repo_path, result, 0.0); +} + /* Apply pre-computed couplings to the graph buffer (must be on main thread). */ int cbm_pipeline_githistory_apply(cbm_pipeline_ctx_t *ctx, const cbm_githistory_result_t *result) { int edge_count = 0; @@ -609,7 +627,8 @@ int cbm_pipeline_pass_githistory(cbm_pipeline_ctx_t *ctx) { cbm_log_info("pass.start", "pass", "githistory"); cbm_githistory_result_t result = {0}; - cbm_pipeline_githistory_compute(ctx->repo_path, &result); + cbm_pipeline_githistory_compute_with_threshold(ctx->repo_path, &result, + ctx->githistory_min_coupling); int edge_count = 0; if (result.count > 0 || result.file_temporal_count > 0) { diff --git a/src/pipeline/pass_httplinks.c b/src/pipeline/pass_httplinks.c index fec3b0736..f18fd85da 100644 --- a/src/pipeline/pass_httplinks.c +++ b/src/pipeline/pass_httplinks.c @@ -1030,8 +1030,10 @@ static int match_and_link(cbm_pipeline_ctx_t *ctx, cbm_route_handler_t *routes, } /* Score path match */ + double min_conf = ctx->httplink_min_confidence > 0.0 ? ctx->httplink_min_confidence + : MIN_PATH_CONFIDENCE; double score = cbm_path_match_score(cs->path, rh->path); - if (score < MIN_PATH_CONFIDENCE) { + if (score < min_conf) { continue; /* minimum confidence threshold */ } diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 11cc889d4..69e0fcaa8 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -69,6 +69,7 @@ enum { PP_CSHARP_M_PREFIX_LEN = 2 }; #include "foundation/compat_regex.h" #include "cbm.h" #include "simhash/minhash.h" + #include "semantic/ast_profile.h" #include @@ -499,7 +500,8 @@ static void insert_def_into_gbuf(extract_worker_state_t *ws, const cbm_file_info def->qualified_name, def->file_path ? def->file_path : fi->rel_path, (int)def->start_line, (int)def->end_line, props); ws->nodes_created++; - if (def->route_path && def->route_path[0] != '\0') { + if (def->route_path && def->route_path[0] != '\0' && + cbm_service_pattern_is_http_route_literal(def->route_path, NULL)) { const char *rm = def->route_method ? def->route_method : "ANY"; char route_qn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; @@ -1003,6 +1005,7 @@ typedef struct { CBMFileResult **result_cache; const cbm_gbuf_t *main_gbuf; /* READ-ONLY during Phase 4 */ const cbm_registry_t *registry; /* READ-ONLY during Phase 4 */ + double lsp_confidence_floor; _Atomic int64_t *shared_ids; _Atomic int *cancelled; _Atomic int next_file_idx; @@ -1245,6 +1248,11 @@ static void emit_http_async_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t (svc == CBM_SVC_HTTP) ? cbm_service_pattern_http_method(call->callee_name) : NULL; const char *broker = (svc == CBM_SVC_ASYNC) ? cbm_service_pattern_broker(res->qualified_name) : NULL; + /* An HTTP call whose URL isn't a valid route path (CLI slash-command syntax + * or a filesystem path) must not become a spurious Route node. */ + if (svc == CBM_SVC_HTTP && !cbm_service_pattern_is_http_route_literal(arg, call->callee_name)) { + return; + } int64_t route_id = build_service_route(gbuf, arg, method, broker, svc); @@ -1299,6 +1307,10 @@ static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf, const char **ik, const char **iv, int ic) { const char *method = cbm_service_pattern_route_method(call->callee_name); + /* Reject CLI slash-command / filesystem-path args masquerading as routes. */ + if (!cbm_service_pattern_is_http_route_literal(route_path, call->callee_name)) { + return; + } char rqn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; snprintf(rqn, sizeof(rqn), "__route__%s__%s", method ? method : "ANY", @@ -1382,8 +1394,12 @@ static bool normalize_url_arg(const char *url, char *norm, int norm_sz) { } /* Detect API paths in call arguments and create HTTP_CALLS edges. */ -static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const CBMCall *call) { +static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call, + const char *rel, CBMLanguage lang) { + const char *source_path = source && source->file_path && source->file_path[0] ? source->file_path : rel; + if (cbm_is_test_file(source_path, lang)) { + return; + } for (int ai = 0; ai < call->arg_count; ai++) { const CBMCallArg *ca = &call->args[ai]; const char *url = ca->value ? ca->value : ca->expr; @@ -1394,11 +1410,15 @@ static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, if (!normalize_url_arg(url, norm, (int)sizeof(norm))) { continue; } + if (!cbm_service_pattern_is_http_route_literal(norm, call->callee_name)) { + continue; + } char route_qn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; snprintf(route_qn, sizeof(route_qn), "__route__ANY__%s", cbm_route_canon_path(norm, cpath, sizeof(cpath))); - int64_t route_id = cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0, + int64_t route_id = cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, + source_path ? source_path : "", 0, 0, "{\"source\":\"arg_url\"}"); char esc_c[CBM_SZ_256]; char esc_n[CBM_SZ_256]; @@ -1590,7 +1610,8 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const cbm_gbuf_node_t *target, const CBMCall *call, const cbm_resolution_t *res, const char *module_qn, const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf, - const char **imp_keys, const char **imp_vals, int imp_count) { + const char **imp_keys, const char **imp_vals, int imp_count, + const char *rel, CBMLanguage lang) { cbm_svc_kind_t svc = cbm_service_pattern_match(res->qualified_name); const char *arg = call->first_string_arg; @@ -1640,7 +1661,7 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, emit_normal_calls_edge(gbuf, source, target, call, res); } - detect_url_in_args(gbuf, source, call); + detect_url_in_args(gbuf, source, call, rel, lang); } /* Find the source node for an edge: enclosing function or file node. */ @@ -1738,8 +1759,11 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB if (lsp_idx) { for (int i = 0; i < result->resolved_calls.count; i++) { CBMResolvedCall *rc_e = &result->resolved_calls.items[i]; + double lsp_floor = rc->lsp_confidence_floor > 0.0 + ? rc->lsp_confidence_floor + : (double)CBM_LSP_CONFIDENCE_FLOOR; if (!rc_e->caller_qn || !rc_e->callee_qn || - rc_e->confidence < CBM_LSP_CONFIDENCE_FLOOR) { + (double)rc_e->confidence < lsp_floor) { continue; } const char *short_name = strrchr(rc_e->callee_qn, '.'); @@ -1798,7 +1822,8 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB /* Fallback to the linear scan for edge cases the index may * miss (e.g. callee_name that wasn't the registered short * name). Keeps semantics identical. */ - lsp = cbm_pipeline_find_lsp_resolution(&result->resolved_calls, call); + lsp = cbm_pipeline_find_lsp_resolution_with_floor( + &result->resolved_calls, call, rc->lsp_confidence_floor); } atomic_fetch_add_explicit(&rc->time_ns_rc_lsp_lookup, extract_now_ns() - _rc_t0, memory_order_relaxed); @@ -1852,7 +1877,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB .strategy = "callee_suffix"}; emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &fake_res, module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals, - imp_count); + imp_count, rel, lang); } continue; } @@ -1874,7 +1899,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB } _rc_t0 = extract_now_ns(); emit_service_edge(ws->local_edge_buf, source_node, target_node, call, &res, module_qn, - rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count); + rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count, rel, lang); atomic_fetch_add_explicit(&rc->time_ns_rc_emit, extract_now_ns() - _rc_t0, memory_order_relaxed); ws->calls_resolved++; @@ -2442,6 +2467,7 @@ int cbm_parallel_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, .result_cache = result_cache, .main_gbuf = ctx->gbuf, .registry = ctx->registry, + .lsp_confidence_floor = ctx->lsp_confidence_floor, .shared_ids = shared_ids, .cancelled = ctx->cancelled, .all_defs = all_defs, diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index 664c8252c..db78d6907 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -33,12 +33,11 @@ enum { #include #include "graph_buffer/graph_buffer.h" #include "foundation/log.h" +#include "service_patterns.h" #include #include -bool cbm_service_pattern_is_http_route_literal(const char *literal, const char *callee_name); - /* True for characters that may appear in a ":name" route parameter. */ static inline bool is_route_ident_char(char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; @@ -446,6 +445,12 @@ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *fun if (path[0] != '/') { return 0; } + /* Reject CLI slash-command paths (e.g. "/ar:ok") extracted from + * command-registry/decorator-like call sites. Same gate as the + * HTTP_CALLS path above — a real route's ':' is at segment start. */ + if (!cbm_service_pattern_is_http_route_literal(path, NULL)) { + return 0; + } char method[CBM_SZ_16] = "ANY"; extract_json_prop(func->properties_json, "route_method", method, sizeof(method)); diff --git a/src/pipeline/pass_semantic_edges.c b/src/pipeline/pass_semantic_edges.c index ad86d647b..96710d10e 100644 --- a/src/pipeline/pass_semantic_edges.c +++ b/src/pipeline/pass_semantic_edges.c @@ -1199,6 +1199,9 @@ int cbm_pipeline_pass_semantic_edges(cbm_pipeline_ctx_t *ctx) { cbm_gbuf_t *gbuf = ctx->gbuf; cbm_sem_config_t cfg = cbm_sem_get_config(); + if (ctx->semantic_threshold > 0.0) { + cfg.threshold = (float)ctx->semantic_threshold; + } CBM_PROF_START(t_phase1a); cbm_sem_func_t *funcs = NULL; diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 0250bf9d8..453081c95 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -12,7 +12,7 @@ */ #include "foundation/constants.h" -enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6, PL_WAL_BUF = 1040 }; +enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6 }; #define PL_NSEC_PER_SEC 1000000000LL #include "pipeline/pipeline.h" #include "pipeline/artifact.h" @@ -47,6 +47,10 @@ static inline void *intptr_to_ptr(intptr_t v) { return p; } +static double pipeline_unit_threshold(double threshold) { + return (threshold > 0.0 && threshold <= 1.0) ? threshold : 0.0; +} + /* ── Global index lock ─────────────────────────────────────────── */ /* Prevents concurrent pipeline runs on the same DB file. * Atomic spinlock: 0 = free, 1 = locked. */ @@ -79,6 +83,10 @@ struct cbm_pipeline { char *branch_qn; cbm_index_mode_t mode; double similarity_threshold; /* Jaccard threshold for SIMILAR edges; <=0 = default (#41) */ + double httplink_min_confidence; + double semantic_threshold; + double githistory_min_coupling; + double lsp_confidence_floor; atomic_int cancelled; cbm_store_t *flush_store; /* when set, use flush_to_store instead of dump_to_sqlite */ bool persistence; /* write .codebase-memory/graph.db.zst after indexing */ @@ -160,6 +168,10 @@ cbm_pipeline_t *cbm_pipeline_new(const char *repo_path, const char *db_path, p->branch_qn = cbm_git_context_branch_qn(p->project_name, &p->git_ctx); p->mode = mode; p->similarity_threshold = 0.0; /* 0 = use CBM_MINHASH_JACCARD_THRESHOLD default */ + p->httplink_min_confidence = 0.0; + p->semantic_threshold = 0.0; + p->githistory_min_coupling = 0.0; + p->lsp_confidence_floor = 0.0; p->persistence = false; p->committed_nodes = -1; p->committed_edges = -1; @@ -184,10 +196,54 @@ void cbm_pipeline_set_flush_store(cbm_pipeline_t *p, cbm_store_t *store) { * Must be called before cbm_pipeline_run(). */ void cbm_pipeline_set_similarity_threshold(cbm_pipeline_t *p, double threshold) { if (p) { - p->similarity_threshold = threshold; + p->similarity_threshold = pipeline_unit_threshold(threshold); } } +void cbm_pipeline_set_httplink_min_confidence(cbm_pipeline_t *p, double threshold) { + if (p) { + p->httplink_min_confidence = pipeline_unit_threshold(threshold); + } +} + +void cbm_pipeline_set_semantic_threshold(cbm_pipeline_t *p, double threshold) { + if (p) { + p->semantic_threshold = pipeline_unit_threshold(threshold); + } +} + +void cbm_pipeline_set_githistory_min_coupling(cbm_pipeline_t *p, double threshold) { + if (p) { + p->githistory_min_coupling = pipeline_unit_threshold(threshold); + } +} + +void cbm_pipeline_set_lsp_confidence_floor(cbm_pipeline_t *p, double threshold) { + if (p) { + p->lsp_confidence_floor = pipeline_unit_threshold(threshold); + } +} + +double cbm_pipeline_httplink_min_confidence(const cbm_pipeline_t *p) { + return p ? p->httplink_min_confidence : 0.0; +} + +double cbm_pipeline_similarity_threshold(const cbm_pipeline_t *p) { + return p ? p->similarity_threshold : 0.0; +} + +double cbm_pipeline_semantic_threshold(const cbm_pipeline_t *p) { + return p ? p->semantic_threshold : 0.0; +} + +double cbm_pipeline_githistory_min_coupling(const cbm_pipeline_t *p) { + return p ? p->githistory_min_coupling : 0.0; +} + +double cbm_pipeline_lsp_confidence_floor(const cbm_pipeline_t *p) { + return p ? p->lsp_confidence_floor : 0.0; +} + void cbm_pipeline_set_persistence(cbm_pipeline_t *p, bool enabled) { if (p) { p->persistence = enabled; @@ -443,11 +499,13 @@ static int pass_structure(cbm_pipeline_t *p, const cbm_file_info_t *files, int f typedef struct { const char *repo_path; cbm_githistory_result_t *result; + double min_coupling_score; } gh_compute_arg_t; static void *gh_compute_thread_fn(void *arg) { gh_compute_arg_t *a = arg; - cbm_pipeline_githistory_compute(a->repo_path, a->result); + cbm_pipeline_githistory_compute_with_threshold(a->repo_path, a->result, + a->min_coupling_score); return NULL; } @@ -809,7 +867,7 @@ static int run_parallel_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, /* Try incremental pipeline or delete old DB for reindex. * Returns >= 0 if incremental was used (the return code), or -1 to proceed with full. */ -static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *files, int file_count) { +static int try_incremental_or_reindex(cbm_pipeline_t *p, cbm_file_info_t *files, int file_count) { char *db_path = resolve_db_path(p); if (!db_path) { return CBM_NOT_FOUND; @@ -840,14 +898,7 @@ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *file } else if (check_store) { cbm_store_close(check_store); } - cbm_log_info("pipeline.route", "path", "reindex", "action", "deleting old db"); - cbm_unlink(db_path); - char wal[PL_WAL_BUF]; - char shm[PL_WAL_BUF]; - snprintf(wal, sizeof(wal), "%s-wal", db_path); - snprintf(shm, sizeof(shm), "%s-shm", db_path); - cbm_unlink(wal); - cbm_unlink(shm); + cbm_log_info("pipeline.route", "path", "reindex", "action", "atomic_rewrite"); free(db_path); return CBM_NOT_FOUND; } @@ -865,7 +916,11 @@ static int run_githistory(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx) { cbm_githistory_result_t gh_result = {0}; cbm_thread_t gh_thread; bool gh_threaded = false; - gh_compute_arg_t gh_arg = {.repo_path = ctx->repo_path, .result = &gh_result}; + gh_compute_arg_t gh_arg = { + .repo_path = ctx->repo_path, + .result = &gh_result, + .min_coupling_score = ctx->githistory_min_coupling, + }; if (p->mode != CBM_MODE_FAST) { if (cbm_default_worker_count(true) > SKIP_ONE) { @@ -874,7 +929,8 @@ static int run_githistory(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx) { } } if (!gh_threaded) { - cbm_pipeline_githistory_compute(ctx->repo_path, &gh_result); + cbm_pipeline_githistory_compute_with_threshold(ctx->repo_path, &gh_result, + ctx->githistory_min_coupling); cbm_log_info("pass.timing", "pass", "githistory_compute", "elapsed_ms", itoa_buf((int)elapsed_ms(t_gh))); } @@ -1002,7 +1058,7 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { * path uses an in-memory store and never writes a DB file, so there is no * old DB to consult or delete. */ if (!p->flush_store) { - rc = try_incremental_or_delete_db(p, files, file_count); + rc = try_incremental_or_reindex(p, files, file_count); if (rc >= 0) { cbm_discover_free(files, file_count); return rc; @@ -1027,6 +1083,10 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { .cancelled = &p->cancelled, .mode = (int)p->mode, .similarity_threshold = p->similarity_threshold, + .httplink_min_confidence = p->httplink_min_confidence, + .semantic_threshold = p->semantic_threshold, + .githistory_min_coupling = p->githistory_min_coupling, + .lsp_confidence_floor = p->lsp_confidence_floor, .path_aliases = path_aliases, }; @@ -1095,16 +1155,18 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { if (!check_cancel(p)) { cbm_clock_gettime(CLOCK_MONOTONIC, &t); - const char *home = cbm_get_home_dir(); char db_path[1024]; if (p->db_path) { snprintf(db_path, sizeof(db_path), "%s", p->db_path); } else { - if (!home) { - home = cbm_tmpdir(); + /* Honor CBM_CACHE_DIR (via cbm_resolve_cache_dir) so tests and + * isolated runs don't write into the user's real store. Falls back + * to the system tmp dir only if no cache dir can be resolved. */ + const char *cdir = cbm_resolve_cache_dir(); + if (!cdir) { + cdir = cbm_tmpdir(); } - snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/%s.db", home, - p->project_name); + snprintf(db_path, sizeof(db_path), "%s/%s.db", cdir, p->project_name); } /* Ensure parent directory exists (e.g. ~/.cache/codebase-memory-mcp/) */ diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index af5d9e7b2..1bc024693 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -79,6 +79,15 @@ void cbm_pipeline_set_flush_store(cbm_pipeline_t *p, cbm_store_t *store); /* Set the Jaccard similarity threshold for SIMILAR-edge creation (pass_similarity). * <=0 (or unset) uses the CBM_MINHASH_JACCARD_THRESHOLD default. Before run(). */ void cbm_pipeline_set_similarity_threshold(cbm_pipeline_t *p, double threshold); +void cbm_pipeline_set_httplink_min_confidence(cbm_pipeline_t *p, double threshold); +void cbm_pipeline_set_semantic_threshold(cbm_pipeline_t *p, double threshold); +void cbm_pipeline_set_githistory_min_coupling(cbm_pipeline_t *p, double threshold); +void cbm_pipeline_set_lsp_confidence_floor(cbm_pipeline_t *p, double threshold); +double cbm_pipeline_similarity_threshold(const cbm_pipeline_t *p); +double cbm_pipeline_httplink_min_confidence(const cbm_pipeline_t *p); +double cbm_pipeline_semantic_threshold(const cbm_pipeline_t *p); +double cbm_pipeline_githistory_min_coupling(const cbm_pipeline_t *p); +double cbm_pipeline_lsp_confidence_floor(const cbm_pipeline_t *p); /* Get the project name derived from repo_path. Returned string is * owned by the pipeline. Valid until cbm_pipeline_free(). */ diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index ab9095a89..411d03ef7 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -11,7 +11,7 @@ */ #include "foundation/constants.h" -enum { INCR_RING_BUF = 4, INCR_RING_MASK = 3, INCR_TS_BUF = 24, INCR_WAL_BUF = 1040 }; +enum { INCR_RING_BUF = 4, INCR_RING_MASK = 3, INCR_TS_BUF = 24 }; #include "pipeline/pipeline.h" #include "pipeline/artifact.h" #include @@ -634,7 +634,7 @@ static void run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_fil itoa_buf_incr((int)elapsed_ms_incr(t))); } } -/* Delete old DB and dump merged graph + hashes to disk. +/* Atomically dump merged graph + hashes to disk. * Mode-skipped hash rows are preserved across the rebuild so subsequent * reindexes can correctly distinguish "never indexed" from "indexed but * not visited this pass". */ @@ -645,14 +645,6 @@ static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char * struct timespec t; cbm_clock_gettime(CLOCK_MONOTONIC, &t); - cbm_unlink(db_path); - char wal[INCR_WAL_BUF]; - char shm[INCR_WAL_BUF]; - snprintf(wal, sizeof(wal), "%s-wal", db_path); - snprintf(shm, sizeof(shm), "%s-shm", db_path); - cbm_unlink(wal); - cbm_unlink(shm); - int dump_rc = cbm_gbuf_dump_to_sqlite(gbuf, db_path); cbm_log_info("incremental.dump", "rc", itoa_buf_incr(dump_rc), "elapsed_ms", itoa_buf_incr((int)elapsed_ms_incr(t))); @@ -842,6 +834,11 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil .registry = registry, .cancelled = cbm_pipeline_cancelled_ptr(p), .mode = cbm_pipeline_get_mode(p), + .similarity_threshold = cbm_pipeline_similarity_threshold(p), + .httplink_min_confidence = cbm_pipeline_httplink_min_confidence(p), + .semantic_threshold = cbm_pipeline_semantic_threshold(p), + .githistory_min_coupling = cbm_pipeline_githistory_min_coupling(p), + .lsp_confidence_floor = cbm_pipeline_lsp_confidence_floor(p), .path_aliases = path_aliases, }; diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index b83c6ba50..d49f2ee4f 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -68,6 +68,10 @@ typedef struct { int mode; /* cbm_index_mode_t (0=full, 1=moderate, 2=fast, 3=advanced) */ double similarity_threshold; /* Jaccard threshold for SIMILAR edges; <=0 means * use the CBM_MINHASH_JACCARD_THRESHOLD default (#41). */ + double httplink_min_confidence; /* <=0 uses httplink pass default 0.25 */ + double semantic_threshold; /* <=0 uses semantic default 0.75 */ + double githistory_min_coupling; /* <=0 uses git-history default 0.3 */ + double lsp_confidence_floor; /* <=0 uses LSP default 0.6 */ /* Extraction result cache (sequential pipeline optimization). * When non-NULL, pass_definitions stores results here instead of freeing, @@ -189,6 +193,10 @@ typedef struct { * Caller owns out[]. */ int cbm_compute_change_coupling(const cbm_commit_files_t *commits, int commit_count, cbm_change_coupling_t *out, int max_out); +int cbm_compute_change_coupling_with_threshold(const cbm_commit_files_t *commits, + int commit_count, + cbm_change_coupling_t *out, int max_out, + double min_coupling_score); /* Go-style implicit interface satisfaction on graph buffer. * Finds Interface nodes, matches method sets against Class nodes, @@ -494,6 +502,9 @@ typedef struct { /* Compute change couplings without touching the graph buffer. * Can run on a separate thread while other passes use the gbuf. */ int cbm_pipeline_githistory_compute(const char *repo_path, cbm_githistory_result_t *result); +int cbm_pipeline_githistory_compute_with_threshold(const char *repo_path, + cbm_githistory_result_t *result, + double min_coupling_score); /* Apply pre-computed couplings to the graph buffer (main thread only). */ int cbm_pipeline_githistory_apply(cbm_pipeline_ctx_t *ctx, const cbm_githistory_result_t *result); diff --git a/src/store/store.c b/src/store/store.c index ec92f42e1..92edabc05 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -32,8 +32,12 @@ enum { ST_RETRY_WAIT_US = 1000, ST_INIT_CAP_8 = 8, ST_INIT_CAP_16 = 16, + ST_SQLITE_BUSY_TIMEOUT_MS = 10000, ST_SQL_BUF = 8192, - ST_MAX_ROW_CHECK = 5, + ST_ARCH_ROUTE_RESULT_LIMIT = 20, + ST_ARCH_ROUTE_SCAN_LIMIT = ST_ARCH_ROUTE_RESULT_LIMIT * 10, + ST_ARCH_ENTRY_POINT_LIMIT = 20, + ST_ARCH_DOC_LIMIT = 20, ST_QN_MAX_DOTS = 5, ST_QN_MIN_DOTS = 3, ST_IN_CLAUSE_MARGIN = 4, @@ -57,8 +61,10 @@ enum { #define SLEN(s) (sizeof(s) - 1) #include "store/store.h" +#include "service_patterns.h" #include "foundation/platform.h" #include "foundation/compat.h" +#include "foundation/compat_fs.h" #include "foundation/log.h" #include "foundation/compat_regex.h" #include "foundation/str_util.h" @@ -376,7 +382,10 @@ static int configure_pragmas(cbm_store_t *s, bool in_memory) { /* busy_timeout must be set BEFORE journal_mode=WAL so that lock * contention during WAL mode activation is handled with a timeout * rather than an immediate SQLITE_BUSY error. */ - rc = exec_sql(s, "PRAGMA busy_timeout = 10000;"); + char busy_sql[ST_BUF_64]; + snprintf(busy_sql, sizeof(busy_sql), "PRAGMA busy_timeout = %d;", + ST_SQLITE_BUSY_TIMEOUT_MS); + rc = exec_sql(s, busy_sql); if (rc != CBM_STORE_OK) { return rc; } @@ -711,9 +720,10 @@ bool cbm_store_check_integrity_full(cbm_store_t *s, bool *path_only_failure) { return false; } - /* Each project gets its own .db file, so the projects table should have - * exactly 1 row. More than 5 rows is definitely corrupt (allows some slack - * for edge cases). Also check that root_path looks like a real path. */ + /* The writer creates one projects row initially, then dependency indexing + * may add rows for projects stored in the parent DB. Treat the table as + * sane if it is readable and non-empty; row count alone is not a corruption + * signal. Real B1 corruption is caught below by malformed root_path values. */ sqlite3_stmt *stmt = NULL; int rc = sqlite3_prepare_v2(s->db, "SELECT count(*) FROM projects;", CBM_NOT_FOUND, &stmt, NULL); @@ -725,12 +735,14 @@ bool cbm_store_check_integrity_full(cbm_store_t *s, bool *path_only_failure) { bool rows_ok = true; if (sqlite3_step(stmt) == SQLITE_ROW) { int row_count = sqlite3_column_int(stmt, 0); - if (row_count > ST_MAX_ROW_CHECK) { - (void)fprintf(stderr, "ERROR store.corrupt table=projects rows=%d (expected 1)\n", - row_count); + if (row_count < 0) { + (void)fprintf(stderr, "ERROR store.corrupt table=projects rows=%d\n", row_count); ok = false; rows_ok = false; } + } else { + ok = false; + rows_ok = false; } sqlite3_finalize(stmt); @@ -786,7 +798,10 @@ cbm_store_t *cbm_store_open(const char *project) { cdir = cbm_tmpdir(); } char path[CBM_SZ_1K]; - snprintf(path, sizeof(path), "%s/%s.db", cdir, project); + int path_len = snprintf(path, sizeof(path), "%s/%s.db", cdir, project); + if (path_len <= 0 || (size_t)path_len >= sizeof(path)) { + return NULL; + } return store_open_internal(path, false); } @@ -945,22 +960,37 @@ int cbm_store_dump_to_file(cbm_store_t *s, const char *dest_path) { /* Ensure parent directory exists */ char dir[CBM_SZ_1K]; - snprintf(dir, sizeof(dir), "%s", dest_path); + int dir_len = snprintf(dir, sizeof(dir), "%s", dest_path); + if (dir_len <= 0 || (size_t)dir_len >= sizeof(dir)) { + store_set_error(s, "dump: destination path too long"); + return CBM_STORE_ERR; + } char *sl = strrchr(dir, '/'); if (sl) { *sl = '\0'; (void)cbm_mkdir(dir); } - /* Write to temp file for atomic swap */ + /* Write to a unique temp file for atomic swap. This avoids deleting or + * colliding with a sibling writer's predictable ".tmp" file. */ char tmp_path[CBM_SZ_1K]; - snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", dest_path); - (void)unlink(tmp_path); + int tmp_len = snprintf(tmp_path, sizeof(tmp_path), "%s.tmp.XXXXXX", dest_path); + if (tmp_len <= 0 || (size_t)tmp_len >= sizeof(tmp_path)) { + store_set_error(s, "dump: temp path too long"); + return CBM_STORE_ERR; + } + int tmp_fd = cbm_mkstemp_s(tmp_path, sizeof(tmp_path)); + if (tmp_fd < 0) { + store_set_error(s, "dump: cannot create temp file"); + return CBM_STORE_ERR; + } + cbm_close_fd(tmp_fd); sqlite3 *dest_db = NULL; int rc = sqlite3_open(tmp_path, &dest_db); if (rc != SQLITE_OK) { store_set_error(s, "dump: cannot open temp file"); + (void)cbm_unlink(tmp_path); return CBM_STORE_ERR; } @@ -968,7 +998,7 @@ int cbm_store_dump_to_file(cbm_store_t *s, const char *dest_path) { if (!bk) { store_set_error(s, "dump: backup init failed"); sqlite3_close(dest_db); - (void)unlink(tmp_path); + (void)cbm_unlink(tmp_path); return CBM_STORE_ERR; } @@ -978,7 +1008,7 @@ int cbm_store_dump_to_file(cbm_store_t *s, const char *dest_path) { if (rc != SQLITE_DONE) { store_set_error(s, "dump: backup step failed"); sqlite3_close(dest_db); - (void)unlink(tmp_path); + (void)cbm_unlink(tmp_path); return CBM_STORE_ERR; } @@ -988,9 +1018,9 @@ int cbm_store_dump_to_file(cbm_store_t *s, const char *dest_path) { /* Atomic rename: old WAL/SHM become stale and get recreated by * the next reader's configure_pragmas call. */ - if (rename(tmp_path, dest_path) != 0) { + if (cbm_replace_file(tmp_path, dest_path) != 0) { store_set_error(s, "dump: rename failed"); - (void)unlink(tmp_path); + (void)cbm_unlink(tmp_path); return CBM_STORE_ERR; } @@ -3573,13 +3603,14 @@ static int arch_entry_points(cbm_store_t *s, const char *project, cbm_architectu "WHERE project=?1 AND json_extract(properties, '$.is_entry_point') = 1 " "AND (json_extract(properties, '$.is_test') IS NULL OR " "json_extract(properties, '$.is_test') != 1) " - "AND file_path NOT LIKE '%test%' LIMIT 20"; + "AND file_path NOT LIKE '%test%' LIMIT ?2"; sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "arch_entry_points"); return CBM_STORE_ERR; } bind_text(stmt, SKIP_ONE, project); + sqlite3_bind_int(stmt, ST_COL_2, ST_ARCH_ENTRY_POINT_LIMIT); int cap = ST_INIT_CAP_8; int n = 0; @@ -3624,12 +3655,38 @@ static char *extract_json_string_prop(const char *json, const char *key, int key return heap_strdup(vbuf); } +static bool arch_route_should_include(const char *name, const char *qn) { + if (!name || !name[0]) { + return false; + } + bool http_like_name = name[0] == '/' || strstr(name, "://") != NULL; + if (!http_like_name) { + return true; + } + /* Absolute URL infra nodes support cross-service matching in the graph, but + * __route__infra__ entries are endpoints from config/package metadata rather + * than application routes. Keep code-created absolute URL routes visible: + * many languages emit HTTP client routes as full URLs. */ + if (qn && strncmp(qn, "__route__infra__", SLEN("__route__infra__")) == 0) { + return false; + } + if (qn && (strncmp(qn, "__grpc__", SLEN("__grpc__")) == 0 || + strncmp(qn, "__graphql__", SLEN("__graphql__")) == 0 || + strncmp(qn, "__trpc__", SLEN("__trpc__")) == 0)) { + return true; + } + return cbm_service_pattern_is_http_route_literal(name, NULL); +} + static int arch_routes(cbm_store_t *s, const char *project, cbm_architecture_info_t *out) { - const char *sql = "SELECT name, properties, COALESCE(file_path, '') FROM nodes " - "WHERE project=?1 AND label='Route' " - "AND (json_extract(properties, '$.is_test') IS NULL OR " - "json_extract(properties, '$.is_test') != 1) " - "LIMIT 20"; + char sql[ST_SQL_BUF]; + snprintf(sql, sizeof(sql), + "SELECT name, properties, COALESCE(file_path, ''), qualified_name FROM nodes " + "WHERE project=?1 AND label='Route' " + "AND (json_extract(properties, '$.is_test') IS NULL OR " + "json_extract(properties, '$.is_test') != 1) " + "LIMIT %d", + ST_ARCH_ROUTE_SCAN_LIMIT); sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "arch_routes"); @@ -3644,9 +3701,19 @@ static int arch_routes(cbm_store_t *s, const char *project, cbm_architecture_inf const char *name = (const char *)sqlite3_column_text(stmt, 0); const char *props = (const char *)sqlite3_column_text(stmt, SKIP_ONE); const char *fp = (const char *)sqlite3_column_text(stmt, CBM_SZ_2); + const char *qn = (const char *)sqlite3_column_text(stmt, CBM_SZ_3); if (cbm_is_test_file_path(fp)) { continue; } + /* Output-level safety net for HTTP-like Route names only. Non-HTTP + * Route classes (gRPC, GraphQL, tRPC, async topics) are legitimate + * architecture signals even when their names are not URL paths. */ + if (!arch_route_should_include(name, qn)) { + continue; + } + if (n >= ST_ARCH_ROUTE_RESULT_LIMIT) { + break; + } if (n >= cap) { cap *= ST_GROWTH; arr = safe_realloc(arr, cap * sizeof(cbm_route_info_t)); @@ -5010,6 +5077,41 @@ static void cluster_add_pkg(const char **pkgs, int *counts, int *count, int cap, } } +static bool cluster_label_is_generic(const char *label) { + static const char *const generic[] = {"get", "set", "run", "main", "init", + "new", "open", "close", "read", "write", + "start", "stop", "test", NULL}; + if (!label) { + return false; + } + for (int i = 0; generic[i]; i++) { + if (strcmp(label, generic[i]) == 0) { + return true; + } + } + return false; +} + +static char *cluster_make_label(const cbm_cluster_info_t *ci) { + if (ci->top_node_count > 0) { + const char *primary = ci->top_nodes[0]; + if (cluster_label_is_generic(primary) && ci->top_node_count > 1) { + const char *secondary = ci->top_nodes[1]; + size_t len = strlen(primary) + strlen(secondary) + 4; + char *label = malloc(len); + if (label) { + snprintf(label, len, "%s/%s", primary, secondary); + return label; + } + } + return heap_strdup(primary); + } + if (ci->package_count > 0) { + return heap_strdup(ci->packages[0]); + } + return heap_strdup("cluster"); +} + /* Build the cluster_info for one community c into *ci. */ static void cluster_build_one(cbm_cluster_info_t *ci, int c, int n, const int *comm, const int *degree, const char **names, const char **qns, int members, @@ -5065,22 +5167,20 @@ static void cluster_build_one(cbm_cluster_info_t *ci, int c, int n, const int *c } if (pc > 0) { ci->packages = malloc((size_t)pc * sizeof(char *)); - int best = 0; for (int i = 0; i < pc; i++) { ci->packages[i] = heap_strdup(pkgs[i]); - if (pkg_counts[i] > pkg_counts[best]) { - best = i; - } } ci->package_count = pc; - ci->label = heap_strdup(pkgs[best]); for (int i = 0; i < pc; i++) { safe_str_free(&pkgs[i]); } } - if (!ci->label) { - ci->label = heap_strdup(ci->top_node_count > 0 ? ci->top_nodes[0] : "cluster"); - } + /* Label: the top hub node is the most informative AND discriminable name + * for the community (e.g. "create_task", "install_plugins", + * "execute_tmux_command"). Labeling by the dominant package made every + * cluster in a single-package repo share one identical, uninformative + * label. The package list is preserved separately in `packages`. */ + ci->label = cluster_make_label(ci); ci->edge_types = malloc(sizeof(char *)); ci->edge_types[0] = heap_strdup("CALLS"); @@ -5807,13 +5907,14 @@ int cbm_store_find_architecture_docs(cbm_store_t *s, const char *project, char * "AND (file_path LIKE '%ARCHITECTURE.md' OR file_path LIKE '%ADR.md' " "OR file_path LIKE '%DECISIONS.md' OR file_path LIKE 'docs/adr/%' " "OR file_path LIKE 'doc/adr/%' OR file_path LIKE 'adr/%') " - "ORDER BY file_path LIMIT 20"; + "ORDER BY file_path LIMIT ?2"; sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "find_arch_docs"); return CBM_STORE_ERR; } bind_text(stmt, SKIP_ONE, project); + sqlite3_bind_int(stmt, ST_COL_2, ST_ARCH_DOC_LIMIT); int cap = ST_INIT_CAP_8; int n = 0; diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index 881f48755..9e8034f2f 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -62,10 +62,6 @@ struct cbm_watcher { /* ── Constants ─────────────────────────────────────────────────── */ -/* Time unit conversions */ -#define NS_PER_SEC 1000000000LL -#define US_PER_MS 1000000LL - /* Adaptive poll interval parameters (ms) */ #define POLL_BASE_MS 5000 #define POLL_FILE_STEP 500 /* add 1s per this many files */ @@ -79,7 +75,7 @@ struct cbm_watcher { static int64_t now_ns(void) { struct timespec ts; cbm_clock_gettime(CLOCK_MONOTONIC, &ts); - return ((int64_t)ts.tv_sec * NS_PER_SEC) + ts.tv_nsec; + return ((int64_t)ts.tv_sec * (int64_t)CBM_NSEC_PER_SEC) + ts.tv_nsec; } /* ── Adaptive interval ──────────────────────────────────────────── */ @@ -399,7 +395,7 @@ static void init_baseline(project_state_t *s, const cbm_watcher_t *w) { cbm_log_info("watcher.baseline", "project", s->project_name, "strategy", "none"); } - s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * US_PER_MS); + s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * (int64_t)CBM_NSEC_PER_MSEC); } /* Check if a project has changes. Returns true if reindex needed. */ @@ -469,7 +465,7 @@ static void poll_project(const char *key, void *val, void *ud) { /* Check for changes */ bool changed = check_changes(s); if (!changed) { - s->next_poll_ns = ctx->now + ((int64_t)s->interval_ms * US_PER_MS); + s->next_poll_ns = ctx->now + ((int64_t)s->interval_ms * (int64_t)CBM_NSEC_PER_MSEC); return; } @@ -491,7 +487,7 @@ static void poll_project(const char *key, void *val, void *ud) { } } - s->next_poll_ns = ctx->now + ((int64_t)s->interval_ms * US_PER_MS); + s->next_poll_ns = ctx->now + ((int64_t)s->interval_ms * (int64_t)CBM_NSEC_PER_MSEC); } /* Callback to snapshot project state pointers into an array. */ diff --git a/tests/test_convergence_probe.c b/tests/test_convergence_probe.c index b530f60ce..fac486552 100644 --- a/tests/test_convergence_probe.c +++ b/tests/test_convergence_probe.c @@ -203,7 +203,9 @@ static cbm_store_t *cp_open_indexed(CP_Proj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_edge_imports.c b/tests/test_edge_imports.c index 8f4d20762..94dd4c673 100644 --- a/tests/test_edge_imports.c +++ b/tests/test_edge_imports.c @@ -107,7 +107,9 @@ static cbm_store_t *ei_index_files(EILangProj *lp, const EILangFile *files, int const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_edge_structural.c b/tests/test_edge_structural.c index af9f5bca0..e77266bff 100644 --- a/tests/test_edge_structural.c +++ b/tests/test_edge_structural.c @@ -174,7 +174,9 @@ static cbm_store_t *es_lang_open_indexed(ES_LangProj *lp) { home = "/tmp"; } char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index f69679430..e014ea1bd 100644 --- a/tests/test_edge_types_probe.c +++ b/tests/test_edge_types_probe.c @@ -102,7 +102,9 @@ static cbm_store_t *et_index_files(EtProj *lp, const EtFile *files, int nfiles) const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_framework.h b/tests/test_framework.h index 2af654b4d..0e94f3b6f 100644 --- a/tests/test_framework.h +++ b/tests/test_framework.h @@ -32,6 +32,14 @@ #include #include +/* Resolve the on-disk cache dir — honors the CBM_CACHE_DIR env var (used by the + * test runner to isolate each run into a per-run temp dir) and otherwise falls + * back to ~/.cache/codebase-memory-mcp. Defined in foundation/platform.c. + * Forward-declared here so every test file builds the SAME db path the pipeline + * writes (the pipeline honors CBM_CACHE_DIR); hardcoding ~/.cache mismatched the + * write path and yielded empty-store failures under isolation. */ +const char *cbm_resolve_cache_dir(void); + /* ── Global counters (defined in test_main.c) ──────────────────── */ extern int tf_pass_count; diff --git a/tests/test_grammar_probe_a.c b/tests/test_grammar_probe_a.c index 268c4cc0e..53c797815 100644 --- a/tests/test_grammar_probe_a.c +++ b/tests/test_grammar_probe_a.c @@ -76,7 +76,9 @@ static cbm_store_t *gpa_open_indexed(GpaProj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_grammar_probe_b.c b/tests/test_grammar_probe_b.c index 2e2577127..433aff8c0 100644 --- a/tests/test_grammar_probe_b.c +++ b/tests/test_grammar_probe_b.c @@ -72,7 +72,9 @@ static cbm_store_t *pb_open_indexed(ProbeLangProj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_grammar_probe_c.c b/tests/test_grammar_probe_c.c index ba8b2b14e..0136bcffb 100644 --- a/tests/test_grammar_probe_c.c +++ b/tests/test_grammar_probe_c.c @@ -60,7 +60,9 @@ static cbm_store_t *gp_open_indexed(GP_Proj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_grammar_probe_d.c b/tests/test_grammar_probe_d.c index de02097c6..a7d3de58f 100644 --- a/tests/test_grammar_probe_d.c +++ b/tests/test_grammar_probe_d.c @@ -74,7 +74,9 @@ static cbm_store_t *gpd_open_indexed(GpdProj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_grammar_probe_e.c b/tests/test_grammar_probe_e.c index 9a8c45769..a9c3fcfe6 100644 --- a/tests/test_grammar_probe_e.c +++ b/tests/test_grammar_probe_e.c @@ -81,7 +81,9 @@ static cbm_store_t *gpe_open_indexed(GpeProj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_grammar_probe_f.c b/tests/test_grammar_probe_f.c index ed734f052..776fff5d4 100644 --- a/tests/test_grammar_probe_f.c +++ b/tests/test_grammar_probe_f.c @@ -70,7 +70,9 @@ static cbm_store_t *gpf_open_indexed(GpfProj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_grammar_probe_g.c b/tests/test_grammar_probe_g.c index 185ca4bac..6f873c35c 100644 --- a/tests/test_grammar_probe_g.c +++ b/tests/test_grammar_probe_g.c @@ -83,7 +83,9 @@ static cbm_store_t *gpg_open_indexed(GpgProj *lp) { if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_incremental.c b/tests/test_incremental.c index 82aa289c1..bb43d1b36 100644 --- a/tests/test_incremental.c +++ b/tests/test_incremental.c @@ -18,6 +18,9 @@ #include #include #include +/* Forward decl (foundation/platform.c): honors CBM_CACHE_DIR so the test reads + * the index from the same dir the pipeline writes it (needed for isolation). */ +const char *cbm_resolve_cache_dir(void); #include #include @@ -227,13 +230,18 @@ static int incremental_setup(void) { if (!g_project) return -1; - const char *home = getenv("HOME"); - if (!home) - home = "/tmp"; - snprintf(g_dbpath, sizeof(g_dbpath), "%s/.cache/codebase-memory-mcp/%s.db", home, g_project); + /* Resolve the cache dir via cbm_resolve_cache_dir() so it honors CBM_CACHE_DIR + * and matches the index WRITE path (pipeline.c). Hardcoding ~/.cache here + * made get_node_count read from a different dir than the index wrote under + * CBM_TEST_ISOLATE, yielding 0-node indexes (and a div-by-zero). */ + const char *cdir = cbm_resolve_cache_dir(); + if (!cdir) { + cdir = "/tmp"; + } + snprintf(g_dbpath, sizeof(g_dbpath), "%s/%s.db", cdir, g_project); char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + snprintf(cache_dir, sizeof(cache_dir), "%s", cdir); cbm_mkdir(cache_dir); unlink(g_dbpath); diff --git a/tests/test_infrascan.c b/tests/test_infrascan.c index a0e45d156..4a33adaae 100644 --- a/tests/test_infrascan.c +++ b/tests/test_infrascan.c @@ -1,12 +1,11 @@ #include "test_framework.h" #include "graph_buffer/graph_buffer.h" #include "pipeline/pipeline_internal.h" +#include "service_patterns.h" #include #include -bool cbm_service_pattern_is_http_route_literal(const char *literal, const char *callee_name); - static int has_data_flow(cbm_gbuf_t *gb, int64_t source_id, int64_t target_id) { const cbm_gbuf_edge_t **edges = NULL; int count = 0; @@ -28,7 +27,27 @@ TEST(infrascan_http_route_literal_guard_rejects_filesystem_paths) { ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/api", "os.path.join")); ASSERT_FALSE(cbm_service_pattern_is_http_route_literal(NULL, "requests.get")); ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("", "requests.get")); + /* CLI slash-command syntax: ':' mid-segment is not a route param + * (autorun's "/ar:allow", "/ar:a" etc. — not HTTP routes). */ + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/ar:allow", "app.command")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/ar:a", "app.command")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/gh:pr", "app.command")); + /* Filesystem paths with document/source extensions are never routes. */ + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/new/file.txt", "open")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/fake/path.pdf", "open")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/Users/test/plans/foo.md", "open")); + /* Filesystem roots. */ + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/usr/bin/uv", "subprocess")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/home/user/.claude/plans/bar.md", "open")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/tmp/alpha", "requests.get")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/Users/dev/project", "requests.get")); + /* Whitespace: command/description strings are not routes. */ + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/autorun test task description", "run")); + /* Positive controls: real routes must still pass. */ ASSERT_TRUE(cbm_service_pattern_is_http_route_literal("/api/orders", "requests.get")); + ASSERT_TRUE(cbm_service_pattern_is_http_route_literal("/users/:id", "app.route")); + ASSERT_TRUE(cbm_service_pattern_is_http_route_literal("/teams/:team/users/:id", "app.route")); + ASSERT_TRUE(cbm_service_pattern_is_http_route_literal("/items/{id}", "router.get")); ASSERT_TRUE(cbm_service_pattern_is_http_route_literal("https://orders.example/api/orders", "requests.get")); PASS(); diff --git a/tests/test_integration.c b/tests/test_integration.c index b81ac378b..d90a27c47 100644 --- a/tests/test_integration.c +++ b/tests/test_integration.c @@ -105,15 +105,16 @@ static int integration_setup(void) { if (!g_project) return -1; - /* Build db path for direct store queries (pipeline writes here) */ - const char *home = getenv("HOME"); - if (!home) - home = "/tmp"; - snprintf(g_dbpath, sizeof(g_dbpath), "%s/.cache/codebase-memory-mcp/%s.db", home, g_project); + /* Build db path for direct store queries (pipeline writes here). Honors + * CBM_CACHE_DIR so it matches the pipeline write path (test isolation). */ + snprintf(g_dbpath, sizeof(g_dbpath), "%s/%s.db", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp", g_project); /* Ensure cache dir exists */ char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); /* Remove stale db from previous test runs */ diff --git a/tests/test_lang_contract.c b/tests/test_lang_contract.c index 3d46d0310..8abee3c9e 100644 --- a/tests/test_lang_contract.c +++ b/tests/test_lang_contract.c @@ -77,7 +77,9 @@ static cbm_store_t *lang_open_indexed(LangProj *lp) { home = "/tmp"; } char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_lsp_resolution_probe.c b/tests/test_lsp_resolution_probe.c index 31bec0687..118a8b979 100644 --- a/tests/test_lsp_resolution_probe.c +++ b/tests/test_lsp_resolution_probe.c @@ -127,7 +127,9 @@ static cbm_store_t *lrp_open_indexed(LRP_Proj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_main.c b/tests/test_main.c index 033ed871c..e7ace7492 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -112,20 +112,56 @@ extern void suite_dump_verify_io(void); * caches at thread teardown (pass_parallel.c). */ extern void cbm_kind_in_set_free_cache(void); +/* Capacity for the per-run isolated cache dir path. comfortably fits + * "/tmp/cbm-test-cache-" + the 6 mkdtemp placeholder chars + headroom. */ +#define TEST_CACHE_DIR_CAP 512 +/* setenv() overwrite flag: nonzero = replace an existing value. */ +#define ENV_OVERWRITE 1 + int main(void) { printf("\n codebase-memory-mcp C test suite\n"); + /* DEFAULT-ON store isolation: redirect every test index into a per-run + * temp dir so the suite never pollutes the user's real + * ~/.cache/codebase-memory-mcp. Opt out with CBM_TEST_NO_ISOLATE=1 (e.g. + * to debug against the real store). + * + * Works because every test helper now builds its db path via + * cbm_resolve_cache_dir() (honors CBM_CACHE_DIR), matching the pipeline + * write path. Earlier these helpers hardcoded ~/.cache and mismatched the + * CBM_CACHE_DIR-honoring write → 815 empty-store failures. The production + * path (pipeline.c + mcp.c) honors CBM_CACHE_DIR regardless. */ + const char *no_iso = getenv("CBM_TEST_NO_ISOLATE"); + if (!no_iso || no_iso[0] == '\0') { + static char test_cache_dir[TEST_CACHE_DIR_CAP]; + snprintf(test_cache_dir, sizeof(test_cache_dir), "/tmp/cbm-test-cache-XXXXXX"); + if (mkdtemp(test_cache_dir)) { + setenv("CBM_CACHE_DIR", test_cache_dir, ENV_OVERWRITE); + } + } + const char *only_suite = getenv("CBM_ONLY_SUITE"); if (only_suite && only_suite[0]) { if (strstr("incremental", only_suite)) RUN_SUITE(incremental); if (strstr("mcp", only_suite)) RUN_SUITE(mcp); if (strstr("tool_consolidation", only_suite)) RUN_SUITE(tool_consolidation); + if (strstr("cli", only_suite)) RUN_SUITE(cli); + if (strstr("pipeline", only_suite)) RUN_SUITE(pipeline); + if (strstr("parallel", only_suite)) RUN_SUITE(parallel); if (strstr("store_nodes", only_suite)) RUN_SUITE(store_nodes); + if (strstr("store_search", only_suite)) RUN_SUITE(store_search); + if (strstr("store_bulk", only_suite)) RUN_SUITE(store_bulk); + if (strstr("store_pragmas", only_suite)) RUN_SUITE(store_pragmas); + if (strstr("store_checkpoint", only_suite)) RUN_SUITE(store_checkpoint); if (strstr("sqlite_writer", only_suite)) RUN_SUITE(sqlite_writer); if (strstr("graph_buffer", only_suite)) RUN_SUITE(graph_buffer); if (strstr("pagerank", only_suite)) RUN_SUITE(pagerank); if (strstr("depindex", only_suite)) RUN_SUITE(depindex); if (strstr("store_arch", only_suite)) RUN_SUITE(store_arch); + if (strstr("infrascan", only_suite)) RUN_SUITE(infrascan); + if (strstr("watcher", only_suite)) RUN_SUITE(watcher); + if (strstr("security", only_suite)) RUN_SUITE(security); + if (strstr("artifact", only_suite)) RUN_SUITE(artifact); TEST_SUMMARY(); return 0; } diff --git a/tests/test_matrix_known_classes.c b/tests/test_matrix_known_classes.c index 8006320b8..4d1406aff 100644 --- a/tests/test_matrix_known_classes.c +++ b/tests/test_matrix_known_classes.c @@ -73,7 +73,9 @@ static cbm_store_t *mkc_open_indexed(MKC_Proj *lp) { if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_matrix_new_constructs.c b/tests/test_matrix_new_constructs.c index 116910b73..fb6bb10d1 100644 --- a/tests/test_matrix_new_constructs.c +++ b/tests/test_matrix_new_constructs.c @@ -68,7 +68,9 @@ static cbm_store_t *mn_open_indexed(MN_LangProj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 6851c8a0c..db0fa0362 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -412,6 +412,56 @@ TEST(tool_list_projects_empty) { PASS(); } +TEST(tool_list_projects_includes_tmp_prefixed_project) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-list-tmp-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); /* skip if mkdtemp fails */ + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char db_path[512]; + int db_len = snprintf(db_path, sizeof(db_path), "%s/tmp-valid-project.db", cache); + ASSERT_TRUE(db_len > 0 && (size_t)db_len < sizeof(db_path)); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, "tmp-valid-project", "/tmp/valid-project"), + CBM_STORE_OK); + cbm_store_close(store); + + cbm_mcp_server_t *srv = setup_mcp_with_data(); + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":10,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "tmp-valid-project")); + free(resp); + cbm_mcp_server_free(srv); + + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + cbm_unlink(db_path); + char wal[512]; + char shm[512]; + int wal_len = snprintf(wal, sizeof(wal), "%s-wal", db_path); + int shm_len = snprintf(shm, sizeof(shm), "%s-shm", db_path); + if (wal_len > 0 && (size_t)wal_len < sizeof(wal)) { + cbm_unlink(wal); + } + if (shm_len > 0 && (size_t)shm_len < sizeof(shm)) { + cbm_unlink(shm); + } + cbm_rmdir(cache); + PASS(); +} + TEST(tool_get_graph_schema_empty) { cbm_mcp_server_t *srv = setup_mcp_with_data(); @@ -484,6 +534,13 @@ TEST(tool_search_graph_includes_node_properties) { ASSERT_NOT_NULL(strstr(inner, "signature")); ASSERT_NOT_NULL(strstr(inner, "func HandleRequest")); ASSERT_NOT_NULL(strstr(inner, "is_exported")); + const char *scan = inner; + int source_keys = 0; + while ((scan = strstr(scan, "\"source\"")) != NULL) { + source_keys++; + scan += strlen("\"source\""); + } + ASSERT_EQ(source_keys, 1); free(inner); free(resp); @@ -1371,7 +1428,8 @@ static cbm_mcp_server_t *setup_snippet_server(char *tmp_dir, size_t tmp_sz) { n_hr.end_line = 5; n_hr.properties_json = "{\"signature\":\"func HandleRequest() error\"," "\"return_type\":\"error\"," - "\"is_exported\":true}"; + "\"is_exported\":true," + "\"source\":\"infra\"}"; int64_t id_hr = cbm_store_upsert_node(st, &n_hr); cbm_node_t n_po = {0}; @@ -2332,6 +2390,7 @@ SUITE(mcp) { /* Tool handlers */ RUN_TEST(tool_list_projects_empty); + RUN_TEST(tool_list_projects_includes_tmp_prefixed_project); RUN_TEST(tool_get_graph_schema_empty); RUN_TEST(tool_unknown_tool); RUN_TEST(tool_search_graph_basic); diff --git a/tests/test_node_creation_probe.c b/tests/test_node_creation_probe.c index d3475c4af..51a6a856e 100644 --- a/tests/test_node_creation_probe.c +++ b/tests/test_node_creation_probe.c @@ -76,7 +76,9 @@ static cbm_store_t *ncp_open_indexed(NcpLangProj *lp) { if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 04e26d0dd..de66f1b9e 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -11,6 +11,7 @@ #include "pipeline/pipeline.h" #include "pipeline/pipeline_internal.h" #include "store/store.h" +#include "cli/cli.h" #include "git/git_context.h" #include "foundation/dump_verify.h" @@ -5571,6 +5572,63 @@ TEST(pipeline_double_free_prevention) { PASS(); } +TEST(pipeline_unit_threshold_setters_clamp_invalid_values) { + cbm_pipeline_t *p = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + + cbm_pipeline_set_similarity_threshold(p, 0.7); + cbm_pipeline_set_httplink_min_confidence(p, 0.25); + cbm_pipeline_set_semantic_threshold(p, 0.75); + cbm_pipeline_set_githistory_min_coupling(p, 0.3); + cbm_pipeline_set_lsp_confidence_floor(p, 0.6); + ASSERT_TRUE(cbm_pipeline_similarity_threshold(p) == 0.7); + ASSERT_TRUE(cbm_pipeline_httplink_min_confidence(p) == 0.25); + ASSERT_TRUE(cbm_pipeline_semantic_threshold(p) == 0.75); + ASSERT_TRUE(cbm_pipeline_githistory_min_coupling(p) == 0.3); + ASSERT_TRUE(cbm_pipeline_lsp_confidence_floor(p) == 0.6); + + cbm_pipeline_set_similarity_threshold(p, -1.0); + cbm_pipeline_set_httplink_min_confidence(p, 0.0); + cbm_pipeline_set_semantic_threshold(p, 1.5); + cbm_pipeline_set_githistory_min_coupling(p, 2.0); + cbm_pipeline_set_lsp_confidence_floor(p, -0.1); + ASSERT_TRUE(cbm_pipeline_similarity_threshold(p) == 0.0); + ASSERT_TRUE(cbm_pipeline_httplink_min_confidence(p) == 0.0); + ASSERT_TRUE(cbm_pipeline_semantic_threshold(p) == 0.0); + ASSERT_TRUE(cbm_pipeline_githistory_min_coupling(p) == 0.0); + ASSERT_TRUE(cbm_pipeline_lsp_confidence_floor(p) == 0.0); + + cbm_pipeline_free(p); + PASS(); +} + +static const cbm_config_entry_t *find_config_entry(const char *key) { + for (int i = 0; CBM_CONFIG_REGISTRY[i].key; i++) { + if (strcmp(CBM_CONFIG_REGISTRY[i].key, key) == 0) { + return &CBM_CONFIG_REGISTRY[i]; + } + } + return NULL; +} + +TEST(config_registry_includes_mcp_timeout_knobs) { + const cbm_config_entry_t *idle = find_config_entry("store_idle_timeout_s"); + ASSERT_NOT_NULL(idle); + ASSERT_STR_EQ(idle->default_val, "60"); + ASSERT_STR_EQ(idle->category, "MCP"); + + const cbm_config_entry_t *validate = find_config_entry("db_validate_busy_timeout_ms"); + ASSERT_NOT_NULL(validate); + ASSERT_STR_EQ(validate->default_val, "1000"); + ASSERT_STR_EQ(validate->category, "MCP"); + + const cbm_config_entry_t *update = find_config_entry("update_check_timeout_s"); + ASSERT_NOT_NULL(update); + ASSERT_STR_EQ(update->default_val, "5"); + ASSERT_STR_EQ(update->category, "MCP"); + PASS(); +} + TEST(trackable_source_files) { /* Common source extensions are trackable */ ASSERT_TRUE(cbm_is_trackable_file("main.go")); @@ -6090,6 +6148,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_cancel); RUN_TEST(pipeline_cancel_null); RUN_TEST(pipeline_run_null); + RUN_TEST(pipeline_unit_threshold_setters_clamp_invalid_values); + RUN_TEST(config_registry_includes_mcp_timeout_knobs); /* File persistence */ RUN_TEST(store_file_persistence); RUN_TEST(store_bulk_persistence); diff --git a/tests/test_security.c b/tests/test_security.c index 789809fc4..1e21c37ff 100644 --- a/tests/test_security.c +++ b/tests/test_security.c @@ -12,6 +12,7 @@ #include #include "../src/foundation/str_util.h" #include "../src/foundation/compat_fs.h" +#include "../src/foundation/compat_thread.h" #include #include @@ -366,6 +367,136 @@ TEST(exec_no_shell_captures_exit_code) { #endif /* _WIN32 */ +/* ══════════════════════════════════════════════════════════════════ + * PORTABLE FILE REPLACEMENT + * ══════════════════════════════════════════════════════════════════ */ + +TEST(compat_replace_file_replaces_destination) { + char *dir = th_mktempdir("cbm_replace_file"); + ASSERT_NOT_NULL(dir); + char root[256]; + snprintf(root, sizeof(root), "%s", dir); + + const char *dest = TH_PATH(root, "target.txt"); + const char *tmp = TH_PATH(root, "target.txt.tmp"); + ASSERT_EQ(th_write_file(dest, "old"), 0); + ASSERT_EQ(th_write_file(tmp, "new"), 0); + + ASSERT_EQ(cbm_replace_file(tmp, dest), 0); + + FILE *fp = fopen(dest, "rb"); + ASSERT_NOT_NULL(fp); + char buf[8] = {0}; + size_t n = fread(buf, 1, sizeof(buf) - 1, fp); + fclose(fp); + ASSERT_EQ((int)n, 3); + ASSERT_STR_EQ(buf, "new"); + + struct stat st; + ASSERT_NEQ(stat(tmp, &st), 0); + th_cleanup(root); + PASS(); +} + +TEST(compat_write_file_atomic_replaces_destination) { + char *dir = th_mktempdir("cbm_write_file_atomic"); + ASSERT_NOT_NULL(dir); + char root[256]; + snprintf(root, sizeof(root), "%s", dir); + + const char *dest = TH_PATH(root, "payload.bin"); + ASSERT_EQ(th_write_file(dest, "old"), 0); + + cbm_file_error_t err = {0}; + ASSERT_EQ(cbm_write_file_atomic(dest, "new", 3, &err), 0); + ASSERT_NULL(err.stage); + ASSERT_EQ(err.code, 0); + + FILE *fp = fopen(dest, "rb"); + ASSERT_NOT_NULL(fp); + char buf[8] = {0}; + size_t n = fread(buf, 1, sizeof(buf) - 1, fp); + fclose(fp); + ASSERT_EQ((int)n, 3); + ASSERT_STR_EQ(buf, "new"); + + struct stat st; + ASSERT_NEQ(stat(TH_PATH(root, "payload.bin.tmp"), &st), 0); + th_cleanup(root); + PASS(); +} + +TEST(compat_write_file_atomic_reports_replace_failure) { + char *dir = th_mktempdir("cbm_write_file_atomic_fail"); + ASSERT_NOT_NULL(dir); + char root[256]; + snprintf(root, sizeof(root), "%s", dir); + + const char *dest = TH_PATH(root, "target.txt"); + ASSERT_TRUE(cbm_mkdir_p(dest, 0755)); + + cbm_file_error_t err = {0}; + ASSERT_NEQ(cbm_write_file_atomic(dest, "new", 3, &err), 0); + ASSERT_NOT_NULL(err.stage); + ASSERT_STR_EQ(err.stage, "rename_temp"); + ASSERT_NEQ(err.code, 0); + + th_cleanup(root); + PASS(); +} + +enum { ATOMIC_CONCURRENT_WRITES = 64 }; + +typedef struct { + const char *dest; + const char *payload; + int failures; +} atomic_writer_arg_t; + +static void *atomic_writer_thread(void *arg) { + atomic_writer_arg_t *wa = (atomic_writer_arg_t *)arg; + size_t len = strlen(wa->payload); + for (int i = 0; i < ATOMIC_CONCURRENT_WRITES; i++) { + cbm_file_error_t err = {0}; + if (cbm_write_file_atomic(wa->dest, wa->payload, len, &err) != 0) { + wa->failures++; + } + } + return NULL; +} + +TEST(compat_write_file_atomic_concurrent_same_destination) { + char *dir = th_mktempdir("cbm_write_file_atomic_concurrent"); + ASSERT_NOT_NULL(dir); + char root[256]; + snprintf(root, sizeof(root), "%s", dir); + + char dest[512]; + snprintf(dest, sizeof(dest), "%s", TH_PATH(root, "payload.bin")); + ASSERT_EQ(th_write_file(dest, "initial"), 0); + + atomic_writer_arg_t a = {.dest = dest, .payload = "alpha", .failures = 0}; + atomic_writer_arg_t b = {.dest = dest, .payload = "bravo", .failures = 0}; + cbm_thread_t ta, tb; + ASSERT_EQ(cbm_thread_create(&ta, 0, atomic_writer_thread, &a), 0); + ASSERT_EQ(cbm_thread_create(&tb, 0, atomic_writer_thread, &b), 0); + ASSERT_EQ(cbm_thread_join(&ta), 0); + ASSERT_EQ(cbm_thread_join(&tb), 0); + ASSERT_EQ(a.failures, 0); + ASSERT_EQ(b.failures, 0); + + FILE *fp = fopen(dest, "rb"); + ASSERT_NOT_NULL(fp); + char buf[16] = {0}; + size_t n = fread(buf, 1, sizeof(buf) - 1, fp); + fclose(fp); + ASSERT_TRUE((n == strlen(a.payload) && strcmp(buf, a.payload) == 0) || + (n == strlen(b.payload) && strcmp(buf, b.payload) == 0)); + + th_cleanup(root); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * SUITE * ══════════════════════════════════════════════════════════════════ */ @@ -418,4 +549,9 @@ SUITE(security) { RUN_TEST(exec_no_shell_null_argv_returns_error); RUN_TEST(exec_no_shell_captures_exit_code); #endif + + RUN_TEST(compat_replace_file_replaces_destination); + RUN_TEST(compat_write_file_atomic_replaces_destination); + RUN_TEST(compat_write_file_atomic_reports_replace_failure); + RUN_TEST(compat_write_file_atomic_concurrent_same_destination); } diff --git a/tests/test_store_arch.c b/tests/test_store_arch.c index 32fb32b56..3dcc2f767 100644 --- a/tests/test_store_arch.c +++ b/tests/test_store_arch.c @@ -252,15 +252,44 @@ TEST(arch_languages) { TEST(arch_routes) { cbm_store_t *s = setup_arch_test_store(); + cbm_node_t infra_url = { + .project = "test", + .label = "Route", + .name = "https://example.com/api/orders", + .qualified_name = "__route__infra__https://example.com/api/orders", + .properties_json = "{\"source\":\"infra\",\"key_path\":\"ServiceUrl\"}"}; + cbm_store_upsert_node(s, &infra_url); + cbm_node_t code_url = { + .project = "test", + .label = "Route", + .name = "https://api.example.com/v1/orders", + .qualified_name = "__route__GET__https://api.example.com/v1/orders", + .properties_json = + "{\"method\":\"GET\",\"path\":\"https://api.example.com/v1/orders\"}"}; + cbm_store_upsert_node(s, &code_url); + cbm_architecture_info_t info; memset(&info, 0, sizeof(info)); const char *aspects[] = {"routes"}; ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); - ASSERT_EQ(info.route_count, 1); - ASSERT_STR_EQ(info.routes[0].method, "POST"); - ASSERT_STR_EQ(info.routes[0].path, "/api/orders"); - ASSERT_STR_EQ(info.routes[0].handler, "HandleRequest"); + ASSERT_EQ(info.route_count, 2); + bool saw_app_route = false; + bool saw_code_url = false; + for (int i = 0; i < info.route_count; i++) { + if (strcmp(info.routes[i].path, "/api/orders") == 0) { + saw_app_route = true; + ASSERT_STR_EQ(info.routes[i].method, "POST"); + ASSERT_STR_EQ(info.routes[i].handler, "HandleRequest"); + } + if (strcmp(info.routes[i].path, "https://api.example.com/v1/orders") == 0) { + saw_code_url = true; + ASSERT_STR_EQ(info.routes[i].method, "GET"); + } + ASSERT_STR_NEQ(info.routes[i].path, "https://example.com/api/orders"); + } + ASSERT_TRUE(saw_app_route); + ASSERT_TRUE(saw_code_url); cbm_store_architecture_free(&info); cbm_store_close(s); diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index ef4e2e2a3..066520386 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -988,8 +988,9 @@ TEST(store_integrity_windows_lowercase_drive_issue367) { PASS(); } -TEST(store_integrity_corrupt_too_many_rows) { - /* Simulate corruption: >5 rows in projects table */ +TEST(store_integrity_multiple_project_rows_allowed) { + /* Dependency projects are stored in the parent DB, so a valid store may + * contain more than one projects row. Row count alone is not corruption. */ cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); sqlite3 *db = cbm_store_get_db(s); @@ -1001,7 +1002,7 @@ TEST(store_integrity_corrupt_too_many_rows) { i, i); sqlite3_exec(db, sql, NULL, NULL, NULL); } - ASSERT_FALSE(cbm_store_check_integrity(s)); + ASSERT_TRUE(cbm_store_check_integrity(s)); cbm_store_close(s); PASS(); } @@ -1037,7 +1038,7 @@ TEST(store_integrity_full_path_only_classification) { ASSERT_FALSE(cbm_store_check_integrity_full(s, &path_only)); ASSERT_TRUE(path_only); - /* Too many rows: fails, path_only == false (genuine corruption). */ + /* Many valid project rows are allowed and are not a path-only failure. */ sqlite3_exec(db, "DELETE FROM projects;", NULL, NULL, NULL); for (int i = 0; i < 10; i++) { char sql[256]; @@ -1048,7 +1049,7 @@ TEST(store_integrity_full_path_only_classification) { sqlite3_exec(db, sql, NULL, NULL, NULL); } path_only = true; - ASSERT_FALSE(cbm_store_check_integrity_full(s, &path_only)); + ASSERT_TRUE(cbm_store_check_integrity_full(s, &path_only)); ASSERT_FALSE(path_only); cbm_store_close(s); @@ -1590,7 +1591,7 @@ SUITE(store_nodes) { RUN_TEST(store_integrity_empty); RUN_TEST(store_integrity_corrupt_bad_path); RUN_TEST(store_integrity_windows_lowercase_drive_issue367); - RUN_TEST(store_integrity_corrupt_too_many_rows); + RUN_TEST(store_integrity_multiple_project_rows_allowed); RUN_TEST(store_integrity_null_check); RUN_TEST(store_integrity_full_path_only_classification); RUN_TEST(store_project_crud); diff --git a/tests/test_tool_consolidation.c b/tests/test_tool_consolidation.c index 2686a2a3a..fdeee13a4 100644 --- a/tests/test_tool_consolidation.c +++ b/tests/test_tool_consolidation.c @@ -1014,8 +1014,8 @@ TEST(dep_search_explicit_dep_project_name) { free(r); /* Clean up any DB file that resolve_store may have created */ char path[1024]; - snprintf(path, sizeof(path), "%s/.cache/codebase-memory-mcp/_tc_deptest_proj_.db", - getenv("HOME")); + snprintf(path, sizeof(path), "%s/_tc_deptest_proj_.db", + cbm_resolve_cache_dir()); (void)unlink(path); cbm_mcp_server_free(srv); PASS(); @@ -1328,8 +1328,8 @@ TEST(cross_project_search_not_confused_by_prefix) { /* Clean up any spurious DB file created by resolve_store */ char path[1024]; - snprintf(path, sizeof(path), "%s/.cache/codebase-memory-mcp/myapp-other-project.db", - getenv("HOME")); + snprintf(path, sizeof(path), "%s/myapp-other-project.db", + cbm_resolve_cache_dir()); (void)unlink(path); cbm_mcp_server_free(srv); @@ -1384,7 +1384,7 @@ TEST(prefix_collision_dash_after_session_name) { ASSERT_NOT_NULL(r); free(r); char path[1024]; - snprintf(path, sizeof(path), "%s/.cache/codebase-memory-mcp/myapp-v2.db", getenv("HOME")); + snprintf(path, sizeof(path), "%s/myapp-v2.db", cbm_resolve_cache_dir()); (void)unlink(path); cbm_mcp_server_free(srv); PASS(); @@ -1400,7 +1400,7 @@ TEST(prefix_collision_underscore_after_session_name) { ASSERT_NOT_NULL(r); free(r); char path[1024]; - snprintf(path, sizeof(path), "%s/.cache/codebase-memory-mcp/myapp_test.db", getenv("HOME")); + snprintf(path, sizeof(path), "%s/myapp_test.db", cbm_resolve_cache_dir()); (void)unlink(path); cbm_mcp_server_free(srv); PASS(); @@ -1434,7 +1434,7 @@ TEST(prefix_collision_completely_different_project) { ASSERT_NOT_NULL(r); free(r); char path[1024]; - snprintf(path, sizeof(path), "%s/.cache/codebase-memory-mcp/other-project.db", getenv("HOME")); + snprintf(path, sizeof(path), "%s/other-project.db", cbm_resolve_cache_dir()); (void)unlink(path); cbm_mcp_server_free(srv); PASS(); @@ -1451,7 +1451,7 @@ TEST(prefix_collision_session_is_substring_of_project) { ASSERT_NOT_NULL(r); free(r); char path[1024]; - snprintf(path, sizeof(path), "%s/.cache/codebase-memory-mcp/abc.db", getenv("HOME")); + snprintf(path, sizeof(path), "%s/abc.db", cbm_resolve_cache_dir()); (void)unlink(path); cbm_mcp_server_free(srv); PASS(); @@ -1468,8 +1468,8 @@ TEST(prefix_collision_session_is_substring_of_project) { TEST(get_code_no_project_uses_open_store_tier1) { /* Create a file DB with one node */ char db_path[1024]; - snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_gc_proj_.db", - getenv("HOME")); + snprintf(db_path, sizeof(db_path), "%s/_tc_gc_proj_.db", + cbm_resolve_cache_dir()); cbm_store_t *s = cbm_store_open_path(db_path); ASSERT_NOT_NULL(s); cbm_store_upsert_project(s, "_tc_gc_proj_", "/tmp"); @@ -1509,8 +1509,8 @@ TEST(get_code_no_project_uses_open_store_tier1) { TEST(get_code_single_fuzzy_result_resolves_not_ambiguous) { /* Create a file DB with one node */ char db_path[1024]; - snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_gc_fuzzy_.db", - getenv("HOME")); + snprintf(db_path, sizeof(db_path), "%s/_tc_gc_fuzzy_.db", + cbm_resolve_cache_dir()); cbm_store_t *s = cbm_store_open_path(db_path); ASSERT_NOT_NULL(s); cbm_store_upsert_project(s, "_tc_gc_fuzzy_", "/tmp"); @@ -1549,8 +1549,8 @@ TEST(get_code_single_fuzzy_result_resolves_not_ambiguous) { * QN, so get_code works even when srv->current_project is unset. */ TEST(get_code_cold_start_parses_project_from_qn) { char db_path[1024]; - snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_gc_cold_.db", - getenv("HOME")); + snprintf(db_path, sizeof(db_path), "%s/_tc_gc_cold_.db", + cbm_resolve_cache_dir()); cbm_store_t *s = cbm_store_open_path(db_path); ASSERT_NOT_NULL(s); cbm_store_upsert_project(s, "_tc_gc_cold_", "/tmp"); @@ -1615,8 +1615,8 @@ TEST(watcher_registered_after_index_repository) { TEST(watcher_registered_on_resolve_store) { /* Pre-populate a DB with a project that has a known root_path */ char db_path[1024]; - snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_watcher_.db", - getenv("HOME")); + snprintf(db_path, sizeof(db_path), "%s/_tc_watcher_.db", + cbm_resolve_cache_dir()); cbm_store_t *s = cbm_store_open_path(db_path); ASSERT_NOT_NULL(s); cbm_store_upsert_project(s, "_tc_watcher_", "/tmp/cbm_watcher_root"); @@ -1648,8 +1648,8 @@ TEST(watcher_registered_on_resolve_store) { TEST(watcher_not_registered_for_unknown_path) { /* Project entry exists but root_path is empty — watcher must NOT be registered */ char db_path[1024]; - snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_watcher_nopath_.db", - getenv("HOME")); + snprintf(db_path, sizeof(db_path), "%s/_tc_watcher_nopath_.db", + cbm_resolve_cache_dir()); cbm_store_t *s = cbm_store_open_path(db_path); ASSERT_NOT_NULL(s); cbm_store_upsert_project(s, "_tc_watcher_nopath_", ""); @@ -1699,8 +1699,8 @@ TEST(compact_defaults_to_true) { /* When compact is not provided, name field should be omitted if it's * the last segment of qualified_name */ char db_path[1024]; - snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_compact_default_.db", - getenv("HOME")); + snprintf(db_path, sizeof(db_path), "%s/_tc_compact_default_.db", + cbm_resolve_cache_dir()); cbm_store_t *s = cbm_store_open_path(db_path); ASSERT_NOT_NULL(s); cbm_store_upsert_project(s, "_tc_compact_default_", "/tmp/compact_test"); @@ -1740,8 +1740,8 @@ TEST(pagerank_output_has_limited_precision) { /* Pagerank values should be serialized with limited precision (~4 sig figs), * not full 17-digit double precision */ char db_path[1024]; - snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_pr_precision_.db", - getenv("HOME")); + snprintf(db_path, sizeof(db_path), "%s/_tc_pr_precision_.db", + cbm_resolve_cache_dir()); cbm_store_t *s = cbm_store_open_path(db_path); ASSERT_NOT_NULL(s); cbm_store_upsert_project(s, "_tc_pr_precision_", "/tmp/pr_test"); @@ -1775,8 +1775,8 @@ TEST(empty_db_not_treated_as_indexed) { /* A DB file with schema but 0 nodes should NOT prevent re-indexing. * Regression test: previously stat(db_path)==0 was enough to skip. */ char db_path[1024]; - snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_empty_db_test_.db", - getenv("HOME")); + snprintf(db_path, sizeof(db_path), "%s/_tc_empty_db_test_.db", + cbm_resolve_cache_dir()); /* Create DB with schema but no data */ cbm_store_t *s = cbm_store_open_path(db_path); ASSERT_NOT_NULL(s); @@ -1813,8 +1813,8 @@ TEST(empty_db_not_treated_as_indexed) { TEST(search_exclude_filters_file_paths) { /* exclude param should remove matching results */ char db_path[1024]; - snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_exclude_test_.db", - getenv("HOME")); + snprintf(db_path, sizeof(db_path), "%s/_tc_exclude_test_.db", + cbm_resolve_cache_dir()); cbm_store_t *s = cbm_store_open_path(db_path); ASSERT_NOT_NULL(s); cbm_store_upsert_project(s, "_tc_exclude_test_", "/tmp/exclude_test"); @@ -1862,8 +1862,8 @@ TEST(search_exclude_filters_file_paths) { TEST(search_exclude_empty_array_no_effect) { /* Empty exclude array should not filter anything */ char db_path[1024]; - snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_excl_empty_.db", - getenv("HOME")); + snprintf(db_path, sizeof(db_path), "%s/_tc_excl_empty_.db", + cbm_resolve_cache_dir()); cbm_store_t *s = cbm_store_open_path(db_path); ASSERT_NOT_NULL(s); cbm_store_upsert_project(s, "_tc_excl_empty_", "/tmp/excl_empty"); @@ -1889,8 +1889,8 @@ TEST(search_exclude_empty_array_no_effect) { TEST(search_exclude_all_returns_empty) { /* Excluding everything should return 0 results, not error */ char db_path[1024]; - snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_excl_all_.db", - getenv("HOME")); + snprintf(db_path, sizeof(db_path), "%s/_tc_excl_all_.db", + cbm_resolve_cache_dir()); cbm_store_t *s = cbm_store_open_path(db_path); ASSERT_NOT_NULL(s); cbm_store_upsert_project(s, "_tc_excl_all_", "/tmp/excl_all"); @@ -1979,8 +1979,8 @@ TEST(project_missing_returns_structured_error) { TEST(source_grep_case_insensitive_by_default) { /* Register a project in the store so get_project_root can resolve it */ char db_path[512]; - snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_ci_test_.db", - getenv("HOME")); + snprintf(db_path, sizeof(db_path), "%s/_tc_ci_test_.db", + cbm_resolve_cache_dir()); char proj_dir[256]; snprintf(proj_dir, sizeof(proj_dir), "%s/cbm_ci_test_%d", cbm_tmpdir(), (int)getpid()); cbm_mkdir_p(proj_dir, 0755); @@ -2022,8 +2022,8 @@ TEST(source_grep_case_insensitive_by_default) { * Pattern "HELLO_WORLD" vs file containing "hello_world" only → 0 matches. */ TEST(source_grep_case_sensitive_flag_works) { char db_path[512]; - snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_cs_test_.db", - getenv("HOME")); + snprintf(db_path, sizeof(db_path), "%s/_tc_cs_test_.db", + cbm_resolve_cache_dir()); char proj_dir[256]; snprintf(proj_dir, sizeof(proj_dir), "%s/cbm_cs_test_%d", cbm_tmpdir(), (int)getpid()); cbm_mkdir_p(proj_dir, 0755); @@ -2087,8 +2087,8 @@ TEST(graph_mode_compact_error_is_descriptive) { * After fix: response contains "mode_warning" field → PASS. */ TEST(source_grep_mode_summary_warns) { char db_path[512]; - snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_sm_test_.db", - getenv("HOME")); + snprintf(db_path, sizeof(db_path), "%s/_tc_sm_test_.db", + cbm_resolve_cache_dir()); char proj_dir[256]; snprintf(proj_dir, sizeof(proj_dir), "%s/cbm_sm_test_%d", cbm_tmpdir(), (int)getpid()); cbm_mkdir_p(proj_dir, 0755);