diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2430c6712..621d407fc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,8 +9,12 @@ on: permissions: contents: read +# One analysis per pull request and one per pushed branch. A newer push to main +# cancels the analysis of an older main commit; only the newest commit's +# results matter for main, and a merge-heavy day otherwise queues one 40-100 +# minute analysis per merge ahead of every pull request run. concurrency: - group: codeql-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }} + group: codeql-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index c2acbd8ac..fb0a52bbc 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -8,6 +8,12 @@ on: permissions: read-all +# Only the newest main commit is scored: a newer push (or the weekly run) +# cancels a pending or running score of an older commit. +concurrency: + group: scorecard-${{ github.ref }} + cancel-in-progress: true + jobs: scorecard: runs-on: ubuntu-latest diff --git a/README.md b/README.md index da4d824e0..84193f184 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,8 @@ The install script placed beside the binary is **reported, not deleted** — uni - **`CROSS_*` edges** link nodes across multiple repos indexed under the same store - **Multi-galaxy 3D UI layout** for cross-repo architecture visualization - **Cross-repo architecture summary** combining services, routes, and dependencies across the indexed fleet +- **Run status**: `index_status` (and the UI layout next to `linked_projects`) carries a `cross_repo` object, `{"status":"never_run"}` until `index_repository(mode="cross-repo-intelligence")` has run with that project as the source, then `"ran"` with `last_run_at`, `outcome`, `targets`, `projects_scanned`, `total_cross_edges` and `skipped_projects`. That is how you tell "never linked" apart from "linked, nothing matched". The status is stored in the project's own index next to its `CROSS_*` edges, so a reindex that rebuilds the index drops both at once and the status goes back to `never_run`. +- **Skipped stores**: `target_projects: ["*"]` skips an index built before the #768 schema change rather than failing the whole run. The response lists each one as `skipped_projects: [{"name", "reason": "pre_768_schema", "hint": "reindex this project"}]`. ### Edge types (selected) - `CALLS` — a callable is invoked at the source site diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index 366a71408..3dab239d9 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -281,6 +281,8 @@ void cbm_channels_push(CBMChannelArray *arr, CBMArena *a, CBMChannel ch) { typedef struct { const char *string; uint32_t length; + /* #2078: serve one "\n" at offset `length` -- see cbm_parse_source. */ + bool virtual_newline; } CBMStringInput; static const char *cbm_string_read(void *payload, uint32_t byte, TSPoint point, @@ -288,6 +290,10 @@ static const char *cbm_string_read(void *payload, uint32_t byte, TSPoint point, (void)point; CBMStringInput *self = (CBMStringInput *)payload; if (byte >= self->length) { + if (self->virtual_newline && byte == self->length) { + *bytes_read = 1; + return "\n"; + } *bytes_read = 0; return ""; } @@ -295,6 +301,60 @@ static const char *cbm_string_read(void *payload, uint32_t byte, TSPoint point, return self->string + byte; } +/* Where the parser stands after reading all `len` real bytes. */ +static TSPoint cbm_source_end_point(const char *source, uint32_t len) { + TSPoint end = {0, 0}; + const char *p = source; + const char *stop = source + len; + const char *nl; + while (p < stop && (nl = memchr(p, '\n', (size_t)(stop - p))) != NULL) { + end.row++; + p = nl + 1; + } + end.column = (uint32_t)(stop - p); + return end; +} + +/* #2078: parse every file as if its last line were terminated. + * + * Many grammars treat the line terminator as part of the construct it ends -- + * a markdown fence, heading or list marker, a Makefile recipe, a Dockerfile + * instruction. A file whose last byte is not "\n" leaves that construct + * unterminated: at best a zero-width MISSING token (#1610/#1746 excuse those at + * EOF), at worst a WIDTH-BEARING error that costs the whole last line or, for a + * one-line file, the whole file, and sometimes a silent loss (an unterminated + * markdown heading produced no Section and no flag). The same bytes plus one + * "\n" parse clean, so the absent newline is supplied instead of excused. + * + * The parser reads one virtual "\n" past EOF; afterwards a single tree edit + * deletes it again. tree-sitter's own edit arithmetic then clamps every node + * that reached into the virtual byte back to the real end -- byte offsets AND + * row/column points -- so every consumer (def line ranges, node text, call + * byte spans, parse-coverage ranges, the retained tree the LSP passes reuse) + * sees only real bytes and real lines; nothing downstream needs to know. The + * caller's buffer is never copied or written. + * + * A file that is empty or already ends in "\n" is parsed exactly as before. */ +TSTree *cbm_parse_source(TSParser *parser, const char *source, uint32_t source_len, + TSParseOptions opts) { + CBMStringInput input = {source, source_len, source_len > 0 && source[source_len - 1] != '\n'}; + TSInput ts_input = {&input, cbm_string_read, TSInputEncodingUTF8, NULL}; + TSTree *tree = ts_parser_parse_with_options(parser, NULL, ts_input, opts); + if (tree && input.virtual_newline) { + TSPoint end = cbm_source_end_point(source, source_len); + TSInputEdit drop_virtual_newline = { + .start_byte = source_len, + .old_end_byte = source_len + 1, + .new_end_byte = source_len, + .start_point = end, + .old_end_point = {end.row + 1, 0}, + .new_end_point = end, + }; + ts_tree_edit(tree, &drop_virtual_newline); + } + return tree; +} + // --- Parse timeout callback --- /* Budget for the tree-sitter progress callback. The PRIMARY gate is per-thread @@ -1264,7 +1324,11 @@ static void cbm_error_regions_push(cbm_error_regions_t *acc, TSNode n) { * #1746: the Dockerfile grammar places that zero-width missing newline before * trailing whitespace rather than at raw EOF. Preserve the broad exact-EOF * rule above; only extend it past blanks when the missing token is specifically - * a newline. */ + * a newline. + * + * #2078: cbm_parse_source now supplies the absent final newline, so a MISSING + * newline at EOF no longer arises from it; the rule stays for the other + * zero-width terminators a grammar can leave at EOF. */ static bool cbm_is_blank_not_newline(char c) { return c == ' ' || c == '\t' || c == '\v' || c == '\f' || c == '\r'; } @@ -2661,8 +2725,6 @@ static void extract_cpp_branch_views(const CBMExtractCtx *raw, const TSLanguage return; } ts_parser_reset(parser); - CBMStringInput input = {view, (uint32_t)raw->source_len}; - TSInput ts_input = {&input, cbm_string_read, TSInputEncodingUTF8, NULL}; TSParseOptions opts = {0}; CBMParseBudget budget = {0}; // cppcheck-suppress unreadVariable if (timeout_micros > 0) { @@ -2672,7 +2734,9 @@ static void extract_cpp_branch_views(const CBMExtractCtx *raw, const TSLanguage opts.payload = &budget; opts.progress_callback = cbm_timeout_cb; } - TSTree *tree = ts_parser_parse_with_options(parser, NULL, ts_input, opts); + /* A view keeps the raw source's length and line structure, so it is + * parsed under the same terminated-last-line rule (#2078). */ + TSTree *tree = cbm_parse_source(parser, view, (uint32_t)raw->source_len, opts); if (!tree) { return; } @@ -2780,15 +2844,7 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C uint64_t t0 = now_ns(); - // Build string input + timeout options for parse_with_options - CBMStringInput str_input = {source, (uint32_t)source_len}; - TSInput ts_input = { - &str_input, - cbm_string_read, - TSInputEncodingUTF8, - NULL, - }; - + // Timeout options for parse_with_options TSParseOptions opts = {0}; CBMParseBudget budget = {0}; // cppcheck-suppress unreadVariable uint64_t budget_ns = 0; @@ -2816,12 +2872,14 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C /* #1735: a SQL data dump's literal-only INSERT rows carry no graph content * but dominate its parse. Keep them out through included ranges; offsets * and positions of everything kept are unchanged. The parser is - * thread-local and reused, so the ranges are cleared right after. */ + * thread-local and reused, so the ranges are cleared right after. The last + * range ends at the real EOF, so the virtual final newline cbm_parse_source + * supplies (#2078) stays outside a ranged parse. */ CBMSqlKeptRanges sql_kept = {NULL, 0}; bool sql_ranged = language == CBM_LANG_SQL && cbm_sql_values_exclusion_on(rel_path) && cbm_sql_values_kept_ranges(source, (uint32_t)source_len, &sql_kept) && ts_parser_set_included_ranges(parser, sql_kept.items, sql_kept.count); - TSTree *tree = ts_parser_parse_with_options(parser, NULL, ts_input, opts); + TSTree *tree = cbm_parse_source(parser, source, (uint32_t)source_len, opts); if (sql_ranged) { (void)ts_parser_set_included_ranges(parser, NULL, 0); } @@ -3042,7 +3100,7 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C TSParser *pp_parser = get_thread_parser(ts_lang, language); if (pp_parser) { ts_parser_reset(pp_parser); - CBMStringInput pp_input = {expanded, (uint32_t)expanded_len}; + CBMStringInput pp_input = {expanded, (uint32_t)expanded_len, false}; TSInput pp_ts_input = { &pp_input, cbm_string_read, diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 1c3b0a64a..57ca1c242 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -917,6 +917,14 @@ void cbm_work_arena_keep_begin(void); /* Free the compaction scratch this thread kept (cbm_work_arena_release calls it). */ void cbm_result_compact_release_thread(void); +/* Parse one whole file as if its last line ended with "\n" (#2078). The + * parser sees the source plus one virtual newline when the last byte is not + * already one; the returned tree is then clamped back to `source_len`, so no + * node range, point or text reaches past the real bytes. Every whole-file parse + * goes through here, so a retained tree and a fallback re-parse agree. */ +TSTree *cbm_parse_source(TSParser *parser, const char *source, uint32_t source_len, + TSParseOptions opts); + // Extract all data from one file. Caller must call cbm_free_result(). // source must remain valid for the duration of the call. // timeout_micros: per-file tree-sitter parse budget in microseconds of the diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index a8b97c639..90bd5c604 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -2688,6 +2688,123 @@ static const char *normalize_string_handler(CBMArena *a, const char *raw) { return unq; } +/* True for a PHP class/namespace name: identifier segments joined by '\'. */ +static bool php_is_class_path(const char *s) { + if (!s || !s[0]) { + return false; + } + for (; *s; s++) { + if (!((*s >= 'a' && *s <= 'z') || (*s >= 'A' && *s <= 'Z') || (*s >= '0' && *s <= '9') || + *s == '_' || *s == '\\')) { + return false; + } + } + return true; +} + +/* Qualify a class name the way PHP resolves it: a leading '\' is already + * absolute; a first segment that a `use` clause imports (by alias or by last + * segment) is replaced by the imported name; anything else is relative to the + * file's namespace. Imports and the namespace are extracted before the call + * walk, so both are complete here. */ +static const char *php_qualify_class(CBMExtractCtx *ctx, const char *written) { + if (written[0] == '\\') { + return written + SKIP_ONE; + } + const char *sep = strchr(written, '\\'); + size_t first_len = sep ? (size_t)(sep - written) : strlen(written); + const CBMImportArray *imps = &ctx->result->imports; + for (int i = 0; i < imps->count; i++) { + const CBMImport *imp = &imps->items[i]; + const char *path = imp->module_path; + if (!imp->local_name || !path || strlen(imp->local_name) != first_len || + strncmp(imp->local_name, written, first_len) != 0) { + continue; + } + path += path[0] == '\\' ? SKIP_ONE : 0; + if (php_is_class_path(path)) { + return cbm_arena_sprintf(ctx->arena, "%s%s", path, sep ? sep : ""); + } + } + const char *ns = ctx->result->namespace_name; + return ns && ns[0] ? cbm_arena_sprintf(ctx->arena, "%s\\%s", ns, written) : written; +} + +/* The fully-qualified class a PHP `X::class` expression names (`use` imports + * and the file namespace applied). NULL for any other expression, including + * `static::class` and `self::class`. */ +static const char *php_class_literal_fqn(CBMExtractCtx *ctx, TSNode node) { + if (strcmp(ts_node_type(node), "class_constant_access_expression") != 0 || + ts_node_named_child_count(node) != PAIR_LEN) { + return NULL; + } + TSNode cls = ts_node_named_child(node, 0); + const char *ck = ts_node_type(cls); + const char *member = + cbm_node_text(ctx->arena, ts_node_named_child(node, SKIP_ONE), ctx->source); + if ((strcmp(ck, "name") != 0 && strcmp(ck, "qualified_name") != 0) || !member || + strcmp(member, "class") != 0) { + return NULL; + } + const char *text = cbm_node_text(ctx->arena, cls, ctx->source); + return php_is_class_path(text) ? php_qualify_class(ctx, text) : NULL; +} + +/* The value of a PHP array element written without a key; a null node for a + * keyed element (`'uses' => ...`) or anything else. */ +static TSNode php_unkeyed_element_value(TSNode elem) { + TSNode none = {0}; + if (strcmp(ts_node_type(elem), "array_element_initializer") != 0 || + ts_node_named_child_count(elem) != SKIP_ONE) { + return none; + } + return ts_node_named_child(elem, 0); +} + +static bool php_is_identifier(const char *s) { + if (!s || !((*s >= 'a' && *s <= 'z') || (*s >= 'A' && *s <= 'Z') || *s == '_')) { + return false; + } + for (; *s; s++) { + if (!((*s >= 'a' && *s <= 'z') || (*s >= 'A' && *s <= 'Z') || (*s >= '0' && *s <= '9') || + *s == '_')) { + return false; + } + } + return true; +} + +/* Laravel's class-based route handlers (#1146), as "Ns\Class::method": + * [UserController::class, 'show'] -> App\Http\Controllers\UserController::show + * GetCurrentUserController::class -> App\...\GetCurrentUserController::__invoke + * with the class qualified through the file's `use` imports; the route passes + * place it on a method QN (cbm_registry_resolve_handler). NULL for any other + * expression. */ +static const char *php_class_route_handler(CBMExtractCtx *ctx, TSNode arg) { + const char *kind = ts_node_type(arg); + if (strcmp(kind, "class_constant_access_expression") == 0) { + const char *cls = php_class_literal_fqn(ctx, arg); + return cls ? cbm_arena_sprintf(ctx->arena, "%s::__invoke", cls) : NULL; + } + if (strcmp(kind, "array_creation_expression") != 0 || + ts_node_named_child_count(arg) != PAIR_LEN) { + return NULL; + } + TSNode cls_node = php_unkeyed_element_value(ts_node_named_child(arg, 0)); + TSNode method_node = php_unkeyed_element_value(ts_node_named_child(arg, SKIP_ONE)); + if (ts_node_is_null(cls_node) || ts_node_is_null(method_node)) { + return NULL; + } + const char *cls = php_class_literal_fqn(ctx, cls_node); + const char *mk = ts_node_type(method_node); + if (!cls || (strcmp(mk, "string") != 0 && strcmp(mk, "encapsed_string") != 0)) { + return NULL; + } + const char *method = + strip_quotes(ctx->arena, cbm_node_text(ctx->arena, method_node, ctx->source)); + return php_is_identifier(method) ? cbm_arena_sprintf(ctx->arena, "%s::%s", cls, method) : NULL; +} + static const char *extract_handler_arg(CBMExtractCtx *ctx, TSNode args) { /* The LAST eligible argument wins, and every argument is examined. * Express, Fastify, gin and Laravel all put middleware between the route @@ -2705,6 +2822,13 @@ static const char *extract_handler_arg(CBMExtractCtx *ctx, TSNode args) { if (strcmp(ts_node_type(arg2), "argument") == 0 && ts_node_named_child_count(arg2) > 0) { arg2 = ts_node_named_child(arg2, 0); } + if (ctx->language == CBM_LANG_PHP) { + const char *class_handler = php_class_route_handler(ctx, arg2); + if (class_handler) { + handler = class_handler; + continue; + } + } const char *ak2 = ts_node_type(arg2); /* `name` = PHP bare identifier handler; string = Laravel string handler * ('showUsers' or 'Controller@method'). */ diff --git a/internal/cbm/lsp/c_lsp.c b/internal/cbm/lsp/c_lsp.c index 68c44fbe4..4c78caf8e 100644 --- a/internal/cbm/lsp/c_lsp.c +++ b/internal/cbm/lsp/c_lsp.c @@ -6777,7 +6777,7 @@ bool cbm_run_c_lsp_cross_with_registry_with_test_owners(CBMArena *arena, const c return false; const TSLanguage *ts_lang = cpp_mode ? tree_sitter_cpp() : tree_sitter_c(); ts_parser_set_language(parser, ts_lang); - tree = ts_parser_parse_string(parser, NULL, source, source_len); + tree = cbm_parse_source(parser, source, (uint32_t)source_len, (TSParseOptions){0}); ts_parser_delete(parser); owns_tree = true; if (!tree) @@ -6847,7 +6847,7 @@ bool cbm_run_c_lsp_cross_with_test_owners(CBMArena *arena, const char *source, i return false; const TSLanguage *ts_lang = cpp_mode ? tree_sitter_cpp() : tree_sitter_c(); ts_parser_set_language(parser, ts_lang); - tree = ts_parser_parse_string(parser, NULL, source, source_len); + tree = cbm_parse_source(parser, source, (uint32_t)source_len, (TSParseOptions){0}); ts_parser_delete(parser); owns_tree = true; if (!tree) diff --git a/internal/cbm/lsp/cs_lsp.c b/internal/cbm/lsp/cs_lsp.c index fc3b548ef..749b126bf 100644 --- a/internal/cbm/lsp/cs_lsp.c +++ b/internal/cbm/lsp/cs_lsp.c @@ -3763,8 +3763,9 @@ void cbm_run_cs_lsp_cross_with_registry(CBMArena *arena, const char *source, int if (!parser) return; ts_parser_set_language(parser, tree_sitter_c_sharp()); - tree = ts_parser_parse_string( - parser, NULL, source, source_len > 0 ? (uint32_t)source_len : (uint32_t)strlen(source)); + tree = cbm_parse_source(parser, source, + source_len > 0 ? (uint32_t)source_len : (uint32_t)strlen(source), + (TSParseOptions){0}); ts_parser_delete(parser); owns = true; } @@ -3805,8 +3806,9 @@ void cbm_run_cs_lsp_cross(CBMArena *arena, const char *source, int source_len, if (!parser) return; ts_parser_set_language(parser, tree_sitter_c_sharp()); - tree = ts_parser_parse_string( - parser, NULL, source, source_len > 0 ? (uint32_t)source_len : (uint32_t)strlen(source)); + tree = cbm_parse_source(parser, source, + source_len > 0 ? (uint32_t)source_len : (uint32_t)strlen(source), + (TSParseOptions){0}); ts_parser_delete(parser); owns = true; } diff --git a/internal/cbm/lsp/go_lsp.c b/internal/cbm/lsp/go_lsp.c index ba7df163b..58c151a53 100644 --- a/internal/cbm/lsp/go_lsp.c +++ b/internal/cbm/lsp/go_lsp.c @@ -3224,7 +3224,7 @@ void cbm_run_go_lsp_cross(CBMArena *arena, const char *source, int source_len, if (!parser) return; ts_parser_set_language(parser, tree_sitter_go()); - tree = ts_parser_parse_string(parser, NULL, source, source_len); + tree = cbm_parse_source(parser, source, (uint32_t)source_len, (TSParseOptions){0}); owns_tree = true; if (!tree) { ts_parser_delete(parser); @@ -3630,7 +3630,7 @@ void cbm_run_go_lsp_cross_with_registry(CBMArena *arena, const char *source, int if (!parser) return; ts_parser_set_language(parser, tree_sitter_go()); - tree = ts_parser_parse_string(parser, NULL, source, source_len); + tree = cbm_parse_source(parser, source, (uint32_t)source_len, (TSParseOptions){0}); owns_tree = true; if (!tree) { ts_parser_delete(parser); diff --git a/internal/cbm/lsp/java_lsp.c b/internal/cbm/lsp/java_lsp.c index 9a79783ff..2b518d890 100644 --- a/internal/cbm/lsp/java_lsp.c +++ b/internal/cbm/lsp/java_lsp.c @@ -3931,7 +3931,7 @@ void cbm_run_java_lsp_cross_with_registry(CBMArena *arena, CBMFileResult *result return; } ts_parser_set_language(parser, tree_sitter_java()); - tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)source_len); + tree = cbm_parse_source(parser, source, (uint32_t)source_len, (TSParseOptions){0}); ts_parser_delete(parser); owns_tree = true; } @@ -3985,7 +3985,7 @@ void cbm_run_java_lsp_cross(CBMArena *arena, const char *source, int source_len, if (!tree) { TSParser *parser = ts_parser_new(); ts_parser_set_language(parser, tree_sitter_java()); - tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)source_len); + tree = cbm_parse_source(parser, source, (uint32_t)source_len, (TSParseOptions){0}); ts_parser_delete(parser); owns_tree = true; } diff --git a/internal/cbm/lsp/kotlin_lsp.c b/internal/cbm/lsp/kotlin_lsp.c index 0ca49afd5..cc6be1680 100644 --- a/internal/cbm/lsp/kotlin_lsp.c +++ b/internal/cbm/lsp/kotlin_lsp.c @@ -5415,7 +5415,7 @@ void cbm_run_kotlin_lsp_cross(CBMArena *arena, const char *source, int source_le return; } ts_parser_set_language(parser, tree_sitter_kotlin()); - tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)source_len); + tree = cbm_parse_source(parser, source, (uint32_t)source_len, (TSParseOptions){0}); ts_parser_delete(parser); owns_tree = true; } diff --git a/internal/cbm/lsp/php_lsp.c b/internal/cbm/lsp/php_lsp.c index 4caf5ba0f..81347a94c 100644 --- a/internal/cbm/lsp/php_lsp.c +++ b/internal/cbm/lsp/php_lsp.c @@ -4498,7 +4498,7 @@ void cbm_run_php_lsp_cross(CBMArena *arena, const char *source, int source_len, if (!parser) return; ts_parser_set_language(parser, tree_sitter_php_only()); - tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)source_len); + tree = cbm_parse_source(parser, source, (uint32_t)source_len, (TSParseOptions){0}); owns_tree = true; if (!tree) { ts_parser_delete(parser); diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 70de5dce7..2b47fbf23 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -5110,7 +5110,7 @@ void cbm_run_py_lsp_cross(CBMArena *arena, const char *source, int source_len, if (!parser) return; ts_parser_set_language(parser, tree_sitter_python()); - tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)source_len); + tree = cbm_parse_source(parser, source, (uint32_t)source_len, (TSParseOptions){0}); owns_tree = true; if (!tree) { ts_parser_delete(parser); @@ -5191,7 +5191,7 @@ void cbm_run_py_lsp_cross_with_registry(CBMArena *arena, const char *source, int if (!parser) return; ts_parser_set_language(parser, tree_sitter_python()); - tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)source_len); + tree = cbm_parse_source(parser, source, (uint32_t)source_len, (TSParseOptions){0}); owns_tree = true; if (!tree) { ts_parser_delete(parser); diff --git a/internal/cbm/lsp/rust_lsp.c b/internal/cbm/lsp/rust_lsp.c index 68fb444a6..44680cb69 100644 --- a/internal/cbm/lsp/rust_lsp.c +++ b/internal/cbm/lsp/rust_lsp.c @@ -6377,7 +6377,7 @@ void cbm_run_rust_lsp_cross_with_registry(CBMArena *arena, const char *source, i if (!parser) return; ts_parser_set_language(parser, tree_sitter_rust()); - tree = ts_parser_parse_string(parser, NULL, source, source_len); + tree = cbm_parse_source(parser, source, (uint32_t)source_len, (TSParseOptions){0}); owns_tree = true; if (!tree) { ts_parser_delete(parser); @@ -6412,7 +6412,7 @@ void cbm_run_rust_lsp_cross_with_manifest(CBMArena *arena, const char *source, i if (!parser) return; ts_parser_set_language(parser, tree_sitter_rust()); - tree = ts_parser_parse_string(parser, NULL, source, source_len); + tree = cbm_parse_source(parser, source, (uint32_t)source_len, (TSParseOptions){0}); owns_tree = true; if (!tree) { ts_parser_delete(parser); diff --git a/internal/cbm/lsp/ts_lsp.c b/internal/cbm/lsp/ts_lsp.c index 967d06762..c25a58bbe 100644 --- a/internal/cbm/lsp/ts_lsp.c +++ b/internal/cbm/lsp/ts_lsp.c @@ -5899,7 +5899,7 @@ void cbm_run_ts_lsp_cross_with_registry(CBMArena *arena, const char *source, int jsx_mode ? (js_mode ? tree_sitter_javascript() : tree_sitter_tsx()) : (js_mode ? tree_sitter_javascript() : tree_sitter_typescript()); ts_parser_set_language(parser, lang); - tree = ts_parser_parse_string(parser, NULL, source, source_len); + tree = cbm_parse_source(parser, source, (uint32_t)source_len, (TSParseOptions){0}); ts_parser_delete(parser); if (!tree) return; @@ -6110,7 +6110,7 @@ void cbm_run_ts_lsp_cross(CBMArena *arena, const char *source, int source_len, jsx_mode ? (js_mode ? tree_sitter_javascript() : tree_sitter_tsx()) : (js_mode ? tree_sitter_javascript() : tree_sitter_typescript()); ts_parser_set_language(parser, lang); - tree = ts_parser_parse_string(parser, NULL, source, source_len); + tree = cbm_parse_source(parser, source, (uint32_t)source_len, (TSParseOptions){0}); ts_parser_delete(parser); if (!tree) return; diff --git a/scripts/test.sh b/scripts/test.sh index 3a4072064..6731b5231 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -195,6 +195,18 @@ if [ "$SANITIZE_GIVEN" -eq 0 ] && [ "${MSYSTEM:-}" = "CLANGARM64" ]; then MAKE_ARGS+=("SANITIZE=-fsanitize=undefined -fsanitize-trap=undefined -fstack-protector-strong -fno-omit-frame-pointer") fi +# ASan stack-use-after-return runs in the test-diag lane only (_test.yml and +# `make -f Makefile.cbm diag` set detect_stack_use_after_return=1). gcc's +# libasan turns it ON by default, and on aarch64 gcc 13/14 never clear a freed +# fake frame's in-use flag: past 8,192 frames per size class every instrumented +# call scans that flag array and the check stops detecting anything (gcc arm64 +# ran `extraction` in 688 s, amd64 in 16 s). Off here; an explicit setting wins. +case "${ASAN_OPTIONS:-}" in + *detect_stack_use_after_return*) ;; + *) ASAN_OPTIONS="${ASAN_OPTIONS:+$ASAN_OPTIONS:}detect_stack_use_after_return=0" ;; +esac +export ASAN_OPTIONS + EXPECTED_SANITIZED=1 if [ "$SANITIZE_GIVEN" -eq 1 ]; then case "$SANITIZE_VALUE" in diff --git a/src/foundation/workspace.c b/src/foundation/workspace.c index 40e009a03..eab52d48f 100644 --- a/src/foundation/workspace.c +++ b/src/foundation/workspace.c @@ -776,6 +776,43 @@ bool cbm_workspace_manifest_is_approved(const char *cache_dir, const char *proje return found; } +/* Resolve a manifest entry to the directory it names — the form the policy is + * defined over. The file holds the entry as a person wrote it, and a spelling + * can reach a directory through a link or through `..` segments; the directory + * that would be indexed is the resolved one, so that is the one classified, at + * approval and again at use. Only an absolute entry is accepted: the manifest + * is read by a long-lived process whose working directory is unrelated to the + * project, so a relative spelling has no base it could honestly be resolved + * against. Returns NULL on success, otherwise the reason the entry is unusable. + * out must hold WS_LINE_MAX bytes. */ +static const char *ws_manifest_entry_resolve(const char *entry, char *out, size_t out_sz) { + if (ws_volume_prefix_len(entry) == 0) { + return "must be an absolute path"; + } + if (!cbm_canonical_path(entry, out, out_sz)) { + return "must name an existing directory"; + } + if (!cbm_is_dir(out)) { + return "is not a directory"; + } + return NULL; +} + +/* "requested path : ", with the resolved form shown when it + * differs from what was written: the person typed one and the policy judged + * the other. */ +static void ws_manifest_entry_refusal(char *err, size_t err_sz, const char *entry, + const char *resolved, const char *reason) { + if (!err || err_sz == 0) { + return; + } + if (resolved && strcmp(resolved, entry) != 0) { + snprintf(err, err_sz, "requested path %s (resolves to %s): %s", entry, resolved, reason); + } else { + snprintf(err, err_sz, "requested path %s: %s", entry, reason); + } +} + bool cbm_workspace_manifest_approve(const char *cache_dir, const char *home_dir, const char *project_root, char *err, size_t err_sz) { if (err && err_sz) { @@ -800,14 +837,19 @@ bool cbm_workspace_manifest_approve(const char *cache_dir, const char *home_dir, } /* Approving a manifest must not become a way around the breadth policy: every - * requested entry has to stand on its own as an indexing root. */ + * requested entry has to stand on its own as an indexing root, judged by the + * directory it resolves to. */ for (int i = 0; i < m.count; i++) { - cbm_ws_verdict_t v = cbm_workspace_classify_root(m.entries[i], home_dir, cache_dir); + char resolved[WS_LINE_MAX]; + const char *unusable = ws_manifest_entry_resolve(m.entries[i], resolved, sizeof(resolved)); + if (unusable) { + ws_manifest_entry_refusal(err, err_sz, m.entries[i], NULL, unusable); + return false; + } + cbm_ws_verdict_t v = cbm_workspace_classify_root(resolved, home_dir, cache_dir); if (v != CBM_WS_ALLOW) { - if (err) { - snprintf(err, err_sz, "requested path %s: %s", m.entries[i], - cbm_workspace_verdict_reason(v)); - } + ws_manifest_entry_refusal(err, err_sz, m.entries[i], resolved, + cbm_workspace_verdict_reason(v)); return false; } } @@ -850,12 +892,19 @@ bool cbm_workspace_manifest_allows(const char *cache_dir, const char *home_dir, return false; } for (int i = 0; i < m.count; i++) { - /* Re-classify at use time as well as at approval time: the credential list - * may have grown since, and a stored approval must not outrank it. */ - if (cbm_workspace_classify_root(m.entries[i], home_dir, cache_dir) != CBM_WS_ALLOW) { + /* Resolve and re-classify at use time as well as at approval time: the + * credential list may have grown since, and a link may lead somewhere + * else than it did when the person approved it. A stored approval must + * not outrank either. An entry that no longer resolves matches nothing, + * and the other entries of the manifest are consulted as before. */ + char resolved[WS_LINE_MAX]; + if (ws_manifest_entry_resolve(m.entries[i], resolved, sizeof(resolved)) != NULL) { continue; } - if (cbm_path_within_root(m.entries[i], candidate)) { + if (cbm_workspace_classify_root(resolved, home_dir, cache_dir) != CBM_WS_ALLOW) { + continue; + } + if (cbm_path_within_root(resolved, candidate)) { return true; } } diff --git a/src/foundation/workspace.h b/src/foundation/workspace.h index 276079155..c4674696d 100644 --- a/src/foundation/workspace.h +++ b/src/foundation/workspace.h @@ -145,12 +145,19 @@ bool cbm_workspace_manifest_read(const char *project_root, cbm_ws_manifest_t *ou bool cbm_workspace_manifest_is_approved(const char *cache_dir, const char *project_root, const cbm_ws_manifest_t *manifest); -/* Record approval for the manifest currently on disk. Refuses when a requested - * entry would not be allowable as a root on its own — approving a manifest must - * not become a way around the breadth policy. */ +/* Record approval for the manifest currently on disk. Each entry is judged in + * its resolved form (links followed, `..` collapsed), since that is the + * directory that would be indexed; an entry that is relative, does not exist + * or is not a directory is refused, as is one that would not be allowable as a + * root on its own — approving a manifest must not become a way around the + * breadth policy. err names the entry as written and, when it differs, the + * directory it resolves to. */ bool cbm_workspace_manifest_approve(const char *cache_dir, const char *home_dir, const char *project_root, char *err, size_t err_sz); -/* True when candidate is at or below an APPROVED manifest entry of project_root. */ +/* True when candidate is at or below an APPROVED manifest entry of project_root. + * Entries are resolved and classified again here: approval binds to the + * manifest text, and where an entry leads is read at the time it is used. An + * entry that no longer resolves matches nothing. */ bool cbm_workspace_manifest_allows(const char *cache_dir, const char *home_dir, const char *project_root, const char *candidate); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 6c9762741..4c50bd472 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -6940,6 +6940,7 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { } add_coverage_report(doc, root, store, project, have_proj_info ? proj_info.indexed_at : NULL, coverage_samples); + (void)cbm_cross_repo_add_status_json(doc, root, store, project); safe_str_free(&proj_info.name); safe_str_free(&proj_info.indexed_at); safe_str_free(&proj_info.root_path); @@ -10109,6 +10110,7 @@ static char *handle_cross_repo_mode(cbm_mcp_server_t *srv, const char *repo_path yyjson_doc_free(jdoc); if (result.no_targets || result.failed) { + cbm_cross_repo_result_free(&result); free(project); return cbm_mcp_text_result( result.no_targets @@ -10146,9 +10148,23 @@ static char *handle_cross_repo_mode(cbm_mcp_server_t *srv, const char *repo_path yyjson_mut_obj_add_int(doc, root, "cross_trpc_calls", result.trpc_edges); yyjson_mut_obj_add_int(doc, root, "total_cross_edges", total); yyjson_mut_obj_add_real(doc, root, "elapsed_ms", result.elapsed_ms); + /* ["*"] skips stores it cannot link (#2133); name them so a partial fleet + * is visible instead of silently smaller. Only emitted when non-empty. */ + if (result.skipped_count > 0) { + yyjson_mut_val *skipped = yyjson_mut_arr(doc); + for (int i = 0; i < result.skipped_count; i++) { + yyjson_mut_val *item = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, item, "name", result.skipped_projects[i].project); + yyjson_mut_obj_add_str(doc, item, "reason", result.skipped_projects[i].reason); + yyjson_mut_obj_add_str(doc, item, "hint", "reindex this project"); + yyjson_mut_arr_append(skipped, item); + } + yyjson_mut_obj_add_val(doc, root, "skipped_projects", skipped); + } char *json = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); + cbm_cross_repo_result_free(&result); free(project); char *out = cbm_mcp_text_result(json, result.cancelled); free(json); diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index a85ad7dca..0d3fdf1cd 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -408,8 +408,9 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca esc_fa); cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, route_id, "CALLS", props); if (call->second_arg_name != NULL && call->second_arg_name[0] != '\0') { - cbm_resolution_t hres = cbm_registry_resolve(ctx->registry, call->second_arg_name, - module_qn, imp_keys, imp_vals, imp_count); + cbm_resolution_t hres = + cbm_registry_resolve_handler(ctx->registry, call->second_arg_name, module_qn, imp_keys, + imp_vals, imp_count, ctx->gbuf); if (hres.qualified_name != NULL && hres.qualified_name[0] != '\0') { const cbm_gbuf_node_t *handler = cbm_gbuf_find_by_qn(ctx->gbuf, hres.qualified_name); if (handler != NULL) { diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index b86d8cee4..09384a8e9 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -13,12 +13,14 @@ #include "pipeline/pipeline_internal.h" // cbm_route_canon_path #include "foundation/constants.h" #include "foundation/log.h" +#include "foundation/mem_core.h" #include "foundation/platform.h" #include "foundation/compat.h" #include "foundation/compat_fs.h" #include "foundation/str_util.h" #include +#include #include #include #include @@ -153,27 +155,38 @@ static bool cr_store_has_exact_project(cbm_store_t *store, const char *project) return matches; } -/* True when `project` is a usable cross-repo input: its store holds exactly - * that project AND carries the schema the read-write open in +typedef enum { + CR_PROJECT_USABLE = 0, + CR_PROJECT_MISSING, /* no store, or the store is not exactly this project */ + CR_PROJECT_LEGACY, /* exact project, but a pre-#768 schema (reindex required) */ +} cr_project_state_t; + +/* Classify `project` as a cross-repo input: usable when its store holds + * exactly that project AND carries the schema the read-write open in * cr_open_existing_project requires. Checking only the first let a pre-#768 * store (still readable, so list_projects shows it) pass validation and the * ["*"] enumeration, then abort the whole run at its write open — after the * source's previous CROSS_* generation had already been deleted. The probe is * read-only, so the legacy store is left untouched for its reindex. (#2133) */ -static bool cr_project_exists(const char *project) { +static cr_project_state_t cr_project_state(const char *project) { char path[CR_PATH_BUF]; if (!cr_db_path(project, path, sizeof(path))) { - return false; + return CR_PROJECT_MISSING; } cbm_store_t *store = cbm_store_open_path_query(path); - bool exists = cr_store_has_exact_project(store, project); - if (exists && !cbm_store_edges_schema_current(store)) { + cr_project_state_t state = + cr_store_has_exact_project(store, project) ? CR_PROJECT_USABLE : CR_PROJECT_MISSING; + if (state == CR_PROJECT_USABLE && !cbm_store_edges_schema_current(store)) { cbm_log_warn("cross_repo.project_unusable", "project", project, "reason", "pre_768_schema_reindex_required"); - exists = false; + state = CR_PROJECT_LEGACY; } cbm_store_close(store); - return exists; + return state; +} + +static bool cr_project_exists(const char *project) { + return cr_project_state(project) == CR_PROJECT_USABLE; } static cbm_store_t *cr_open_existing_project(const char *project) { @@ -1139,8 +1152,33 @@ static cr_match_result_t match_typed_routes(cbm_store_t *src_store, const char * static void free_project_list(char **projects, int count); +/* Remember a store the ["*"] enumeration skipped, so the caller can name it + * instead of the run silently covering fewer projects than exist. */ +static bool cr_record_skip(cbm_cross_repo_result_t *result, const char *project, + const char *reason) { + if (!result) { + return true; + } + cbm_cross_repo_skip_t *grown = + cbm_realloc(CBM_MEM_CLASS_OTHER, result->skipped_projects, + ((size_t)result->skipped_count + 1U) * sizeof(*result->skipped_projects)); + if (!grown) { + return false; + } + result->skipped_projects = grown; + char *name = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, project); + if (!name) { + return false; + } + grown[result->skipped_count].project = name; + grown[result->skipped_count].reason = reason; + result->skipped_count++; + return true; +} + /* When target_projects = ["*"], scan the cache directory for all .db files. */ -static int collect_all_projects(char ***out, cr_run_context_t *ctx) { +static int collect_all_projects(char ***out, cr_run_context_t *ctx, + cbm_cross_repo_result_t *result) { *out = NULL; const char *dir = cr_cache_dir(); cbm_dir_t *d = cbm_opendir(dir); @@ -1188,7 +1226,15 @@ static int collect_all_projects(char ***out, cr_run_context_t *ctx) { } memcpy(project, ent->name, project_length); project[project_length] = '\0'; - if (!cbm_validate_project_name(project) || !cr_project_exists(project)) { + if (!cbm_validate_project_name(project)) { + continue; + } + cr_project_state_t state = cr_project_state(project); + if (state == CR_PROJECT_LEGACY && !cr_record_skip(result, project, "pre_768_schema")) { + failed = true; + break; + } + if (state != CR_PROJECT_USABLE) { continue; } if (count >= cap) { @@ -1273,6 +1319,106 @@ static int collect_named_projects(const char **targets, int target_count, char * return unique_count; } +/* ── Last-run marker ─────────────────────────────────────────────── */ + +static bool cr_status_key(const char *project, char *buf, size_t bufsz) { + int written = snprintf(buf, bufsz, "cross_repo_last_run:%s", project); + return written > 0 && (size_t)written < bufsz; +} + +/* Record this run in the SOURCE store's store_meta, next to the CROSS_* + * edges it just rewrote: a reindex that rebuilds the store drops both + * together, so the marker can never outlive the links it describes. Written + * once the source's previous CROSS_* generation was cleaned, whatever the + * outcome, because from then on the old summary no longer matches the graph. + * Project names are validated ([A-Za-z0-9._-]) or "*", so no JSON escaping is + * needed. Best-effort: a marker failure is logged, never fails the run. */ +static void cr_record_last_run(cbm_store_t *src_store, const char *project, const char **targets, + int target_count, const cbm_cross_repo_result_t *result) { + char key[CR_QN_BUF]; + if (!cr_status_key(project, key, sizeof(key))) { + return; + } + size_t cap = CR_PROPS_BUF; + for (int i = 0; i < target_count; i++) { + cap += (targets[i] ? strlen(targets[i]) : 0) + CR_COL_4; + } + char *json = cbm_alloc(CBM_MEM_CLASS_OTHER, cap); + if (!json) { + return; + } + char when[CBM_SZ_32]; + time_t now = time(NULL); + struct tm tm_now; + if (!cbm_gmtime_r(&now, &tm_now) || + strftime(when, sizeof(when), "%Y-%m-%dT%H:%M:%SZ", &tm_now) == 0) { + when[0] = '\0'; + } + const char *outcome = result->failed ? "failed" : result->cancelled ? "cancelled" : "complete"; + size_t len = (size_t)snprintf( + json, cap, "{\"last_run_at\":\"%s\",\"outcome\":\"%s\",\"targets\":[", when, outcome); + for (int i = 0; i < target_count && len < cap; i++) { + len += (size_t)snprintf(json + len, cap - len, "%s\"%s\"", i ? "," : "", + targets[i] ? targets[i] : ""); + } + if (len < cap) { + int total = result->http_edges + result->async_edges + result->channel_edges + + result->grpc_edges + result->graphql_edges + result->trpc_edges; + len += (size_t)snprintf(json + len, cap - len, + "],\"projects_scanned\":%d,\"total_cross_edges\":%d," + "\"skipped_projects\":%d}", + result->projects_scanned, total, result->skipped_count); + } + if (len >= cap || cbm_store_meta_put(src_store, key, json) != CBM_STORE_OK) { + cbm_log_warn("cross_repo.status_unrecorded", "project", project); + } + cbm_free(CBM_MEM_CLASS_OTHER, json); +} + +bool cbm_cross_repo_add_status_json(struct yyjson_mut_doc *doc, struct yyjson_mut_val *parent, + cbm_store_t *store, const char *project) { + char key[CR_QN_BUF]; + if (!doc || !parent || !store || !project || !cr_status_key(project, key, sizeof(key))) { + return false; + } + char *raw = NULL; + int rc = cbm_store_meta_get(store, key, &raw); + yyjson_doc *recorded = rc == CBM_STORE_OK ? yyjson_read(raw, strlen(raw), 0) : NULL; + cbm_free(CBM_MEM_CLASS_STORE, raw); + yyjson_val *recorded_root = recorded ? yyjson_doc_get_root(recorded) : NULL; + yyjson_mut_val *status = NULL; + if (yyjson_is_obj(recorded_root)) { + status = yyjson_val_mut_copy(doc, recorded_root); + } + if (!status) { + status = yyjson_mut_obj(doc); + } + yyjson_doc_free(recorded); + if (!status) { + return false; + } + /* "never_run" is the honest reading of an absent marker: no run with this + * project as the source has completed its cleanup since the store was + * (re)built. A target-side run still leaves its CROSS_* edges here. */ + const char *state = rc == CBM_STORE_NOT_FOUND ? "never_run" + : yyjson_mut_obj_size(status) > 0 ? "ran" + : "unavailable"; + yyjson_mut_obj_add_str(doc, status, "status", state); + return yyjson_mut_obj_add_val(doc, parent, "cross_repo", status); +} + +void cbm_cross_repo_result_free(cbm_cross_repo_result_t *result) { + if (!result) { + return; + } + for (int i = 0; i < result->skipped_count; i++) { + cbm_free(CBM_MEM_CLASS_OTHER, result->skipped_projects[i].project); + } + cbm_free(CBM_MEM_CLASS_OTHER, result->skipped_projects); + result->skipped_projects = NULL; + result->skipped_count = 0; +} + static cr_run_status_t add_match_count(int *total, cr_match_result_t matched) { *total += matched.count; return matched.status; @@ -1304,7 +1450,7 @@ cbm_cross_repo_result_t cbm_cross_repo_match_cancellable(const char *project, int resolved_count = 0; if (target_count == SKIP_ONE && target_projects[0] && strcmp(target_projects[0], "*") == 0) { - resolved_count = collect_all_projects(&resolved, &run); + resolved_count = collect_all_projects(&resolved, &run, &result); } else { resolved_count = collect_named_projects(target_projects, target_count, &resolved, &run); } @@ -1442,6 +1588,7 @@ cbm_cross_repo_result_t cbm_cross_repo_match_cancellable(const char *project, result.projects_scanned++; } + cr_record_last_run(src_store, project, target_projects, target_count, &result); cbm_store_close(src_store); free_project_list(resolved, resolved_count); diff --git a/src/pipeline/pass_cross_repo.h b/src/pipeline/pass_cross_repo.h index 898283c3e..85c832b2d 100644 --- a/src/pipeline/pass_cross_repo.h +++ b/src/pipeline/pass_cross_repo.h @@ -9,6 +9,12 @@ #include +/* A store the ["*"] enumeration skipped instead of linking. */ +typedef struct { + char *project; /* heap-owned */ + const char *reason; /* static, e.g. "pre_768_schema" (reindex required) */ +} cbm_cross_repo_skip_t; + /* Result of a cross-repo matching run. */ typedef struct { int http_edges; /* CROSS_HTTP_CALLS edges created */ @@ -23,8 +29,26 @@ typedef struct { bool no_targets; /* failed: nothing but the source project resolved (#1133) */ bool cancelled; /* stopped at a bounded cancellation checkpoint */ bool partial_results; /* committed writes before cancellation were retained */ + cbm_cross_repo_skip_t *skipped_projects; /* ["*"] only; free with ..._result_free */ + int skipped_count; } cbm_cross_repo_result_t; +/* Release the heap-owned parts of a result (safe on a zeroed result). */ +void cbm_cross_repo_result_free(cbm_cross_repo_result_t *result); + +struct yyjson_mut_doc; +struct yyjson_mut_val; + +/* Add a "cross_repo" object to `parent` describing the last cross-repo run + * with `project` as the source, as recorded in that project's store: + * {"status":"never_run"} -- no run recorded since the store was (re)built + * {"status":"ran","last_run_at","outcome","targets","projects_scanned", + * "total_cross_edges","skipped_projects"} + * Read-only (works on a query store). Returns false only on invalid input or + * allocation failure. */ +bool cbm_cross_repo_add_status_json(struct yyjson_mut_doc *doc, struct yyjson_mut_val *parent, + cbm_store_t *store, const char *project); + /* Run cross-repo matching for `project` against `target_projects`. * If target_count == 1 and target_projects[0] == "*", matches against all * indexed projects. Writes CROSS_* edges bidirectionally into both the diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index e8e9ef131..02dc4638b 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2370,7 +2370,8 @@ static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou esc_rp); cbm_gbuf_insert_edge(gbuf, source->id, rid, "CALLS", props); if (handler_ref && handler_ref[0] != '\0') { - cbm_resolution_t hres = cbm_registry_resolve(registry, handler_ref, module_qn, ik, iv, ic); + cbm_resolution_t hres = + cbm_registry_resolve_handler(registry, handler_ref, module_qn, ik, iv, ic, main_gbuf); if (hres.qualified_name && hres.qualified_name[0] != '\0') { const cbm_gbuf_node_t *h = cbm_gbuf_find_by_qn(main_gbuf, hres.qualified_name); if (h) { diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index 9c4ace14b..c82cdf645 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -2060,10 +2060,10 @@ typedef enum { /* The File node at exactly `rel_path`, or NULL. Looked up through the name * index (File nodes are named by basename) and matched on the full path, so * a same-named file in another directory never qualifies. */ -static const cbm_gbuf_node_t *psr4_file_node(const cbm_pipeline_ctx_t *ctx, const char *rel_path) { +static const cbm_gbuf_node_t *psr4_file_node(const cbm_gbuf_t *gbuf, const char *rel_path) { const cbm_gbuf_node_t **hits = NULL; int hit_count = 0; - if (cbm_gbuf_find_by_name(ctx->gbuf, path_leaf(rel_path), &hits, &hit_count) != 0 || !hits) { + if (cbm_gbuf_find_by_name(gbuf, path_leaf(rel_path), &hits, &hit_count) != 0 || !hits) { return NULL; } for (int i = 0; i < hit_count; i++) { @@ -2084,9 +2084,8 @@ static const cbm_gbuf_node_t *psr4_file_node(const cbm_pipeline_ctx_t *ctx, cons * to the namespace bucket, which binds it to whichever file of the namespace * came first. `use function` / `use const` name namespace members, not class * files, so they are not applicable. */ -static psr4_outcome_t resolve_php_psr4_class(const cbm_pipeline_ctx_t *ctx, - const char *source_file_qn, const CBMImport *imp, - const cbm_gbuf_node_t **out) { +static psr4_outcome_t resolve_php_psr4_class(const cbm_gbuf_t *gbuf, const char *source_file_qn, + const CBMImport *imp, const cbm_gbuf_node_t **out) { *out = NULL; CBMHashTable *pkgmap = cbm_pipeline_get_pkgmap(); if (!pkgmap || imp->kind != CBM_IMPORT_KIND_DEFAULT) { @@ -2124,7 +2123,7 @@ static psr4_outcome_t resolve_php_psr4_class(const cbm_pipeline_ctx_t *ctx, *c = '/'; } } - const cbm_gbuf_node_t *file = psr4_file_node(ctx, rel); + const cbm_gbuf_node_t *file = psr4_file_node(gbuf, rel); if (file && (!source_file_qn || !file->qualified_name || strcmp(file->qualified_name, source_file_qn) != 0)) { *out = file; @@ -2134,6 +2133,60 @@ static psr4_outcome_t resolve_php_psr4_class(const cbm_pipeline_ctx_t *ctx, return covered ? PSR4_UNRESOLVED : PSR4_NOT_APPLICABLE; } +static bool psr4_class_label(const char *label) { + return label && (strcmp(label, "Class") == 0 || strcmp(label, "Interface") == 0 || + strcmp(label, "Trait") == 0 || strcmp(label, "Enum") == 0); +} + +/* The QN of `member` on the class `cls` defined in `file_path`, or NULL when + * that file defines no such class or the class has no such member. */ +static const char *psr4_member_in_file(const cbm_gbuf_t *gbuf, const char *file_path, + const char *cls, const char *member) { + const cbm_gbuf_node_t **hits = NULL; + int hit_count = 0; + if (cbm_gbuf_find_by_name(gbuf, cls, &hits, &hit_count) != 0 || !hits) { + return NULL; + } + for (int i = 0; i < hit_count; i++) { + const cbm_gbuf_node_t *c = hits[i]; + if (!c || !psr4_class_label(c->label) || !c->qualified_name || !c->file_path || + strcmp(c->file_path, file_path) != 0) { + continue; + } + char qn[CBM_SZ_1K]; + int w = snprintf(qn, sizeof(qn), "%s.%s", c->qualified_name, member); + if (w <= 0 || (size_t)w >= sizeof(qn)) { + return NULL; + } + const cbm_gbuf_node_t *m = cbm_gbuf_find_by_qn(gbuf, qn); + return m ? m->qualified_name : NULL; + } + return NULL; +} + +cbm_psr4_member_t cbm_pipeline_psr4_member_qn(const cbm_gbuf_t *gbuf, const char *class_fqn, + const char *member, const char **out_qn) { + *out_qn = NULL; + if (!gbuf || !class_fqn || !member || !member[0]) { + return CBM_PSR4_MEMBER_NOT_COVERED; + } + const CBMImport imp = {.module_path = class_fqn, .kind = CBM_IMPORT_KIND_DEFAULT}; + const cbm_gbuf_node_t *file = NULL; + psr4_outcome_t placed = resolve_php_psr4_class(gbuf, NULL, &imp, &file); + if (placed == PSR4_NOT_APPLICABLE) { + return CBM_PSR4_MEMBER_NOT_COVERED; + } + /* Covered from here on: composer would load this file or none, so a + * missing file, class or member leaves the member unresolved. */ + if (placed != PSR4_RESOLVED || !file->file_path) { + return CBM_PSR4_MEMBER_UNRESOLVED; + } + const char *cls = strrchr(class_fqn, '\\'); + cls = cls ? cls + SKIP_ONE : class_fqn; + *out_qn = psr4_member_in_file(gbuf, file->file_path, cls, member); + return *out_qn ? CBM_PSR4_MEMBER_RESOLVED : CBM_PSR4_MEMBER_UNRESOLVED; +} + const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t *ctx, const char *source_rel, const char *source_file_qn, @@ -2154,7 +2207,7 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t /* PHP class imports covered by a composer psr-4 prefix name exactly one * file; when it is absent the import stays unresolved (#1186). */ const cbm_gbuf_node_t *psr4_target = NULL; - switch (resolve_php_psr4_class(ctx, source_file_qn, imp, &psr4_target)) { + switch (resolve_php_psr4_class(ctx->gbuf, source_file_qn, imp, &psr4_target)) { case PSR4_RESOLVED: return psr4_target; case PSR4_UNRESOLVED: diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 4e90c1c72..896ba4e6a 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -552,6 +552,44 @@ static inline int cbm_pipeline_relpath_is_excluded(const char *rel_path, char *c CBMHashTable *cbm_pipeline_get_pkgmap(void); void cbm_pipeline_set_pkgmap(CBMHashTable *map); +/* Resolve a route handler reference (the registry's answer for route passes). + * A class-qualified "Ns\\Class::method" reference — the extractor's form for + * Laravel `[Class::class, 'method']` and invokable `Class::class` handlers + * (#1146), with the class already qualified through the file's `use` imports + * and namespace — is placed on a method QN only by what places the class file: + * the composer.json PSR-4 class file in gbuf (cbm_pipeline_psr4_member_qn). + * When a PSR-4 prefix covers the class, that answer is final: a covered class + * whose file or member is absent stays unresolved, as its `use` import does + * (#1186). Only a class no prefix covers falls back to the one candidate whose + * directories end with the class's namespace. Never by short name alone, so a + * vendor controller that is not in the repo gets no handler. A reference + * without "::" resolves exactly as cbm_registry_resolve does. */ +cbm_resolution_t cbm_registry_resolve_handler(const cbm_registry_t *r, const char *handler_ref, + const char *module_qn, const char **import_map_keys, + const char **import_map_vals, int import_map_count, + const cbm_gbuf_t *gbuf); + +/* Where composer's PSR-4 autoloader places a PHP class member. Tri-state, so + * "no prefix covers the class" (another strategy may answer) is never + * confused with "a prefix covers it but places nothing" (final). */ +typedef enum { + CBM_PSR4_MEMBER_NOT_COVERED = 0, /* no psr-4 prefix covers the class */ + CBM_PSR4_MEMBER_RESOLVED, /* *out_qn is the member's QN */ + CBM_PSR4_MEMBER_UNRESOLVED, /* covered, but no class file holds the + * class with this member: leave it + * unresolved, never guess elsewhere */ +} cbm_psr4_member_t; + +/* Place `member` on the PHP class `class_fqn` ("Ns\\Class", NUL-terminated) + * the way composer's PSR-4 autoloader places the class: the class file the + * import resolver picks for `use Ns\\Class;` (#1186), the class that file + * defines, then that class's member. *out_qn is set only on + * CBM_PSR4_MEMBER_RESOLVED (else NULL) and is owned by gbuf. Missing + * arguments or no package map give CBM_PSR4_MEMBER_NOT_COVERED. Read-only on + * gbuf. */ +cbm_psr4_member_t cbm_pipeline_psr4_member_qn(const cbm_gbuf_t *gbuf, const char *class_fqn, + const char *member, const char **out_qn); + /* Unified module resolver: relative → pkgmap → fqn_module fallback. * Handles bare specifiers via pkgmap lookup with prefix matching. * Caller must free() the returned string. */ diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 6860784fb..1701f4118 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -25,6 +25,7 @@ enum { REG_MAX_CANDIDATES = 256 }; #define DEFAULT_CONFIDENCE 0.5 #include "pipeline/pipeline.h" +#include "pipeline/pipeline_internal.h" #include "cbm.h" /* cbm_label_is_relation — the resolve-time relation veto */ #include "foundation/compat.h" /* CBM_TLS */ #include "foundation/hash_table.h" @@ -1770,6 +1771,159 @@ cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *calle return res; } +/* ── Class-qualified route handlers (PHP, #1146) ─────────────────── */ + +/* Method QNs are path-based: ..... + * A PHP class is named by its namespace instead, so a "Ns\\Class::method" + * handler is placed on a QN by what places the class file itself. */ +#define CONF_PSR4_MEMBER 0.95 +#define CONF_NAMESPACE_PATH_MEMBER 0.90 + +static bool seg_eq_nocase(const char *a, size_t alen, const char *b, size_t blen) { + if (alen != blen) { + return false; + } + for (size_t i = 0; i < alen; i++) { + char ca = (char)((a[i] >= 'A' && a[i] <= 'Z') ? a[i] - 'A' + 'a' : a[i]); + char cb = (char)((b[i] >= 'A' && b[i] <= 'Z') ? b[i] - 'A' + 'a' : b[i]); + if (ca != cb) { + return false; + } + } + return true; +} + +/* Does qn end with ".."? Returns the offset of that tail or -1. */ +static int qn_class_member_tail(const char *qn, const char *cls, size_t cls_len, + const char *member) { + size_t qlen = strlen(qn); + size_t mlen = strlen(member); + if (qlen < cls_len + mlen + PAIR_LEN) { + return CBM_NOT_FOUND; + } + const char *m = qn + qlen - mlen; + const char *c = m - SKIP_ONE - cls_len; + if (strcmp(m, member) != 0 || m[-SKIP_ONE] != '.' || strncmp(c, cls, cls_len) != 0 || + c[-SKIP_ONE] != '.') { + return CBM_NOT_FOUND; + } + return (int)(c - SKIP_ONE - qn); +} + +/* PSR-4: the class file Composer's autoloader loads for the FQN, the class it + * defines, and that class's member — through the PSR-4 import resolver + * (cbm_pipeline_psr4_member_qn), so a root whose folder does not mirror its + * namespace (Acme\\Blog\\ -> packages/blog/src) places the class as well. + * Returns its tri-state outcome; *out_qn is the member QN when RESOLVED. */ +static cbm_psr4_member_t psr4_member_qn(const cbm_gbuf_t *gbuf, const char *fqn, size_t fqn_len, + const char *member, const char **out_qn) { + char cls_fqn[CBM_SZ_512]; + *out_qn = NULL; + if (!gbuf || fqn_len >= sizeof(cls_fqn)) { + return CBM_PSR4_MEMBER_NOT_COVERED; + } + memcpy(cls_fqn, fqn, fqn_len); + cls_fqn[fqn_len] = '\0'; + return cbm_pipeline_psr4_member_qn(gbuf, cls_fqn, member, out_qn); +} + +/* Do the directory segments of qn (between the project root segment and the + * file stem, i.e. before offset tail = "..") end with every + * segment of the namespace fqn[0, ns_len)? Case-insensitive: App\\Http is + * conventionally app/Http. An empty namespace is trivially aligned. */ +static bool namespace_matches_dirs(const char *qn, int tail, const char *fqn, size_t ns_len) { + const char *root_dot = strchr(qn, '.'); + const char *stem_end = qn + tail; + if (!root_dot || stem_end <= root_dot) { + return false; + } + const char *dir_end = stem_end - SKIP_ONE; /* back over the file stem */ + while (*dir_end != '.') { + dir_end--; + } + size_t ns_end = ns_len; + while (ns_end > 0) { + size_t ns_start = ns_end; + while (ns_start > 0 && fqn[ns_start - SKIP_ONE] != '\\') { + ns_start--; + } + if (dir_end <= root_dot) { + return false; /* more namespace segments than directories */ + } + const char *dir_start = dir_end; + while (dir_start[-SKIP_ONE] != '.') { + dir_start--; + } + if (!seg_eq_nocase(fqn + ns_start, ns_end - ns_start, dir_start, + (size_t)(dir_end - dir_start))) { + return false; + } + dir_end = dir_start - SKIP_ONE; + ns_end = ns_start > 0 ? ns_start - SKIP_ONE : 0; + } + return true; +} + +/* Namespace/path alignment: the one "." candidate that sits + * where its namespace says. NULL when none or several do. */ +static const char *aligned_member_qn(const cbm_registry_t *r, const char *fqn, size_t ns_len, + const char *cls, size_t cls_len, const char *member) { + const qn_array_t *arr = cbm_ht_get(r->by_name, member); + const char *match = NULL; + for (int i = 0; arr && i < arr->count; i++) { + const char *qn = arr->items[i]; + int tail = qn_class_member_tail(qn, cls, cls_len, member); + if (tail == CBM_NOT_FOUND || !namespace_matches_dirs(qn, tail, fqn, ns_len)) { + continue; + } + if (match) { + return NULL; /* ambiguous */ + } + match = qn; + } + return match; +} + +cbm_resolution_t cbm_registry_resolve_handler(const cbm_registry_t *r, const char *handler_ref, + const char *module_qn, const char **import_map_keys, + const char **import_map_vals, int import_map_count, + const cbm_gbuf_t *gbuf) { + const char *sep = handler_ref ? strstr(handler_ref, "::") : NULL; + if (!r || !sep) { + return cbm_registry_resolve(r, handler_ref, module_qn, import_map_keys, import_map_vals, + import_map_count); + } + const char *member = sep + PAIR_LEN; + size_t fqn_len = (size_t)(sep - handler_ref); + size_t ns_len = fqn_len; + while (ns_len > 0 && handler_ref[ns_len - SKIP_ONE] != '\\') { + ns_len--; + } + const char *cls = handler_ref + ns_len; + size_t cls_len = fqn_len - ns_len; + ns_len = ns_len > 0 ? ns_len - SKIP_ONE : 0; /* drop the separator before the class */ + if (cls_len == 0 || !member[0]) { + return empty_result(); + } + const char *qn = NULL; + switch (psr4_member_qn(gbuf, handler_ref, fqn_len, member, &qn)) { + case CBM_PSR4_MEMBER_RESOLVED: + return (cbm_resolution_t){qn, "php_psr4", CONF_PSR4_MEMBER, REG_RESOLVED}; + case CBM_PSR4_MEMBER_UNRESOLVED: + /* A covered class lives where PSR-4 says or nowhere; a namespace- + * mirroring folder elsewhere would be a guessed edge (#1186). */ + return empty_result(); + case CBM_PSR4_MEMBER_NOT_COVERED: + break; + } + qn = aligned_member_qn(r, handler_ref, ns_len, cls, cls_len, member); + if (qn) { + return (cbm_resolution_t){qn, "php_namespace_path", CONF_NAMESPACE_PATH_MEMBER, + REG_RESOLVED}; + } + return empty_result(); +} + cbm_resolution_t cbm_registry_resolve_lineage(const cbm_registry_t *r, const char *callee_name, const char *module_qn, const char **import_map_keys, const char **import_map_vals, int import_map_count) { diff --git a/src/store/store.c b/src/store/store.c index 02715e29d..bcb813034 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -2734,6 +2734,77 @@ int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz) { return CBM_STORE_OK; } +int cbm_store_meta_get(cbm_store_t *s, const char *key, char **out) { + if (!out) { + return CBM_STORE_ERR; + } + *out = NULL; + if (!s || !s->db || !key) { + return CBM_STORE_ERR; + } + sqlite3_stmt *get = NULL; + /* A store that never advanced its generation has no store_meta table: + * that is "no value recorded", not an error. */ + if (sqlite3_prepare_v2(s->db, "SELECT v FROM store_meta WHERE k = ?1;", CBM_NOT_FOUND, &get, + NULL) != SQLITE_OK) { + sqlite3_finalize(get); + return CBM_STORE_NOT_FOUND; + } + bind_text(get, SKIP_ONE, key); + int rc = sqlite3_step(get); + if (rc == SQLITE_ROW) { + const char *value = (const char *)sqlite3_column_text(get, 0); + *out = cbm_mem_strdup(CBM_MEM_CLASS_STORE, value ? value : ""); + sqlite3_finalize(get); + return *out ? CBM_STORE_OK : CBM_STORE_ERR; + } + sqlite3_finalize(get); + return rc == SQLITE_DONE ? CBM_STORE_NOT_FOUND : CBM_STORE_ERR; +} + +int cbm_store_meta_put(cbm_store_t *s, const char *key, const char *value) { + static const char savepoint[] = "SAVEPOINT cbm_meta_put;"; + static const char rollback[] = "ROLLBACK TO cbm_meta_put;"; + static const char release[] = "RELEASE cbm_meta_put;"; + if (!s || !s->db || !key || !value) { + return CBM_STORE_ERR; + } + if (exec_sql(s, savepoint) != CBM_STORE_OK) { + return CBM_STORE_ERR; + } + /* store_meta is only ever created by the generation seed, which writes + * db_uid + mutation_gen with it. Creating the table here without them + * would make cbm_store_generation() report malformed metadata, so an + * absent table is seeded through that one path. */ + bool absent = false; + char uid[17] = {0}; + char counter[21] = {0}; + uint64_t counter_value = 0; + if (generation_metadata_read(s, &absent, uid, counter, &counter_value) != CBM_STORE_OK || + (absent && cbm_store_generation_advance(s) != CBM_STORE_OK)) { + return rollback_savepoint_preserving_error(s, rollback, release); + } + sqlite3_stmt *set = NULL; + if (sqlite3_prepare_v2(s->db, "INSERT OR REPLACE INTO store_meta (k, v) VALUES (?1, ?2);", + CBM_NOT_FOUND, &set, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "store_meta put prepare"); + sqlite3_finalize(set); + return rollback_savepoint_preserving_error(s, rollback, release); + } + bind_text(set, SKIP_ONE, key); + bind_text(set, ST_COL_2, value); + int rc = sqlite3_step(set); + sqlite3_finalize(set); + if (rc != SQLITE_DONE) { + store_set_error_sqlite(s, "store_meta put"); + return rollback_savepoint_preserving_error(s, rollback, release); + } + if (exec_sql(s, release) != CBM_STORE_OK) { + return rollback_savepoint_preserving_error(s, rollback, release); + } + return CBM_STORE_OK; +} + int cbm_store_get_project(cbm_store_t *s, const char *name, cbm_project_t *out) { sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_get_project, diff --git a/src/store/store.h b/src/store/store.h index fd690b5da..3cea85b90 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -523,6 +523,17 @@ int cbm_store_generation_advance(cbm_store_t *s); * an error. */ int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz); +/* Per-store feature metadata in store_meta (key/value text). Keys are + * namespaced by the owning feature, e.g. "cross_repo_last_run:". + * get: CBM_STORE_OK with *out owned by the caller (release with + * cbm_free(CBM_MEM_CLASS_STORE, *out)), or + * CBM_STORE_NOT_FOUND when the key -- or store_meta itself -- is absent. + * Read-only: safe on a store opened for query. + * put: upserts the key; seeds store_meta through the generation path when a + * legacy store lacks it. Atomic (savepoint); needs a read-write store. */ +int cbm_store_meta_get(cbm_store_t *s, const char *key, char **out); +int cbm_store_meta_put(cbm_store_t *s, const char *key, const char *value); + /* Seal a fully-written staging database before atomic publication. * Raises synchronous to FULL, requires an exclusive TRUNCATE checkpoint to * complete, then leaves the database in verified DELETE journal mode so the diff --git a/src/ui/http_server.c b/src/ui/http_server.c index 66f1affb8..3d4121f4d 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -19,6 +19,7 @@ #include "ui/layout3d.h" #include "mcp/mcp.h" #include "store/store.h" +#include "pipeline/pass_cross_repo.h" #include "watcher/watcher.h" #include "cli/cli.h" #include "git/git_context.h" @@ -28,6 +29,7 @@ #endif /* pipeline.h no longer needed — indexing runs as subprocess */ #include "foundation/log.h" +#include "foundation/mem_core.h" #include "foundation/platform.h" #include "foundation/secure_random.h" #include "foundation/sha256.h" @@ -160,6 +162,17 @@ static void handle_ui_config(cbm_http_conn_t *c, const cbm_http_req_t *req) { /* ── Server state ─────────────────────────────────────────────── */ #define MAX_INDEX_JOBS 4 +/* Capacity of the buffer POST /api/index resolves the root into, and of the + * job field that buffer is copied to: the job carries the resolved string + * whole, so the path handed to the daemon is the one the route checked. */ +#define INDEX_JOB_ROOT_CAP 4096 +#define INDEX_JOB_ERROR_CAP 256 +/* GET /api/index-status sizes its body from the table: per entry the framing + * below ({"slot":N,"status":"indexing","path":"","error":""} and a separator, + * well under 64 bytes) plus both strings at cbm_json_escape's widest expansion, + * six bytes for a control character (\u00XX). */ +#define INDEX_STATUS_ENTRY_FRAMING 64 +#define JSON_ESCAPE_MAX_EXPANSION 6 enum { HTTP_RUN_IDLE = 0, @@ -170,10 +183,10 @@ enum { typedef struct { cbm_http_server_t *server; - char root_path[1024]; + char root_path[INDEX_JOB_ROOT_CAP]; /* resolved form, see handle_index_start */ char project_name[256]; atomic_int status; /* 0=idle, 1=running, 2=done, 3=error */ - char error_msg[256]; + char error_msg[INDEX_JOB_ERROR_CAP]; cbm_thread_t thread; bool thread_started; atomic_int completed; @@ -1182,7 +1195,7 @@ static void handle_index_start(cbm_http_server_t *server, cbm_http_conn_t *c, * it accepted roots the MCP path refused — an operator's boundary held on one * entry point and not the other. Canonicalize first: the policy is defined * over resolved paths, and a symlink would otherwise launder the verdict. */ - char canonical_root[4096]; + char canonical_root[INDEX_JOB_ROOT_CAP]; char boundary_err[1024]; if (!cbm_canonical_path(rpath, canonical_root, sizeof(canonical_root))) { yyjson_doc_free(doc); @@ -1198,6 +1211,15 @@ static void handle_index_start(cbm_http_server_t *server, cbm_http_conn_t *c, cbm_http_replyf(c, 403, g_cors_json, "{\"error\":\"%s\"}", escaped); return; } + /* The job carries exactly the string the checks above ran on. The field + * has the resolved buffer's capacity, so a resolved path always fits; the + * check keeps a resized field from ever handing the worker a cut-off path + * instead of the one that was checked. */ + if (strlen(canonical_root) >= INDEX_JOB_ROOT_CAP) { + yyjson_doc_free(doc); + cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"root_path too long\"}"); + return; + } /* Find free job slot */ int slot = -1; @@ -1226,7 +1248,7 @@ static void handle_index_start(cbm_http_server_t *server, cbm_http_conn_t *c, job->thread_started = false; } job->server = server; - snprintf(job->root_path, sizeof(job->root_path), "%s", rpath); + snprintf(job->root_path, sizeof(job->root_path), "%s", canonical_root); snprintf(job->project_name, sizeof(job->project_name), "%s", project_name); job->error_msg[0] = '\0'; atomic_store(&job->status, 1); @@ -1243,43 +1265,67 @@ static void handle_index_start(cbm_http_server_t *server, cbm_http_conn_t *c, } job->thread_started = true; + /* The reply names the stored root; escape it as the status listing does, + * or a quote or backslash in the path ends the JSON string early. */ + char esc_path[JSON_ESCAPE_MAX_EXPANSION * INDEX_JOB_ROOT_CAP]; + cbm_json_escape(esc_path, (int)sizeof(esc_path), job->root_path); cbm_http_replyf(c, 202, g_cors_json, "{\"status\":\"indexing\",\"slot\":%d,\"path\":\"%s\"}", - slot, job->root_path); + slot, esc_path); +} + +/* Append src JSON-escaped, under http_appendf's clamp contract: once *pos has + * reached bufsz nothing more is written. */ +static void http_append_json_escaped(char *buf, size_t bufsz, int *pos, const char *src) { + if (*pos < 0) { + return; + } + if ((size_t)*pos >= bufsz) { + *pos = (int)bufsz; + return; + } + *pos += cbm_json_escape(buf + *pos, (int)(bufsz - (size_t)*pos), src); } /* GET /api/index-status — returns status of all index jobs */ static void handle_index_status(cbm_http_server_t *server, cbm_http_conn_t *c) { - char buf[2048] = "["; - int pos = 1; + /* Size the body for a full table: every slot's framing plus its root and + * error message at the widest escape. A fixed 2 KB stack buffer used to + * clamp here, so one long resolved root (the field holds 4 KB) came back + * as a cut JSON body with status 200. The append helpers keep clamping, + * so this calculation is not the only thing keeping pos in range. */ + size_t buf_size = 2 + (size_t)MAX_INDEX_JOBS * (INDEX_STATUS_ENTRY_FRAMING + + JSON_ESCAPE_MAX_EXPANSION * + (INDEX_JOB_ROOT_CAP + INDEX_JOB_ERROR_CAP)); + char *buf = cbm_alloc(CBM_MEM_CLASS_OTHER, buf_size); + if (!buf) { + cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"oom\"}"); + return; + } + int pos = 0; + http_appendf(buf, buf_size, &pos, "["); for (int i = 0; i < MAX_INDEX_JOBS; i++) { int st = atomic_load(&server->index_jobs[i].status); if (st == 0) continue; if (pos > 1) - http_appendf(buf, sizeof(buf), &pos, ","); + http_appendf(buf, buf_size, &pos, ","); const char *ss = st == 1 ? "indexing" : st == 2 ? "done" : "error"; - /* root_path comes from POST /api/index and is up to 1023 bytes, so four - * occupied slots exceed this buffer. http_appendf pins pos to - * sizeof(buf) on truncation, so the separator and the close have to go - * through it as well rather than indexing raw. Both fields are free-form, - * so escape them — a quote in a path would otherwise end its JSON string - * early. */ - /* Escaping can double each byte: root_path is 1024, error_msg 256. */ - char esc_path[2048]; - char esc_error[512]; - cbm_json_escape(esc_path, (int)sizeof(esc_path), server->index_jobs[i].root_path); - cbm_json_escape(esc_error, (int)sizeof(esc_error), - st == 3 ? server->index_jobs[i].error_msg : ""); - http_appendf(buf, sizeof(buf), &pos, - "{\"slot\":%d,\"status\":\"%s\",\"path\":\"%s\",\"error\":\"%s\"}", i, ss, - esc_path, esc_error); + /* Both fields are free-form, so escape them — a quote in a path would + * otherwise end its JSON string early. */ + http_appendf(buf, buf_size, &pos, "{\"slot\":%d,\"status\":\"%s\",\"path\":\"", i, ss); + http_append_json_escaped(buf, buf_size, &pos, server->index_jobs[i].root_path); + http_appendf(buf, buf_size, &pos, "\",\"error\":\""); + http_append_json_escaped(buf, buf_size, &pos, + st == 3 ? server->index_jobs[i].error_msg : ""); + http_appendf(buf, buf_size, &pos, "\"}"); } - http_appendf(buf, sizeof(buf), &pos, "]"); - if ((size_t)pos >= sizeof(buf)) { - pos = (int)sizeof(buf) - 1; + http_appendf(buf, buf_size, &pos, "]"); + if ((size_t)pos >= buf_size) { + pos = (int)buf_size - 1; } buf[pos] = '\0'; - cbm_http_replyf(c, 200, g_cors_json, "%s", buf); + cbm_http_reply_buf(c, 200, g_cors_json, buf, (size_t)pos); + cbm_free(CBM_MEM_CLASS_OTHER, buf); } static void unwatch_project(cbm_http_server_t *srv, const char *name) { @@ -1729,6 +1775,9 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { free(linked[li]); } + /* Next to linked_projects: an empty list alone cannot tell "never linked" + * from "linked, nothing matched". */ + (void)cbm_cross_repo_add_status_json(mdoc, mroot, store, project); cbm_store_close(store); yyjson_mut_obj_add_val(mdoc, mroot, "linked_projects", lp_arr); diff --git a/test-infrastructure/Dockerfile.msan b/test-infrastructure/Dockerfile.msan index ce9b616d0..144abf279 100644 --- a/test-infrastructure/Dockerfile.msan +++ b/test-infrastructure/Dockerfile.msan @@ -51,17 +51,19 @@ RUN printf '%s\n' \ # The key is fetched with `wget -O ` rather than `-qO- > `: under # retry a redirect is opened once for all attempts, so a partial write from a # failed attempt would be prepended to the output of a later successful one. -RUN retry apt-get -o Acquire::Retries=3 update \ +# APT normally treats transient index-download failures as warnings and exits +# zero. Make every update fail on those errors so the outer retry can run. +RUN retry apt-get -o Acquire::Retries=3 --error-on=any update \ && retry apt-get -o Acquire::Retries=3 install -y --no-install-recommends wget gnupg ca-certificates \ && retry wget -q -O /etc/apt/trusted.gpg.d/apt.llvm.org.asc https://apt.llvm.org/llvm-snapshot.gpg.key \ && echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" > /etc/apt/sources.list.d/llvm-21.list \ - && retry apt-get -o Acquire::Retries=3 update \ + && retry apt-get -o Acquire::Retries=3 --error-on=any update \ && retry apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ clang-21 libclang-rt-21-dev llvm-21 \ && ln -sf /usr/bin/clang-21 /usr/bin/clang \ && ln -sf /usr/bin/clang++-21 /usr/bin/clang++ -RUN retry apt-get -o Acquire::Retries=3 update \ +RUN retry apt-get -o Acquire::Retries=3 --error-on=any update \ && retry apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ cmake \ ninja-build \ diff --git a/tests/repro/repro_issue523.c b/tests/repro/repro_issue523.c index 82d45b38d..14191e004 100644 --- a/tests/repro/repro_issue523.c +++ b/tests/repro/repro_issue523.c @@ -195,6 +195,7 @@ TEST(repro_issue523_crossrepo_http_calls_edge) { /* ── Cleanup ──────────────────────────────────────────────── */ rh_cleanup(&client, NULL); rh_cleanup(&server, NULL); + cbm_cross_repo_result_free(&result); /* * WHY RED: result.http_edges == 0 on current code. @@ -315,6 +316,7 @@ TEST(repro_issue523_scheme_stripped_url_match) { rh_cleanup(&client, NULL); rh_cleanup(&server, NULL); + cbm_cross_repo_result_free(&r); ASSERT_GTE(r.http_edges, 1); PASS(); } @@ -355,6 +357,7 @@ TEST(repro_issue523_template_fuzzy_match) { rh_cleanup(&client, NULL); rh_cleanup(&server, NULL); + cbm_cross_repo_result_free(&r); ASSERT_GTE(r.http_edges, 1); PASS(); } @@ -398,6 +401,7 @@ TEST(repro_issue523_reverse_direction_match) { rh_cleanup(&client, NULL); rh_cleanup(&server, NULL); + cbm_cross_repo_result_free(&r); ASSERT_GTE(r.http_edges, 1); ASSERT_GTE(client_cross, 1); PASS(); @@ -464,6 +468,9 @@ TEST(repro_issue523_idempotent_cross_edges) { rh_cleanup(&client, NULL); rh_cleanup(&server, NULL); + cbm_cross_repo_result_free(&r1); + cbm_cross_repo_result_free(&r2); + cbm_cross_repo_result_free(&r3); ASSERT_EQ(client_1, 1); ASSERT_EQ(server_1, 1); diff --git a/tests/test_cross_repo.c b/tests/test_cross_repo.c index cb4ab0607..23d74fb5b 100644 --- a/tests/test_cross_repo.c +++ b/tests/test_cross_repo.c @@ -10,6 +10,7 @@ #include "pipeline/pipeline_internal.h" #include +#include #include #include #include @@ -270,12 +271,14 @@ TEST(cross_repo_null_target_fails_without_dereference) { const char *targets[] = {NULL}; cbm_cross_repo_result_t result = cbm_cross_repo_match("null-target-source", targets, 1); rejected = result.failed; + cbm_cross_repo_result_free(&result); #else fflush(NULL); pid_t child = fork(); if (child == 0) { const char *targets[] = {NULL}; cbm_cross_repo_result_t result = cbm_cross_repo_match("null-target-source", targets, 1); + cbm_cross_repo_result_free(&result); _exit(result.failed ? 0 : 2); } int status = 0; @@ -307,6 +310,7 @@ TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens) { const char *targets[] = {"*"}; cbm_cross_repo_result_t result = cbm_cross_repo_match("wildcard-source", targets, 1); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&result); ASSERT_FALSE(result.failed); ASSERT_EQ(result.projects_scanned, 4); @@ -366,6 +370,7 @@ TEST(cross_repo_wildcard_skips_pre768_store_issue2133) { cbm_cross_repo_result_t result = cbm_cross_repo_match("wild-src", targets, 1); int edges = cross_repo_count_edges(&fixture, "wild-src", "CROSS_HTTP_CALLS"); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&result); ASSERT_FALSE(result.failed); ASSERT_EQ(result.projects_scanned, 1); @@ -393,6 +398,8 @@ TEST(cross_repo_named_pre768_target_fails_before_cleanup_issue2133) { cbm_cross_repo_result_t result = cbm_cross_repo_match("named-src", with_pre768, 2); int after = cross_repo_count_edges(&fixture, "named-src", "CROSS_HTTP_CALLS"); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&initial); + cbm_cross_repo_result_free(&result); ASSERT_FALSE(initial.failed); ASSERT_EQ(before, 1); @@ -401,6 +408,96 @@ TEST(cross_repo_named_pre768_target_fails_before_cleanup_issue2133) { PASS(); } +/* Render the "cross_repo" status object the MCP/UI surfaces attach for + * `project`, read from its store the way index_status does (query open). */ +static char *cross_repo_status_json(const cross_repo_fixture_t *fixture, const char *project) { + char path[512]; + if (!cross_repo_project_path(fixture, project, path, sizeof(path))) { + return NULL; + } + cbm_store_t *store = cbm_store_open_path_query(path); + if (!store) { + return NULL; + } + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + char *json = NULL; + if (cbm_cross_repo_add_status_json(doc, root, store, project)) { + json = yyjson_mut_write(doc, 0, NULL); + } + yyjson_mut_doc_free(doc); + cbm_store_close(store); + return json; +} + +/* #2133 follow-up: an empty CROSS_* set could mean "never linked" or "linked, + * nothing matched" and no surface told them apart. The source store records + * each run; before any run the status says so explicitly. */ +TEST(cross_repo_status_distinguishes_never_run_from_ran_without_links) { + cross_repo_fixture_t fixture; + bool setup = cross_repo_fixture_begin(&fixture) && + cross_repo_create_project(&fixture, "status-src") && + cross_repo_create_project(&fixture, "status-api"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed status fixture"); + } + + char *before = cross_repo_status_json(&fixture, "status-src"); + const char *targets[] = {"status-api"}; + cbm_cross_repo_result_t result = cbm_cross_repo_match("status-src", targets, 1); + char *after = cross_repo_status_json(&fixture, "status-src"); + cross_repo_fixture_end(&fixture); + + bool before_never = before && strstr(before, "\"cross_repo\":{\"status\":\"never_run\"}"); + bool after_ran = + after && strstr(after, "\"status\":\"ran\"") && strstr(after, "\"outcome\":\"complete\"") && + strstr(after, "\"targets\":[\"status-api\"]") && strstr(after, "\"projects_scanned\":1") && + strstr(after, "\"total_cross_edges\":0") && strstr(after, "\"last_run_at\":\"20"); + free(before); + free(after); + cbm_cross_repo_result_free(&result); + + ASSERT_FALSE(result.failed); + ASSERT_TRUE(before_never); + ASSERT_TRUE(after_ran); + PASS(); +} + +/* #2133 follow-up: ["*"] skips a pre-#768 store (it cannot be linked until a + * reindex); the result must name it rather than silently covering fewer + * projects, and the recorded status counts it. */ +TEST(cross_repo_wildcard_reports_skipped_pre768_store) { + cross_repo_fixture_t fixture; + bool setup = cross_repo_fixture_begin(&fixture) && + cross_repo_seed_http_pair(&fixture, "skip-src", "skip-api", "/orders", "s") && + cross_repo_create_pre768_project(&fixture, "aa-pre768-store"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed skipped-store fixture"); + } + + const char *targets[] = {"*"}; + cbm_cross_repo_result_t result = cbm_cross_repo_match("skip-src", targets, 1); + char *status = cross_repo_status_json(&fixture, "skip-src"); + cross_repo_fixture_end(&fixture); + + bool named = result.skipped_count == 1 && result.skipped_projects && + strcmp(result.skipped_projects[0].project, "aa-pre768-store") == 0 && + strcmp(result.skipped_projects[0].reason, "pre_768_schema") == 0; + bool status_counts = + status && strstr(status, "\"skipped_projects\":1") && strstr(status, "\"targets\":[\"*\"]"); + free(status); + cbm_cross_repo_result_free(&result); + + ASSERT_FALSE(result.failed); + ASSERT_EQ(result.projects_scanned, 1); + ASSERT_TRUE(named); + ASSERT_TRUE(status_counts); + PASS(); +} + static bool cross_repo_seed_bounded_scan(const cross_repo_fixture_t *fixture, const char *source_project, const char *target_project) { enum { TEST_SCAN_ROWS = 4097 }; @@ -488,6 +585,7 @@ TEST(cross_repo_scan_bound_counts_examined_rows_not_matches) { const char *target = "bounded-target"; cbm_cross_repo_result_t result = cbm_cross_repo_match("bounded-source", &target, 1); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&result); ASSERT_FALSE(result.failed); ASSERT_EQ(result.projects_scanned, 1); @@ -517,6 +615,8 @@ TEST(cross_repo_propagates_delete_failure) { failed = cbm_cross_repo_match("delete-source", &target, 1); } cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&initial); + cbm_cross_repo_result_free(&failed); ASSERT_TRUE(trigger_created); ASSERT_TRUE(failed.failed); @@ -540,6 +640,7 @@ TEST(cross_repo_failed_bidirectional_insert_is_not_counted) { const char *target = "insert-target"; cbm_cross_repo_result_t result = cbm_cross_repo_match("insert-source", &target, 1); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&result); ASSERT_TRUE(result.failed); ASSERT_EQ(result.http_edges, 0); @@ -592,6 +693,7 @@ TEST(cross_repo_cancel_mid_run_keeps_completed_target_and_stops_before_later_tar int later_target_edges = cross_repo_count_edges(&fixture, "cancel-target-c", "CROSS_HTTP_CALLS"); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&result); ASSERT_EQ(hook.fired, 1); ASSERT_TRUE(result.cancelled); @@ -623,6 +725,8 @@ TEST(cross_repo_pre_cancel_preserves_existing_cross_edges) { cbm_cross_repo_match_cancellable("pre-cancel-source", &target, 1, &cancelled); int after = cross_repo_count_edges(&fixture, "pre-cancel-source", "CROSS_HTTP_CALLS"); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&initial); + cbm_cross_repo_result_free(&result); ASSERT_FALSE(initial.failed); ASSERT_TRUE(before > 0); @@ -656,6 +760,8 @@ TEST(cross_repo_self_only_target_fails_and_keeps_edges_issue1133) { cbm_cross_repo_result_t result = cbm_cross_repo_match("self-source", &self, 1); int after = cross_repo_count_edges(&fixture, "self-source", "CROSS_HTTP_CALLS"); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&initial); + cbm_cross_repo_result_free(&result); ASSERT_FALSE(initial.failed); ASSERT_TRUE(before > 0); @@ -679,6 +785,7 @@ TEST(cross_repo_wildcard_with_no_other_project_fails_issue1133) { const char *targets[] = {"*"}; cbm_cross_repo_result_t result = cbm_cross_repo_match("lonely-source", targets, 1); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&result); ASSERT_TRUE(result.failed); ASSERT_TRUE(result.no_targets); @@ -729,6 +836,7 @@ TEST(cross_repo_accepts_project_with_missed_shadow_row_issue1609) { const char *target = "shadow-target"; cbm_cross_repo_result_t result = cbm_cross_repo_match("shadow-source", &target, 1); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&result); ASSERT_FALSE(result.failed); ASSERT_EQ(result.projects_scanned, 1); @@ -756,6 +864,8 @@ TEST(cross_repo_caller_local_handler_blocks_cross_http_issue1459) { int mono_edges = cross_repo_count_edges(&fixture, "mono-1459", "CROSS_HTTP_CALLS"); int api_edges = cross_repo_count_edges(&fixture, "api-1459", "CROSS_HTTP_CALLS"); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&fwd); + cbm_cross_repo_result_free(&rev); ASSERT_FALSE(fwd.failed); ASSERT_FALSE(rev.failed); @@ -789,6 +899,8 @@ TEST(cross_repo_methodless_call_to_local_get_route_blocks_cross_http_issue1459) int mono_edges = cross_repo_count_edges(&fixture, "mono2-1459", "CROSS_HTTP_CALLS"); int api_edges = cross_repo_count_edges(&fixture, "api3-1459", "CROSS_HTTP_CALLS"); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&fwd); + cbm_cross_repo_result_free(&rev); ASSERT_FALSE(fwd.failed); ASSERT_FALSE(rev.failed); @@ -817,6 +929,8 @@ TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459) { const char *consumer = "client-1459"; cbm_cross_repo_result_t rev = cbm_cross_repo_match("api2-1459", &consumer, 1); cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&fwd); + cbm_cross_repo_result_free(&rev); ASSERT_FALSE(fwd.failed); ASSERT_FALSE(rev.failed); @@ -836,6 +950,8 @@ SUITE(cross_repo) { RUN_TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens); RUN_TEST(cross_repo_wildcard_skips_pre768_store_issue2133); RUN_TEST(cross_repo_named_pre768_target_fails_before_cleanup_issue2133); + RUN_TEST(cross_repo_status_distinguishes_never_run_from_ran_without_links); + RUN_TEST(cross_repo_wildcard_reports_skipped_pre768_store); RUN_TEST(cross_repo_scan_bound_counts_examined_rows_not_matches); RUN_TEST(cross_repo_propagates_delete_failure); RUN_TEST(cross_repo_failed_bidirectional_insert_is_not_counted); diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index 30541db2c..260b7a85a 100644 --- a/tests/test_edge_types_probe.c +++ b/tests/test_edge_types_probe.c @@ -922,6 +922,237 @@ TEST(routes_laravel_withrouting_parallel_subdir_issue1146) { PASS(); } +/* Assert the exact HANDLES edge set as " -> " + * pairs. The tail is matched at a segment boundary against the handler's + * qualified name ("UserController.show" matches + * ".app.Http.Controllers.UserController.UserController.show"), so a + * same-named method on another class never satisfies it; the total count must + * match too, so a fabricated extra edge fails the assertion. */ +typedef struct { + const char *handler_tail; + const char *route; +} EtHandles; + +static int et_qn_has_tail(const char *qn, const char *tail) { + size_t ql = qn ? strlen(qn) : 0; + size_t tl = strlen(tail); + return ql >= tl && strcmp(qn + ql - tl, tail) == 0 && (ql == tl || qn[ql - tl - 1] == '.'); +} + +/* route_qns (optional, NULL-terminated): Route qualified_names that must + * exist, so a test asserting that a route gets NO handler cannot pass merely + * because the route itself was never extracted. */ +static int et_handles_exact_routes(const EtFile *files, int nfiles, const EtHandles *want, + int parallel, const char *const *route_qns) { + EtProj lp; + cbm_store_t *store = + parallel ? et_index_parallel(&lp, files, nfiles) : et_index_files(&lp, files, nfiles); + int wanted = 0; + int found[ET_ROUTE_ASSERT_MAX] = {0}; + while (want[wanted].handler_tail && wanted < ET_ROUTE_ASSERT_MAX) { + wanted++; + } + cbm_edge_t *edges = NULL; + int n = 0; + int ok = store != NULL && + cbm_store_find_edges_by_type(store, lp.project, "HANDLES", &edges, &n) == + CBM_STORE_OK && + n == wanted; + for (int i = 0; store && i < n; i++) { + cbm_node_t src = {0}; + cbm_node_t tgt = {0}; + int have_src = cbm_store_find_node_by_id(store, edges[i].source_id, &src) == CBM_STORE_OK; + int have_tgt = cbm_store_find_node_by_id(store, edges[i].target_id, &tgt) == CBM_STORE_OK; + int matched = 0; + for (int wi = 0; have_src && have_tgt && wi < wanted; wi++) { + if (!found[wi] && tgt.name && strcmp(tgt.name, want[wi].route) == 0 && + et_qn_has_tail(src.qualified_name, want[wi].handler_tail)) { + found[wi] = matched = 1; + break; + } + } + if (!matched) { + ok = 0; + fprintf(stderr, " [ET-HANDLES] unexpected %s -> %s\n", + have_src && src.qualified_name ? src.qualified_name : "", + have_tgt && tgt.name ? tgt.name : ""); + } + if (have_src) cbm_node_free_fields(&src); + if (have_tgt) cbm_node_free_fields(&tgt); + } + for (int wi = 0; wi < wanted; wi++) { + if (!found[wi]) { + ok = 0; + fprintf(stderr, " [ET-HANDLES] missing %s -> %s\n", want[wi].handler_tail, + want[wi].route); + } + } + for (int ri = 0; route_qns && route_qns[ri]; ri++) { + cbm_node_t route = {0}; + if (!store || + cbm_store_find_node_by_qn(store, lp.project, route_qns[ri], &route) != CBM_STORE_OK) { + ok = 0; + fprintf(stderr, " [ET-HANDLES] missing Route %s\n", route_qns[ri]); + } else { + cbm_node_free_fields(&route); + } + } + if (!ok) { + fprintf(stderr, " [ET-HANDLES] FAIL (%s path) expected=%d actual=%d\n", + parallel ? "parallel" : "sequential", wanted, n); + } + if (edges) cbm_store_free_edges(edges, n); + et_cleanup(&lp, store); + return ok; +} + +static int et_handles_exact(const EtFile *files, int nfiles, const EtHandles *want, int parallel) { + return et_handles_exact_routes(files, nfiles, want, parallel, NULL); +} + +/* #1146: Laravel's two class-based handler forms — + * Route::get('/x', [UserController::class, 'show']) (controller method) + * Route::get('/x', GetCurrentUserController::class) (invokable: __invoke) + * got no HANDLES edge, because the handler scan only took identifiers and + * strings. The cross-repo matcher needs HANDLES on the Route, so apps written + * this way (the #1146 reporter's included) produced 0 CROSS_HTTP_CALLS. + * Controls: an Admin\UserController::show with the same short name (the `use` + * statement picks the class), an alias import, a fully-qualified class that is + * not imported, a package class under a PSR-4 root whose folder does not + * mirror its namespace (Acme\Blog\ -> packages/blog/src/), a decoy __invoke on + * another class, and two vendor classes that are not in the repo — those must + * get no HANDLES edge at all rather than bind to some other class's + * same-named method. composer.json comes first so the no-composer variant + * below can index the same fixture without it. */ +static const EtFile et_laravel_class_handlers[] = { + {"composer.json", "{\"autoload\": {\"psr-4\": {\"App\\\\\": \"app/\", " + "\"Acme\\\\Blog\\\\\": \"packages/blog/src/\"}}}\n"}, + {"packages/blog/src/Http/PostsController.php", + " []]; }\n}\n"}, + {"app/Http/Controllers/UserController.php", + " $id]; }\n" + " public function index() { return []; }\n}\n"}, + {"app/Http/Controllers/Admin/UserController.php", + " $id]; }\n}\n"}, + {"app/Http/Controllers/PostController.php", + " $id]; }\n}\n"}, + {"app/Http/Controllers/GetCurrentUserController.php", + " true]; }\n}\n"}, + {"app/Http/Controllers/HealthController.php", + " true]; }\n}\n"}, + {"routes/api.php", + " MIN_FILES_FOR_PARALLEL), + * whose route emitter (pass_parallel.c) is a separate code path. */ +TEST(handles_laravel_class_handlers_parallel_issue1146) { + ASSERT_TRUE(et_handles_exact(et_laravel_class_handlers, ET_LARAVEL_CLASS_FILES, + et_laravel_class_handles, 1)); + PASS(); +} + +/* Without composer.json there is no PSR-4 map: a class is placed only where + * its namespace mirrors the folders (App\Http\Controllers -> app/Http/ + * Controllers). The package class, whose folder does not mirror its + * namespace, then gets no handler rather than a guessed one. */ +TEST(handles_laravel_class_handlers_no_composer_issue1146) { + static const EtHandles want[] = { + {"Controllers.UserController.UserController.show", "/users/{id}"}, + {"GetCurrentUserController.__invoke", "/me"}, + {"PostController.show", "/posts/{id}"}, + {NULL, NULL}}; + ASSERT_TRUE( + et_handles_exact(et_laravel_class_handlers + 1, ET_LARAVEL_CLASS_FILES - 1, want, 0)); + PASS(); +} + +/* A PSR-4 prefix that covers a handler class decides where the class lives, + * as it does for the class's `use` import (#1186): when composer would load + * no file holding the member, the route stays without a handler. Both routes + * below are covered by Acme\Blog\ -> packages/blog/src/: + * /blog/missing MissingController has no class file there at all; + * /blog/archive PostsController's class file exists but has no archive(). + * legacy/Acme/Blog/Http mirrors the namespace and holds both members, so the + * namespace/folder fallback would bind both routes to a class composer never + * loads. It must not run for a covered class. These files are added to the + * fixture above, whose four handlers must keep their edges. */ +static const EtFile et_laravel_psr4_absent_extra[] = { + {"legacy/Acme/Blog/Http/MissingController.php", + " true]; }\n}\n"}, + {"legacy/Acme/Blog/Http/PostsController.php", + " true]; }\n}\n"}, + {"routes/blog.php", + " #include +#include /* the index replies are asserted as parsed JSON */ +#include /* PATH_MAX: which platforms resolve a root past 1 KB */ #include #include #include @@ -627,7 +630,7 @@ static int th_server_start_with_watcher(th_server_t *ts, cbm_watcher_t *watcher) typedef struct { atomic_int calls; - char root_path[512]; + char root_path[4096]; /* the route resolves into a 4 KB buffer; record it whole */ char project_name[256]; } th_ui_index_executor_t; @@ -974,6 +977,10 @@ TEST(ui_server_process_kill_route_is_unavailable) { TEST(ui_server_routes_indexing_through_joinable_daemon_executor) { char *root = th_mktempdir("cbm_httpd_daemon_index"); ASSERT_NOT_NULL(root); + /* The executor receives the root in resolved form (the string the route + * checked), which on macOS differs from the /tmp spelling. */ + char resolved_root[4096]; + ASSERT_TRUE(cbm_canonical_path(root, resolved_root, sizeof(resolved_root))); th_ui_index_executor_t executor = {0}; atomic_init(&executor.calls, 0); th_server_t ts; @@ -998,7 +1005,7 @@ TEST(ui_server_routes_indexing_through_joinable_daemon_executor) { ASSERT_GT(response_length, 0); ASSERT_EQ(th_status(response), 202); ASSERT_TRUE(called); - ASSERT_STR_EQ(executor.root_path, root); + ASSERT_STR_EQ(executor.root_path, resolved_root); ASSERT_STR_EQ(executor.project_name, "ui-project"); th_cleanup(root); PASS(); @@ -1048,6 +1055,285 @@ TEST(ui_server_free_never_joins_active_index_worker) { PASS(); } +/* ── POST /api/index carries the resolved root ────────────────── */ + +/* The route resolves the request's root_path, runs the workspace check on the + * resolved form, and the job must then carry exactly that string: what was + * checked is what the daemon receives. These cases drive the real endpoint + * with the recording executor and read every place the stored root surfaces + * (the executor argument, the 202 reply, /api/index-status). */ + +typedef struct { + bool was_set; + char value[4096]; +} th_allowed_root_saved_t; + +/* Set (or, with NULL, clear) CBM_ALLOWED_ROOT and remember the previous value + * so the test can put it back before asserting — a failed assertion returns + * early, and a boundary left behind would leak into every later test. */ +static void th_allowed_root_set(th_allowed_root_saved_t *saved, const char *value) { + const char *current = getenv("CBM_ALLOWED_ROOT"); + saved->was_set = current != NULL; + snprintf(saved->value, sizeof(saved->value), "%s", current ? current : ""); + if (value) { + cbm_setenv("CBM_ALLOWED_ROOT", value, 1); + } else { + cbm_unsetenv("CBM_ALLOWED_ROOT"); + } +} + +static void th_allowed_root_restore(const th_allowed_root_saved_t *saved) { + if (saved->was_set) { + cbm_setenv("CBM_ALLOWED_ROOT", saved->value, 1); + } else { + cbm_unsetenv("CBM_ALLOWED_ROOT"); + } +} + +typedef struct { + th_ui_index_executor_t executor; + int status; /* HTTP status of the POST, -1 when no reply came */ + bool executor_called; /* only waited for after a 202 */ + char response[8192]; /* the raw POST reply */ + char index_status[16384]; /* GET /api/index-status, read after the POST */ +} th_ui_index_probe_t; + +/* Response body: everything after the header block, or "" when absent. */ +static const char *th_response_body(const char *response) { + const char *body = strstr(response, "\r\n\r\n"); + return body ? body + 4 : ""; +} + +/* Boot a server with the recording executor, POST one /api/index request, + * wait for the worker only when the route accepted the job, read + * /api/index-status and stop the server. Everything observed lands in the + * probe so the caller asserts after teardown. */ +static void th_ui_index_probe(th_ui_index_probe_t *probe, const char *root_path, + const char *project_name) { + memset(probe, 0, sizeof(*probe)); + atomic_init(&probe->executor.calls, 0); + probe->status = -1; + th_server_t ts; + ts.srv = cbm_http_server_new(0); + if (!ts.srv) { + return; + } + cbm_http_server_set_index_executor(ts.srv, th_ui_index_executor, &probe->executor); + if (th_server_thread_start(&ts.tid, ts.srv) != 0) { + (void)cbm_http_server_free(ts.srv); + return; + } + int port = cbm_http_server_port(ts.srv); + char body[8192]; + snprintf(body, sizeof(body), "{\"root_path\":\"%s\",\"project_name\":\"%s\"}", root_path, + project_name); + char request[8192 + 256]; + snprintf(request, sizeof(request), + "POST /api/index HTTP/1.1\r\nContent-Type: application/json\r\n" + "Content-Length: %zu\r\n\r\n%s", + strlen(body), body); + if (th_http(port, request, probe->response, sizeof(probe->response)) > 0) { + probe->status = th_status(probe->response); + } + if (probe->status == 202) { + probe->executor_called = th_wait_atomic_int(&probe->executor.calls, 1, 2000); + } + char status_request[128]; + snprintf(status_request, sizeof(status_request), + "GET /api/index-status HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port); + (void)th_http(port, status_request, probe->index_status, sizeof(probe->index_status)); + th_server_stop(&ts); +} + +TEST(ui_server_index_start_refuses_root_outside_allowed) { + char *tmp = th_mktempdir("cbm_httpd_index_bound"); + ASSERT_NOT_NULL(tmp); + char base[256]; + snprintf(base, sizeof(base), "%s", tmp); + char allowed[512]; + char outside[512]; + snprintf(allowed, sizeof(allowed), "%s/allowed", base); + snprintf(outside, sizeof(outside), "%s/outside", base); + ASSERT_EQ(th_mkdir_p(allowed), 0); + ASSERT_EQ(th_mkdir_p(outside), 0); + /* The boundary is declared in resolved form, as allow-root records it. */ + char canonical_base[4096]; + ASSERT_TRUE(cbm_canonical_path(base, canonical_base, sizeof(canonical_base))); + char allowed_root[4096 + 16]; + snprintf(allowed_root, sizeof(allowed_root), "%s/allowed", canonical_base); + /* Spelled under the allowed root, resolving beside it: the verdict has to + * come from the resolved form. */ + char request_path[600]; + snprintf(request_path, sizeof(request_path), "%s/allowed/../outside", base); + + th_allowed_root_saved_t saved; + th_allowed_root_set(&saved, allowed_root); + th_ui_index_probe_t probe; + th_ui_index_probe(&probe, request_path, "bound"); + th_allowed_root_restore(&saved); + th_cleanup(base); + + ASSERT_EQ(probe.status, 403); + ASSERT_NOT_NULL(strstr(probe.response, "outside the allowed root")); + ASSERT_EQ(atomic_load(&probe.executor.calls), 0); + ASSERT_STR_EQ(th_response_body(probe.index_status), "[]"); + PASS(); +} + +TEST(ui_server_index_start_stores_resolved_root) { + char *tmp = th_mktempdir("cbm_httpd_index_root"); + ASSERT_NOT_NULL(tmp); + char base[256]; + snprintf(base, sizeof(base), "%s", tmp); + char inner[512]; + snprintf(inner, sizeof(inner), "%s/allowed/inner", base); + ASSERT_EQ(th_mkdir_p(inner), 0); + /* The resolved form of the allowed directory itself, as the server + * computes it. A "/allowed" suffix on a resolved parent is not that form + * on Windows, where the resolved path uses backslashes throughout. */ + char allowed_dir[512]; + snprintf(allowed_dir, sizeof(allowed_dir), "%s/allowed", base); + char allowed_root[4096]; + ASSERT_TRUE(cbm_canonical_path(allowed_dir, allowed_root, sizeof(allowed_root))); + /* The request names the allowed root through a ".." segment, so its + * spelling is not the resolved form (on macOS /tmp is itself a link to + * /private/tmp, so the prefix differs as well). */ + char request_path[600]; + snprintf(request_path, sizeof(request_path), "%s/allowed/inner/..", base); + ASSERT_TRUE(strcmp(request_path, allowed_root) != 0); + + th_allowed_root_saved_t saved; + th_allowed_root_set(&saved, allowed_root); + th_ui_index_probe_t probe; + th_ui_index_probe(&probe, request_path, "resolved"); + th_allowed_root_restore(&saved); + th_cleanup(base); + + ASSERT_EQ(probe.status, 202); + ASSERT_TRUE(probe.executor_called); + /* The daemon receives the string the check ran on, not the request + * spelling. */ + ASSERT_STR_EQ(probe.executor.root_path, allowed_root); + ASSERT_STR_EQ(probe.executor.project_name, "resolved"); + /* The 202 reply and the status listing report that same stored root, + * JSON-escaped (a Windows path carries backslashes). */ + char escaped[8192]; + cbm_json_escape(escaped, (int)sizeof(escaped), allowed_root); + char expected[8192 + 64]; + snprintf(expected, sizeof(expected), "\"path\":\"%s\"}", escaped); + ASSERT_NOT_NULL(strstr(probe.response, expected)); + snprintf(expected, sizeof(expected), "\"path\":\"%s\",\"error\"", escaped); + ASSERT_NOT_NULL(strstr(probe.index_status, expected)); + PASS(); +} + +TEST(ui_server_index_start_long_root_not_truncated) { + char *tmp = th_mktempdir("cbm_httpd_index_long"); + ASSERT_NOT_NULL(tmp); + char base[256]; + snprintf(base, sizeof(base), "%s", tmp); + /* A root well past 1 KB, the capacity the job used to hold, and well + * inside the 4 KB the route resolves; 200-byte components keep every + * component under NAME_MAX. */ + char component[201]; + memset(component, 'd', sizeof(component) - 1); + component[sizeof(component) - 1] = '\0'; + char deep[2048]; + int n = snprintf(deep, sizeof(deep), "%s", base); + while (n < 1400) { + n += snprintf(deep + n, sizeof(deep) - (size_t)n, "/%s", component); + } + bool created = th_mkdir_p(deep) == 0; + char canonical[4096]; + bool resolved = created && cbm_canonical_path(deep, canonical, sizeof(canonical)) != 0; + + /* No boundary here: the question is the length the job keeps. */ + th_allowed_root_saved_t saved; + th_allowed_root_set(&saved, NULL); + th_ui_index_probe_t probe; + th_ui_index_probe(&probe, deep, "long"); + th_allowed_root_restore(&saved); + th_cleanup(base); + + if (probe.status == 202) { + /* The platform resolved the path, so the worker has to receive it + * whole — the same string the route checked. */ + ASSERT_TRUE(resolved); + ASSERT_TRUE(probe.executor_called); + ASSERT_TRUE(strlen(probe.executor.root_path) > 1023); + ASSERT_STR_EQ(probe.executor.root_path, canonical); + } else { + /* The platform cannot resolve a path this long (macOS: PATH_MAX is + * 1024, so mkdir and stat refuse it before the route sees it); the + * route answers 400 and keeps nothing. */ + ASSERT_EQ(probe.status, 400); + ASSERT_EQ(atomic_load(&probe.executor.calls), 0); + ASSERT_STR_EQ(th_response_body(probe.index_status), "[]"); + } +#if defined(PATH_MAX) && PATH_MAX >= 2048 + /* Linux resolves it (PATH_MAX 4096), and that is the one platform where a + * cut-off could reach the worker; the accepted branch is the only honest + * outcome there, so the refusal branch may not stand in for it. */ + ASSERT_TRUE(created); + ASSERT_EQ(probe.status, 202); +#endif + PASS(); +} + +/* The 202 reply names the stored root; a quote or backslash in it has to + * travel escaped or the reply is not JSON. */ +TEST(ui_server_index_start_reply_escapes_path) { + char *tmp = th_mktempdir("cbm_httpd_index_quote"); + ASSERT_NOT_NULL(tmp); + char base[256]; + snprintf(base, sizeof(base), "%s", tmp); + char root[512]; +#ifdef _WIN32 + /* A quote is not a legal file-name byte on Windows; the separators of the + * resolved path already need escaping there. */ + snprintf(root, sizeof(root), "%s\\plain", base); +#else + snprintf(root, sizeof(root), "%s/q\"uote", base); +#endif + ASSERT_EQ(th_mkdir_p(root), 0); + char canonical[4096]; + ASSERT_TRUE(cbm_canonical_path(root, canonical, sizeof(canonical))); + /* The request body is JSON too, so the root goes in escaped. */ + char request_path[1024]; + cbm_json_escape(request_path, (int)sizeof(request_path), root); + + th_ui_index_probe_t probe; + th_ui_index_probe(&probe, request_path, "quoted"); + th_cleanup(base); + + bool parsed = false; + int slot = -1; + char reply_path[4096] = ""; + const char *body = th_response_body(probe.response); + yyjson_doc *doc = yyjson_read(body, strlen(body), 0); + if (doc) { + yyjson_val *obj = yyjson_doc_get_root(doc); + yyjson_val *path = yyjson_obj_get(obj, "path"); + yyjson_val *slot_val = yyjson_obj_get(obj, "slot"); + parsed = yyjson_is_obj(obj); + if (yyjson_is_str(path)) { + snprintf(reply_path, sizeof(reply_path), "%s", yyjson_get_str(path)); + } + if (yyjson_is_int(slot_val)) { + slot = yyjson_get_int(slot_val); + } + yyjson_doc_free(doc); + } + + ASSERT_EQ(probe.status, 202); + ASSERT_TRUE(probe.executor_called); + ASSERT_STR_EQ(probe.executor.root_path, canonical); + ASSERT_TRUE(parsed); + ASSERT_STR_EQ(reply_path, canonical); + ASSERT_TRUE(slot >= 0); + PASS(); +} + TEST(ui_server_root_without_embedded_assets_is_not_found) { /* The frontend is linked into the image, so its availability is decided at * build time, not warmed at runtime: a binary built without --with-ui has @@ -2318,8 +2604,8 @@ TEST(ui_server_logs_escape_dense_no_overflow) { /* The index-status endpoint renders every active job into a fixed 2 KB stack * buffer. http_appendf clamps its own writes, but the separator and the closing - * bracket were raw indexes, so two jobs holding ~1 KB root paths (the field is - * 1024 bytes and the value comes straight from POST /api/index) pushed pos to + * bracket were raw indexes, so two jobs holding ~1 KB root paths (the field + * was 1024 bytes and now holds the 4 KB resolved root) pushed pos to * the clamp and the close then wrote past the buffer. Drive it through the real * endpoint with the index executor stubbed out, in a forked child so the * overflow surfaces as a killing signal. */ @@ -2417,6 +2703,173 @@ TEST(ui_server_index_status_long_paths_no_overflow) { #endif } +/* Grow path in place to exactly target bytes with 'd' components, none + * longer than 200 so every component stays under NAME_MAX. */ +static void th_pad_path_to(char *path, size_t cap, int target) { + char fill[201]; + memset(fill, 'd', sizeof(fill) - 1); + fill[sizeof(fill) - 1] = '\0'; + int n = (int)strlen(path); + while (n < target && (size_t)n + 2 < cap) { + int room = target - n - 1; /* after the '/' */ + int len = room > 200 ? 200 : room; + if (len <= 0) { + break; + } + n += snprintf(path + n, cap - (size_t)n, "/%.*s", len, fill); + } +} + +/* Parse an index-status body and compare each entry's "path" with the root + * expected for its slot. Results go to out-params so the caller can tear + * down before asserting. */ +static void th_index_status_check(const char *body, int expected_count, char expected[][4096], + bool *parsed, int *count, bool *paths_whole) { + *parsed = false; + *count = 0; + *paths_whole = false; + yyjson_doc *doc = yyjson_read(body, strlen(body), 0); + if (!doc) { + return; + } + yyjson_val *arr = yyjson_doc_get_root(doc); + if (yyjson_is_arr(arr)) { + *parsed = true; + *count = (int)yyjson_arr_size(arr); + bool whole = *count == expected_count; + size_t idx; + size_t max_entries; + yyjson_val *entry; + yyjson_arr_foreach(arr, idx, max_entries, entry) { + yyjson_val *slot = yyjson_obj_get(entry, "slot"); + yyjson_val *path = yyjson_obj_get(entry, "path"); + int s = yyjson_is_int(slot) ? yyjson_get_int(slot) : -1; + whole = whole && s >= 0 && s < expected_count && yyjson_is_str(path) && + strcmp(yyjson_get_str(path), expected[s]) == 0; + } + *paths_whole = whole; + } + yyjson_doc_free(doc); +} + +/* The listing is sized for what the job table holds. Every slot takes a root + * as long as the platform resolves; the body has to parse as JSON and carry + * each root whole, first with one job and then with the table full. Roots + * enter the table the way the clamp test's do: real directories through the + * route, held open by the blocking executor so every slot stays occupied. */ +TEST(ui_server_index_status_long_roots_render_whole) { + char *tmp = th_mktempdir("cbm_status_whole"); + ASSERT_NOT_NULL(tmp); + char base[256]; + snprintf(base, sizeof(base), "%s", tmp); + /* Linux: just under the 4 KB field; the 4 KB request body still wraps it. */ + int target = 4096 - 160; +#if defined(_WIN32) + /* MAX_PATH: long roots are not characterised there; the listing is still + * checked for shape and content with every slot occupied. */ + target = 200; +#elif defined(PATH_MAX) && PATH_MAX < 4096 + /* macOS: the longest root the route can resolve; four still exceed 2 KB. */ + target = PATH_MAX - 96; +#endif + char deep[MAX_TEST_INDEX_JOBS][4096]; + char expected[MAX_TEST_INDEX_JOBS][4096]; + for (int j = 0; j < MAX_TEST_INDEX_JOBS; j++) { + snprintf(deep[j], sizeof(deep[j]), "%s/%d", base, j); + th_pad_path_to(deep[j], sizeof(deep[j]), target); + ASSERT_EQ((int)strlen(deep[j]), target); + ASSERT_EQ(th_mkdir_p(deep[j]), 0); + ASSERT_TRUE(cbm_canonical_path(deep[j], expected[j], sizeof(expected[j]))); + } + + th_ui_blocking_index_executor_t executor = {0}; + atomic_init(&executor.calls, 0); + atomic_init(&executor.release, 0); + th_server_t ts; + ts.srv = cbm_http_server_new(0); + ASSERT_NOT_NULL(ts.srv); + cbm_http_server_set_index_executor(ts.srv, th_ui_blocking_index_executor, &executor); + ASSERT_EQ(th_server_thread_start(&ts.tid, ts.srv), 0); + int port = cbm_http_server_port(ts.srv); + char status_request[128]; + snprintf(status_request, sizeof(status_request), + "GET /api/index-status HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port); + + int accepted = 0; + bool started = true; + bool one_parsed = false; + bool one_whole = false; + bool all_parsed = false; + bool all_whole = false; + int one_count = 0; + int all_count = 0; + size_t one_body_len = 0; + size_t all_body_len = 0; + char listing[65536]; + for (int j = 0; j < MAX_TEST_INDEX_JOBS; j++) { + char body[4600]; + snprintf(body, sizeof(body), "{\"root_path\":\"%s\",\"project_name\":\"p%d\"}", deep[j], j); + char request[4900]; + snprintf(request, sizeof(request), + "POST /api/index HTTP/1.1\r\nContent-Type: application/json\r\n" + "Content-Length: %zu\r\n\r\n%s", + strlen(body), body); + char response[8192]; + int rn = th_http(port, request, response, sizeof(response)); + if (rn > 0 && th_status(response) == 202) { + accepted++; + } + /* Wait for the state the listing depends on: this job running. */ + started = started && th_wait_atomic_int(&executor.calls, j + 1, 5000); + if (j == 0 || j == MAX_TEST_INDEX_JOBS - 1) { + int n = th_http(port, status_request, listing, sizeof(listing)); + const char *status_body = n > 0 ? th_response_body(listing) : ""; + if (j == 0) { + one_body_len = strlen(status_body); + th_index_status_check(status_body, 1, expected, &one_parsed, &one_count, + &one_whole); + } else { + all_body_len = strlen(status_body); + th_index_status_check(status_body, MAX_TEST_INDEX_JOBS, expected, &all_parsed, + &all_count, &all_whole); + } + } + } + /* Release the workers and tear down; free refuses until each worker has + * flagged completion, the same way the active-worker test waits. */ + atomic_store(&executor.release, 1); + cbm_http_server_stop(ts.srv); + ASSERT_EQ(cbm_thread_join(&ts.tid), 0); + bool freed = false; + uint64_t deadline = cbm_now_ms() + 2000; + while (!freed && cbm_now_ms() < deadline) { + freed = cbm_http_server_free(ts.srv); + if (!freed) + cbm_usleep(1000); + } + th_cleanup(base); + + ASSERT_TRUE(freed); + ASSERT_EQ(accepted, MAX_TEST_INDEX_JOBS); + ASSERT_TRUE(started); + if (!one_parsed) { + char m[128]; + snprintf(m, sizeof(m), "one-job index-status body of %zu bytes is not JSON", one_body_len); + FAIL(m); + } + ASSERT_EQ(one_count, 1); + ASSERT_TRUE(one_whole); + if (!all_parsed) { + char m[128]; + snprintf(m, sizeof(m), "full-table index-status body of %zu bytes is not JSON", + all_body_len); + FAIL(m); + } + ASSERT_EQ(all_count, MAX_TEST_INDEX_JOBS); + ASSERT_TRUE(all_whole); + PASS(); +} + /* ── Suite ────────────────────────────────────────────────────── */ SUITE(httpd) { @@ -2424,6 +2877,7 @@ SUITE(httpd) { RUN_TEST(ui_server_browse_wide_dir_no_overflow); RUN_TEST(ui_server_logs_escape_dense_no_overflow); RUN_TEST(ui_server_index_status_long_paths_no_overflow); + RUN_TEST(ui_server_index_status_long_roots_render_whole); /* Parser / helpers */ RUN_TEST(httpd_parse_simple_get); RUN_TEST(httpd_parse_security_headers_and_rejects_duplicates); @@ -2458,6 +2912,10 @@ SUITE(httpd) { RUN_TEST(ui_server_process_kill_route_is_unavailable); RUN_TEST(ui_server_routes_indexing_through_joinable_daemon_executor); RUN_TEST(ui_server_free_never_joins_active_index_worker); + RUN_TEST(ui_server_index_start_refuses_root_outside_allowed); + RUN_TEST(ui_server_index_start_stores_resolved_root); + RUN_TEST(ui_server_index_start_long_root_not_truncated); + RUN_TEST(ui_server_index_start_reply_escapes_path); RUN_TEST(ui_server_root_without_embedded_assets_is_not_found); RUN_TEST(ui_server_same_origin_request_is_allowed); RUN_TEST(ui_server_rejects_foreign_and_null_origins); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 747f0a5e2..856a48a04 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -13802,6 +13802,102 @@ TEST(tool_cross_repo_dedupes_targets_before_scanning_and_counting) { PASS(); } +/* A store written before the #768 edges.local_name_gen column: readable, but + * refused by every read-write open until a reindex. */ +static bool mcp_cross_repo_create_pre768_store(const char *cache, const char *project) { + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + sqlite3 *db = NULL; + if (sqlite3_open(db_path, &db) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + char sql[CBM_SZ_1K]; + snprintf(sql, sizeof(sql), + "CREATE TABLE projects(name TEXT PRIMARY KEY, indexed_at TEXT NOT NULL," + " root_path TEXT NOT NULL);" + "CREATE TABLE nodes(id INTEGER PRIMARY KEY AUTOINCREMENT, project TEXT NOT NULL," + " label TEXT NOT NULL, name TEXT NOT NULL, qualified_name TEXT NOT NULL," + " file_path TEXT DEFAULT '', start_line INTEGER DEFAULT 0," + " end_line INTEGER DEFAULT 0, properties TEXT DEFAULT '{}'," + " UNIQUE(project, qualified_name));" + "CREATE TABLE edges(id INTEGER PRIMARY KEY AUTOINCREMENT, project TEXT NOT NULL," + " source_id INTEGER NOT NULL, target_id INTEGER NOT NULL, type TEXT NOT NULL," + " properties TEXT DEFAULT '{}', UNIQUE(source_id, target_id, type));" + "INSERT INTO projects VALUES('%s', '2026-06-01T00:00:00Z', '/pre768');", + project); + bool ok = sqlite3_exec(db, sql, NULL, NULL, NULL) == SQLITE_OK; + sqlite3_close(db); + return ok; +} + +/* #2133 follow-up, both user-visible halves: index_status tells "never run" + * from "ran" via a `cross_repo` object, and a ["*"] run names the legacy + * store it skipped (with the reindex hint) instead of silently linking fewer + * projects. */ +TEST(tool_cross_repo_status_and_wildcard_skipped_projects) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-status-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char *source_project = cbm_project_name_from_path(cache); + ASSERT_NOT_NULL(source_project); + const char *target_project = "cross-status-target"; + const char *legacy_project = "cross-status-pre768"; + ASSERT_TRUE(mcp_cross_repo_seed_http_match(cache, source_project, target_project, cache)); + ASSERT_TRUE(mcp_cross_repo_create_pre768_store(cache, legacy_project)); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); + + char status_args[CBM_SZ_1K]; + snprintf(status_args, sizeof(status_args), "{\"project\":\"%s\",\"format\":\"json\"}", + source_project); + char *before = cbm_mcp_handle_tool(srv, "index_status", status_args); + bool before_never = + response_contains_json_fragment(before, "\"cross_repo\":{\"status\":\"never_run\"}"); + + char args[CBM_SZ_2K]; + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"*\"]}", + cache); + char *run = cbm_mcp_handle_tool(srv, "index_repository", args); + bool run_ok = run && strstr(run, "\"isError\":true") == NULL && + response_contains_json_fragment(run, "\"total_cross_edges\":1"); + bool skipped_named = response_contains_json_fragment( + run, "\"skipped_projects\":[{\"name\":\"cross-status-pre768\"," + "\"reason\":\"pre_768_schema\",\"hint\":\"reindex this project\"}]"); + + char *after = cbm_mcp_handle_tool(srv, "index_status", status_args); + bool after_ran = response_contains_json_fragment(after, "\"status\":\"ran\"") && + response_contains_json_fragment(after, "\"total_cross_edges\":1") && + response_contains_json_fragment(after, "\"skipped_projects\":1"); + + free(before); + free(run); + free(after); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, source_project); + cleanup_project_db(cache, target_project); + cleanup_project_db(cache, legacy_project); + free(source_project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + ASSERT_TRUE(before_never); + ASSERT_TRUE(run_ok); + ASSERT_TRUE(skipped_named); + ASSERT_TRUE(after_ran); + PASS(); +} + /* `name` is the documented index project-name override and must identify the * cross-repo source too. Deriving from repo_path here makes custom-named * projects impossible to rescan even though ordinary indexing created them. */ @@ -23573,6 +23669,7 @@ SUITE(mcp_mutation_guard) { RUN_TEST(tool_cross_repo_checks_cancellation_after_acquiring_leases); RUN_TEST(tool_cross_repo_missing_inputs_fail_without_creating_ghost_databases); RUN_TEST(tool_cross_repo_dedupes_targets_before_scanning_and_counting); + RUN_TEST(tool_cross_repo_status_and_wildcard_skipped_projects); RUN_TEST(tool_cross_repo_honors_source_name_override); RUN_TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested); RUN_TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal); diff --git a/tests/test_parse_coverage.c b/tests/test_parse_coverage.c index 5dbc38e4a..def6dffa9 100644 --- a/tests/test_parse_coverage.c +++ b/tests/test_parse_coverage.c @@ -507,11 +507,17 @@ TEST(real_error_before_eof_still_flagged_without_final_newline_issue1610) { } /* GUARD: a MISSING/ERROR node WITH WIDTH at EOF is a genuine loss and must - * still be flagged. A Makefile whose final recipe line lacks its newline really - * does drop the recipe from the tree — cbm's flag is honest there. */ + * still be flagged. The construct here is broken whether or not the line is + * terminated, so neither the zero-width rule nor the virtual final newline + * (#2078) may excuse it. + * + * This guard used a Makefile whose final recipe line lacks its newline, which + * really did drop the recipe from the tree. Since #2078 the parser sees that + * line terminated and the recipe parses, so there is no loss left to report -- + * see makefile_unterminated_recipe_is_parsed_issue2078. */ TEST(width_bearing_error_at_eof_still_flagged_issue1610) { - const char *src = "all:\n\techo hi"; /* no trailing newline; recipe is lost */ - CBMFileResult *r = do_extract(src, CBM_LANG_MAKEFILE, "Makefile"); + const char *src = "def ok():\n return 1\nx = (1,"; /* unclosed at EOF */ + CBMFileResult *r = do_extract(src, CBM_LANG_PYTHON, "eof.py"); ASSERT_NOT_NULL(r); bool flagged = r->parse_incomplete; cbm_free_result(r); @@ -521,6 +527,22 @@ TEST(width_bearing_error_at_eof_still_flagged_issue1610) { PASS(); } +/* #2078: the recipe the old #1610 guard lost is now parsed -- the file is not + * flagged, with or without a trailing blank. */ +TEST(makefile_unterminated_recipe_is_parsed_issue2078) { + const char *cases[] = {"all:\n\techo hi", "all:\n\techo hi "}; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + CBMFileResult *r = do_extract(cases[i], CBM_LANG_MAKEFILE, "Makefile"); + ASSERT_NOT_NULL(r); + bool flagged = r->parse_incomplete; + cbm_free_result(r); + if (flagged) { + FAIL("an unterminated final recipe line must parse like a terminated one"); + } + } + PASS(); +} + /* #1838: tree-sitter-perl v1.0.0 rejects a valid line-oriented format and * reports the file as partial. The supported upstream v1.2.1 grammar accepts * the format and preserves declaration extraction beyond its dot terminator. */ @@ -611,11 +633,12 @@ TEST(real_error_before_eof_still_flagged_with_trailing_blank_issue1746) { PASS(); } -/* GUARD: a WIDTH-BEARING loss at EOF stays honest with a blank tail too — the - * Makefile recipe really is dropped, and only zero-width nodes are excused. */ +/* GUARD: a WIDTH-BEARING loss at EOF stays honest with a blank tail too, and + * only zero-width nodes are excused. (Formerly the unterminated Makefile + * recipe, which #2078 now parses -- see the #1610 guard above.) */ TEST(width_bearing_error_at_eof_still_flagged_with_trailing_blank_issue1746) { - const char *src = "all:\n\techo hi "; - CBMFileResult *r = do_extract(src, CBM_LANG_MAKEFILE, "Makefile"); + const char *src = "def ok():\n return 1\nx = (1, "; + CBMFileResult *r = do_extract(src, CBM_LANG_PYTHON, "eof.py"); ASSERT_NOT_NULL(r); bool flagged = r->parse_incomplete; cbm_free_result(r); @@ -1615,6 +1638,246 @@ TEST(cs_malformed_conditional_remains_partial_issue1748) { PASS(); } +/* ── #2078: every file is parsed as if it ended with a newline ─────────────── + * + * Several grammars need a line terminator that a file's last line may simply + * not have. tree-sitter-markdown is the sharpest case: an opening code fence, + * an ATX heading or a bare list marker as the final bytes of a file leaves a + * WIDTH-BEARING ERROR (so the #1610 zero-width rule cannot excuse it), and a + * file that is only "```" came back as a whole-file error. Appending one "\n" + * made every one of these clean -- the reporter proved it on 21 real files. + * + * cbm now feeds the parser one virtual "\n" past EOF when the last byte is not + * already a newline, then clamps the tree back to the real length, so no + * consumer sees a byte, a line or a range that is not in the file. */ +TEST(markdown_unterminated_last_line_is_clean_issue2078) { + const char *cases[] = { + "text\n```", /* opening fence at EOF (upstream PR #262, closed unmerged) */ + "```", /* the whole file is one opening fence */ + "# Top", /* ATX heading */ + "- ", /* bare list marker */ + }; + int flagged = 0; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + CBMFileResult *r = do_extract(cases[i], CBM_LANG_MARKDOWN, "doc.md"); + ASSERT_NOT_NULL(r); + if (r->parse_incomplete || r->parse_unusable) { + fprintf(stderr, " case %zu flagged: partial=%d unusable=%d ranges=%s\n", i, + r->parse_incomplete, r->parse_unusable, + r->error_ranges ? r->error_ranges : "(none)"); + flagged++; + } + cbm_free_result(r); + } + if (flagged) { + FAIL("a markdown file must not be flagged only because its last line is unterminated"); + } + PASS(); +} + +/* Controls: the same bytes WITH the newline were already clean and stay so. */ +TEST(markdown_terminated_last_line_controls_unchanged_issue2078) { + const char *cases[] = {"text\n```\n", "```\n", "# Top\n", "- \n"}; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + CBMFileResult *r = do_extract(cases[i], CBM_LANG_MARKDOWN, "doc.md"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->parse_incomplete); + ASSERT_FALSE(r->parse_unusable); + cbm_free_result(r); + } + PASS(); +} + +/* Everything extraction reports, as one string, so two results compare + * byte-for-byte: defs (every text field and range), calls (exact byte spans), + * imports and the coverage verdict. */ +typedef struct { + char *buf; + size_t cap; + size_t len; +} dump_buf_t; + +static void dump_str(dump_buf_t *b, const char *tag, const char *s) { + int n = snprintf(b->buf + b->len, b->cap - b->len, " %s=%s", tag, s ? s : "~"); + if (n > 0 && (size_t)n < b->cap - b->len) { + b->len += (size_t)n; + } +} + +static void dump_num(dump_buf_t *b, const char *tag, long v) { + int n = snprintf(b->buf + b->len, b->cap - b->len, " %s=%ld", tag, v); + if (n > 0 && (size_t)n < b->cap - b->len) { + b->len += (size_t)n; + } +} + +/* The Module def spans the whole file, and its end line follows the line + * convention for a terminated last line (#1967), so it is compared on its own + * rather than through the dump. */ +static char *dump_extraction(const CBMFileResult *r) { + dump_buf_t b = {(char *)calloc(1u << 16, 1), 1u << 16, 0}; + if (!b.buf) { + return NULL; + } + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->label && strcmp(d->label, "Module") == 0) { + continue; + } + dump_str(&b, "\nD", d->qualified_name); + dump_str(&b, "name", d->name); + dump_str(&b, "label", d->label); + dump_num(&b, "start", d->start_line); + dump_num(&b, "end", d->end_line); + dump_num(&b, "lines", d->lines); + dump_num(&b, "cx", d->complexity); + dump_num(&b, "params", d->param_count); + dump_str(&b, "sig", d->signature); + dump_str(&b, "ret", d->return_type); + dump_str(&b, "doc", d->docstring); + dump_str(&b, "prof", d->structural_profile); + dump_str(&b, "tok", d->body_tokens); + } + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *c = &r->calls.items[i]; + dump_str(&b, "\nC", c->callee_name); + dump_str(&b, "in", c->enclosing_func_qn); + dump_num(&b, "line", c->start_line); + dump_num(&b, "from", (long)c->site_start_byte); + dump_num(&b, "to", (long)c->site_end_byte); + dump_num(&b, "args", c->arg_count); + } + for (int i = 0; i < r->imports.count; i++) { + dump_str(&b, "\nI", r->imports.items[i].local_name); + dump_str(&b, "path", r->imports.items[i].module_path); + } + dump_str(&b, "\nP", r->error_ranges); + dump_num(&b, "partial", r->parse_incomplete); + dump_num(&b, "unusable", r->parse_unusable); + dump_str(&b, "", "\n"); + return b.buf; +} + +static uint32_t module_end_line(const CBMFileResult *r) { + for (int i = 0; i < r->defs.count; i++) { + if (r->defs.items[i].label && strcmp(r->defs.items[i].label, "Module") == 0) { + return r->defs.items[i].end_line; + } + } + return 0; +} + +static char *extract_dump(const char *src, CBMLanguage lang, const char *path, + uint32_t *module_end) { + CBMFileResult *r = do_extract(src, lang, path); + if (!r) { + return NULL; + } + char *d = dump_extraction(r); + if (module_end) { + *module_end = module_end_line(r); + } + cbm_free_result(r); + return d; +} + +/* GUARD: for code grammars a trailing newline is insignificant, so a file + * without one must extract byte-identically to the same file with one -- every + * def, range, text field and call byte span. The virtual newline may never + * show up as phantom content or a phantom line. */ +TEST(code_without_final_newline_extracts_identically_issue2078) { + struct { + const char *src; /* no trailing newline */ + CBMLanguage lang; + const char *path; + uint32_t lines; + } cases[] = { + {"#include \n\nstatic int beta(int x) {\n return x + 1;\n}\n\n" + "void alpha(void) { printf(\"a\"); beta(2); }", + CBM_LANG_C, "a.c", 7}, + {"import os\n\n\nclass K:\n def m(self, a):\n return os.path.join(a)\n\n\n" + "def f(x):\n \"\"\"doc\"\"\"\n return K().m(x)", + CBM_LANG_PYTHON, "a.py", 11}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + size_t n = strlen(cases[i].src); + char *terminated = (char *)malloc(n + 2); + ASSERT_NOT_NULL(terminated); + memcpy(terminated, cases[i].src, n); + terminated[n] = '\n'; + terminated[n + 1] = '\0'; + uint32_t bare_module_end = 0; + char *bare = extract_dump(cases[i].src, cases[i].lang, cases[i].path, &bare_module_end); + char *term = extract_dump(terminated, cases[i].lang, cases[i].path, NULL); + free(terminated); + ASSERT_NOT_NULL(bare); + ASSERT_NOT_NULL(term); + bool same = strcmp(bare, term) == 0; + if (!same) { + fprintf(stderr, " %s differs:\n--- without final newline%s--- with final newline%s", + cases[i].path, bare, term); + } + free(bare); + free(term); + if (!same) { + FAIL("extraction must not depend on whether the last line is terminated"); + } + if (bare_module_end != cases[i].lines) { + fprintf(stderr, " %s: module ends on line %u, file has %u\n", cases[i].path, + bare_module_end, cases[i].lines); + FAIL("the virtual newline must not add a line to the module"); + } + } + PASS(); +} + +/* GUARD against phantom content: a heading on an unterminated last line is a + * one-line file. No def may end past line 1, no text may carry the virtual + * newline, and the result must equal the terminated file's. */ +TEST(virtual_newline_adds_no_phantom_line_or_text_issue2078) { + CBMFileResult *r = do_extract("# Top", CBM_LANG_MARKDOWN, "top.md"); + ASSERT_NOT_NULL(r); + bool found = has_def(r, "Top"); + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->end_line > 1 || d->start_line > 1) { + fprintf(stderr, " def %s spans %u-%u\n", d->name ? d->name : "?", d->start_line, + d->end_line); + cbm_free_result(r); + FAIL("no def may reach past the last real line"); + } + if ((d->name && strchr(d->name, '\n')) || (d->signature && strchr(d->signature, '\n'))) { + cbm_free_result(r); + FAIL("no extracted text may contain the virtual newline"); + } + } + cbm_free_result(r); + if (!found) { + /* Before #2078 the unterminated heading was silently dropped: no + * Section, and no parse_partial flag to say so. */ + FAIL("the heading on an unterminated last line must be extracted as a Section"); + } + PASS(); +} + +/* GUARD: the empty file and a file that is exactly "\n" get no virtual + * newline (nothing is unterminated) and stay clean. */ +TEST(empty_and_newline_only_files_unchanged_issue2078) { + const char *srcs[] = {"", "\n"}; + CBMLanguage langs[] = {CBM_LANG_MARKDOWN, CBM_LANG_PYTHON, CBM_LANG_C}; + const char *paths[] = {"e.md", "e.py", "e.c"}; + for (size_t s = 0; s < 2; s++) { + for (size_t l = 0; l < 3; l++) { + CBMFileResult *r = do_extract(srcs[s], langs[l], paths[l]); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->parse_incomplete); + ASSERT_FALSE(r->parse_unusable); + cbm_free_result(r); + } + } + PASS(); +} + /* #1967: every line counter over one source buffer answers the same * question the same way. A trailing newline ends the last line; it does not * open a new one. */ @@ -1658,6 +1921,7 @@ SUITE(parse_coverage) { RUN_TEST(missing_final_newline_not_flagged_across_grammars_issue1610); RUN_TEST(real_error_before_eof_still_flagged_without_final_newline_issue1610); RUN_TEST(width_bearing_error_at_eof_still_flagged_issue1610); + RUN_TEST(makefile_unterminated_recipe_is_parsed_issue2078); RUN_TEST(c_ifdef_split_range_narrows_to_dropped_branch); RUN_TEST(c_ifdef_split_range_excludes_lines_the_preprocessor_explained); RUN_TEST(c_ifdef_split_range_never_starts_on_a_directive); @@ -1684,4 +1948,9 @@ SUITE(parse_coverage) { RUN_TEST(cs_collection_expression_in_conditional_is_complete_issue1748); RUN_TEST(cs_calls_inside_collection_expression_extracted_issue1748); RUN_TEST(cs_malformed_conditional_remains_partial_issue1748); + RUN_TEST(markdown_unterminated_last_line_is_clean_issue2078); + RUN_TEST(markdown_terminated_last_line_controls_unchanged_issue2078); + RUN_TEST(code_without_final_newline_extracts_identically_issue2078); + RUN_TEST(virtual_newline_adds_no_phantom_line_or_text_issue2078); + RUN_TEST(empty_and_newline_only_files_unchanged_issue2078); } diff --git a/tests/test_venue_parity_contract.sh b/tests/test_venue_parity_contract.sh index c09a0144d..c847106b2 100755 --- a/tests/test_venue_parity_contract.sh +++ b/tests/test_venue_parity_contract.sh @@ -24,10 +24,13 @@ # 5. Interface probes — every entry must answer --help (exit 0, "Usage:") # and reject unknown flags with exit 2 + "Please consult --help." so an # agent can always discover exactly what a run will do. +# 6. Sanitizer runtime defaults — scripts/test.sh, not each venue, sets the +# ASan options every lane runs with (stack-use-after-return stays off +# unless a lane asks for it explicitly, as test-diag does). # # Usage: tests/test_venue_parity_contract.sh [repo-root] (root override so # the suite can prove the contract FAILS on a violation, not just passes; -# layer 5 runs only against the real repo root). +# layers 5 and 6 run only against the real repo root). set -euo pipefail @@ -459,3 +462,63 @@ scripts/ci/test-impact-shadow.sh fi echo "interface probes OK ($(printf '%s' "$HELP_ENTRIES" | grep -c .) --help entries, $(printf '%s' "$STRICT_ENTRIES" | grep -c .) strict-flag entries)" fi + +# ── Layer 6: sanitizer runtime defaults (real repo root only). Drives the REAL +# scripts/test.sh in --suites mode with a no-op `make` first on PATH and a +# probe standing in for the test-runner: the probe answers the build-config +# handshake, then records the ASAN_OPTIONS it inherited. Every case sets or +# unsets ASAN_OPTIONS itself, because this contract runs INSIDE test.sh, whose +# own export would otherwise leak in. +if [ -d "$ROOT/scripts" ] && [ -f "$ROOT/scripts/test.sh" ]; then + asan_failures=0 + asan_tmp="$(mktemp -d)" + trap 'rm -rf "$asan_tmp"' EXIT + mkdir -p "$asan_tmp/bin" "$asan_tmp/build" + printf '#!/usr/bin/env bash\nexit 0\n' > "$asan_tmp/bin/make" + cat > "$asan_tmp/build/test-runner" <<'EOF' +#!/usr/bin/env bash +if [ "${1:-}" = "--build-config" ]; then + printf 'sanitized=1 test_seams=1\n' + exit 0 +fi +printf '%s' "${ASAN_OPTIONS-}" > "$CBM_ASAN_PROBE_OUT" +EOF + chmod +x "$asan_tmp/bin/make" "$asan_tmp/build/test-runner" + + # asan_case LABEL GIVEN WANT: GIVEN is the caller's ASAN_OPTIONS ("" + # for none), WANT the exact value the runner must receive. + asan_case() { + local label="$1" given="$2" want="$3" seen rc + rm -f "$asan_tmp/seen" + ( + if [ "$given" = "" ]; then + unset ASAN_OPTIONS + else + export ASAN_OPTIONS="$given" + fi + cd "$ROOT" && PATH="$asan_tmp/bin:$PATH" CBM_NO_CCACHE=1 \ + CBM_ASAN_PROBE_OUT="$asan_tmp/seen" \ + bash scripts/test.sh --suites probe "BUILD_DIR=$asan_tmp/build" + ) >"$asan_tmp/log" 2>&1 && rc=0 || rc=$? + seen="$(cat "$asan_tmp/seen" 2>/dev/null || printf '')" + if [ "$rc" -ne 0 ] || [ "$seen" != "$want" ]; then + echo "SANITIZER DEFAULTS: $label: runner got ASAN_OPTIONS=[$seen], want [$want] (test.sh rc=$rc)" >&2 + sed 's/^/ /' "$asan_tmp/log" >&2 + asan_failures=1 + fi + } + + asan_case "unset" "" \ + "detect_stack_use_after_return=0" + asan_case "other options kept" "detect_leaks=1:halt_on_error=1" \ + "detect_leaks=1:halt_on_error=1:detect_stack_use_after_return=0" + asan_case "explicit setting wins (test-diag)" \ + "detect_stack_use_after_return=1:strict_string_checks=1:detect_stack_use_after_scope=1" \ + "detect_stack_use_after_return=1:strict_string_checks=1:detect_stack_use_after_scope=1" + + if [ "$asan_failures" -ne 0 ]; then + echo "VENUE PARITY CONTRACT VIOLATED — sanitizer runtime defaults failed (layer 6)" >&2 + exit 1 + fi + echo "sanitizer runtime defaults OK (3 ASAN_OPTIONS cases through scripts/test.sh)" +fi diff --git a/tests/test_workspace.c b/tests/test_workspace.c index afe6726e2..7f163db29 100644 --- a/tests/test_workspace.c +++ b/tests/test_workspace.c @@ -334,13 +334,29 @@ TEST(ws_manifest_rejects_control_characters) { /* THE property the design rests on: a manifest grants nothing until a person * approves it, and editing it lapses that approval rather than inheriting it. */ TEST(ws_manifest_approval_is_keyed_to_content) { - char *base = th_mktempdir("cbm_ws_m3"); - char *cache = th_mktempdir("cbm_ws_m3c"); - ASSERT(base != NULL); - ASSERT(cache != NULL); + /* th_mktempdir returns one static buffer: copy each result at once. */ + char base[256]; + char cache[256]; + char *created = th_mktempdir("cbm_ws_m3"); + ASSERT(created != NULL); + snprintf(base, sizeof(base), "%s", created); + created = th_mktempdir("cbm_ws_m3c"); + ASSERT(created != NULL); + snprintf(cache, sizeof(cache), "%s", created); + + /* Entries must name existing directories. */ + char sdk[1024]; + char protos[1024]; + snprintf(sdk, sizeof(sdk), "%s/sdk", base); + snprintf(protos, sizeof(protos), "%s/protos", base); + ASSERT_EQ(cbm_mkdir(sdk), 0); + ASSERT_EQ(cbm_mkdir(protos), 0); + char path[1024]; + char body[4096]; snprintf(path, sizeof(path), "%s/%s", base, CBM_WS_MANIFEST_NAME); - ws_write(path, "/opt/sdk\n"); + snprintf(body, sizeof(body), "%s\n", sdk); + ws_write(path, body); cbm_ws_manifest_t before; ASSERT_TRUE(cbm_workspace_manifest_read(base, &before)); @@ -352,7 +368,8 @@ TEST(ws_manifest_approval_is_keyed_to_content) { ASSERT_TRUE(cbm_workspace_manifest_is_approved(cache, base, &before)); /* Widen the requests, as a `git pull` would. Approval must lapse. */ - ws_write(path, "/opt/sdk\n/srv/protos\n"); + snprintf(body, sizeof(body), "%s\n%s\n", sdk, protos); + ws_write(path, body); cbm_ws_manifest_t after; ASSERT_TRUE(cbm_workspace_manifest_read(base, &after)); ASSERT_TRUE(strcmp(before.digest, after.digest) != 0); @@ -363,18 +380,35 @@ TEST(ws_manifest_approval_is_keyed_to_content) { PASS(); } -/* Approving a manifest must not become a route around the breadth policy. */ +/* Approving a manifest must not become a route around the breadth policy. The + * entry has to exist, so the overbroad directory is "/etc" on POSIX and, on + * Windows, where one component below the drive is already an ordinary + * workspace, the drive root of the temporary directory. */ TEST(ws_manifest_approval_refuses_overbroad_requests) { - char *base = th_mktempdir("cbm_ws_m4"); - char *cache = th_mktempdir("cbm_ws_m4c"); - ASSERT(base != NULL); - ASSERT(cache != NULL); + char base[256]; + char cache[256]; + char *created = th_mktempdir("cbm_ws_m4"); + ASSERT(created != NULL); + snprintf(base, sizeof(base), "%s", created); + created = th_mktempdir("cbm_ws_m4c"); + ASSERT(created != NULL); + snprintf(cache, sizeof(cache), "%s", created); + char path[1024]; + char body[1024]; + const char *refusal = NULL; snprintf(path, sizeof(path), "%s/%s", base, CBM_WS_MANIFEST_NAME); - ws_write(path, "/etc\n"); +#ifdef _WIN32 + snprintf(body, sizeof(body), "%.3s\n", base); + refusal = cbm_workspace_verdict_reason(CBM_WS_DENY_ABSOLUTE); +#else + snprintf(body, sizeof(body), "/etc\n"); + refusal = cbm_workspace_verdict_reason(CBM_WS_DENY_TOO_SHALLOW); +#endif + ws_write(path, body); char err[1024]; ASSERT_FALSE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err))); - ASSERT_TRUE(strstr(err, "too broad") != NULL); + ASSERT_TRUE(strstr(err, refusal) != NULL); th_cleanup(base); th_cleanup(cache); PASS(); @@ -463,6 +497,323 @@ TEST(ws_linked_home_classified_as_home) { #endif } +/* ── Manifest entries are judged in their resolved form ──────────────────── */ + +enum { WS_T_PATH = 4096 }; + +/* th_mktempdir hands back one static buffer, so copy at once — and in canonical + * form, because the policy compares resolved spellings and /tmp is a link on + * macOS. */ +static bool ws_tempdir_canonical(const char *prefix, char *out, size_t out_sz) { + char *created = th_mktempdir(prefix); + return created && cbm_canonical_path(created, out, out_sz) == 1; +} + +static bool ws_join(char *out, size_t out_sz, const char *a, const char *b) { + int n = snprintf(out, out_sz, "%s/%s", a, b); + return n > 0 && (size_t)n < out_sz; +} + +/* Create / and return it in canonical form. */ +static bool ws_mkdir_canonical(const char *parent, const char *name, char *out, size_t out_sz) { + char made[WS_T_PATH]; + if (!ws_join(made, sizeof(made), parent, name) || cbm_mkdir(made) != 0) { + return false; + } + return cbm_canonical_path(made, out, out_sz) == 1; +} + +static void ws_manifest_write(const char *base, const char *body) { + char path[WS_T_PATH]; + if (ws_join(path, sizeof(path), base, CBM_WS_MANIFEST_NAME)) { + ws_write(path, body); + } +} + +static void ws_manifest_write_line(const char *base, const char *entry) { + char body[WS_T_PATH]; + int n = snprintf(body, sizeof(body), "%s\n", entry); + if (n > 0 && (size_t)n < sizeof(body)) { + ws_manifest_write(base, body); + } +} + +/* Put the manifest on record as approved by writing the store line directly, + * in the form cbm_workspace_manifest_approve writes (" "). + * This is the record an approval leaves behind when the entry was judged by + * its spelling, and the use-time check must stand on its own against it. */ +static bool ws_manifest_record_approval(const char *cache, const char *base) { + cbm_ws_manifest_t m; + if (!cbm_workspace_manifest_read(base, &m) || !m.present) { + return false; + } + char store[WS_T_PATH]; + char line[WS_T_PATH]; + if (!ws_join(store, sizeof(store), cache, "approved_manifests")) { + return false; + } + int n = snprintf(line, sizeof(line), "%s %s\n", m.digest, base); + if (n <= 0 || (size_t)n >= sizeof(line)) { + return false; + } + ws_write(store, line); + return cbm_workspace_manifest_is_approved(cache, base, &m); +} + +/* The directory a manifest entry names is the one that would be indexed, so + * that is the form the policy judges — at approval and again at use. Spelled + * through `..`, an entry reads as an ordinary path several components deep + * while naming the home directory or the volume root. */ +TEST(ws_manifest_entry_classified_in_resolved_form) { + char base[WS_T_PATH]; + char cache[WS_T_PATH]; + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m5", base, sizeof(base))); + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m5c", cache, sizeof(cache))); + + char home[WS_T_PATH]; + char src[WS_T_PATH]; + char home_sub[WS_T_PATH]; + ASSERT_TRUE(ws_mkdir_canonical(base, "home", home, sizeof(home))); + ASSERT_TRUE(ws_mkdir_canonical(base, "src", src, sizeof(src))); + ASSERT_TRUE(ws_mkdir_canonical(home, "notes", home_sub, sizeof(home_sub))); + + char entry[WS_T_PATH]; + char expected[WS_T_PATH]; + char err[1024]; + + /* "/src/../home" is the home directory. */ + ASSERT_TRUE(ws_join(entry, sizeof(entry), src, "../home")); + ws_manifest_write_line(base, entry); + ASSERT_FALSE(cbm_workspace_manifest_approve(cache, home, base, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, entry)); + ASSERT(snprintf(expected, sizeof(expected), "(resolves to %s)", home) > 0); + ASSERT_NOT_NULL(strstr(err, expected)); + ASSERT_NOT_NULL(strstr(err, cbm_workspace_verdict_reason(CBM_WS_DENY_SENSITIVE))); + + /* An approval already on record for this spelling does not outrank the + * use-time check. */ + ASSERT_TRUE(ws_manifest_record_approval(cache, base)); + ASSERT_FALSE(cbm_workspace_manifest_allows(cache, home, base, home_sub)); + + /* Enough `..` segments reach the volume root from anywhere. */ + ASSERT_TRUE(ws_join(entry, sizeof(entry), base, "../../../../../../../../../../../..")); + ws_manifest_write_line(base, entry); + ASSERT_FALSE(cbm_workspace_manifest_approve(cache, home, base, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, entry)); + ASSERT_NOT_NULL(strstr(err, cbm_workspace_verdict_reason(CBM_WS_DENY_ABSOLUTE))); + + th_cleanup(base); + th_cleanup(cache); + PASS(); +} + +/* An entry that does not resolve to a directory is refused at approval, naming + * the entry; at use it simply matches nothing, and the other entries of the + * same manifest keep working. */ +TEST(ws_manifest_unresolvable_entry_refused) { + char base[WS_T_PATH]; + char cache[WS_T_PATH]; + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m6", base, sizeof(base))); + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m6c", cache, sizeof(cache))); + + char entry[WS_T_PATH]; + char err[1024]; + + /* Missing. */ + ASSERT_TRUE(ws_join(entry, sizeof(entry), base, "missing")); + ws_manifest_write_line(base, entry); + ASSERT_FALSE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, entry)); + ASSERT_NOT_NULL(strstr(err, "must name an existing directory")); + + /* Present, but a file. */ + ASSERT_TRUE(ws_join(entry, sizeof(entry), base, "notes.txt")); + ws_write(entry, "not a directory\n"); + ws_manifest_write_line(base, entry); + ASSERT_FALSE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, entry)); + ASSERT_NOT_NULL(strstr(err, "is not a directory")); + + /* Relative: the file is read by a long-lived process whose working + * directory is unrelated to the project, so there is no base to resolve + * it against. */ + ws_manifest_write_line(base, "src"); + ASSERT_FALSE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "requested path src:")); + ASSERT_NOT_NULL(strstr(err, "must be an absolute path")); + + /* At use: a directory that disappeared after approval matches nothing and + * does not take the rest of the manifest with it. */ + char present[WS_T_PATH]; + char present_sub[WS_T_PATH]; + char body[WS_T_PATH]; + ASSERT_TRUE(ws_mkdir_canonical(base, "present", present, sizeof(present))); + ASSERT_TRUE(ws_mkdir_canonical(present, "inc", present_sub, sizeof(present_sub))); + ASSERT_TRUE(ws_join(entry, sizeof(entry), base, "gone")); + ASSERT(snprintf(body, sizeof(body), "%s\n%s\n", entry, present) > 0); + ws_manifest_write(base, body); + ASSERT_TRUE(ws_manifest_record_approval(cache, base)); + ASSERT_TRUE(cbm_workspace_manifest_allows(cache, HOME, base, present_sub)); + ASSERT_FALSE(cbm_workspace_manifest_allows(cache, HOME, base, base)); + + th_cleanup(base); + th_cleanup(cache); + PASS(); +} + +/* The ordinary shapes keep their result: an absolute existing directory, an + * entry equal to the project root, an entry outside every root in the grant + * store (the manifest is consulted on its own), and on POSIX a link to an + * ordinary directory. */ +TEST(ws_manifest_ordinary_entries_unchanged) { + char base[WS_T_PATH]; + char cache[WS_T_PATH]; + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m7", base, sizeof(base))); + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m7c", cache, sizeof(cache))); + + char sdk[WS_T_PATH]; + char sdk_inc[WS_T_PATH]; + char granted[WS_T_PATH]; + ASSERT_TRUE(ws_mkdir_canonical(base, "sdk", sdk, sizeof(sdk))); + ASSERT_TRUE(ws_mkdir_canonical(sdk, "inc", sdk_inc, sizeof(sdk_inc))); + ASSERT_TRUE(ws_mkdir_canonical(base, "granted", granted, sizeof(granted))); + + char err[1024]; + /* The grant store names a root the sdk is not under. */ + ASSERT_TRUE(cbm_workspace_grant_add(cache, HOME, granted, false, err, sizeof(err))); + + ws_manifest_write_line(base, sdk); + ASSERT_TRUE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err))); + ASSERT_STR_EQ(err, ""); + ASSERT_TRUE(cbm_workspace_manifest_allows(cache, HOME, base, sdk_inc)); + ASSERT_FALSE(cbm_workspace_manifest_allows(cache, HOME, base, granted)); + + /* Equal to the project root. */ + ws_manifest_write_line(base, base); + ASSERT_TRUE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_manifest_allows(cache, HOME, base, base)); + ASSERT_TRUE(cbm_workspace_manifest_allows(cache, HOME, base, sdk_inc)); + +#ifndef _WIN32 + /* A link to an ordinary directory is that directory. Windows is not + * covered here: symlink(2) has no counterpart in the test helpers, and + * CreateSymbolicLinkW needs a privilege the CI runner does not hold; the + * resolved-form rules themselves are exercised on every platform by the + * `..` cases above. */ + char alias[WS_T_PATH]; + ASSERT_TRUE(ws_join(alias, sizeof(alias), base, "alias")); + ASSERT_EQ(symlink(sdk, alias), 0); + ws_manifest_write_line(base, alias); + ASSERT_TRUE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_manifest_allows(cache, HOME, base, sdk_inc)); +#endif + + th_cleanup(base); + th_cleanup(cache); + PASS(); +} + +#ifndef _WIN32 +/* A link is judged by where it leads. Windows is not covered: symlink(2) has + * no counterpart in the test helpers and CreateSymbolicLinkW needs a privilege + * the CI runner does not hold; the resolved-form rules are exercised on every + * platform by ws_manifest_entry_classified_in_resolved_form. */ +TEST(ws_manifest_link_to_sensitive_directory_refused) { + char base[WS_T_PATH]; + char cache[WS_T_PATH]; + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m8", base, sizeof(base))); + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m8c", cache, sizeof(cache))); + + char home[WS_T_PATH]; + char home_sub[WS_T_PATH]; + char keys[WS_T_PATH]; + char keys_sub[WS_T_PATH]; + ASSERT_TRUE(ws_mkdir_canonical(base, "home", home, sizeof(home))); + ASSERT_TRUE(ws_mkdir_canonical(home, "notes", home_sub, sizeof(home_sub))); + ASSERT_TRUE(ws_mkdir_canonical(base, ".ssh", keys, sizeof(keys))); + ASSERT_TRUE(ws_mkdir_canonical(keys, "hosts", keys_sub, sizeof(keys_sub))); + + char to_home[WS_T_PATH]; + char to_keys[WS_T_PATH]; + ASSERT_TRUE(ws_join(to_home, sizeof(to_home), base, "to-home")); + ASSERT_TRUE(ws_join(to_keys, sizeof(to_keys), base, "to-keys")); + ASSERT_EQ(symlink(home, to_home), 0); + ASSERT_EQ(symlink(keys, to_keys), 0); + + char expected[WS_T_PATH]; + char err[1024]; + + ws_manifest_write_line(base, to_home); + ASSERT_FALSE(cbm_workspace_manifest_approve(cache, home, base, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, to_home)); + ASSERT(snprintf(expected, sizeof(expected), "(resolves to %s)", home) > 0); + ASSERT_NOT_NULL(strstr(err, expected)); + ASSERT_NOT_NULL(strstr(err, cbm_workspace_verdict_reason(CBM_WS_DENY_SENSITIVE))); + + ws_manifest_write_line(base, to_keys); + ASSERT_FALSE(cbm_workspace_manifest_approve(cache, home, base, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, to_keys)); + ASSERT(snprintf(expected, sizeof(expected), "(resolves to %s)", keys) > 0); + ASSERT_NOT_NULL(strstr(err, expected)); + ASSERT_NOT_NULL(strstr(err, cbm_workspace_verdict_reason(CBM_WS_DENY_SENSITIVE))); + + /* On record as approved: the use-time check still refuses both. */ + char body[WS_T_PATH]; + ASSERT(snprintf(body, sizeof(body), "%s\n%s\n", to_home, to_keys) > 0); + ws_manifest_write(base, body); + ASSERT_TRUE(ws_manifest_record_approval(cache, base)); + ASSERT_FALSE(cbm_workspace_manifest_allows(cache, home, base, home_sub)); + ASSERT_FALSE(cbm_workspace_manifest_allows(cache, home, base, keys_sub)); + + th_cleanup(base); + th_cleanup(cache); + PASS(); +} + +/* Approval binds to the manifest text; where a link leads is read again each + * time the manifest is used. A link approved while it led to an ordinary + * directory and later pointed at the home directory grants nothing there. + * POSIX only, for the reason given at the test above. */ +TEST(ws_manifest_link_retarget_is_seen_at_use) { + char base[WS_T_PATH]; + char cache[WS_T_PATH]; + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m9", base, sizeof(base))); + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m9c", cache, sizeof(cache))); + + char shared[WS_T_PATH]; + char shared_sub[WS_T_PATH]; + char home[WS_T_PATH]; + char home_sub[WS_T_PATH]; + ASSERT_TRUE(ws_mkdir_canonical(base, "shared", shared, sizeof(shared))); + ASSERT_TRUE(ws_mkdir_canonical(shared, "proto", shared_sub, sizeof(shared_sub))); + ASSERT_TRUE(ws_mkdir_canonical(base, "home", home, sizeof(home))); + ASSERT_TRUE(ws_mkdir_canonical(home, "notes", home_sub, sizeof(home_sub))); + + char extra[WS_T_PATH]; + ASSERT_TRUE(ws_join(extra, sizeof(extra), base, "extra")); + ASSERT_EQ(symlink(shared, extra), 0); + + char err[1024]; + ws_manifest_write_line(base, extra); + ASSERT_TRUE(cbm_workspace_manifest_approve(cache, home, base, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_manifest_allows(cache, home, base, shared_sub)); + + /* Same text, new target. */ + ASSERT_EQ(unlink(extra), 0); + ASSERT_EQ(symlink(home, extra), 0); + cbm_ws_manifest_t m; + ASSERT_TRUE(cbm_workspace_manifest_read(base, &m)); + ASSERT_TRUE(cbm_workspace_manifest_is_approved(cache, base, &m)); + + ASSERT_FALSE(cbm_workspace_manifest_allows(cache, home, base, home_sub)); + ASSERT_FALSE(cbm_workspace_manifest_allows(cache, home, base, shared_sub)); + + th_cleanup(base); + th_cleanup(cache); + PASS(); +} +#endif /* _WIN32 */ + SUITE(workspace) { RUN_TEST(ws_manifest_absent_is_not_an_error); RUN_TEST(ws_manifest_parses_entries_and_skips_comments); @@ -486,4 +837,11 @@ SUITE(workspace) { RUN_TEST(ws_every_verdict_has_a_reason); RUN_TEST(ws_home_dir_is_resolved); RUN_TEST(ws_linked_home_classified_as_home); + RUN_TEST(ws_manifest_entry_classified_in_resolved_form); + RUN_TEST(ws_manifest_unresolvable_entry_refused); + RUN_TEST(ws_manifest_ordinary_entries_unchanged); +#ifndef _WIN32 + RUN_TEST(ws_manifest_link_to_sensitive_directory_refused); + RUN_TEST(ws_manifest_link_retarget_is_seen_at_use); +#endif }