mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge main into fix/issue-2078 for the release batch
Conflict in tests/test_parse_coverage.c: this branch's #2078 tests and main's #1967 line-count test (#2336) were both appended before the suite; both kept, every TEST still registered. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
commit
ea2e36aa74
16 files changed
+1066
-68
No files matched your search
@@ -236,6 +236,33 @@ be indexed and later returned. And the credential list is a denylist, so it
|
||||
raises the cost of a mistake rather than closing the class — a directory it does
|
||||
not name is permitted.
|
||||
|
||||
### Shared / HPC filesystems
|
||||
|
||||
On clusters and other shared machines, home directories often live on a network
|
||||
filesystem (NFS, Lustre, GPFS, SMB/CIFS), and `~/.cache` is sometimes a symlink
|
||||
into a shared tree. Three rules keep CBM working there:
|
||||
|
||||
- **Put the cache and the rendezvous on local storage.** Set `CBM_CACHE_DIR` (indexes,
|
||||
`_config.db`) and, if needed, `CBM_RUNTIME_DIR` (the daemon rendezvous) to a
|
||||
local disk you own, for example `/local/scratch/$USER/cbm` or `/tmp/$USER-cbm`.
|
||||
Set the same values in your MCP client's environment and your shell.
|
||||
- **Do not route the cache into another account's tree.** A symlinked `~/.cache` is
|
||||
resolved and allowed, but every directory on the *resolved* path must be owned
|
||||
by you or by root. If a parent such as `/shared/fs` belongs to another account,
|
||||
that account can rename or replace anything below it, so CBM refuses the path
|
||||
and names the directory and its owner:
|
||||
|
||||
```text
|
||||
... (cache-private) - '/shared/fs' is owned by uid 1234 (alice), not by you (uid 1000) or root; ...
|
||||
```
|
||||
|
||||
This check has no override. Choose a location with a safe ancestry instead.
|
||||
- **Avoid network filesystems for the cache.** The indexes are SQLite databases in
|
||||
WAL mode, which needs shared-memory locking that network filesystems do not
|
||||
provide reliably. On Linux, CBM logs one `daemon.cache_root_network_fs` warning
|
||||
at startup when the cache root is on NFS, Lustre, GPFS, CIFS or SMB2. The cache
|
||||
still works there, but it can be slow, and a database can be corrupted.
|
||||
|
||||
## 5. Agent and Editor Integration Files
|
||||
|
||||
The `install` command can also write MCP entries and instruction blocks into agent/editor config files such as Claude Code, Codex, Gemini, VS Code, Cursor, Zed, and others.
|
||||
|
||||
+23
-25
@@ -1498,6 +1498,26 @@ static void cbm_mark_pp_error_rows(TSNode n, uint8_t *rows, uint32_t row_count,
|
||||
}
|
||||
}
|
||||
|
||||
/* The ONE line counter for a source buffer (#1967). Every caller that asks
|
||||
* "how many lines does this file have" uses it, so the coverage report never
|
||||
* holds two answers for the same buffer.
|
||||
*
|
||||
* Convention: a '\n' TERMINATES a line; it does not open a new one. So the
|
||||
* count is the number of '\n' that have at least one byte after them, plus
|
||||
* one. "a\nb" and "a\nb\n" both have 2 lines (the trailing newline adds no
|
||||
* phantom empty line, matching what an editor shows); "a\nb" without a final
|
||||
* newline still counts its last line. An empty buffer counts as 1 line, because
|
||||
* tree-sitter still reports row 0 for it and 1-based line maps index line 1. */
|
||||
uint32_t cbm_source_line_count(const char *src, int src_len) {
|
||||
uint32_t n = 1;
|
||||
for (int i = 0; i + 1 < src_len; i++) {
|
||||
if (src[i] == '\n') {
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Recovery subtraction (#963): tree-sitter error recovery plus the
|
||||
* ERROR-descending def walker often still extract constructs INSIDE a failed
|
||||
* region (verified: a function in an #ifdef-split ERROR region and even a
|
||||
@@ -1515,12 +1535,7 @@ static void cbm_mark_pp_error_rows(TSNode n, uint8_t *rows, uint32_t row_count,
|
||||
* Now the uncovered gaps are reported instead, and a gap holding only blank,
|
||||
* comment or preprocessor lines is not a miss at all. */
|
||||
static uint32_t *cbm_line_offsets(const char *src, int src_len, uint32_t *out_lines) {
|
||||
uint32_t lines = 1;
|
||||
for (int i = 0; i < src_len; i++) {
|
||||
if (src[i] == '\n') {
|
||||
lines++;
|
||||
}
|
||||
}
|
||||
uint32_t lines = cbm_source_line_count(src, src_len);
|
||||
uint32_t *offs =
|
||||
(uint32_t *)cbm_alloc(CBM_MEM_CLASS_EXTRACT, (size_t)(lines + 1) * sizeof(uint32_t));
|
||||
if (!offs) {
|
||||
@@ -2125,18 +2140,6 @@ static void cbm_refine_regions_with_pp_lines(cbm_error_regions_t *regs, const ui
|
||||
* this repo covers 25.5% of its file, and the next widest 3.9%. */
|
||||
#define CBM_UNUSABLE_PCT 80
|
||||
|
||||
/* Number of 1-based lines in `src`. A file that does not end with a newline
|
||||
* still has a last line, so the count is separators plus one. */
|
||||
static uint32_t cbm_count_lines(const char *src, int src_len) {
|
||||
uint32_t n = 1;
|
||||
for (int i = 0; i < src_len; i++) {
|
||||
if (src[i] == '\n' && i + 1 < src_len) {
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Serialize collected regions as "start-end,start-end,...", with a trailing
|
||||
* ",+<N>" when the cap threw N ranges away.
|
||||
*
|
||||
@@ -2926,12 +2929,7 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C
|
||||
* total loss (root is ERROR), because then it vouches for
|
||||
* nothing and there is no refinement to make. */
|
||||
if (strcmp(ts_node_type(pp_root), "ERROR") != 0) {
|
||||
uint32_t orig_lines = 1;
|
||||
for (int ci = 0; ci < source_len; ci++) {
|
||||
if (source[ci] == '\n') {
|
||||
orig_lines++;
|
||||
}
|
||||
}
|
||||
uint32_t orig_lines = cbm_source_line_count(source, source_len);
|
||||
uint8_t *map = (uint8_t *)cbm_arena_alloc(a, (size_t)orig_lines + 2);
|
||||
int exp_lines = preprocessed->expanded_line_count;
|
||||
uint8_t *bad_rows =
|
||||
@@ -3127,7 +3125,7 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C
|
||||
* so the report can say "read the source" instead. See
|
||||
* parse_unusable in cbm.h for which files land here and why. */
|
||||
if (regs.count == 1 && regs.dropped == 0) {
|
||||
uint32_t total = cbm_count_lines(source, source_len);
|
||||
uint32_t total = cbm_source_line_count(source, source_len);
|
||||
uint32_t span = regs.ends[0] - regs.starts[0] + 1;
|
||||
if (total > 0 && span * 100 >= total * CBM_UNUSABLE_PCT) {
|
||||
result->parse_unusable = true;
|
||||
|
||||
@@ -1013,6 +1013,11 @@ uint64_t cbm_usage_field_lookup_test_work(void);
|
||||
uint64_t cbm_usage_slow_parent_fallback_test_count(void);
|
||||
#endif
|
||||
|
||||
// Number of 1-based lines in a source buffer. The single line-count convention
|
||||
// for coverage reporting (#1967): a trailing '\n' ends the last line and opens
|
||||
// no new one; an empty buffer counts as 1 line. See the definition in cbm.c.
|
||||
uint32_t cbm_source_line_count(const char *src, int src_len);
|
||||
|
||||
// Toggle C/C++ preprocessor Macro-node extraction (#375). The pipeline enables
|
||||
// it only for full/advanced index modes (it dominates extraction on macro-dense
|
||||
// codebases). Default ON. Set before extraction; read-only during.
|
||||
|
||||
@@ -1763,6 +1763,59 @@ static const char *php_chain_prefix_arg(CBMArena *a, TSNode call_node, const cha
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* The value of a plain PHP string literal ('...' or "..." with no
|
||||
* interpolation or escapes), "" for an empty one; NULL for anything else. */
|
||||
static const char *php_plain_string_value(CBMArena *a, TSNode node, const char *source) {
|
||||
const char *kind = ts_node_type(node);
|
||||
if (strcmp(kind, "string") != 0 && strcmp(kind, "encapsed_string") != 0) {
|
||||
return NULL;
|
||||
}
|
||||
uint32_t nc = ts_node_named_child_count(node);
|
||||
for (uint32_t i = 0; i < nc; i++) {
|
||||
if (strcmp(ts_node_type(ts_node_named_child(node, i)), "string_content") != 0) {
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
return nc == 0 ? "" : cbm_node_text(a, ts_node_named_child(node, 0), source);
|
||||
}
|
||||
|
||||
/* The first argument's value node of a PHP call (unwrapping `argument`). */
|
||||
static TSNode php_first_arg_value(TSNode call_node) {
|
||||
TSNode none = {0};
|
||||
TSNode args = ts_node_child_by_field_name(call_node, TS_FIELD("arguments"));
|
||||
if (ts_node_is_null(args) || ts_node_named_child_count(args) == 0) {
|
||||
return none;
|
||||
}
|
||||
TSNode arg = ts_node_named_child(args, 0);
|
||||
if (strcmp(ts_node_type(arg), "argument") == 0) {
|
||||
return ts_node_named_child_count(arg) == SKIP_ONE ? ts_node_named_child(arg, 0) : none;
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
|
||||
/* The literal 'prefix' of an attribute-array group (#1146):
|
||||
* Route::group(['prefix' => 'v1/autocomplete', 'as' => ...], function () {...}),
|
||||
* the form Firefly III's whole API is written in. */
|
||||
static const char *php_group_array_prefix(CBMArena *a, TSNode group_call, const char *source) {
|
||||
TSNode attrs = php_first_arg_value(group_call);
|
||||
if (ts_node_is_null(attrs) || strcmp(ts_node_type(attrs), "array_creation_expression") != 0) {
|
||||
return NULL;
|
||||
}
|
||||
uint32_t nc = ts_node_named_child_count(attrs);
|
||||
for (uint32_t i = 0; i < nc; i++) {
|
||||
TSNode el = ts_node_named_child(attrs, i);
|
||||
if (strcmp(ts_node_type(el), "array_element_initializer") != 0 ||
|
||||
ts_node_named_child_count(el) != PAIR_LEN) {
|
||||
continue;
|
||||
}
|
||||
const char *key = php_plain_string_value(a, ts_node_named_child(el, 0), source);
|
||||
if (key && strcmp(key, "prefix") == 0) {
|
||||
return php_plain_string_value(a, ts_node_named_child(el, SKIP_ONE), source);
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static const char *php_group_prefix_for_call(CBMArena *a, TSNode node, const char *source) {
|
||||
const char *parts[PHP_PREFIX_PARTS_MAX];
|
||||
int part_count = 0;
|
||||
@@ -1785,7 +1838,14 @@ static const char *php_group_prefix_for_call(CBMArena *a, TSNode node, const cha
|
||||
strcmp(ts_node_type(p), "scoped_call_expression") == 0)) {
|
||||
TSNode gname = ts_node_child_by_field_name(p, TS_FIELD("name"));
|
||||
char *gtxt = ts_node_is_null(gname) ? NULL : cbm_node_text(a, gname, source);
|
||||
if (gtxt && strcmp(gtxt, "group") == 0) {
|
||||
const char *array_prefix = gtxt && strcmp(gtxt, "group") == 0
|
||||
? php_group_array_prefix(a, p, source)
|
||||
: NULL;
|
||||
if (array_prefix) {
|
||||
if (part_count < PHP_PREFIX_PARTS_MAX) {
|
||||
parts[part_count++] = array_prefix; /* inner-first */
|
||||
}
|
||||
} else if (gtxt && strcmp(gtxt, "group") == 0) {
|
||||
/* Scan the receiver chain for prefix('...'). */
|
||||
TSNode recv = ts_node_child_by_field_name(p, TS_FIELD("object"));
|
||||
for (int hops = 0; hops < PHP_GROUP_WALK_MAX && !ts_node_is_null(recv);
|
||||
@@ -1843,6 +1903,81 @@ static const char *php_group_prefix_for_call(CBMArena *a, TSNode node, const cha
|
||||
return pos ? cbm_arena_strndup(a, buf, pos) : NULL;
|
||||
}
|
||||
|
||||
/* A Laravel route registrar receiver (#1146): the Route facade — also at the
|
||||
* root of a registrar chain, Route::middleware('auth')->get(...) — or the
|
||||
* $router instance route files are loaded with (RouteServiceProvider, Lumen).
|
||||
* The same literal `Route` scope the #952 callee qualification accepts. */
|
||||
static bool php_is_laravel_registrar(CBMArena *a, TSNode call_node, const char *source) {
|
||||
TSNode cur = call_node;
|
||||
for (int hops = 0; hops < PHP_GROUP_WALK_MAX && !ts_node_is_null(cur); hops++) {
|
||||
const char *kind = ts_node_type(cur);
|
||||
if (strcmp(kind, "scoped_call_expression") == 0) {
|
||||
TSNode scope = ts_node_child_by_field_name(cur, TS_FIELD("scope"));
|
||||
const char *st = ts_node_is_null(scope) ? NULL : cbm_node_text(a, scope, source);
|
||||
return st && strcmp(st, "Route") == 0;
|
||||
}
|
||||
if (strcmp(kind, "variable_name") == 0) {
|
||||
const char *vt = cbm_node_text(a, cur, source);
|
||||
return vt && strcmp(vt, "$router") == 0;
|
||||
}
|
||||
if (strcmp(kind, "member_call_expression") != 0) {
|
||||
return false;
|
||||
}
|
||||
cur = ts_node_child_by_field_name(cur, TS_FIELD("object"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Laravel accepts a route URI with or without its leading slash and trims
|
||||
* '/' from both ends: Route::get('users', ...) serves /users, and '' is the
|
||||
* group root. The route passes only mint a Route for a path that starts with
|
||||
* '/', so give a recognised registration's literal URI its slash here, where
|
||||
* the first argument is still at hand (#1146). Returns NULL — leave the call
|
||||
* alone — for any other call, any non-literal URI, or one already slashed:
|
||||
* `$request->get('name')`, `Cache::get('key')` and a collection's get('k')
|
||||
* keep their argument. */
|
||||
static const char *php_laravel_route_uri(CBMArena *a, TSNode call_node, const char *source) {
|
||||
static const char *const verbs[] = {"get", "post", "put", "patch", "delete", NULL};
|
||||
TSNode mname = ts_node_child_by_field_name(call_node, TS_FIELD("name"));
|
||||
const char *verb = ts_node_is_null(mname) ? NULL : cbm_node_text(a, mname, source);
|
||||
bool is_verb = false;
|
||||
for (int i = 0; verb && verbs[i] && !is_verb; i++) {
|
||||
is_verb = strcmp(verb, verbs[i]) == 0;
|
||||
}
|
||||
if (!is_verb || !php_is_laravel_registrar(a, call_node, source)) {
|
||||
return NULL;
|
||||
}
|
||||
TSNode arg0 = php_first_arg_value(call_node);
|
||||
const char *uri = ts_node_is_null(arg0) ? NULL : php_plain_string_value(a, arg0, source);
|
||||
if (!uri || uri[0] == '/') {
|
||||
return NULL;
|
||||
}
|
||||
size_t len = strlen(uri);
|
||||
while (len > 0 && uri[len - SKIP_ONE] == '/') {
|
||||
len--;
|
||||
}
|
||||
return cbm_arena_sprintf(a, "/%.*s", (int)len, uri);
|
||||
}
|
||||
|
||||
/* Is a Laravel registration's action (its last argument) a string — 'index'
|
||||
* inside a Route::controller() group, 'UserController@show'? Such an action
|
||||
* does not say which class owns the method, so resolving it could only guess a
|
||||
* same-named function anywhere in the repo. */
|
||||
static bool php_route_action_is_string(TSNode call_node) {
|
||||
TSNode args = ts_node_child_by_field_name(call_node, TS_FIELD("arguments"));
|
||||
uint32_t nc = ts_node_is_null(args) ? 0 : ts_node_named_child_count(args);
|
||||
if (nc < PAIR_LEN) {
|
||||
return false;
|
||||
}
|
||||
TSNode action = ts_node_named_child(args, nc - SKIP_ONE);
|
||||
if (strcmp(ts_node_type(action), "argument") == 0 &&
|
||||
ts_node_named_child_count(action) == SKIP_ONE) {
|
||||
action = ts_node_named_child(action, 0);
|
||||
}
|
||||
const char *kind = ts_node_type(action);
|
||||
return strcmp(kind, "string") == 0 || strcmp(kind, "encapsed_string") == 0;
|
||||
}
|
||||
|
||||
static bool is_nested_verilog_call_wrapper(CBMLanguage lang, TSNode node) {
|
||||
if (lang != CBM_LANG_VERILOG || strcmp(ts_node_type(node), "subroutine_call") != 0) {
|
||||
return false;
|
||||
@@ -3941,6 +4076,16 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML
|
||||
}
|
||||
if (!ts_node_is_null(args)) {
|
||||
call.first_string_arg = extract_url_or_topic_arg(ctx, args, call.callee_name);
|
||||
/* #1146: a Laravel registration's slashless URI ('users', '')
|
||||
* becomes '/users' / '/' so it mints and composes below. */
|
||||
const char *laravel_uri = NULL;
|
||||
if (ctx->language == CBM_LANG_PHP && call.callee_name &&
|
||||
cbm_service_pattern_route_method(call.callee_name) != NULL) {
|
||||
laravel_uri = php_laravel_route_uri(ctx->arena, node, ctx->source);
|
||||
if (laravel_uri) {
|
||||
call.first_string_arg = laravel_uri;
|
||||
}
|
||||
}
|
||||
/* #952: routes registered inside Laravel `prefix()->group()`
|
||||
* closures must carry the composed path — the resolve passes
|
||||
* only see the flat CBMCall, so the enclosing chain can only
|
||||
@@ -3959,7 +4104,10 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML
|
||||
: cbm_arena_strndup(ctx->arena, gp, strlen(gp));
|
||||
}
|
||||
}
|
||||
if (call.first_string_arg && call.first_string_arg[0] == '/') {
|
||||
/* A slashless registration's string action would only be
|
||||
* guessed at (#1146): mint its Route without a handler. */
|
||||
if (call.first_string_arg && call.first_string_arg[0] == '/' &&
|
||||
!(laravel_uri && php_route_action_is_string(node))) {
|
||||
call.second_arg_name = extract_handler_arg(ctx, args);
|
||||
}
|
||||
if (ctx->language == CBM_LANG_OBJECTSCRIPT_UDL ||
|
||||
|
||||
@@ -2987,7 +2987,11 @@ static bool try_drf_action_decorator(CBMArena *a, TSNode dchild, const char *sou
|
||||
}
|
||||
|
||||
// Try to extract a route from a single decorator call node.
|
||||
// Returns true if a route method was found (even with fallback path "/").
|
||||
// Returns true if a route method was found. The fallback path "/" applies only
|
||||
// to a zero-argument call (`@app.route()`): a call that HAS arguments but none
|
||||
// path-shaped is not a route -- unittest.mock's `@patch("subprocess.run")`
|
||||
// shares its name with the HTTP verb and used to mint a PATCH "/" handler for
|
||||
// every mocked test function (distilled from PR #1245).
|
||||
static bool try_route_from_decorator_call(CBMArena *a, TSNode dchild, const char *source,
|
||||
const char **out_path, const char **out_method) {
|
||||
TSNode fn = ts_node_child_by_field_name(dchild, TS_FIELD("function"));
|
||||
@@ -3012,6 +3016,9 @@ static bool try_route_from_decorator_call(CBMArena *a, TSNode dchild, const char
|
||||
*out_method = method;
|
||||
return true;
|
||||
}
|
||||
if (ts_node_named_child_count(args) > 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
*out_path = "/";
|
||||
*out_method = method;
|
||||
|
||||
@@ -577,6 +577,11 @@ static const method_suffix_t route_reg_suffixes[] = {
|
||||
{".add_route", "ANY"},
|
||||
{".add_api_route", "ANY"},
|
||||
{".add_api_websocket_route", "ANY"},
|
||||
/* FastAPI/Starlette WebSocket decorators (`@router.websocket('/ws')`,
|
||||
* `@app.websocket_route('/events')`): not an HTTP verb, so ANY — mirrors
|
||||
* .add_api_websocket_route above (#1245). */
|
||||
{".websocket", "ANY"},
|
||||
{".websocket_route", "ANY"},
|
||||
{NULL, NULL},
|
||||
};
|
||||
|
||||
|
||||
+205
-4
@@ -21,6 +21,9 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#if defined(__linux__)
|
||||
#include <sys/vfs.h>
|
||||
#endif
|
||||
|
||||
enum {
|
||||
CBM_DAEMON_IPC_SEND_TIMEOUT_MS = 5000,
|
||||
@@ -54,6 +57,76 @@ const char *cbm_daemon_ipc_validation_detail(void) {
|
||||
return ipc_validation_detail_buffer;
|
||||
}
|
||||
|
||||
/* #1717: a cache root on a network filesystem (a shared HPC home, an NFS or
|
||||
* SMB mount) is ACCEPTED -- this is advice, never a refusal. SQLite's WAL mode
|
||||
* relies on shared-memory locking that network filesystems do not provide
|
||||
* reliably, so indexes there can be slow or corrupt. Detection is statfs
|
||||
* f_type, Linux only; other platforms never warn. */
|
||||
static const char *ipc_network_fs_name(unsigned long magic) {
|
||||
static const struct {
|
||||
unsigned long magic;
|
||||
const char *name;
|
||||
} kinds[] = {
|
||||
{0x6969UL, "NFS"}, {0xFF534D42UL, "CIFS"}, {0xFE534D42UL, "SMB2"},
|
||||
{0x0BD00BD0UL, "Lustre"}, {0x47504653UL, "GPFS"},
|
||||
};
|
||||
for (size_t index = 0; index < sizeof(kinds) / sizeof(kinds[0]); index++) {
|
||||
if (kinds[index].magic == magic) {
|
||||
return kinds[index].name;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static atomic_bool g_ipc_network_cache_warned;
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
static bool g_ipc_fs_magic_override_active;
|
||||
static unsigned long g_ipc_fs_magic_override;
|
||||
void cbm_daemon_ipc_set_fs_magic_for_test(bool active, unsigned long magic) {
|
||||
g_ipc_fs_magic_override_active = active;
|
||||
g_ipc_fs_magic_override = magic;
|
||||
atomic_store(&g_ipc_network_cache_warned, false);
|
||||
}
|
||||
#endif
|
||||
|
||||
static bool ipc_path_fs_magic(const char *path, unsigned long *magic_out) {
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
if (g_ipc_fs_magic_override_active) {
|
||||
*magic_out = g_ipc_fs_magic_override;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
#if defined(__linux__)
|
||||
struct statfs status;
|
||||
if (statfs(path, &status) != 0) {
|
||||
return false;
|
||||
}
|
||||
/* f_type is a signed word on some ABIs; the magics are 32-bit. */
|
||||
*magic_out = (unsigned long)status.f_type & 0xFFFFFFFFUL;
|
||||
return true;
|
||||
#else
|
||||
(void)path;
|
||||
(void)magic_out;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool cbm_daemon_ipc_warn_if_network_cache_root(const char *cache_root) {
|
||||
unsigned long magic = 0;
|
||||
const char *kind = NULL;
|
||||
if (!cache_root || !cache_root[0] || !ipc_path_fs_magic(cache_root, &magic) ||
|
||||
!(kind = ipc_network_fs_name(magic))) {
|
||||
return false;
|
||||
}
|
||||
if (atomic_exchange(&g_ipc_network_cache_warned, true)) {
|
||||
return false; /* one warning per process */
|
||||
}
|
||||
cbm_log_warn("daemon.cache_root_network_fs", "path", cache_root, "fs", kind, "reason",
|
||||
"sqlite_wal_unreliable_on_network_fs", "hint",
|
||||
"set CBM_CACHE_DIR to local storage (docs/CONFIGURATION.md)");
|
||||
return true;
|
||||
}
|
||||
|
||||
static cbm_daemon_ipc_listen_failure_t ipc_listen_failure;
|
||||
|
||||
/* The failure recorder and its reset are only reached from the POSIX socket
|
||||
@@ -401,6 +474,9 @@ int cbm_daemon_ipc_wait_pending(const cbm_ipc_pending_ops_t *ops, uint32_t timeo
|
||||
#include <poll.h>
|
||||
#include <pthread.h>
|
||||
#include <stddef.h>
|
||||
#if !defined(__linux__)
|
||||
#include <pwd.h>
|
||||
#endif
|
||||
#include <sys/file.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
@@ -1788,14 +1864,138 @@ static bool posix_directory_parent_secure(int directory_fd) {
|
||||
* never chmod'd or ACL-rewritten. The euid-only enforcement that stops a
|
||||
* squatted private directory lives in the created/final/snapshot checks, not
|
||||
* here — this only walks ancestors. */
|
||||
static bool posix_directory_transition_secure(int parent_fd, int child_fd) {
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
/* #1717 seam: report one directory (by dev/ino) as owned by a foreign uid, so
|
||||
* the foreign-owned-ancestor refusal and its diagnostic are testable without
|
||||
* root (an unprivileged test cannot chown a directory to another account). */
|
||||
static bool g_posix_foreign_owner_active;
|
||||
static dev_t g_posix_foreign_owner_dev;
|
||||
static ino_t g_posix_foreign_owner_ino;
|
||||
static uid_t g_posix_foreign_owner_uid;
|
||||
bool cbm_daemon_ipc_posix_set_foreign_owned_dir_for_test(const char *directory_path,
|
||||
unsigned long foreign_uid) {
|
||||
struct stat status;
|
||||
g_posix_foreign_owner_active = false;
|
||||
if (!directory_path || lstat(directory_path, &status) != 0 || !S_ISDIR(status.st_mode)) {
|
||||
return false;
|
||||
}
|
||||
g_posix_foreign_owner_dev = status.st_dev;
|
||||
g_posix_foreign_owner_ino = status.st_ino;
|
||||
g_posix_foreign_owner_uid = (uid_t)foreign_uid;
|
||||
g_posix_foreign_owner_active = true;
|
||||
return true;
|
||||
}
|
||||
void cbm_daemon_ipc_posix_clear_foreign_owned_dir_for_test(void) {
|
||||
g_posix_foreign_owner_active = false;
|
||||
}
|
||||
#endif
|
||||
|
||||
static uid_t posix_directory_observed_owner(const struct stat *status) {
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
if (g_posix_foreign_owner_active && status->st_dev == g_posix_foreign_owner_dev &&
|
||||
status->st_ino == g_posix_foreign_owner_ino) {
|
||||
return g_posix_foreign_owner_uid;
|
||||
}
|
||||
#endif
|
||||
return status->st_uid;
|
||||
}
|
||||
|
||||
enum { POSIX_OWNER_NAME_CAP = 33, POSIX_PASSWD_LINE_CAP = 1024 };
|
||||
|
||||
/* The account name for uid, or "" when it cannot be named. Diagnostic only.
|
||||
*
|
||||
* Linux reads /etc/passwd directly instead of calling getpwuid(): the Linux
|
||||
* release binary is statically linked against glibc, where getpwuid() loads the
|
||||
* HOST's NSS modules at runtime -- a version-mismatch crash hazard exactly on
|
||||
* the sssd/LDAP hosts (#1717's shared HPC filesystems) this message serves.
|
||||
* A directory-service account is then shown by uid only. */
|
||||
static void posix_owner_name(uid_t uid, char out[POSIX_OWNER_NAME_CAP]) {
|
||||
out[0] = '\0';
|
||||
#if defined(__linux__)
|
||||
FILE *passwd = cbm_fopen("/etc/passwd", "r");
|
||||
if (!passwd) {
|
||||
return;
|
||||
}
|
||||
char line[POSIX_PASSWD_LINE_CAP];
|
||||
while (fgets(line, sizeof(line), passwd)) {
|
||||
char *name_end = strchr(line, ':');
|
||||
char *password_end = name_end ? strchr(name_end + 1, ':') : NULL;
|
||||
if (!password_end || name_end == line) {
|
||||
continue;
|
||||
}
|
||||
char *uid_end = NULL;
|
||||
errno = 0;
|
||||
unsigned long line_uid = strtoul(password_end + 1, &uid_end, 10);
|
||||
if (errno == 0 && uid_end != password_end + 1 && *uid_end == ':' &&
|
||||
line_uid == (unsigned long)uid) {
|
||||
(void)snprintf(out, POSIX_OWNER_NAME_CAP, "%.*s", (int)(name_end - line), line);
|
||||
break;
|
||||
}
|
||||
}
|
||||
(void)fclose(passwd);
|
||||
#else
|
||||
struct passwd entry;
|
||||
struct passwd *found = NULL;
|
||||
char storage[POSIX_PASSWD_LINE_CAP];
|
||||
if (getpwuid_r(uid, &entry, storage, sizeof(storage), &found) == 0 && found && found->pw_name) {
|
||||
(void)snprintf(out, POSIX_OWNER_NAME_CAP, "%s", found->pw_name);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/* The foreign-owned-ancestor refusal. The remedy is the part the reader acts
|
||||
* on, so it must survive the fixed detail buffer: the path is shortened from
|
||||
* the LEFT ("...") to fit, which keeps the refusing component at its end. */
|
||||
static void posix_foreign_owner_detail_set(const char *child_path, uid_t owner) {
|
||||
char name[POSIX_OWNER_NAME_CAP];
|
||||
posix_owner_name(owner, name);
|
||||
char owner_text[64];
|
||||
if (name[0]) {
|
||||
(void)snprintf(owner_text, sizeof(owner_text), "uid %ld (%s)", (long)owner, name);
|
||||
} else {
|
||||
(void)snprintf(owner_text, sizeof(owner_text), "uid %ld", (long)owner);
|
||||
}
|
||||
char tail[sizeof(ipc_validation_detail_buffer)];
|
||||
int tail_length = snprintf(tail, sizeof(tail),
|
||||
"' is owned by %s, not by you (uid %ld) or root; that account "
|
||||
"controls this path. Use a location whose parent directories are "
|
||||
"yours or root's (e.g. set CBM_CACHE_DIR)",
|
||||
owner_text, (long)geteuid());
|
||||
if (tail_length < 0) {
|
||||
return;
|
||||
}
|
||||
size_t used = (size_t)tail_length + 1; /* the opening quote */
|
||||
size_t room = used < sizeof(ipc_validation_detail_buffer)
|
||||
? sizeof(ipc_validation_detail_buffer) - 1 - used
|
||||
: 0;
|
||||
size_t path_length = strlen(child_path);
|
||||
const char *ellipsis = "";
|
||||
const char *shown = child_path;
|
||||
if (path_length > room) {
|
||||
ellipsis = "...";
|
||||
shown = child_path + path_length - (room > 3 ? room - 3 : 0);
|
||||
}
|
||||
ipc_validation_detail_set("'%s%s%s", ellipsis, shown, tail);
|
||||
}
|
||||
|
||||
static bool posix_directory_transition_secure(int parent_fd, int child_fd, const char *child_path) {
|
||||
struct stat parent;
|
||||
struct stat child;
|
||||
if (parent_fd < 0 || child_fd < 0 || !posix_directory_parent_secure(parent_fd) ||
|
||||
fstat(parent_fd, &parent) != 0 || fstat(child_fd, &child) != 0 ||
|
||||
!S_ISDIR(parent.st_mode) || !S_ISDIR(child.st_mode) ||
|
||||
!posix_directory_ancestor_owner_trusted(parent.st_uid) ||
|
||||
!posix_directory_ancestor_owner_trusted(child.st_uid)) {
|
||||
!posix_directory_ancestor_owner_trusted(posix_directory_observed_owner(&parent))) {
|
||||
return false;
|
||||
}
|
||||
uid_t child_owner = posix_directory_observed_owner(&child);
|
||||
if (!posix_directory_ancestor_owner_trusted(child_owner)) {
|
||||
/* #1717: this refusal used to set no detail, so the caller printed
|
||||
* "ancestry component validation failed (errno 17)" -- a stale EEXIST
|
||||
* from the mkdirat() on the existing component, naming neither the
|
||||
* directory nor the reason. The policy is unchanged: an account that
|
||||
* owns an ancestor can rename or replace everything below it after
|
||||
* this walk, so it could redirect the private directory. Name it. */
|
||||
posix_foreign_owner_detail_set(child_path, child_owner);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -1870,7 +2070,8 @@ static int private_directory_tree_open(const char *directory_path) {
|
||||
: -1;
|
||||
struct stat status;
|
||||
ok = next_fd >= 0 && fd_set_cloexec(next_fd) && fstat(next_fd, &status) == 0 &&
|
||||
S_ISDIR(status.st_mode) && posix_directory_transition_secure(current_fd, next_fd);
|
||||
S_ISDIR(status.st_mode) &&
|
||||
posix_directory_transition_secure(current_fd, next_fd, path);
|
||||
if (ok && created) {
|
||||
ok = status.st_uid == geteuid() && fchmod(next_fd, 0700) == 0 &&
|
||||
cbm_macos_extended_acl_fd_clear(next_fd) &&
|
||||
|
||||
@@ -92,6 +92,17 @@ bool cbm_daemon_ipc_private_directory_secure(const char *directory_path);
|
||||
* append it to their error messages; policy decisions never read it. */
|
||||
const char *cbm_daemon_ipc_validation_detail(void);
|
||||
|
||||
/* #1717: advice, never a refusal. When cache_root lives on a network
|
||||
* filesystem (Linux statfs: NFS, CIFS, SMB2, Lustre, GPFS), log one
|
||||
* `daemon.cache_root_network_fs` warning per process pointing at local
|
||||
* storage. Returns true only when this call emitted that warning. */
|
||||
bool cbm_daemon_ipc_warn_if_network_cache_root(const char *cache_root);
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
/* Report `magic` as the cache root's statfs f_type on every platform (active),
|
||||
* or restore real detection; either way re-arm the once-per-process warning. */
|
||||
void cbm_daemon_ipc_set_fs_magic_for_test(bool active, unsigned long magic);
|
||||
#endif
|
||||
|
||||
/* This process's most recent listener-publication failure: the stage that
|
||||
* refused, the errno the failing step reported (0 when the step reported
|
||||
* none), and the artifact path it was operating on ("" when no path applies).
|
||||
@@ -127,6 +138,12 @@ bool cbm_daemon_ipc_win_sid_trusted_for_testing(void *sid);
|
||||
* so they can be tested without a chown-able overflow-owned directory. */
|
||||
void cbm_daemon_ipc_posix_set_ancestor_overflow_uid_for_test(bool active,
|
||||
unsigned long overflow_uid);
|
||||
/* #1717 seam (POSIX): report the directory at directory_path (matched by
|
||||
* dev/ino) as owned by foreign_uid during the ancestor walk. False when the
|
||||
* path is not a directory. Clear restores real ownership. */
|
||||
bool cbm_daemon_ipc_posix_set_foreign_owned_dir_for_test(const char *directory_path,
|
||||
unsigned long foreign_uid);
|
||||
void cbm_daemon_ipc_posix_clear_foreign_owned_dir_for_test(void);
|
||||
bool cbm_daemon_ipc_posix_uid_map_is_single_uid_for_test(const char *uid_map, unsigned long euid);
|
||||
bool cbm_daemon_ipc_posix_ancestor_stat_ok_for_test(unsigned long owner, unsigned int mode,
|
||||
unsigned long euid, bool overflow_active,
|
||||
|
||||
@@ -1460,6 +1460,8 @@ static main_build_identity_status_t main_build_identity(cbm_daemon_build_identit
|
||||
if (!cbm_daemon_ipc_private_directory_secure(canonical_cache)) {
|
||||
return MAIN_BUILD_IDENTITY_CACHE_PRIVATE;
|
||||
}
|
||||
/* #1717: advice only -- a network-filesystem cache root is accepted. */
|
||||
(void)cbm_daemon_ipc_warn_if_network_cache_root(canonical_cache);
|
||||
/* Every cache consumer in this process must use the exact path whose
|
||||
* fingerprint joins the account-wide cohort. Keeping an original symlink
|
||||
* spelling in the environment would let a later retarget move storage
|
||||
|
||||
+39
-36
@@ -185,7 +185,10 @@ static int write_file_atomic(const char *path, const char *data, size_t len,
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
|
||||
FILE *fp = fopen(tmp, "wb");
|
||||
/* cbm_fopen / cbm_rename_replace: wide paths on Windows. The ANSI CRT fopen
|
||||
* and MoveFileExA mangled a repo path outside the active code page (a CJK
|
||||
* checkout), so every persistent export failed there (#1171). */
|
||||
FILE *fp = cbm_fopen(tmp, "wb");
|
||||
if (!fp) {
|
||||
file_error_set(out_err, "open_temp", errno);
|
||||
return CBM_NOT_FOUND;
|
||||
@@ -207,22 +210,14 @@ static int write_file_atomic(const char *path, const char *data, size_t len,
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
/* MoveFileEx replace approach suggested by @Ayush7Ranjan in #492. */
|
||||
if (!MoveFileExA(tmp, path, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
|
||||
DWORD saved_error = GetLastError();
|
||||
cbm_unlink(tmp);
|
||||
file_error_set(out_err, "rename_temp", (int)saved_error);
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
#else
|
||||
if (rename(tmp, path) != 0) {
|
||||
/* Replace-existing rename (MoveFileExW write-through on Windows, the
|
||||
* approach suggested by @Ayush7Ranjan in #492); errno carries the cause. */
|
||||
if (cbm_rename_replace(tmp, path) != 0) {
|
||||
int saved_errno = errno;
|
||||
cbm_unlink(tmp);
|
||||
file_error_set(out_err, "rename_temp", saved_errno);
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -777,30 +772,31 @@ static void ensure_gitattributes(const char *repo_path) {
|
||||
char ga_path[CBM_SZ_4K];
|
||||
artifact_path(ga_path, sizeof(ga_path), repo_path, ".gitattributes");
|
||||
|
||||
/* Atomic create-only-if-absent: O_EXCL closes the TOCTOU window
|
||||
* between checking existence and writing. If the file exists, open
|
||||
* fails with EEXIST and we leave it untouched. */
|
||||
int fd = open(ga_path, O_WRONLY | O_CREAT | O_EXCL, 0644);
|
||||
if (fd < 0) {
|
||||
if (errno != EEXIST) {
|
||||
cbm_log_warn("artifact.gitattributes.open path=%s err=%s", ga_path, strerror(errno));
|
||||
/* Atomic create-only-if-absent: the "x" mode (O_CREAT|O_EXCL) closes the
|
||||
* TOCTOU window between checking existence and writing. If the file
|
||||
* exists, the open fails with EEXIST and we leave it untouched.
|
||||
* cbm_fopen, not open(): the ANSI CRT open() cannot create the file under
|
||||
* a repo path outside the active code page on Windows, which silently
|
||||
* dropped the merge=ours protection there (#1171). */
|
||||
errno = 0;
|
||||
FILE *fp = cbm_fopen(ga_path, "wbx");
|
||||
if (!fp) {
|
||||
int open_errno = errno;
|
||||
if (open_errno != EEXIST) {
|
||||
cbm_log_warn("artifact.gitattributes.open", "path", ga_path, "err",
|
||||
strerror(open_errno));
|
||||
}
|
||||
/* fall through to merge driver setup either way */
|
||||
} else {
|
||||
FILE *fp = fdopen(fd, "w");
|
||||
if (fp) {
|
||||
/* Order matters: attributes apply left to right and the `binary`
|
||||
* macro expands to `-diff -merge -text`, so a trailing `binary`
|
||||
* unsets `merge=ours` and the conflict prevention this file
|
||||
* exists for never engages. The macro must come first. */
|
||||
(void)fputs("# Auto-generated by codebase-memory-mcp\n"
|
||||
"# Prevent merge conflicts on compressed artifact\n" CBM_ARTIFACT_FILENAME
|
||||
" binary merge=ours\n",
|
||||
fp);
|
||||
(void)fclose(fp);
|
||||
} else {
|
||||
(void)close(fd);
|
||||
}
|
||||
/* Order matters: attributes apply left to right and the `binary`
|
||||
* macro expands to `-diff -merge -text`, so a trailing `binary`
|
||||
* unsets `merge=ours` and the conflict prevention this file
|
||||
* exists for never engages. The macro must come first. */
|
||||
(void)fputs("# Auto-generated by codebase-memory-mcp\n"
|
||||
"# Prevent merge conflicts on compressed artifact\n" CBM_ARTIFACT_FILENAME
|
||||
" binary merge=ours\n",
|
||||
fp);
|
||||
(void)fclose(fp);
|
||||
}
|
||||
|
||||
/* Best-effort: configure merge driver */
|
||||
@@ -1188,7 +1184,7 @@ int cbm_artifact_import(const char *repo_path, const char *cache_db_path) {
|
||||
* stale WAL next to the cache path would be replayed on top of the
|
||||
* imported file at the next open (#897). */
|
||||
cbm_remove_db_sidecars(cache_db_path);
|
||||
if (rename(tmp_path, cache_db_path) != 0) {
|
||||
if (cbm_rename_replace(tmp_path, cache_db_path) != 0) {
|
||||
cbm_log_error("artifact.import", "err", "rename_to_cache");
|
||||
cbm_unlink(tmp_path);
|
||||
return CBM_NOT_FOUND;
|
||||
@@ -1218,8 +1214,15 @@ bool cbm_artifact_exists(const char *repo_path) {
|
||||
char zst_path[CBM_SZ_4K];
|
||||
artifact_path(zst_path, sizeof(zst_path), repo_path, CBM_ARTIFACT_FILENAME);
|
||||
|
||||
struct stat st;
|
||||
if (stat(zst_path, &st) != 0 || st.st_size == 0) {
|
||||
/* Present and non-empty. cbm_fopen, not stat(): the ANSI CRT stat misses a
|
||||
* repo path outside the active Windows code page (#1171). */
|
||||
FILE *fp = cbm_fopen(zst_path, "rb");
|
||||
if (!fp) {
|
||||
return false;
|
||||
}
|
||||
int first = fgetc(fp);
|
||||
(void)fclose(fp);
|
||||
if (first == EOF) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -334,6 +334,82 @@ TEST(artifact_gitattributes_created) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Read a small file through the UTF-8 path layer (wide on Windows), so a
|
||||
* non-ASCII path is never misread by the test's own ANSI CRT call. Returns the
|
||||
* byte count, or 0 when the file cannot be opened or is empty. */
|
||||
static size_t read_small_file_utf8(const char *path, char *buf, size_t cap) {
|
||||
buf[0] = '\0';
|
||||
FILE *fp = cbm_fopen(path, "rb");
|
||||
if (!fp) {
|
||||
return 0;
|
||||
}
|
||||
size_t rd = fread(buf, 1, cap - 1, fp);
|
||||
(void)fclose(fp);
|
||||
buf[rd] = '\0';
|
||||
return rd;
|
||||
}
|
||||
|
||||
/* #1171: a repository (and a cache directory) under a non-ASCII path must
|
||||
* export, report the artifact present, and import — also onto an existing
|
||||
* cache db. On Windows the atomic writer used the ANSI CRT fopen + MoveFileExA
|
||||
* and the existence check the ANSI stat, so CJK characters outside the active
|
||||
* code page made every step fail. The same export also writes the
|
||||
* .gitattributes merge=ours protection, which ensure_gitattributes created
|
||||
* with the ANSI CRT open(): on Windows it was silently missing. The bytes are
|
||||
* escaped UTF-8 for a CJK repo directory name and a CJK cache directory name
|
||||
* (kept ASCII in source). */
|
||||
#define ART_CJK_REPO "\xe6\x8c\x81\xe4\xb9\x85\xe5\x8c\x96\xe5\xa4\x8d\xe7\x8e\xb0"
|
||||
#define ART_CJK_CACHE "\xe5\xaf\xbc\xe5\x85\xa5"
|
||||
TEST(artifact_roundtrip_non_ascii_paths) {
|
||||
setup_artifact_test();
|
||||
create_test_db(g_db);
|
||||
|
||||
char repo[1024];
|
||||
snprintf(repo, sizeof(repo), "%s/cbm-" ART_CJK_REPO, g_tmpdir);
|
||||
ASSERT_TRUE(cbm_mkdir_p(repo, 0755));
|
||||
|
||||
ASSERT_EQ(cbm_artifact_export(g_db, repo, "test-proj", CBM_ARTIFACT_FAST), 0);
|
||||
char zst[1024];
|
||||
snprintf(zst, sizeof(zst), "%s/.codebase-memory/graph.db.zst", repo);
|
||||
ASSERT_TRUE(cbm_file_exists(zst));
|
||||
ASSERT_TRUE(cbm_artifact_exists(repo));
|
||||
|
||||
char ga[1024];
|
||||
snprintf(ga, sizeof(ga), "%s/.codebase-memory/.gitattributes", repo);
|
||||
char ga_content[512];
|
||||
ASSERT_TRUE(read_small_file_utf8(ga, ga_content, sizeof(ga_content)) > 0);
|
||||
ASSERT_NOT_NULL(strstr(ga_content, CBM_ARTIFACT_FILENAME " binary merge=ours"));
|
||||
|
||||
/* Create-only-if-absent: a re-export never rewrites an existing
|
||||
* .gitattributes the user may have edited. */
|
||||
static const char user_ga[] = "# user-owned\n";
|
||||
FILE *uga = cbm_fopen(ga, "wb");
|
||||
ASSERT_NOT_NULL(uga);
|
||||
ASSERT_TRUE(fputs(user_ga, uga) >= 0);
|
||||
ASSERT_EQ(fclose(uga), 0);
|
||||
ASSERT_EQ(cbm_artifact_export(g_db, repo, "test-proj", CBM_ARTIFACT_FAST), 0);
|
||||
ASSERT_EQ(read_small_file_utf8(ga, ga_content, sizeof(ga_content)), strlen(user_ga));
|
||||
ASSERT_STR_EQ(ga_content, user_ga);
|
||||
|
||||
char cache_dir[1024];
|
||||
snprintf(cache_dir, sizeof(cache_dir), "%s/" ART_CJK_CACHE, g_tmpdir);
|
||||
ASSERT_TRUE(cbm_mkdir_p(cache_dir, 0755));
|
||||
char import_db[1024];
|
||||
snprintf(import_db, sizeof(import_db), "%s/imported.db", cache_dir);
|
||||
ASSERT_EQ(cbm_artifact_import(repo, import_db), 0);
|
||||
/* Re-import replaces the existing cache db (the second-session path). */
|
||||
ASSERT_EQ(cbm_artifact_import(repo, import_db), 0);
|
||||
|
||||
cbm_store_t *s = cbm_store_open_path(import_db);
|
||||
ASSERT_NOT_NULL(s);
|
||||
ASSERT_EQ(cbm_store_count_nodes(s, "test-proj"), 2);
|
||||
ASSERT_EQ(cbm_store_count_edges(s, "test-proj"), 1);
|
||||
cbm_store_close(s);
|
||||
|
||||
cleanup_dir(g_tmpdir);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(artifact_export_rename_failure_logs_specific_error) {
|
||||
setup_artifact_test();
|
||||
create_test_db(g_db);
|
||||
@@ -1102,6 +1178,7 @@ SUITE(artifact) {
|
||||
RUN_TEST(artifact_schema_version_mismatch);
|
||||
RUN_TEST(artifact_import_missing);
|
||||
RUN_TEST(artifact_gitattributes_created);
|
||||
RUN_TEST(artifact_roundtrip_non_ascii_paths);
|
||||
RUN_TEST(artifact_export_rename_failure_logs_specific_error);
|
||||
RUN_TEST(pipeline_persistence_export_failure_returns_error);
|
||||
RUN_TEST(artifact_import_rejects_size_mismatch);
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
#include <sys/types.h>
|
||||
#include <sys/un.h>
|
||||
#include <sys/wait.h>
|
||||
#include <pwd.h>
|
||||
#include <unistd.h>
|
||||
#ifdef __linux__
|
||||
#include <sched.h> /* #1830 userns real smoke: unshare(CLONE_NEWUSER). */
|
||||
@@ -5274,6 +5275,147 @@ TEST(daemon_ipc_posix_wsl_drvfs_refusal_names_remedy_issue1687) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #1717: an ancestor owned by ANOTHER non-root account (a shared filesystem
|
||||
* whose parent directory belongs to a colleague) is refused -- that account can
|
||||
* rename or replace everything below it, so the policy stays. What was wrong is
|
||||
* the message: the refusal set no detail, and the caller printed
|
||||
* "ancestry component validation failed (errno 17)", a stale EEXIST from
|
||||
* mkdirat() that named neither the directory nor the reason. The reporter
|
||||
* could not tell which directory, or why. The refusal must name the refusing
|
||||
* directory, its owner uid, and the remedy. The seam reports one directory as
|
||||
* foreign-owned because an unprivileged test cannot chown to another account. */
|
||||
enum { IPC_TEST_FOREIGN_MAX_DEPTH = 16 };
|
||||
|
||||
typedef struct {
|
||||
bool paths_ok;
|
||||
bool seam_set;
|
||||
bool refused;
|
||||
bool cache_created;
|
||||
bool secured_without_seam;
|
||||
size_t foreign_path_length;
|
||||
char detail[512];
|
||||
} ipc_test_foreign_result_t;
|
||||
|
||||
/* Build <parent>/<depth x 24-char dirs>/shared-fs, mark shared-fs as owned by
|
||||
* foreign_uid through the seam, and ask for <...>/shared-fs/cache. Deeper
|
||||
* chains produce a path long enough that the detail must be shortened. */
|
||||
static void ipc_test_foreign_ancestor_run(const char *tag, int depth, unsigned long foreign_uid,
|
||||
ipc_test_foreign_result_t *result) {
|
||||
char parent[TEST_PATH_CAP];
|
||||
char chain[IPC_TEST_FOREIGN_MAX_DEPTH + 1][TEST_PATH_CAP];
|
||||
char cache[TEST_PATH_CAP];
|
||||
int made = 0;
|
||||
memset(result, 0, sizeof(*result));
|
||||
result->paths_ok = depth <= IPC_TEST_FOREIGN_MAX_DEPTH && ipc_test_parent_new(parent, tag);
|
||||
const char *base = parent;
|
||||
for (int level = 0; result->paths_ok && level <= depth; level++) {
|
||||
const char *leaf = level == depth ? "shared-fs" : "level-component-24-chars";
|
||||
int w = snprintf(chain[level], sizeof(chain[level]), "%s/%s", base, leaf);
|
||||
result->paths_ok = w > 0 && w < (int)sizeof(chain[level]) && mkdir(chain[level], 0755) == 0;
|
||||
made += result->paths_ok ? 1 : 0;
|
||||
base = chain[level];
|
||||
}
|
||||
if (result->paths_ok) {
|
||||
int c = snprintf(cache, sizeof(cache), "%s/cache", chain[depth]);
|
||||
result->paths_ok = c > 0 && c < (int)sizeof(cache);
|
||||
result->foreign_path_length = strlen(chain[depth]);
|
||||
}
|
||||
if (result->paths_ok) {
|
||||
result->seam_set =
|
||||
cbm_daemon_ipc_posix_set_foreign_owned_dir_for_test(chain[depth], foreign_uid);
|
||||
}
|
||||
if (result->seam_set) {
|
||||
result->refused = !cbm_daemon_ipc_private_directory_secure(cache);
|
||||
const char *why = cbm_daemon_ipc_validation_detail();
|
||||
(void)snprintf(result->detail, sizeof(result->detail), "%s", why ? why : "");
|
||||
struct stat probe;
|
||||
result->cache_created = lstat(cache, &probe) == 0;
|
||||
cbm_daemon_ipc_posix_clear_foreign_owned_dir_for_test();
|
||||
/* Control: with real ownership the same path is accepted, so the
|
||||
* refusal above is the foreign owner and nothing else. */
|
||||
result->secured_without_seam = cbm_daemon_ipc_private_directory_secure(cache);
|
||||
(void)rmdir(cache);
|
||||
}
|
||||
cbm_daemon_ipc_posix_clear_foreign_owned_dir_for_test();
|
||||
while (made > 0) {
|
||||
(void)rmdir(chain[--made]);
|
||||
}
|
||||
if (depth <= IPC_TEST_FOREIGN_MAX_DEPTH) {
|
||||
ipc_test_remove_flat_dir(parent);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(daemon_ipc_posix_foreign_owned_ancestor_refusal_names_directory_issue1717) {
|
||||
/* A uid no account resolves to: the detail names the uid alone. */
|
||||
unsigned long foreign_uid = (unsigned long)geteuid() + 4242UL;
|
||||
ASSERT_TRUE(getpwuid((uid_t)foreign_uid) == NULL); /* precondition */
|
||||
ipc_test_foreign_result_t result;
|
||||
ipc_test_foreign_ancestor_run("posix-foreign-ancestor", 0, foreign_uid, &result);
|
||||
char owner_text[64];
|
||||
(void)snprintf(owner_text, sizeof(owner_text), "owned by uid %lu,", foreign_uid);
|
||||
|
||||
ASSERT_TRUE(result.paths_ok);
|
||||
ASSERT_TRUE(result.seam_set);
|
||||
ASSERT_TRUE(result.refused);
|
||||
ASSERT_TRUE(!result.cache_created); /* nothing is created below a foreign ancestor */
|
||||
ASSERT_NOT_NULL(strstr(result.detail, "/shared-fs'"));
|
||||
ASSERT_NOT_NULL(strstr(result.detail, owner_text));
|
||||
ASSERT_NOT_NULL(strstr(result.detail, "CBM_CACHE_DIR"));
|
||||
ASSERT_TRUE(strstr(result.detail, "errno") == NULL);
|
||||
ASSERT_TRUE(result.secured_without_seam);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #1717: the owner is named when the account resolves. uid 1 is a local system
|
||||
* account in /etc/passwd on macOS and on the Linux images we test ("daemon" on
|
||||
* both; Alpine calls it "bin"), so the expected name is taken from the same
|
||||
* local database rather than hardcoded. */
|
||||
TEST(daemon_ipc_posix_foreign_owned_ancestor_names_owner_account_issue1717) {
|
||||
const unsigned long named_uid = 1;
|
||||
struct passwd *expected = getpwuid((uid_t)named_uid);
|
||||
if (!expected || !expected->pw_name || !expected->pw_name[0]) {
|
||||
SKIP_PLATFORM("uid 1 has no local account name on this system");
|
||||
}
|
||||
char owner_text[96];
|
||||
(void)snprintf(owner_text, sizeof(owner_text), "owned by uid %lu (%s),", named_uid,
|
||||
expected->pw_name);
|
||||
ipc_test_foreign_result_t result;
|
||||
ipc_test_foreign_ancestor_run("posix-foreign-named", 0, named_uid, &result);
|
||||
|
||||
ASSERT_TRUE(result.paths_ok);
|
||||
ASSERT_TRUE(result.seam_set);
|
||||
ASSERT_TRUE(result.refused);
|
||||
ASSERT_NOT_NULL(strstr(result.detail, owner_text));
|
||||
ASSERT_NOT_NULL(strstr(result.detail, "/shared-fs'"));
|
||||
ASSERT_TRUE(result.secured_without_seam);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #1717: the detail buffer is fixed (384 bytes). A long path must not push the
|
||||
* remedy out: the path is shortened from the left, so the refusing component
|
||||
* and the whole remedy survive. */
|
||||
TEST(daemon_ipc_posix_foreign_owned_ancestor_long_path_keeps_remedy_issue1717) {
|
||||
const unsigned long named_uid = 1;
|
||||
ipc_test_foreign_result_t result;
|
||||
ipc_test_foreign_ancestor_run("posix-foreign-long", IPC_TEST_FOREIGN_MAX_DEPTH, named_uid,
|
||||
&result);
|
||||
static const char remedy[] =
|
||||
"Use a location whose parent directories are yours or root's (e.g. set CBM_CACHE_DIR)";
|
||||
|
||||
ASSERT_TRUE(result.paths_ok);
|
||||
ASSERT_TRUE(result.foreign_path_length > 384); /* the input really overflows */
|
||||
ASSERT_TRUE(result.refused);
|
||||
ASSERT_TRUE(strlen(result.detail) < 384);
|
||||
ASSERT_TRUE(strncmp(result.detail, "'...", 4) == 0);
|
||||
ASSERT_NOT_NULL(strstr(result.detail, "/shared-fs' is owned by uid 1"));
|
||||
size_t detail_length = strlen(result.detail);
|
||||
ASSERT_TRUE(detail_length >= sizeof(remedy) - 1);
|
||||
ASSERT_STR_EQ(result.detail + detail_length - (sizeof(remedy) - 1), remedy);
|
||||
PASS();
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
/* #1830: /proc/self/uid_map single-uid detection. A single-uid map is exactly
|
||||
* one line "<inside> <outside> 1" whose inside id is our euid; anything else —
|
||||
* the init map, a count other than 1, a foreign inside id, extra lines, or junk
|
||||
@@ -5496,6 +5638,65 @@ static void ipc_test_log_capture_sink(const char *line) {
|
||||
ipc_test_log_capture[ipc_test_log_capture_used] = '\0';
|
||||
}
|
||||
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
/* #1717: a cache root on a network filesystem (shared HPC home, NFS/SMB mount)
|
||||
* is ACCEPTED, but SQLite WAL is unreliable there, so startup logs ONE warning
|
||||
* with the local-storage advice. The seam supplies the statfs f_type on every
|
||||
* platform (real detection is Linux-only), so the mapping and the
|
||||
* once-per-process rule are bound deterministically here. */
|
||||
static bool ipc_test_network_warning_emitted(unsigned long magic, const char *fs_name) {
|
||||
ipc_test_log_capture_used = 0;
|
||||
ipc_test_log_capture[0] = '\0';
|
||||
cbm_daemon_ipc_set_fs_magic_for_test(true, magic);
|
||||
cbm_log_set_sink_ex(ipc_test_log_capture_sink, CBM_LOG_SINK_REPLACE);
|
||||
bool warned = cbm_daemon_ipc_warn_if_network_cache_root("/shared/fs/me/.cache/cbm");
|
||||
cbm_log_set_sink(NULL);
|
||||
char expected[32];
|
||||
(void)snprintf(expected, sizeof(expected), "fs=%s", fs_name);
|
||||
const char *line = strstr(ipc_test_log_capture, "daemon.cache_root_network_fs");
|
||||
return warned && line && strstr(line, expected) && strstr(line, "CBM_CACHE_DIR") &&
|
||||
strstr(line, "path=/shared/fs/me/.cache/cbm");
|
||||
}
|
||||
|
||||
TEST(daemon_ipc_network_cache_root_warns_once_issue1717) {
|
||||
CBMLogLevel saved_level = cbm_log_get_level();
|
||||
cbm_log_set_level(CBM_LOG_WARN);
|
||||
bool nfs = ipc_test_network_warning_emitted(0x6969UL, "NFS");
|
||||
bool cifs = ipc_test_network_warning_emitted(0xFF534D42UL, "CIFS");
|
||||
bool smb2 = ipc_test_network_warning_emitted(0xFE534D42UL, "SMB2");
|
||||
bool lustre = ipc_test_network_warning_emitted(0x0BD00BD0UL, "Lustre");
|
||||
bool gpfs = ipc_test_network_warning_emitted(0x47504653UL, "GPFS");
|
||||
|
||||
/* Once per process: a second startup check stays silent. */
|
||||
cbm_daemon_ipc_set_fs_magic_for_test(true, 0x6969UL);
|
||||
bool first = cbm_daemon_ipc_warn_if_network_cache_root("/nfs/cache");
|
||||
bool second = cbm_daemon_ipc_warn_if_network_cache_root("/nfs/cache");
|
||||
|
||||
/* Local filesystems (ext4, xfs, tmpfs) never warn. */
|
||||
cbm_daemon_ipc_set_fs_magic_for_test(true, 0xEF53UL);
|
||||
bool ext4 = cbm_daemon_ipc_warn_if_network_cache_root("/home/me/.cache/cbm");
|
||||
cbm_daemon_ipc_set_fs_magic_for_test(true, 0x58465342UL);
|
||||
bool xfs = cbm_daemon_ipc_warn_if_network_cache_root("/home/me/.cache/cbm");
|
||||
cbm_daemon_ipc_set_fs_magic_for_test(true, 0x01021994UL);
|
||||
bool tmpfs = cbm_daemon_ipc_warn_if_network_cache_root("/home/me/.cache/cbm");
|
||||
|
||||
cbm_daemon_ipc_set_fs_magic_for_test(false, 0);
|
||||
cbm_log_set_level(saved_level);
|
||||
|
||||
ASSERT_TRUE(nfs);
|
||||
ASSERT_TRUE(cifs);
|
||||
ASSERT_TRUE(smb2);
|
||||
ASSERT_TRUE(lustre);
|
||||
ASSERT_TRUE(gpfs);
|
||||
ASSERT_TRUE(first);
|
||||
ASSERT_TRUE(!second);
|
||||
ASSERT_TRUE(!ext4);
|
||||
ASSERT_TRUE(!xfs);
|
||||
ASSERT_TRUE(!tmpfs);
|
||||
PASS();
|
||||
}
|
||||
#endif
|
||||
|
||||
/* #1828: a full /tmp made every daemon start die at pending publication, and
|
||||
* the only durable trace was `daemon.ipc.listen_failed stage=pending_publication`
|
||||
* -- no syscall, no errno, no path. The reporter needed hours (and a wrong
|
||||
@@ -5599,6 +5800,9 @@ SUITE(daemon_ipc) {
|
||||
RUN_TEST(daemon_ipc_posix_world_writable_ancestor_still_refused_issue1537);
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
RUN_TEST(daemon_ipc_posix_wsl_drvfs_refusal_names_remedy_issue1687);
|
||||
RUN_TEST(daemon_ipc_posix_foreign_owned_ancestor_refusal_names_directory_issue1717);
|
||||
RUN_TEST(daemon_ipc_posix_foreign_owned_ancestor_names_owner_account_issue1717);
|
||||
RUN_TEST(daemon_ipc_posix_foreign_owned_ancestor_long_path_keeps_remedy_issue1717);
|
||||
RUN_TEST(daemon_ipc_posix_uid_map_single_uid_parse_issue1830);
|
||||
RUN_TEST(daemon_ipc_posix_overflow_ancestor_tolerated_only_in_single_uid_ns_issue1830);
|
||||
RUN_TEST(daemon_ipc_posix_overflow_uid_is_never_cached_issue1830);
|
||||
@@ -5633,6 +5837,9 @@ SUITE(daemon_ipc) {
|
||||
RUN_TEST(daemon_ipc_posix_rejects_non_socket_and_symlink_endpoints);
|
||||
#ifndef _WIN32
|
||||
RUN_TEST(daemon_ipc_listen_failure_names_errno_and_path);
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
RUN_TEST(daemon_ipc_network_cache_root_warns_once_issue1717);
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
@@ -922,6 +922,161 @@ TEST(routes_laravel_withrouting_parallel_subdir_issue1146) {
|
||||
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. */
|
||||
static int et_route_set_exact(const EtFile *files, int nfiles, const char **want, int parallel) {
|
||||
EtProj lp;
|
||||
cbm_store_t *store =
|
||||
parallel ? et_index_parallel(&lp, files, nfiles) : et_index_files(&lp, files, nfiles);
|
||||
cbm_node_t *nodes = NULL;
|
||||
int node_count = 0;
|
||||
int wanted = 0;
|
||||
int found[ET_ROUTE_ASSERT_MAX] = {0};
|
||||
while (want[wanted] && wanted < ET_ROUTE_ASSERT_MAX) {
|
||||
wanted++;
|
||||
}
|
||||
int ok = store != NULL &&
|
||||
cbm_store_find_nodes_by_label(store, lp.project, "Route", &nodes, &node_count) ==
|
||||
CBM_STORE_OK &&
|
||||
node_count == wanted;
|
||||
for (int ni = 0; store && ni < node_count; ni++) {
|
||||
char got[512];
|
||||
const char *qn = nodes[ni].qualified_name ? nodes[ni].qualified_name : "";
|
||||
const char *m = strncmp(qn, "__route__", 9) == 0 ? qn + 9 : qn;
|
||||
const char *end = strstr(m, "__");
|
||||
snprintf(got, sizeof(got), "%.*s %s", end ? (int)(end - m) : 0, m,
|
||||
nodes[ni].name ? nodes[ni].name : "");
|
||||
int matched = 0;
|
||||
for (int wi = 0; wi < wanted; wi++) {
|
||||
if (!found[wi] && strcmp(got, want[wi]) == 0) {
|
||||
found[wi] = matched = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
ok = 0;
|
||||
fprintf(stderr, " [ET-ROUTESET] unexpected %s\n", got);
|
||||
}
|
||||
}
|
||||
for (int wi = 0; wi < wanted; wi++) {
|
||||
if (!found[wi]) {
|
||||
ok = 0;
|
||||
fprintf(stderr, " [ET-ROUTESET] missing %s\n", want[wi]);
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
fprintf(stderr, " [ET-ROUTESET] FAIL (%s path) expected=%d actual=%d\n",
|
||||
parallel ? "parallel" : "sequential", wanted, node_count);
|
||||
}
|
||||
cbm_store_free_nodes(nodes, node_count);
|
||||
et_cleanup(&lp, store);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* #1146: Laravel writes most route URIs without the leading slash —
|
||||
* Route::get('users', ...) serves /users, Route::put('{article}', ...) inside
|
||||
* prefix('articles') serves /articles/{article}, and '' is the group root.
|
||||
* Both route passes only minted a Route for a first argument starting with
|
||||
* '/', so Firefly III's API (258 registrations) and laravel.io's PUT/DELETE
|
||||
* API routes had no Route at all. Covers the facade, a facade chain
|
||||
* (Route::middleware()->get), the $router instance, the chain-form and the
|
||||
* array-form prefix group (Route::group(['prefix' => ...], fn)), and a
|
||||
* trailing slash. */
|
||||
static const EtFile et_laravel_slashless[] = {
|
||||
{"routes/api.php",
|
||||
"<?php\nuse Illuminate\\Support\\Facades\\Route;\n\n"
|
||||
"Route::get('users', 'UserController@index');\n"
|
||||
"Route::post('users/', 'UserController@store');\n"
|
||||
"Route::prefix('articles')->group(function () {\n"
|
||||
" Route::get('', 'ArticlesController@index');\n"
|
||||
" Route::put('{article}', 'ArticlesController@update');\n"
|
||||
" Route::delete('{article}', 'ArticlesController@delete');\n"
|
||||
"});\n"
|
||||
"Route::group(['prefix' => 'v1/autocomplete', 'as' => 'api.v1.'], static function (): void {\n"
|
||||
" Route::get('accounts', ['uses' => 'AccountController@accounts', 'as' => 'accounts']);\n"
|
||||
"});\n"
|
||||
"Route::middleware('auth')->get('dashboard', 'DashboardController@show');\n"
|
||||
"$router->get('lumen/users', 'UserController@index');\n"}};
|
||||
|
||||
static const char *et_laravel_slashless_routes[] = {"GET /users",
|
||||
"POST /users",
|
||||
"GET /articles",
|
||||
"PUT /articles/{article}",
|
||||
"DELETE /articles/{article}",
|
||||
"GET /v1/autocomplete/accounts",
|
||||
"GET /dashboard",
|
||||
"GET /lumen/users",
|
||||
NULL};
|
||||
|
||||
TEST(routes_laravel_slashless_issue1146) {
|
||||
ASSERT_TRUE(et_route_set_exact(et_laravel_slashless, 1, et_laravel_slashless_routes, 0));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(routes_laravel_slashless_parallel_issue1146) {
|
||||
ASSERT_TRUE(et_route_set_exact(et_laravel_slashless, 1, et_laravel_slashless_routes, 1));
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* The string action of a slashless registration — 'index' inside a
|
||||
* Route::controller() group, 'UserController@store' — does not say which
|
||||
* class owns the method, so resolving it could only guess a same-named
|
||||
* function anywhere in the repo (on krayin/laravel-crm 'update' and 'destroy'
|
||||
* bound a chart.js function). Such routes are minted without a guessed
|
||||
* HANDLES edge. Decoys: a JS function and a PHP method per action name. */
|
||||
TEST(routes_laravel_slashless_no_guessed_handlers_issue1146) {
|
||||
static const char *want[] = {"GET /leads", "PUT /leads/edit/{id}", "POST /users", NULL};
|
||||
static const EtFile f[] = {
|
||||
{"routes/web.php",
|
||||
"<?php\nuse Illuminate\\Support\\Facades\\Route;\n\n"
|
||||
"Route::controller(LeadController::class)->prefix('leads')->group(function () {\n"
|
||||
" Route::get('', 'index');\n"
|
||||
" Route::put('edit/{id}', 'update');\n"
|
||||
"});\n"
|
||||
"Route::post('users', 'UserController@store');\n"},
|
||||
{"resources/js/chart.js",
|
||||
"function index() { return 1; }\nfunction update() { return 2; }\n"},
|
||||
{"app/Http/Controllers/ActivityController.php",
|
||||
"<?php\nnamespace App\\Http\\Controllers;\n\n"
|
||||
"class ActivityController {\n public function store() { return 1; }\n}\n"}};
|
||||
ASSERT_TRUE(et_route_set_exact(f, 3, want, 0));
|
||||
EtProj lp;
|
||||
cbm_store_t *store = et_index_files(&lp, f, 3);
|
||||
int handles = store ? cbm_store_count_edges_by_type(store, lp.project, "HANDLES") : -1;
|
||||
et_cleanup(&lp, store);
|
||||
ASSERT_EQ(handles, 0);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Precision guard for the slashless form: only a Laravel route registration
|
||||
* (the Route facade, or $router) has its URI normalised. A string first
|
||||
* argument of any other get/post call — a request input, a cache key, config,
|
||||
* a collection or session lookup, a property that merely holds a router, a
|
||||
* test client call — stays what it is and mints no Route. */
|
||||
TEST(routes_laravel_slashless_no_junk_issue1146) {
|
||||
static const char *want[] = {"GET /real", NULL};
|
||||
static const EtFile f[] = {
|
||||
{"routes/web.php",
|
||||
"<?php\nuse Illuminate\\Support\\Facades\\Route;\n"
|
||||
"use Illuminate\\Support\\Facades\\Cache;\n\n"
|
||||
"Route::get('real', 'RealController@show');\n\n"
|
||||
"class UsersController {\n"
|
||||
" public function index($request) {\n"
|
||||
" $name = $request->get('name');\n"
|
||||
" $q = $request->query->get('q');\n"
|
||||
" $v = Cache::get('users.count');\n"
|
||||
" $c = config('app.name');\n"
|
||||
" $k = collect(['a' => 1])->get('a');\n"
|
||||
" $s = session()->get('key');\n"
|
||||
" $r = $this->router->get('status');\n"
|
||||
" $p = $this->post('login', []);\n"
|
||||
" return $this->get('users');\n"
|
||||
" }\n}\n"}};
|
||||
ASSERT_TRUE(et_route_set_exact(f, 1, want, 0));
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Rails (Ruby) — ActionDispatch router. The handler MUST be passed as a bare
|
||||
* identifier (not the idiomatic `to: 'list_items'` string, which extract_handler_arg
|
||||
* cannot capture). mapper.get resolves by name to the Mapper#get method whose QN
|
||||
@@ -2138,6 +2293,10 @@ 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(routes_laravel_slashless_issue1146);
|
||||
RUN_TEST(routes_laravel_slashless_parallel_issue1146);
|
||||
RUN_TEST(routes_laravel_slashless_no_guessed_handlers_issue1146);
|
||||
RUN_TEST(routes_laravel_slashless_no_junk_issue1146);
|
||||
RUN_TEST(handles_rails_ruby);
|
||||
RUN_TEST(handles_actix_rust);
|
||||
|
||||
|
||||
@@ -4715,6 +4715,77 @@ TEST(extract_java_method_annotations_issue382) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Distilled from PR #1245 (Andrew Hundt): unittest.mock's `@patch("x")` is a
|
||||
* bare decorator whose name collides with the HTTP verb, and its only argument
|
||||
* is a dotted target, not a path. The "/" fallback then minted a Route handler
|
||||
* PATCH "/" for every mocked test function. Rule: a decorator call WITH
|
||||
* arguments but no path-shaped one is not a route. */
|
||||
TEST(extract_python_mock_patch_is_not_route) {
|
||||
CBMFileResult *r = extract("from unittest.mock import patch\n\n"
|
||||
"@patch(\"subprocess.run\")\n"
|
||||
"def test_cmd(mock_run):\n"
|
||||
" pass\n\n"
|
||||
"@app.patch(\"/items/{id}\")\n"
|
||||
"def update_item():\n"
|
||||
" pass\n",
|
||||
CBM_LANG_PYTHON, "t", "test_routes.py");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
|
||||
const CBMDefinition *mocked = find_def_by_name(r, "test_cmd");
|
||||
ASSERT_NOT_NULL(mocked);
|
||||
ASSERT_NULL(mocked->route_path);
|
||||
ASSERT_NULL(mocked->route_method);
|
||||
|
||||
const CBMDefinition *route = find_def_by_name(r, "update_item");
|
||||
ASSERT_NOT_NULL(route);
|
||||
ASSERT_STR_EQ(route->route_path, "/items/{id}");
|
||||
ASSERT_STR_EQ(route->route_method, "PATCH");
|
||||
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Companion to the mock-patch case: the narrowing must not cost real routes.
|
||||
* A receiver-less framework decorator with a path-shaped argument (Litestar /
|
||||
* BlackSheep `@get("/x")`) stays a route, `@mock.patch("x")` (receiver, but
|
||||
* no path) is not one, and a zero-argument `@app.route()` keeps the "/"
|
||||
* default main emits today. */
|
||||
TEST(extract_python_bare_decorator_route_rules) {
|
||||
CBMFileResult *r = extract("from litestar import get\n"
|
||||
"from unittest import mock\n\n"
|
||||
"@get(\"/health\")\n"
|
||||
"def health():\n"
|
||||
" pass\n\n"
|
||||
"@mock.patch(\"os.getcwd\")\n"
|
||||
"def test_cwd(mock_cwd):\n"
|
||||
" pass\n\n"
|
||||
"@app.route()\n"
|
||||
"def index():\n"
|
||||
" pass\n",
|
||||
CBM_LANG_PYTHON, "t", "routes.py");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
|
||||
const CBMDefinition *health = find_def_by_name(r, "health");
|
||||
ASSERT_NOT_NULL(health);
|
||||
ASSERT_STR_EQ(health->route_path, "/health");
|
||||
ASSERT_STR_EQ(health->route_method, "GET");
|
||||
|
||||
const CBMDefinition *mocked = find_def_by_name(r, "test_cwd");
|
||||
ASSERT_NOT_NULL(mocked);
|
||||
ASSERT_NULL(mocked->route_path);
|
||||
ASSERT_NULL(mocked->route_method);
|
||||
|
||||
const CBMDefinition *index = find_def_by_name(r, "index");
|
||||
ASSERT_NOT_NULL(index);
|
||||
ASSERT_STR_EQ(index->route_path, "/");
|
||||
ASSERT_STR_EQ(index->route_method, "ANY");
|
||||
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── ArkTS (HarmonyOS .ets) ─────────────────────────────────────── */
|
||||
|
||||
TEST(arkts_component_struct) {
|
||||
@@ -9829,6 +9900,8 @@ SUITE(extraction) {
|
||||
RUN_TEST(js_index_module_qn_not_collide_with_folder);
|
||||
RUN_TEST(python_regular_module_qn_unchanged);
|
||||
RUN_TEST(extract_java_method_annotations_issue382);
|
||||
RUN_TEST(extract_python_mock_patch_is_not_route);
|
||||
RUN_TEST(extract_python_bare_decorator_route_rules);
|
||||
RUN_TEST(arkts_component_struct);
|
||||
RUN_TEST(arkts_exported_struct_decorators);
|
||||
RUN_TEST(arkts_member_decorators);
|
||||
|
||||
@@ -1878,7 +1878,25 @@ TEST(empty_and_newline_only_files_unchanged_issue2078) {
|
||||
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. */
|
||||
TEST(source_line_count_one_convention) {
|
||||
ASSERT_EQ(cbm_source_line_count("", 0), 1u);
|
||||
ASSERT_EQ(cbm_source_line_count("a", 1), 1u);
|
||||
ASSERT_EQ(cbm_source_line_count("a\n", 2), 1u);
|
||||
ASSERT_EQ(cbm_source_line_count("\n", 1), 1u);
|
||||
ASSERT_EQ(cbm_source_line_count("a\nb", 3), 2u);
|
||||
ASSERT_EQ(cbm_source_line_count("a\nb\n", 4), 2u);
|
||||
ASSERT_EQ(cbm_source_line_count("a\n\n", 3), 2u);
|
||||
ASSERT_EQ(cbm_source_line_count("a\r\nb\r\n", 6), 2u);
|
||||
/* src_len bounds the count, not a NUL terminator. */
|
||||
ASSERT_EQ(cbm_source_line_count("a\nb\nc", 2), 1u);
|
||||
PASS();
|
||||
}
|
||||
|
||||
SUITE(parse_coverage) {
|
||||
RUN_TEST(source_line_count_one_convention);
|
||||
RUN_TEST(c_ifdef_split_brace_sets_parse_incomplete);
|
||||
RUN_TEST(c_ifdef_split_brace_neighbors_still_extracted);
|
||||
RUN_TEST(c_error_range_points_at_failed_region);
|
||||
|
||||
@@ -8230,6 +8230,56 @@ TEST(pipeline_capitalized_axios_import_is_http_client_sequential) {
|
||||
return capitalized_axios_case(0);
|
||||
}
|
||||
|
||||
/* FastAPI/Starlette WebSocket route registration (distilled from #1245). The
|
||||
* route-registration suffix table knew `.add_api_websocket_route` but not the
|
||||
* decorator forms `.websocket` / `.websocket_route`, so a WebSocket endpoint
|
||||
* registered via `@router.websocket('/ws')` produced no Route node at all —
|
||||
* the endpoint was invisible to the graph. `@app.get('/health')` is the
|
||||
* positive control (already a route suffix). Fewer than 50 files exercises
|
||||
* pass_calls.c; the same suffix table drives the parallel resolver. */
|
||||
TEST(pipeline_python_websocket_decorators_register_routes) {
|
||||
char tmp[256];
|
||||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_ws_routes_XXXXXX");
|
||||
if (!cbm_mkdtemp(tmp)) {
|
||||
FAIL("tmpdir");
|
||||
}
|
||||
|
||||
write_temp_file(tmp, "app.py",
|
||||
"from fastapi import APIRouter, FastAPI, WebSocket\n\n"
|
||||
"app = FastAPI()\n"
|
||||
"router = APIRouter()\n\n"
|
||||
"@app.get('/health')\n"
|
||||
"def health():\n"
|
||||
" return {'ok': True}\n\n"
|
||||
"@router.websocket('/ws')\n"
|
||||
"async def ws_endpoint(websocket: WebSocket):\n"
|
||||
" await websocket.accept()\n\n"
|
||||
"@app.websocket_route('/events')\n"
|
||||
"async def events_endpoint(websocket: WebSocket):\n"
|
||||
" await websocket.accept()\n");
|
||||
|
||||
char db_path[512];
|
||||
snprintf(db_path, sizeof(db_path), "%s/py_ws.db", tmp);
|
||||
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||||
ASSERT_NOT_NULL(p);
|
||||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||||
const char *project = cbm_pipeline_project_name(p);
|
||||
|
||||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||||
ASSERT_NOT_NULL(s);
|
||||
|
||||
/* Positive control: the HTTP decorator mints its Route node. */
|
||||
ASSERT_GTE(count_nodes_named(s, project, "/health"), 1);
|
||||
/* The two WebSocket decorator forms must mint Route nodes too. */
|
||||
ASSERT_GTE(count_nodes_named(s, project, "/ws"), 1);
|
||||
ASSERT_GTE(count_nodes_named(s, project, "/events"), 1);
|
||||
|
||||
cbm_store_close(s);
|
||||
cbm_pipeline_free(p);
|
||||
th_rmtree(tmp);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Python bare-call local-binding suppression, sequential path. The bare-call
|
||||
* counterpart of the receiver guard above: `run` is a PARAMETER, so `run()`
|
||||
* cannot be the module-level `run` and must not bind SatoriLive.run.
|
||||
@@ -18410,6 +18460,7 @@ SUITE(pipeline) {
|
||||
RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges);
|
||||
RUN_TEST(pipeline_capitalized_axios_import_is_http_client);
|
||||
RUN_TEST(pipeline_capitalized_axios_import_is_http_client_sequential);
|
||||
RUN_TEST(pipeline_python_websocket_decorators_register_routes);
|
||||
RUN_TEST(pipeline_python_receiver_parallel_suppresses_weak_method_edges);
|
||||
RUN_TEST(pipeline_python_bare_local_binding_suppresses_weak_edge);
|
||||
RUN_TEST(pipeline_python_bare_local_binding_parallel_suppresses_weak_edge);
|
||||
|
||||
Reference in new issue
Block a user