Merge main into fix/c-typedefs-macros-enumerators for the release batch

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-10-08 01:31:33 +02:00
38 changed files with 2569 additions and 126 deletions
+5 -1
View File
@@ -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:
+6
View File
@@ -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
+2
View File
@@ -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
+74 -16
View File
@@ -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,
+8
View File
@@ -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
+124
View File
@@ -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'). */
+2 -2
View File
@@ -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)
+6 -4
View File
@@ -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;
}
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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;
+12
View File
@@ -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
+59 -10
View File
@@ -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 <entry>: <reason>", 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;
}
}
+11 -4
View File
@@ -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);
+16
View File
@@ -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);
+3 -2
View File
@@ -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) {
+158 -11
View File
@@ -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 <sqlite3/sqlite3.h>
#include <yyjson/yyjson.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
@@ -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);
+24
View File
@@ -9,6 +9,12 @@
#include <stdatomic.h>
/* 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
+2 -1
View File
@@ -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) {
+60 -7
View File
@@ -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:
+38
View File
@@ -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. */
+154
View File
@@ -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: <project>.<dirs>.<file stem>.<Class>.<method>.
* 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 ".<cls>.<member>"? 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 = ".<Class>.<member>") 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 "<Class>.<member>" 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) {
+71
View File
@@ -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,
+11
View File
@@ -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:<project>".
* 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
+76 -27
View File
@@ -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);
+5 -3
View File
@@ -51,17 +51,19 @@ RUN printf '%s\n' \
# The key is fetched with `wget -O <file>` rather than `-qO- > <file>`: 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 \
+7
View File
@@ -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);
+116
View File
@@ -10,6 +10,7 @@
#include "pipeline/pipeline_internal.h"
#include <sqlite3/sqlite3.h>
#include <yyjson/yyjson.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
@@ -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);
+236
View File
@@ -922,6 +922,237 @@ TEST(routes_laravel_withrouting_parallel_subdir_issue1146) {
PASS();
}
/* Assert the exact HANDLES edge set as "<handler-QN tail> -> <route name>"
* pairs. The tail is matched at a segment boundary against the handler's
* qualified name ("UserController.show" matches
* "<project>.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",
"<?php\nnamespace Acme\\Blog\\Http;\n\n"
"class PostsController {\n"
" public function index() { return ['posts' => []]; }\n}\n"},
{"app/Http/Controllers/UserController.php",
"<?php\nnamespace App\\Http\\Controllers;\n\n"
"class UserController {\n"
" public function show($id) { return ['id' => $id]; }\n"
" public function index() { return []; }\n}\n"},
{"app/Http/Controllers/Admin/UserController.php",
"<?php\nnamespace App\\Http\\Controllers\\Admin;\n\n"
"class UserController {\n"
" public function show($id) { return ['admin' => $id]; }\n}\n"},
{"app/Http/Controllers/PostController.php",
"<?php\nnamespace App\\Http\\Controllers;\n\n"
"class PostController {\n"
" public function show($id) { return ['post' => $id]; }\n}\n"},
{"app/Http/Controllers/GetCurrentUserController.php",
"<?php\nnamespace App\\Http\\Controllers;\n\n"
"class GetCurrentUserController {\n"
" public function __invoke() { return ['me' => true]; }\n}\n"},
{"app/Http/Controllers/HealthController.php",
"<?php\nnamespace App\\Http\\Controllers;\n\n"
"class HealthController {\n"
" public function __invoke() { return ['ok' => true]; }\n}\n"},
{"routes/api.php",
"<?php\n"
"use App\\Http\\Controllers\\UserController;\n"
"use App\\Http\\Controllers\\GetCurrentUserController as CurrentUser;\n"
"use Acme\\Blog\\Http\\PostsController;\n"
"use Illuminate\\Support\\Facades\\Route;\n\n"
"Route::get('/users/{id}', [UserController::class, 'show']);\n"
"Route::get('/me', CurrentUser::class);\n"
"Route::get('/posts/{id}', [\\App\\Http\\Controllers\\PostController::class, 'show']);\n"
"Route::get('/blog', [PostsController::class, 'index']);\n"
"Route::get('/vendor/{id}', [VendorController::class, 'show']);\n"
"Route::get('/vendor-ping', VendorInvokable::class);\n"}};
enum { ET_LARAVEL_CLASS_FILES = 8 };
static const EtHandles et_laravel_class_handles[] = {
{"Controllers.UserController.UserController.show", "/users/{id}"},
{"GetCurrentUserController.__invoke", "/me"},
{"PostController.show", "/posts/{id}"},
{"PostsController.index", "/blog"},
{NULL, NULL}};
TEST(handles_laravel_class_handlers_issue1146) {
ASSERT_TRUE(et_handles_exact(et_laravel_class_handlers, ET_LARAVEL_CLASS_FILES,
et_laravel_class_handles, 0));
PASS();
}
/* Same fixture through the parallel pipeline (> 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",
"<?php\nnamespace Acme\\Blog\\Http;\n\n"
"class MissingController {\n"
" public function index() { return ['stale' => true]; }\n}\n"},
{"legacy/Acme/Blog/Http/PostsController.php",
"<?php\nnamespace Acme\\Blog\\Http;\n\n"
"class PostsController {\n"
" public function archive() { return ['stale' => true]; }\n}\n"},
{"routes/blog.php",
"<?php\n"
"use Illuminate\\Support\\Facades\\Route;\n\n"
"Route::get('/blog/missing', [\\Acme\\Blog\\Http\\MissingController::class, 'index']);\n"
"Route::get('/blog/archive', [\\Acme\\Blog\\Http\\PostsController::class, 'archive']);\n"}};
enum { ET_LARAVEL_PSR4_ABSENT_EXTRA = 3 };
static const char *const et_laravel_psr4_absent_routes[] = {
"__route__GET__/blog/missing", "__route__GET__/blog/archive", NULL};
static int et_laravel_psr4_absent(int parallel) {
EtFile f[ET_LARAVEL_CLASS_FILES + ET_LARAVEL_PSR4_ABSENT_EXTRA];
memcpy(f, et_laravel_class_handlers, sizeof(et_laravel_class_handlers));
memcpy(f + ET_LARAVEL_CLASS_FILES, et_laravel_psr4_absent_extra,
sizeof(et_laravel_psr4_absent_extra));
return et_handles_exact_routes(f, ET_LARAVEL_CLASS_FILES + ET_LARAVEL_PSR4_ABSENT_EXTRA,
et_laravel_class_handles, parallel,
et_laravel_psr4_absent_routes);
}
TEST(handles_laravel_psr4_absent_class_issue1146) {
ASSERT_TRUE(et_laravel_psr4_absent(0));
PASS();
}
TEST(handles_laravel_psr4_absent_class_parallel_issue1146) {
ASSERT_TRUE(et_laravel_psr4_absent(1));
PASS();
}
/* Assert the exact Route set as "METHOD /path" strings: the method comes from
* the Route QN ("__route__PUT__/articles/{}"), the path from the node name
* (the raw composed path). The count must match too, so a junk Route fails. */
@@ -2293,6 +2524,11 @@ SUITE(edge_types_probe) {
RUN_TEST(routes_laravel_routeserviceprovider_control_issue1146);
RUN_TEST(routes_laravel_withrouting_nonliteral_prefix_issue1146);
RUN_TEST(routes_laravel_withrouting_parallel_subdir_issue1146);
RUN_TEST(handles_laravel_class_handlers_issue1146);
RUN_TEST(handles_laravel_class_handlers_parallel_issue1146);
RUN_TEST(handles_laravel_class_handlers_no_composer_issue1146);
RUN_TEST(handles_laravel_psr4_absent_class_issue1146);
RUN_TEST(handles_laravel_psr4_absent_class_parallel_issue1146);
RUN_TEST(routes_laravel_slashless_issue1146);
RUN_TEST(routes_laravel_slashless_parallel_issue1146);
RUN_TEST(routes_laravel_slashless_no_guessed_handlers_issue1146);
+462 -4
View File
@@ -18,6 +18,7 @@
#include "../src/foundation/compat_thread.h"
#include "../src/foundation/log.h"
#include "../src/foundation/platform.h"
#include "../src/foundation/str_util.h" /* cbm_json_escape: expected index-status fragment */
#include "../src/cli/cli.h"
#include "../src/daemon/host_internal.h"
#include "../src/git/git_context.h" /* #798 follow-up: live-socket git-resolve repro */
@@ -28,7 +29,9 @@
#include "ui/http_server.h"
#include <store/store.h>
#include <watcher/watcher.h>
#include <yyjson/yyjson.h> /* the index replies are asserted as parsed JSON */
#include <limits.h> /* PATH_MAX: which platforms resolve a root past 1 KB */
#include <stdio.h>
#include <stdatomic.h>
#include <stdlib.h>
@@ -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);
+97
View File
@@ -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);
+277 -8
View File
@@ -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 <stdio.h>\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);
}
+64 -1
View File
@@ -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-<unset>}" > "$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 ("<unset>"
# 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" = "<unset>" ]; 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 '<runner not reached>')"
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" "<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
+371 -13
View File
@@ -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 <parent>/<name> 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 ("<digest> <project>").
* 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];
/* "<base>/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
}