mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge pull request #1464 from DeusData/fix/bounds-and-workspace-roots
fix: tighten buffer, recursion and indexing-root bounds
This commit is contained in:
24 files changed
+1428
-91
No files matched your search
@@ -188,6 +188,7 @@ FOUNDATION_SRCS = \
|
||||
src/foundation/str_intern.c \
|
||||
src/foundation/log.c \
|
||||
src/foundation/str_util.c \
|
||||
src/foundation/workspace.c \
|
||||
src/foundation/platform.c \
|
||||
src/foundation/system_info.c \
|
||||
src/foundation/slab_alloc.c \
|
||||
@@ -453,6 +454,7 @@ TEST_FOUNDATION_SRCS = \
|
||||
tests/test_str_intern.c \
|
||||
tests/test_log.c \
|
||||
tests/test_str_util.c \
|
||||
tests/test_workspace.c \
|
||||
tests/test_platform.c \
|
||||
tests/test_diagnostics.c \
|
||||
tests/test_dump_verify.c \
|
||||
|
||||
@@ -670,7 +670,7 @@ codebase-memory-mcp config reset auto_index # reset to default
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CBM_ALLOWED_ROOT` | *(unset)* | Restrict `index_repository` to paths within this directory. When set, a `repo_path` that resolves (after symlink / `..` resolution) outside this root is refused; unset imposes no restriction. Useful when the server may be driven by an untrusted caller, e.g. agentic or multi-tenant deployments. |
|
||||
| `CBM_ALLOWED_ROOT` | *(unset)* | Confine `index_repository` to paths within this directory. When set, a `repo_path` that resolves (after symlink / `..` resolution) outside this root is refused, and the same check now applies to the graph UI's `POST /api/index` route rather than only to the MCP tool. Unset imposes no *containment* restriction — but see the always-on limits below, which apply whether or not this is set. Useful when the server may be driven by an untrusted caller, e.g. agentic or multi-tenant deployments. |
|
||||
| `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the database storage directory. All project indexes and config are stored here. One account can use only one canonical cache root at a time; close active CBM sessions/commands before switching it. |
|
||||
| `CBM_DIAGNOSTICS` | `false` | Set to `1` or `true` to enable the shared daemon's periodic `snapshot.json` and retained `trajectory.ndjson` below a fresh owner-private directory in the system temp directory. Exact paths are logged by `diagnostics.start`. |
|
||||
| `CBM_DOWNLOAD_URL` | *(GitHub releases)* | Override the download URL for updates. Used for testing or self-hosted deployments. |
|
||||
|
||||
+2
-2
@@ -172,8 +172,8 @@ sha256sum -c checksums.txt
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| Latest `0.8.x` | Yes — security fixes land in the newest release |
|
||||
| < 0.8 | No — please upgrade to the latest release |
|
||||
| Latest `0.9.x` | Yes — security fixes land in the newest release |
|
||||
| < 0.9 | No — please upgrade to the latest release |
|
||||
|
||||
Only the latest release is supported. Security fixes are shipped in a new
|
||||
patched release rather than backported to older versions; upgrading to the
|
||||
|
||||
+20
-1
@@ -116,7 +116,7 @@ These environment variables affect runtime behavior:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `CBM_ALLOWED_ROOT` | *(unset)* | Restrict `index_repository` to paths within this directory. When set, a `repo_path` that resolves (after symlink / `..` resolution) outside this root is refused; unset imposes no restriction. Useful when the server may be driven by an untrusted caller (agentic or multi-tenant deployments). |
|
||||
| `CBM_ALLOWED_ROOT` | *(unset)* | Confine `index_repository` to paths within this directory. When set, a `repo_path` that resolves (after symlink / `..` resolution) outside this root is refused, and the same check now applies to the graph UI's `POST /api/index` route rather than only to the MCP tool. Unset imposes no *containment* restriction — but see the always-on limits below, which apply whether or not this is set. Useful when the server may be driven by an untrusted caller, e.g. agentic or multi-tenant deployments. |
|
||||
| `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the cache directory used for indexes, `_config.db`, and UI `config.json`. |
|
||||
| `CBM_DIAGNOSTICS` | `false` | Enable periodic `snapshot.json` and retained `trajectory.ndjson` below a fresh owner-private directory in the system temp directory. The daemon records the randomized paths in the `diagnostics.start` discovery record (a single JSON line) in `${CBM_CACHE_DIR}/logs/cbm-daemon.log`; that one record is emitted even when `CBM_LOG_LEVEL` suppresses ordinary logging, so the paths always remain discoverable. |
|
||||
| `CBM_DOWNLOAD_URL` | GitHub releases | Override the update download URL. |
|
||||
@@ -125,6 +125,25 @@ These environment variables affect runtime behavior:
|
||||
|
||||
Environment used by daemon-owned components—such as diagnostics, daemon logging, and process-wide indexing resource limits—is captured from the first daemon-backed session that starts the daemon. Later sessions join the existing process and cannot replace those values. To change them, close every daemon-backed session, update the relevant agent configurations consistently, and restart a session. `CBM_ALLOWED_ROOT` remains session-specific, a conflicting `CBM_CACHE_DIR` is rejected, and one-shot CLI commands use their own current environment without starting the daemon.
|
||||
|
||||
|
||||
### Roots that are always refused
|
||||
|
||||
Independently of `CBM_ALLOWED_ROOT`, some directories are refused as an indexing
|
||||
root because they are too broad or too sensitive to index as a unit:
|
||||
|
||||
- a filesystem root, a Windows drive root, or a UNC share root;
|
||||
- a top-level system tree — `/etc`, `/var`, `/usr`, `/home`, `/Users`, and on
|
||||
Windows `C:\Windows`, `C:\Users`, `C:\ProgramData`, `C:\Program Files`;
|
||||
- your home directory itself (directories *below* it are fine);
|
||||
- a credential directory at any depth — `.ssh`, `.aws`, `.gnupg`, `.kube`,
|
||||
`.docker`, `.netrc`, `.git-credentials`, `.password-store`, macOS `Keychains`.
|
||||
|
||||
Two limits are worth stating plainly. This constrains *scope*, not
|
||||
*sensitivity*: inside a root that is allowed, every file the process can read may
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -326,26 +326,36 @@ static char *extract_callee_from_fields(CBMArena *a, TSNode node, const char *so
|
||||
}
|
||||
|
||||
// Haskell/OCaml: extract callee from apply/infix nodes.
|
||||
/* Apply-style node kinds whose function head can nest another application. */
|
||||
static bool fp_is_apply_kind(const char *kind) {
|
||||
return strcmp(kind, "apply") == 0 || strcmp(kind, "application_expression") == 0 ||
|
||||
strcmp(kind, "exp_apply") == 0;
|
||||
}
|
||||
|
||||
static char *extract_fp_callee(CBMArena *a, TSNode node, const char *source, const char *nk) {
|
||||
if (strcmp(nk, "apply") == 0 || strcmp(nk, "application_expression") == 0 ||
|
||||
strcmp(nk, "exp_apply") == 0) {
|
||||
if (ts_node_child_count(node) > 0) {
|
||||
TSNode callee = ts_node_child(node, 0);
|
||||
const char *ck = ts_node_type(callee);
|
||||
if (strcmp(ck, "identifier") == 0 || strcmp(ck, "variable") == 0 ||
|
||||
strcmp(ck, "constructor") == 0 || strcmp(ck, "value_path") == 0 ||
|
||||
/* PureScript: exp_apply's function head is an `exp_name` whose
|
||||
* text is the (possibly qualified) function name. */
|
||||
strcmp(ck, "exp_name") == 0) {
|
||||
return cbm_node_text(a, callee, source);
|
||||
}
|
||||
/* Curried application `f a b` nests exp_apply/apply — descend the
|
||||
* function head to recover the leftmost callee. */
|
||||
if (strcmp(ck, "exp_apply") == 0 || strcmp(ck, "apply") == 0 ||
|
||||
strcmp(ck, "application_expression") == 0) {
|
||||
return extract_fp_callee(a, callee, source, ck);
|
||||
}
|
||||
/* Curried application `f a b …` nests one apply node per argument on the
|
||||
* function head. Walk that left spine iteratively: recursing once per
|
||||
* application makes stack use follow the parse-tree depth of the indexed
|
||||
* file, and a long enough chain runs out. The other descendant finders in
|
||||
* this tree are already depth-bounded.
|
||||
*
|
||||
* Reassigning node/nk before continuing leaves the fall-through below
|
||||
* operating on the innermost apply node — where the recursive form left it. */
|
||||
while (fp_is_apply_kind(nk) && ts_node_child_count(node) > 0) {
|
||||
TSNode callee = ts_node_child(node, 0);
|
||||
const char *ck = ts_node_type(callee);
|
||||
if (strcmp(ck, "identifier") == 0 || strcmp(ck, "variable") == 0 ||
|
||||
strcmp(ck, "constructor") == 0 || strcmp(ck, "value_path") == 0 ||
|
||||
/* PureScript: exp_apply's function head is an `exp_name` whose
|
||||
* text is the (possibly qualified) function name. */
|
||||
strcmp(ck, "exp_name") == 0) {
|
||||
return cbm_node_text(a, callee, source);
|
||||
}
|
||||
if (!fp_is_apply_kind(ck)) {
|
||||
break;
|
||||
}
|
||||
node = callee;
|
||||
nk = ck;
|
||||
}
|
||||
if (strcmp(nk, "infix") == 0 || strcmp(nk, "infix_expression") == 0) {
|
||||
TSNode op = ts_node_child_by_field_name(node, TS_FIELD("operator"));
|
||||
|
||||
@@ -432,13 +432,36 @@ void cbm_lex_free(cbm_lex_result_t *r) {
|
||||
* PARSER
|
||||
* ══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* The WHERE grammar descends once per nested '(' and once per NOT, so parse
|
||||
* depth follows the query text rather than anything bounded. A few tens of KB of
|
||||
* either exhausts the stack before any semantic limit applies. 256 is far past
|
||||
* any hand-written or generated query while leaving the stack untouched. */
|
||||
enum { CYPHER_MAX_PARSE_DEPTH = 256 };
|
||||
|
||||
typedef struct {
|
||||
const cbm_token_t *tokens;
|
||||
int count;
|
||||
int pos;
|
||||
int depth; /* current recursive-descent depth; see CYPHER_MAX_PARSE_DEPTH */
|
||||
char error[CBM_SZ_512];
|
||||
} parser_t;
|
||||
|
||||
/* Enter one level of recursive descent. Returns false when the cap is hit, in
|
||||
* which case the caller must return NULL without recursing. */
|
||||
static bool parse_depth_enter(parser_t *p) {
|
||||
if (p->depth >= CYPHER_MAX_PARSE_DEPTH) {
|
||||
snprintf(p->error, sizeof(p->error), "expression nested deeper than %d levels",
|
||||
CYPHER_MAX_PARSE_DEPTH);
|
||||
return false;
|
||||
}
|
||||
p->depth++;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void parse_depth_leave(parser_t *p) {
|
||||
p->depth--;
|
||||
}
|
||||
|
||||
static const cbm_token_t *peek(parser_t *p) {
|
||||
if (p->pos >= p->count) {
|
||||
return &p->tokens[p->count - SKIP_ONE]; /* EOF */
|
||||
@@ -1203,7 +1226,11 @@ static cbm_expr_t *parse_condition_expr(parser_t *p) {
|
||||
/* Atom: ( expr ) | condition */
|
||||
static cbm_expr_t *parse_atom_expr(parser_t *p) { // NOLINT(misc-no-recursion)
|
||||
if (match(p, TOK_LPAREN)) {
|
||||
if (!parse_depth_enter(p)) {
|
||||
return NULL;
|
||||
}
|
||||
cbm_expr_t *e = parse_or_expr(p);
|
||||
parse_depth_leave(p);
|
||||
expect(p, TOK_RPAREN);
|
||||
return e;
|
||||
}
|
||||
@@ -1213,7 +1240,11 @@ static cbm_expr_t *parse_atom_expr(parser_t *p) { // NOLINT(misc-no-recursion)
|
||||
/* NOT: NOT atom | atom */
|
||||
static cbm_expr_t *parse_not_expr(parser_t *p) { // NOLINT(misc-no-recursion)
|
||||
if (match(p, TOK_NOT)) {
|
||||
if (!parse_depth_enter(p)) {
|
||||
return NULL;
|
||||
}
|
||||
cbm_expr_t *child = parse_not_expr(p);
|
||||
parse_depth_leave(p);
|
||||
return child ? expr_not(child) : NULL;
|
||||
}
|
||||
return parse_atom_expr(p);
|
||||
|
||||
+44
-17
@@ -39,17 +39,31 @@ struct cbm_gitignore {
|
||||
|
||||
/* ── Pattern matching engine ─────────────────────────────────────── */
|
||||
|
||||
/* Backtracking budget. `**` retries the remainder at every position, and
|
||||
* consecutive literal + `**` groups multiply, so cost is exponential in the
|
||||
* number of groups. Patterns come from a committed .gitignore / .cbmignore and
|
||||
* every discovered path is matched against every pattern, so a small ignore file
|
||||
* can make discovery take unbounded time. Cap the match steps per
|
||||
* (pattern, path) pair.
|
||||
*
|
||||
* Exhausting the budget reports "no match", so a pathological pattern fails to
|
||||
* ignore rather than ignoring the wrong files: the file gets indexed, which is
|
||||
* the recoverable direction. The cap is far above any real pattern — ordinary
|
||||
* globs finish in tens of steps. */
|
||||
enum { GI_MATCH_MAX_STEPS = 20000 };
|
||||
|
||||
/* Forward declaration for recursive calls. */
|
||||
static bool glob_match(const char *pat, const char *str); // NOLINT(misc-no-recursion)
|
||||
static bool glob_match(const char *pat, const char *str,
|
||||
int *budget); // NOLINT(misc-no-recursion)
|
||||
|
||||
/* Match a ** (doublestar-slash) pattern: try rest at every / boundary. */
|
||||
static bool glob_match_doublestar_slash(const char *pat, // NOLINT(misc-no-recursion)
|
||||
const char *str) {
|
||||
if (glob_match(pat, str)) {
|
||||
const char *str, int *budget) {
|
||||
if (glob_match(pat, str, budget)) {
|
||||
return true;
|
||||
}
|
||||
for (const char *s = str; *s; s++) {
|
||||
if (*s == '/' && glob_match(pat, s + SKIP_ONE)) {
|
||||
if (*s == '/' && glob_match(pat, s + SKIP_ONE, budget)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -58,9 +72,9 @@ static bool glob_match_doublestar_slash(const char *pat, // NOLINT(misc-no-recur
|
||||
|
||||
/* Match a ** (doublestar) followed by non-slash: try at every position. */
|
||||
static bool glob_match_doublestar_any(const char *pat, // NOLINT(misc-no-recursion)
|
||||
const char *str) {
|
||||
const char *str, int *budget) {
|
||||
for (const char *s = str;; s++) {
|
||||
if (glob_match(pat, s)) {
|
||||
if (glob_match(pat, s, budget)) {
|
||||
return true;
|
||||
}
|
||||
if (!*s) {
|
||||
@@ -70,9 +84,10 @@ static bool glob_match_doublestar_any(const char *pat, // NOLINT(misc-no-recursi
|
||||
}
|
||||
|
||||
/* Match a * (single star): match any sequence not containing /. */
|
||||
static bool glob_match_star(const char *pat, const char *str) { // NOLINT(misc-no-recursion)
|
||||
static bool glob_match_star(const char *pat, const char *str,
|
||||
int *budget) { // NOLINT(misc-no-recursion)
|
||||
for (const char *s = str;; s++) {
|
||||
if (glob_match(pat, s)) {
|
||||
if (glob_match(pat, s, budget)) {
|
||||
return true;
|
||||
}
|
||||
if (!*s || *s == '/') {
|
||||
@@ -119,24 +134,29 @@ static bool glob_match_charclass(const char *pat, char ch, const char **pat_out)
|
||||
* Handles: * (non-slash), ** (any path), ? (single non-slash), [class]
|
||||
*/
|
||||
/* Handle ** at current position. Returns match result. */
|
||||
static bool glob_match_doublestar(const char *pat, const char *str) { // NOLINT(misc-no-recursion)
|
||||
static bool glob_match_doublestar(const char *pat, const char *str,
|
||||
int *budget) { // NOLINT(misc-no-recursion)
|
||||
if (pat[GI_CHAR_IDX2] == '/') {
|
||||
return glob_match_doublestar_slash(pat + GI_SKIP3, str);
|
||||
return glob_match_doublestar_slash(pat + GI_SKIP3, str, budget);
|
||||
}
|
||||
if (pat[GI_CHAR_IDX2] == '\0') {
|
||||
return true;
|
||||
}
|
||||
return glob_match_doublestar_any(pat + GI_CHAR_IDX2, str);
|
||||
return glob_match_doublestar_any(pat + GI_CHAR_IDX2, str, budget);
|
||||
}
|
||||
|
||||
static bool glob_match(const char *pat, const char *str) { // NOLINT(misc-no-recursion)
|
||||
static bool glob_match(const char *pat, const char *str,
|
||||
int *budget) { // NOLINT(misc-no-recursion)
|
||||
if (--*budget <= 0) {
|
||||
return false; /* budget exhausted — see GI_MATCH_MAX_STEPS */
|
||||
}
|
||||
while (*pat && *str) {
|
||||
if (pat[0] == '*' && pat[GI_CHAR_IDX1] == '*') {
|
||||
return glob_match_doublestar(pat, str);
|
||||
return glob_match_doublestar(pat, str, budget);
|
||||
}
|
||||
|
||||
if (*pat == '*') {
|
||||
return glob_match_star(pat + SKIP_ONE, str);
|
||||
return glob_match_star(pat + SKIP_ONE, str, budget);
|
||||
}
|
||||
|
||||
if (*pat == '?') {
|
||||
@@ -171,6 +191,13 @@ static bool glob_match(const char *pat, const char *str) { // NOLINT(misc-no-rec
|
||||
return *pat == '\0' && *str == '\0';
|
||||
}
|
||||
|
||||
/* Match one pattern against one path with a fresh backtracking budget. Not
|
||||
* recursive itself — it seeds the budget and hands off to the engine. */
|
||||
static bool glob_match_bounded(const char *pat, const char *str) {
|
||||
int budget = GI_MATCH_MAX_STEPS;
|
||||
return glob_match(pat, str, &budget);
|
||||
}
|
||||
|
||||
/* ── Pattern parsing ─────────────────────────────────────────────── */
|
||||
|
||||
static void gi_add_pattern(cbm_gitignore_t *gi, const char *line, int len) {
|
||||
@@ -320,7 +347,7 @@ cbm_gitignore_t *cbm_gitignore_load(const char *path) {
|
||||
|
||||
/* Match a non-rooted pattern against basename and path suffixes. */
|
||||
static bool match_unrooted(const char *pattern, const char *rel_path, const char *basename) {
|
||||
if (glob_match(pattern, basename)) {
|
||||
if (glob_match_bounded(pattern, basename)) {
|
||||
return true;
|
||||
}
|
||||
if (!strchr(rel_path, '/')) {
|
||||
@@ -329,7 +356,7 @@ static bool match_unrooted(const char *pattern, const char *rel_path, const char
|
||||
/* Try matching at every / boundary */
|
||||
const char *s = rel_path;
|
||||
while (*s) {
|
||||
if (glob_match(pattern, s)) {
|
||||
if (glob_match_bounded(pattern, s)) {
|
||||
return true;
|
||||
}
|
||||
const char *next = strchr(s, '/');
|
||||
@@ -359,7 +386,7 @@ int cbm_gitignore_match_result(const cbm_gitignore_t *gi, const char *rel_path,
|
||||
continue;
|
||||
}
|
||||
|
||||
bool this_match = p->rooted ? glob_match(p->pattern, rel_path)
|
||||
bool this_match = p->rooted ? glob_match_bounded(p->pattern, rel_path)
|
||||
: match_unrooted(p->pattern, rel_path, basename);
|
||||
|
||||
if (this_match) {
|
||||
|
||||
@@ -271,6 +271,23 @@ bool cbm_validate_shell_arg(const char *s) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool cbm_validate_shell_path_arg(const char *path) {
|
||||
if (!cbm_validate_shell_arg(path)) {
|
||||
return false;
|
||||
}
|
||||
#ifdef _WIN32
|
||||
/* cmd.exe expands %VAR% and, with delayed expansion, !VAR! inside the
|
||||
* double quotes these commands use; ^ is its escape character. None of the
|
||||
* three can be neutralised by quoting, so reject them outright. */
|
||||
for (const char *p = path; *p; p++) {
|
||||
if (*p == '%' || *p == '!' || *p == '^') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
bool cbm_validate_project_name(const char *name) {
|
||||
if (!name || !*name)
|
||||
return false;
|
||||
|
||||
@@ -57,6 +57,16 @@ char **cbm_str_split(CBMArena *a, const char *s, char delim, int *out_count);
|
||||
* Returns true if safe, false if the string contains shell metacharacters. */
|
||||
bool cbm_validate_shell_arg(const char *s);
|
||||
|
||||
/* Validate a filesystem path that will be interpolated into a shell command.
|
||||
* Everything cbm_validate_shell_arg rejects, plus the cmd.exe expansion
|
||||
* metacharacters % ! ^ on Windows: a path reaching `git -C "%s"` through
|
||||
* cmd.exe would otherwise get %VAR% / delayed-!VAR! expansion applied to it.
|
||||
*
|
||||
* Use this — not cbm_validate_shell_arg — for every path that crosses into a
|
||||
* shell command, so the three git shell-out sites cannot drift apart again.
|
||||
* Returns true if safe. */
|
||||
bool cbm_validate_shell_path_arg(const char *path);
|
||||
|
||||
/* Validate a project name is safe for file path construction.
|
||||
* Allows: alphanumeric, dash, underscore, dot (but not leading dot or dot-dot).
|
||||
* Rejects: path separators (/ \), directory traversal (..), and control chars.
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
/*
|
||||
* workspace.c — workspace boundary policy. See workspace.h for the contract and
|
||||
* for why this is only the breadth half of the boundary.
|
||||
*/
|
||||
#include "foundation/workspace.h"
|
||||
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/compat_fs.h"
|
||||
#include "foundation/platform.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
enum {
|
||||
/* POSIX: two components, so every top-level tree is refused in one rule with
|
||||
* no list to maintain — "/etc", "/home", "/Users", "/var", "/opt", "/srv".
|
||||
* Depth alone cannot do this on Windows, where the equivalent system trees
|
||||
* and a perfectly ordinary workspace are both one component below the drive
|
||||
* ("C:/Windows" and "D:/repos"), so Windows uses one component plus the
|
||||
* named system directories below. Windows' top-level set is small and stable
|
||||
* enough for a list; POSIX's is not. */
|
||||
WS_MIN_DEPTH_POSIX = 2,
|
||||
WS_MIN_DEPTH_WINDOWS = 1,
|
||||
};
|
||||
|
||||
static bool ws_is_sep(char c) {
|
||||
return c == '/' || c == '\\';
|
||||
}
|
||||
|
||||
/* Length of the volume prefix: "/" on POSIX, "C:/" for a drive, "//srv/share"
|
||||
* for a UNC share. Returns 0 when the path is not absolute. */
|
||||
static size_t ws_volume_prefix_len(const char *path) {
|
||||
if (!path || !path[0]) {
|
||||
return 0;
|
||||
}
|
||||
/* UNC: two separators, a host, then a share. */
|
||||
if (ws_is_sep(path[0]) && ws_is_sep(path[1])) {
|
||||
size_t i = 2;
|
||||
while (path[i] && !ws_is_sep(path[i])) { /* host */
|
||||
i++;
|
||||
}
|
||||
while (ws_is_sep(path[i])) {
|
||||
i++;
|
||||
}
|
||||
while (path[i] && !ws_is_sep(path[i])) { /* share */
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
if (ws_is_sep(path[0])) {
|
||||
return 1;
|
||||
}
|
||||
/* Drive-letter root, with or without a trailing separator. */
|
||||
if (path[1] == ':' &&
|
||||
((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z'))) {
|
||||
return ws_is_sep(path[2]) ? 3 : 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool ws_is_unc(const char *path) {
|
||||
return path && ws_is_sep(path[0]) && ws_is_sep(path[1]);
|
||||
}
|
||||
|
||||
static bool ws_is_windows_style(const char *path) {
|
||||
if (!path || !path[0]) {
|
||||
return false;
|
||||
}
|
||||
return path[1] == ':' &&
|
||||
((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z'));
|
||||
}
|
||||
|
||||
int cbm_workspace_path_depth(const char *canonical_path) {
|
||||
size_t prefix = ws_volume_prefix_len(canonical_path);
|
||||
if (prefix == 0) {
|
||||
return 0;
|
||||
}
|
||||
int depth = 0;
|
||||
const char *p = canonical_path + prefix;
|
||||
/* macOS firmlinks /etc, /tmp and /var under /private, so canonicalizing any
|
||||
* of them adds a component: "/etc" resolves to "/private/etc" and would count
|
||||
* as two deep, sailing past a minimum of two — exactly the path the reports
|
||||
* demonstrate. Treat a leading "private" as transparent so depth reflects the
|
||||
* tree the user named. "/private/tmp/proj" still counts two and is allowed. */
|
||||
if (strncmp(p, "private", 7) == 0 && (ws_is_sep(p[7]) || p[7] == '\0')) {
|
||||
p += 7;
|
||||
}
|
||||
while (*p) {
|
||||
while (ws_is_sep(*p)) {
|
||||
p++;
|
||||
}
|
||||
if (!*p) {
|
||||
break;
|
||||
}
|
||||
depth++;
|
||||
while (*p && !ws_is_sep(*p)) {
|
||||
p++;
|
||||
}
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
/* Case-insensitive on Windows-style paths, exact elsewhere: NTFS is
|
||||
* case-insensitive, so ".SSH" must not slip past a case-sensitive compare. */
|
||||
static bool ws_component_equals(const char *component, size_t len, const char *name,
|
||||
bool fold_case) {
|
||||
if (strlen(name) != len) {
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
char a = component[i];
|
||||
char b = name[i];
|
||||
if (fold_case) {
|
||||
if (a >= 'A' && a <= 'Z') {
|
||||
a = (char)(a - 'A' + 'a');
|
||||
}
|
||||
if (b >= 'A' && b <= 'Z') {
|
||||
b = (char)(b - 'A' + 'a');
|
||||
}
|
||||
}
|
||||
if (a != b) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Directory and file names that carry credentials. Matched against every
|
||||
* component, so both "…/.ssh" and "…/.ssh/sub" are refused. Additive by design:
|
||||
* a pull request appending a name here needs no design authority. */
|
||||
static const char *const WS_CREDENTIAL_NAMES[] = {
|
||||
".ssh",
|
||||
".aws",
|
||||
".gnupg",
|
||||
".gpg",
|
||||
".kube",
|
||||
".docker",
|
||||
".netrc",
|
||||
"_netrc",
|
||||
".git-credentials",
|
||||
".azure",
|
||||
".gcloud",
|
||||
"Keychains",
|
||||
".password-store",
|
||||
".authinfo",
|
||||
};
|
||||
|
||||
/* Windows system trees, matched only as the FIRST component below the drive.
|
||||
* Nothing legitimate is indexed inside them at any depth.
|
||||
*
|
||||
* "Users" is deliberately NOT here. Every Windows user's files live under
|
||||
* C:\Users\<name>, so matching it the way credential names are matched — against
|
||||
* every component — refuses every ordinary Windows project path. It is handled
|
||||
* below as the tree root only, mirroring POSIX where "/Users" is refused but
|
||||
* "/Users/dev/projects" is not. */
|
||||
static const char *const WS_WINDOWS_SYSTEM_TREES[] = {
|
||||
"Windows",
|
||||
"ProgramData",
|
||||
"Program Files",
|
||||
"Program Files (x86)",
|
||||
};
|
||||
|
||||
/* Roots that are the tree itself rather than something inside it. */
|
||||
static const char *const WS_WINDOWS_USER_TREE = "Users";
|
||||
|
||||
/* Match only the first component below the volume prefix. */
|
||||
static bool ws_first_component_matches(const char *path, const char *const *names, size_t count,
|
||||
bool fold_case) {
|
||||
const char *p = path + ws_volume_prefix_len(path);
|
||||
while (ws_is_sep(*p)) {
|
||||
p++;
|
||||
}
|
||||
const char *start = p;
|
||||
while (*p && !ws_is_sep(*p)) {
|
||||
p++;
|
||||
}
|
||||
size_t len = (size_t)(p - start);
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
if (ws_component_equals(start, len, names[i], fold_case)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool ws_any_component_matches(const char *path, const char *const *names, size_t count,
|
||||
bool fold_case) {
|
||||
size_t prefix = ws_volume_prefix_len(path);
|
||||
const char *p = path + prefix;
|
||||
while (*p) {
|
||||
while (ws_is_sep(*p)) {
|
||||
p++;
|
||||
}
|
||||
if (!*p) {
|
||||
break;
|
||||
}
|
||||
const char *start = p;
|
||||
while (*p && !ws_is_sep(*p)) {
|
||||
p++;
|
||||
}
|
||||
size_t len = (size_t)(p - start);
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
if (ws_component_equals(start, len, names[i], fold_case)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* True when b is a or lives under a. Compares on a separator boundary so
|
||||
* "/a/bc" is not treated as living under "/a/b". */
|
||||
static bool ws_is_ancestor_or_equal(const char *a, const char *b) {
|
||||
if (!a || !b || !a[0] || !b[0]) {
|
||||
return false;
|
||||
}
|
||||
size_t la = strlen(a);
|
||||
while (la > 1 && ws_is_sep(a[la - 1])) {
|
||||
la--;
|
||||
}
|
||||
if (strncmp(a, b, la) != 0) {
|
||||
return false;
|
||||
}
|
||||
return b[la] == '\0' || ws_is_sep(b[la]);
|
||||
}
|
||||
|
||||
static bool ws_paths_equal(const char *a, const char *b) {
|
||||
return ws_is_ancestor_or_equal(a, b) && ws_is_ancestor_or_equal(b, a);
|
||||
}
|
||||
|
||||
cbm_ws_verdict_t cbm_workspace_classify_root(const char *canonical_path, const char *home_dir,
|
||||
const char *cache_dir) {
|
||||
if (!canonical_path || !canonical_path[0] || ws_volume_prefix_len(canonical_path) == 0) {
|
||||
/* A relative or empty path is not a usable root; refuse it the same way
|
||||
* as a volume root rather than letting it fall through as allowed. */
|
||||
return CBM_WS_DENY_ABSOLUTE;
|
||||
}
|
||||
|
||||
bool windows_style = ws_is_windows_style(canonical_path);
|
||||
int depth = cbm_workspace_path_depth(canonical_path);
|
||||
|
||||
/* A volume, drive or share root, whatever the platform. */
|
||||
if (depth == 0) {
|
||||
return CBM_WS_DENY_ABSOLUTE;
|
||||
}
|
||||
|
||||
/* Order matters below, and not for cosmetic reasons.
|
||||
*
|
||||
* The home directory normally CONTAINS the cache directory, so testing the
|
||||
* cache first would report every $HOME as "holds the cache" — and, worse,
|
||||
* would make it absolutely denied when the design says a person may override
|
||||
* it. Home is therefore classified first.
|
||||
*
|
||||
* Depth comes before the cache test for the same kind of reason: "/Users" is
|
||||
* both too broad and an ancestor of the cache, and "too broad, name a
|
||||
* project directory below it" is the reason that actually helps the reader. */
|
||||
if (home_dir && home_dir[0] && ws_paths_equal(canonical_path, home_dir)) {
|
||||
return CBM_WS_DENY_SENSITIVE;
|
||||
}
|
||||
|
||||
/* Below a drive or a UNC share the first component is already user space
|
||||
* ("D:/repos", "//srv/share/proj"), so one component is enough there. Below a
|
||||
* POSIX root it is a system tree, so require two. */
|
||||
int min_depth =
|
||||
(windows_style || ws_is_unc(canonical_path)) ? WS_MIN_DEPTH_WINDOWS : WS_MIN_DEPTH_POSIX;
|
||||
if (depth < min_depth) {
|
||||
return CBM_WS_DENY_TOO_SHALLOW;
|
||||
}
|
||||
|
||||
/* No rule here for "this root contains the cache directory".
|
||||
*
|
||||
* An earlier draft refused such roots outright, on the theory that indexing
|
||||
* them would absorb every other project's graph database. Two things make
|
||||
* that wrong. The indexer only parses recognised source files, and a graph
|
||||
* database is binary SQLite it would never extract; and refusing an entire
|
||||
* root is the wrong remedy even where the concern holds — the right one is to
|
||||
* not walk the cache. Excluding the cache from discovery is tracked
|
||||
* separately; classifying the root is not the place for it.
|
||||
*
|
||||
* cache_dir stays in the signature because the exclusion work will need it
|
||||
* and because callers already have it to hand. */
|
||||
(void)cache_dir;
|
||||
|
||||
if (ws_any_component_matches(canonical_path, WS_CREDENTIAL_NAMES,
|
||||
sizeof(WS_CREDENTIAL_NAMES) / sizeof(WS_CREDENTIAL_NAMES[0]),
|
||||
windows_style)) {
|
||||
return CBM_WS_DENY_SENSITIVE;
|
||||
}
|
||||
|
||||
if (windows_style && ws_first_component_matches(canonical_path, WS_WINDOWS_SYSTEM_TREES,
|
||||
sizeof(WS_WINDOWS_SYSTEM_TREES) /
|
||||
sizeof(WS_WINDOWS_SYSTEM_TREES[0]),
|
||||
true)) {
|
||||
return CBM_WS_DENY_SENSITIVE;
|
||||
}
|
||||
|
||||
/* "C:/Users" is the user tree itself — refuse it as a root, but leave the
|
||||
* projects below it alone; that is where Windows users actually work. */
|
||||
if (windows_style && depth == 1 &&
|
||||
ws_first_component_matches(canonical_path, &WS_WINDOWS_USER_TREE, 1, true)) {
|
||||
return CBM_WS_DENY_SENSITIVE;
|
||||
}
|
||||
|
||||
return CBM_WS_ALLOW;
|
||||
}
|
||||
|
||||
const char *cbm_workspace_verdict_reason(cbm_ws_verdict_t verdict) {
|
||||
switch (verdict) {
|
||||
case CBM_WS_ALLOW:
|
||||
return "allowed";
|
||||
case CBM_WS_DENY_TOO_SHALLOW:
|
||||
return "path is too broad to index as one root; name a project directory below it";
|
||||
case CBM_WS_DENY_ABSOLUTE:
|
||||
return "path is a volume root or holds the codebase-memory cache; it cannot be indexed";
|
||||
case CBM_WS_DENY_SENSITIVE:
|
||||
return "path is a home or credential directory";
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return "refused";
|
||||
}
|
||||
|
||||
bool cbm_workspace_verdict_is_overridable(cbm_ws_verdict_t verdict) {
|
||||
return verdict == CBM_WS_DENY_SENSITIVE;
|
||||
}
|
||||
|
||||
/* ── Grant store ──────────────────────────────────────────────────────────── */
|
||||
|
||||
enum { WS_LINE_MAX = 4096 };
|
||||
|
||||
/* A line may carry a leading '!' meaning "a person explicitly approved this
|
||||
* sensitive root". The marker is stored rather than inferred so re-classifying
|
||||
* later (a new credential name is added to the list, say) cannot silently
|
||||
* upgrade an ordinary grant into a sensitive one. */
|
||||
static const char WS_SENSITIVE_MARK = '!';
|
||||
|
||||
bool cbm_workspace_grant_path(const char *cache_dir, char *out, size_t out_sz) {
|
||||
if (!cache_dir || !cache_dir[0] || !out || out_sz == 0) {
|
||||
return false;
|
||||
}
|
||||
int n = snprintf(out, out_sz, "%s/allowed_roots", cache_dir);
|
||||
return n > 0 && (size_t)n < out_sz;
|
||||
}
|
||||
|
||||
/* Walk the grant file, invoking visit for each entry. visit returns true to stop.
|
||||
* Returns the number of entries seen. */
|
||||
static int ws_grant_walk(const char *cache_dir,
|
||||
bool (*visit)(const char *root, bool sensitive, void *ctx), void *ctx) {
|
||||
char store[WS_LINE_MAX];
|
||||
if (!cbm_workspace_grant_path(cache_dir, store, sizeof(store))) {
|
||||
return 0;
|
||||
}
|
||||
FILE *f = cbm_fopen(store, "r");
|
||||
if (!f) {
|
||||
return 0;
|
||||
}
|
||||
char line[WS_LINE_MAX];
|
||||
int seen = 0;
|
||||
while (fgets(line, (int)sizeof(line), f)) {
|
||||
size_t len = strlen(line);
|
||||
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) {
|
||||
line[--len] = '\0';
|
||||
}
|
||||
if (len == 0 || line[0] == '#') {
|
||||
continue;
|
||||
}
|
||||
bool sensitive = line[0] == WS_SENSITIVE_MARK;
|
||||
const char *root = sensitive ? line + 1 : line;
|
||||
if (!root[0]) {
|
||||
continue;
|
||||
}
|
||||
seen++;
|
||||
if (visit && visit(root, sensitive, ctx)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(void)fclose(f);
|
||||
return seen;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
const char *candidate;
|
||||
bool contained; /* candidate is at or below some granted root */
|
||||
bool exact_sensitive; /* candidate is itself a grant marked sensitive */
|
||||
} ws_match_t;
|
||||
|
||||
static bool ws_match_visit(const char *root, bool sensitive, void *ctx) {
|
||||
ws_match_t *m = ctx;
|
||||
if (cbm_path_within_root(root, m->candidate)) {
|
||||
m->contained = true;
|
||||
if (sensitive && ws_paths_equal(root, m->candidate)) {
|
||||
m->exact_sensitive = true;
|
||||
}
|
||||
}
|
||||
return false; /* visit every entry: a later one may be the exact match */
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
char *out;
|
||||
size_t out_sz;
|
||||
size_t used;
|
||||
} ws_list_t;
|
||||
|
||||
static bool ws_list_visit(const char *root, bool sensitive, void *ctx) {
|
||||
ws_list_t *l = ctx;
|
||||
int n = snprintf(l->out + l->used, l->out_sz - l->used, "%s%s\n",
|
||||
sensitive ? "(approved) " : "", root);
|
||||
if (n > 0 && (size_t)n < l->out_sz - l->used) {
|
||||
l->used += (size_t)n;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool cbm_workspace_grant_list(const char *cache_dir, char *out, size_t out_sz) {
|
||||
if (!out || out_sz == 0) {
|
||||
return false;
|
||||
}
|
||||
out[0] = '\0';
|
||||
ws_list_t l = {out, out_sz, 0};
|
||||
return ws_grant_walk(cache_dir, ws_list_visit, &l) > 0;
|
||||
}
|
||||
|
||||
bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir,
|
||||
const char *canonical_path, bool approve_sensitive, char *err,
|
||||
size_t err_sz) {
|
||||
if (err && err_sz) {
|
||||
err[0] = '\0';
|
||||
}
|
||||
if (!cache_dir || !cache_dir[0] || !canonical_path || !canonical_path[0]) {
|
||||
if (err) {
|
||||
snprintf(err, err_sz, "no path given");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
cbm_ws_verdict_t verdict = cbm_workspace_classify_root(canonical_path, home_dir, cache_dir);
|
||||
if (verdict != CBM_WS_ALLOW) {
|
||||
if (!cbm_workspace_verdict_is_overridable(verdict)) {
|
||||
if (err) {
|
||||
snprintf(err, err_sz, "%s", cbm_workspace_verdict_reason(verdict));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!approve_sensitive) {
|
||||
/* Refuse by default and name the flag, rather than asking a question
|
||||
* whose answer we cannot authenticate. */
|
||||
if (err) {
|
||||
snprintf(err, err_sz, "%s; re-run with --approve-sensitive if that is intended",
|
||||
cbm_workspace_verdict_reason(verdict));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* Already present is success, not a duplicate error. */
|
||||
ws_match_t existing = {canonical_path, false, false};
|
||||
(void)ws_grant_walk(cache_dir, ws_match_visit, &existing);
|
||||
if (existing.contained) {
|
||||
return true;
|
||||
}
|
||||
|
||||
char store[WS_LINE_MAX];
|
||||
if (!cbm_workspace_grant_path(cache_dir, store, sizeof(store))) {
|
||||
if (err) {
|
||||
snprintf(err, err_sz, "cache path too long");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
FILE *f = cbm_fopen(store, "a");
|
||||
if (!f) {
|
||||
if (err) {
|
||||
snprintf(err, err_sz, "cannot write %s", store);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool sensitive = verdict == CBM_WS_DENY_SENSITIVE;
|
||||
(void)fprintf(f, "%s%s\n", sensitive ? "!" : "", canonical_path);
|
||||
bool ok = fclose(f) == 0;
|
||||
if (!ok && err) {
|
||||
snprintf(err, err_sz, "cannot write %s", store);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir,
|
||||
const char *cache_dir, const char *configured_root, char *err,
|
||||
size_t err_sz) {
|
||||
if (err && err_sz) {
|
||||
err[0] = '\0';
|
||||
}
|
||||
if (!canonical_path || !canonical_path[0]) {
|
||||
if (err) {
|
||||
snprintf(err, err_sz, "no repository path given");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ws_match_t match = {canonical_path, false, false};
|
||||
int grants = ws_grant_walk(cache_dir, ws_match_visit, &match);
|
||||
|
||||
/* A configured root behaves as an additional grant so existing
|
||||
* CBM_ALLOWED_ROOT deployments keep working unchanged. */
|
||||
bool configured = configured_root && configured_root[0];
|
||||
bool configured_contains = configured && cbm_path_within_root(configured_root, canonical_path);
|
||||
|
||||
/* Containment first when a boundary has actually been declared. A path
|
||||
* outside a configured root is best explained as exactly that, and the
|
||||
* wording is what callers and the shell contracts already match on. Breadth
|
||||
* then applies to paths that ARE inside the declared root but are still too
|
||||
* broad to index as one unit. */
|
||||
bool boundary_declared = grants > 0 || configured;
|
||||
if (boundary_declared && !match.contained && !configured_contains) {
|
||||
if (err) {
|
||||
/* Keep the "outside the allowed root" wording: changing it broke an
|
||||
* assertion whose early return then leaked CBM_ALLOWED_ROOT into
|
||||
* every later test in that suite. Guidance is appended, not
|
||||
* substituted. */
|
||||
snprintf(err, err_sz,
|
||||
"%s is outside the allowed root. To allow it, run: "
|
||||
"codebase-memory-mcp allow-root %s",
|
||||
canonical_path, canonical_path);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
cbm_ws_verdict_t verdict = cbm_workspace_classify_root(canonical_path, home_dir, cache_dir);
|
||||
if (verdict == CBM_WS_ALLOW) {
|
||||
return true;
|
||||
}
|
||||
/* An explicit human approval recorded for exactly this path is the only thing
|
||||
* that lifts a sensitive refusal. Absolute and shallow refusals cannot be
|
||||
* lifted at all. */
|
||||
if (verdict == CBM_WS_DENY_SENSITIVE && match.exact_sensitive) {
|
||||
return true;
|
||||
}
|
||||
if (err) {
|
||||
if (cbm_workspace_verdict_is_overridable(verdict)) {
|
||||
snprintf(err, err_sz,
|
||||
"%s: %s. To index it anyway, run: codebase-memory-mcp allow-root "
|
||||
"--approve-sensitive %s",
|
||||
canonical_path, cbm_workspace_verdict_reason(verdict), canonical_path);
|
||||
} else {
|
||||
snprintf(err, err_sz, "%s: %s", canonical_path, cbm_workspace_verdict_reason(verdict));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ── Environment helpers ──────────────────────────────────────────────────── */
|
||||
|
||||
/* Callers should not each re-derive these; a caller that resolved the home
|
||||
* directory differently would classify the same path differently. */
|
||||
const char *cbm_workspace_home_dir(void) {
|
||||
const char *home = getenv("HOME");
|
||||
if (home && home[0]) {
|
||||
return home;
|
||||
}
|
||||
home = getenv("USERPROFILE");
|
||||
return (home && home[0]) ? home : NULL;
|
||||
}
|
||||
|
||||
const char *cbm_workspace_cache_dir(void) {
|
||||
return cbm_resolve_cache_dir();
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
#ifndef CBM_FOUNDATION_WORKSPACE_H
|
||||
#define CBM_FOUNDATION_WORKSPACE_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/*
|
||||
* Workspace boundary policy — classify a directory as an indexing root.
|
||||
*
|
||||
* This is the breadth half of the boundary: it rejects roots that are too broad
|
||||
* or too sensitive to be indexed as a unit. It is NOT the authorization half.
|
||||
* Whether a caller may index a given tree at all is answered by the user-level
|
||||
* grant store, because that is the only input a caller cannot write. A verdict
|
||||
* of CBM_WS_ALLOW here means "this path is not obviously too broad", never
|
||||
* "this path is authorized".
|
||||
*
|
||||
* Every function takes an ALREADY-CANONICALIZED absolute path. Classifying
|
||||
* before symlink resolution lets `/a/b/c/d/e/link -> /` present as a deep path
|
||||
* and launder straight through, so callers must resolve first.
|
||||
*
|
||||
* home_dir and cache_dir are passed in rather than read from the environment so
|
||||
* the policy is a pure function and can be tested without touching the host.
|
||||
* Either may be NULL, which disables the checks that depend on it.
|
||||
*/
|
||||
|
||||
typedef enum {
|
||||
CBM_WS_ALLOW = 0,
|
||||
/* Fewer path components than the platform minimum — e.g. "/etc", "/home". */
|
||||
CBM_WS_DENY_TOO_SHALLOW,
|
||||
/* A filesystem root, a drive root, a UNC share root, or a path that is or
|
||||
* contains the cbm cache directory. Never overridable. */
|
||||
CBM_WS_DENY_ABSOLUTE,
|
||||
/* The home directory itself, or a credential/system directory. Overridable
|
||||
* by an explicit human action, never by a manifest or a tool call. */
|
||||
CBM_WS_DENY_SENSITIVE,
|
||||
} cbm_ws_verdict_t;
|
||||
|
||||
/* Classify canonical_path as a candidate indexing root. */
|
||||
cbm_ws_verdict_t cbm_workspace_classify_root(const char *canonical_path, const char *home_dir,
|
||||
const char *cache_dir);
|
||||
|
||||
/* Stable, human-facing reason for a verdict. Never NULL. */
|
||||
const char *cbm_workspace_verdict_reason(cbm_ws_verdict_t verdict);
|
||||
|
||||
/* True when an explicit human action may override this verdict. Absolute denials
|
||||
* and shallow paths are never overridable; sensitive ones are. */
|
||||
bool cbm_workspace_verdict_is_overridable(cbm_ws_verdict_t verdict);
|
||||
|
||||
/* Number of path components below the volume root, ignoring repeated and
|
||||
* trailing separators. "/" is 0, "/etc" is 1, "C:/repos" is 1, "//srv/share" is
|
||||
* 0 (the share root itself). Exposed for tests. */
|
||||
int cbm_workspace_path_depth(const char *canonical_path);
|
||||
|
||||
/* Canonical containment check: is abs_path at or below root_path, after symlink
|
||||
* and junction resolution? The definition currently lives in src/mcp/mcp.c; it is
|
||||
* declared here so the MCP handler, the HTTP UI route and the CLI all reach the
|
||||
* same implementation. Two entry points evaluating the same policy separately is
|
||||
* what produced the UI/MCP divergence in the first place. */
|
||||
bool cbm_path_within_root(const char *root_path, const char *abs_path);
|
||||
|
||||
/*
|
||||
* ── Grant store ──────────────────────────────────────────────────────────────
|
||||
*
|
||||
* The authorization half. Grants live in a user-level file under the cache
|
||||
* directory and are only ever written by an explicit human action (the CLI), so
|
||||
* neither an indexed repository nor a tool caller can widen its own boundary.
|
||||
*
|
||||
* Enforcement is deliberately two-tier, because default-deny with an empty store
|
||||
* would refuse every first run:
|
||||
*
|
||||
* - The breadth policy above is ALWAYS enforced. Out of the box that already
|
||||
* refuses "/", "/etc", "$HOME", "~/.ssh" and friends — the paths the reports
|
||||
* actually demonstrate — with no configuration at all.
|
||||
* - Containment in a granted root is enforced ONLY once the store is non-empty
|
||||
* or a root is configured. Declaring your first root is therefore what turns
|
||||
* on true confinement, and it never silently loosens: adding a root can only
|
||||
* narrow what was previously unrestricted.
|
||||
*/
|
||||
|
||||
/* Absolute path of the grant file for a cache directory. */
|
||||
bool cbm_workspace_grant_path(const char *cache_dir, char *out, size_t out_sz);
|
||||
|
||||
/* Record canonical_path as an allowed root. approve_sensitive records an explicit
|
||||
* human override for a CBM_WS_DENY_SENSITIVE path; absolute denials and shallow
|
||||
* paths are never grantable. Returns false and fills err on refusal. */
|
||||
bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir,
|
||||
const char *canonical_path, bool approve_sensitive, char *err,
|
||||
size_t err_sz);
|
||||
|
||||
/* Newline-separated granted roots into out. True when at least one exists. */
|
||||
bool cbm_workspace_grant_list(const char *cache_dir, char *out, size_t out_sz);
|
||||
|
||||
/* The whole decision, used by every entry point that accepts a repo path.
|
||||
* canonical_path must already be canonicalized. configured_root is the legacy
|
||||
* CBM_ALLOWED_ROOT / session policy, treated as an additional grant, or NULL.
|
||||
* On refusal, err receives a message that names the command which would fix it. */
|
||||
bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir,
|
||||
const char *cache_dir, const char *configured_root, char *err,
|
||||
size_t err_sz);
|
||||
|
||||
/* The home and cache directories the policy should be evaluated against. Shared
|
||||
* so two callers cannot derive them differently and reach different verdicts for
|
||||
* the same path. Either may return NULL. */
|
||||
const char *cbm_workspace_home_dir(void);
|
||||
const char *cbm_workspace_cache_dir(void);
|
||||
|
||||
#endif /* CBM_FOUNDATION_WORKSPACE_H */
|
||||
+1
-11
@@ -40,17 +40,7 @@ static void trim_newlines(char *s) {
|
||||
}
|
||||
|
||||
static bool git_validate_repo_path(const char *repo_path) {
|
||||
if (!cbm_validate_shell_arg(repo_path)) {
|
||||
return false;
|
||||
}
|
||||
#ifdef _WIN32
|
||||
for (const char *p = repo_path; *p; p++) {
|
||||
if (*p == '%' || *p == '!' || *p == '^') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return true;
|
||||
return cbm_validate_shell_path_arg(repo_path);
|
||||
}
|
||||
|
||||
static int git_capture(const char *repo_path, const char *git_args, char **out) {
|
||||
|
||||
+14
-6
@@ -64,6 +64,7 @@ enum {
|
||||
#include "mcp/index_supervisor.h"
|
||||
#include "mcp/compact_out.h"
|
||||
#include "foundation/str_util.h"
|
||||
#include "foundation/workspace.h"
|
||||
#include "foundation/dump_verify.h"
|
||||
#include "foundation/compat_regex.h"
|
||||
#include "pipeline/artifact.h"
|
||||
@@ -7895,17 +7896,24 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
|
||||
|
||||
repo_path = canonicalize_repo_path_if_exists(repo_path);
|
||||
|
||||
/* Optional workspace boundary. Embedded/daemon sessions always use their
|
||||
* explicit policy, including an explicit NULL meaning unrestricted. A
|
||||
* standalone server retains the process-wide CBM_ALLOWED_ROOT fallback. */
|
||||
/* Workspace boundary. Embedded/daemon sessions supply their explicit policy,
|
||||
* including an explicit NULL meaning unrestricted; a standalone server falls
|
||||
* back to the process-wide CBM_ALLOWED_ROOT. The decision itself lives in one
|
||||
* shared function so this handler and the HTTP UI indexing route cannot drift
|
||||
* apart — they had, and the divergence was the defect. */
|
||||
const char *allowed_root =
|
||||
srv->allowed_root_policy_set ? srv->allowed_root : getenv("CBM_ALLOWED_ROOT");
|
||||
if (allowed_root && allowed_root[0] && repo_path &&
|
||||
!cbm_path_within_root(allowed_root, repo_path)) {
|
||||
/* repo_path is legitimately absent when the caller names an already-known
|
||||
* project instead; the root is resolved downstream. Only a path supplied here
|
||||
* is classified here — the previous check had the same tolerance. */
|
||||
char boundary_err[CBM_SZ_1K];
|
||||
if (repo_path && repo_path[0] &&
|
||||
!cbm_workspace_root_allowed(repo_path, cbm_workspace_home_dir(), cbm_workspace_cache_dir(),
|
||||
allowed_root, boundary_err, sizeof(boundary_err))) {
|
||||
free(mode_str);
|
||||
free(name_override);
|
||||
free(repo_path);
|
||||
return cbm_mcp_text_result("repo_path is outside the allowed root", true);
|
||||
return cbm_mcp_text_result(boundary_err, true);
|
||||
}
|
||||
|
||||
if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) {
|
||||
|
||||
+1
-11
@@ -221,17 +221,7 @@ static int write_file_atomic(const char *path, const char *data, size_t len,
|
||||
* % ! ^. Callers then use DOUBLE quotes (honored by both POSIX sh and cmd.exe, unlike
|
||||
* single quotes on cmd.exe), so a repo path may legitimately contain spaces. */
|
||||
bool cbm_artifact_repo_path_is_shell_safe(const char *repo_path) {
|
||||
if (!cbm_validate_shell_arg(repo_path)) {
|
||||
return false;
|
||||
}
|
||||
#ifdef _WIN32
|
||||
for (const char *p = repo_path; *p; p++) {
|
||||
if (*p == '%' || *p == '!' || *p == '^') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return true;
|
||||
return cbm_validate_shell_path_arg(repo_path);
|
||||
}
|
||||
|
||||
/* Get current git HEAD hash. buf must be >= CBM_SZ_64. Returns false on error. */
|
||||
|
||||
@@ -303,7 +303,11 @@ static void calls_append_args(char *props, size_t cap, const CBMCall *call) {
|
||||
n = snprintf(one, sizeof(one), "%s{\"i\":%d,\"e\":\"%s\"}", i > 0 ? "," : "", a->index,
|
||||
esc_e);
|
||||
}
|
||||
if (n <= 0 || (size_t)n >= cap - pos - PAIR_LEN) {
|
||||
/* Add rather than subtract: pos is unsigned, so `cap - pos - PAIR_LEN`
|
||||
* wraps once pos reaches cap - PAIR_LEN and stops bounding the memcpy
|
||||
* below. The closing write at the end of this function already guards
|
||||
* additively; match it. */
|
||||
if (n <= 0 || pos + (size_t)n + PAIR_LEN >= cap) {
|
||||
break; /* not enough room — close the array with what fits */
|
||||
}
|
||||
memcpy(props + pos, one, (size_t)n);
|
||||
|
||||
@@ -111,7 +111,7 @@ static int parse_git_log(const char *repo_path, commit_t **out, int *out_count)
|
||||
*out = NULL;
|
||||
*out_count = 0;
|
||||
|
||||
if (!cbm_validate_shell_arg(repo_path)) {
|
||||
if (!cbm_validate_shell_path_arg(repo_path)) {
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
|
||||
|
||||
+67
-10
@@ -35,6 +35,7 @@
|
||||
#include "foundation/compat_thread.h"
|
||||
#include "foundation/subprocess.h" /* cbm_build_win_cmdline — shared MS-CRT arg quoting */
|
||||
#include "foundation/win_utf8.h" /* cbm_utf8_to_wide — CreateProcessW wide cmdline (#423/#20) */
|
||||
#include "foundation/workspace.h"
|
||||
|
||||
#include <sqlite3/sqlite3.h>
|
||||
#include <yyjson/yyjson.h>
|
||||
@@ -482,8 +483,18 @@ static void handle_logs(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
int start = (g_log_head - count + LOG_RING_SIZE) % LOG_RING_SIZE;
|
||||
int total = g_log_count;
|
||||
|
||||
/* Copy lines under lock */
|
||||
size_t buf_size = (size_t)count * (LOG_LINE_MAX + 10) + 64;
|
||||
/* Copy lines under lock.
|
||||
*
|
||||
* JSON escaping expands '"', '\\' and '\n' to two bytes each, so an
|
||||
* line made mostly of those serialises to roughly twice its stored length.
|
||||
* The previous budget of LOG_LINE_MAX + 10 per line under-counted that by
|
||||
* half. Ring contents come from indexer stderr, which is not escaped on
|
||||
* ingest and can legitimately contain both doubling characters — a POSIX
|
||||
* filename may.
|
||||
*
|
||||
* Budget the escaped worst case, and clamp the framing writes below anyway
|
||||
* so the size calculation is not the only thing keeping pos in range. */
|
||||
size_t buf_size = (size_t)count * (2 * LOG_LINE_MAX + 8) + 64;
|
||||
char *buf = malloc(buf_size);
|
||||
if (!buf) {
|
||||
cbm_mutex_unlock(&g_log_mutex);
|
||||
@@ -496,9 +507,9 @@ static void handle_logs(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
int idx = (start + i) % LOG_RING_SIZE;
|
||||
if (i > 0)
|
||||
buf[pos++] = ',';
|
||||
http_appendf(buf, buf_size, &pos, ",");
|
||||
/* Escape quotes in log lines */
|
||||
buf[pos++] = '"';
|
||||
http_appendf(buf, buf_size, &pos, "\"");
|
||||
for (int j = 0; g_log_ring[idx][j] && (size_t)pos < buf_size - 10; j++) {
|
||||
char ch = g_log_ring[idx][j];
|
||||
if (ch == '"') {
|
||||
@@ -514,11 +525,19 @@ static void handle_logs(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
buf[pos++] = ch;
|
||||
}
|
||||
}
|
||||
buf[pos++] = '"';
|
||||
http_appendf(buf, buf_size, &pos, "\"");
|
||||
}
|
||||
cbm_mutex_unlock(&g_log_mutex);
|
||||
http_appendf(buf, buf_size, &pos, "],\"total\":%d}", total);
|
||||
|
||||
/* http_appendf pins pos to buf_size on truncation and then writes nothing,
|
||||
* so a saturated buffer would reach the "%s" reply with no terminator in
|
||||
* range. Terminate explicitly. */
|
||||
if ((size_t)pos >= buf_size) {
|
||||
pos = (int)buf_size - 1;
|
||||
}
|
||||
buf[pos] = '\0';
|
||||
|
||||
cbm_http_replyf(c, 200, g_cors_json, "%s", buf);
|
||||
free(buf);
|
||||
}
|
||||
@@ -627,7 +646,9 @@ static void append_roots_json(char *buf, size_t bufsz, int *pos) {
|
||||
continue;
|
||||
}
|
||||
if (count++ > 0) {
|
||||
buf[(*pos)++] = ',';
|
||||
/* Once a wide listing saturates buf, http_appendf has already
|
||||
* pinned *pos to bufsz, so this separator goes through it too. */
|
||||
http_appendf(buf, bufsz, pos, ",");
|
||||
}
|
||||
http_appendf(buf, bufsz, pos, "\"%c:/\"", 'A' + i);
|
||||
}
|
||||
@@ -1032,6 +1053,28 @@ static void handle_index_start(cbm_http_server_t *server, cbm_http_conn_t *c,
|
||||
return;
|
||||
}
|
||||
|
||||
/* Same workspace boundary the MCP indexing tool applies, through the same
|
||||
* function. This route used to check only that the path was a directory, so
|
||||
* 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 boundary_err[1024];
|
||||
if (!cbm_canonical_path(rpath, canonical_root, sizeof(canonical_root))) {
|
||||
yyjson_doc_free(doc);
|
||||
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"cannot resolve root_path\"}");
|
||||
return;
|
||||
}
|
||||
if (!cbm_workspace_root_allowed(canonical_root, cbm_workspace_home_dir(),
|
||||
cbm_workspace_cache_dir(), getenv("CBM_ALLOWED_ROOT"),
|
||||
boundary_err, sizeof(boundary_err))) {
|
||||
yyjson_doc_free(doc);
|
||||
char escaped[1024];
|
||||
cbm_json_escape(escaped, (int)sizeof(escaped), boundary_err);
|
||||
cbm_http_replyf(c, 403, g_cors_json, "{\"error\":\"%s\"}", escaped);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Find free job slot */
|
||||
int slot = -1;
|
||||
for (int i = 0; i < MAX_INDEX_JOBS; i++) {
|
||||
@@ -1089,14 +1132,28 @@ static void handle_index_status(cbm_http_server_t *server, cbm_http_conn_t *c) {
|
||||
if (st == 0)
|
||||
continue;
|
||||
if (pos > 1)
|
||||
buf[pos++] = ',';
|
||||
http_appendf(buf, sizeof(buf), &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,
|
||||
server->index_jobs[i].root_path,
|
||||
st == 3 ? server->index_jobs[i].error_msg : "");
|
||||
esc_path, esc_error);
|
||||
}
|
||||
http_appendf(buf, sizeof(buf), &pos, "]");
|
||||
if ((size_t)pos >= sizeof(buf)) {
|
||||
pos = (int)sizeof(buf) - 1;
|
||||
}
|
||||
buf[pos++] = ']';
|
||||
buf[pos] = '\0';
|
||||
cbm_http_replyf(c, 200, g_cors_json, "%s", buf);
|
||||
}
|
||||
|
||||
@@ -512,6 +512,58 @@ static cbm_store_t *setup_cypher_store(void) {
|
||||
return s;
|
||||
}
|
||||
|
||||
/* The query string is caller-supplied and the WHERE grammar recurses once per
|
||||
* nested '(' and once per NOT, with no depth counter between the MCP entry point
|
||||
* and the recursive descent. A few tens of KB of '(' therefore exhausted the
|
||||
* stack at parse time. Parse in a forked child so the crash surfaces as a
|
||||
* killing signal rather than taking the test runner with it; a bounded parser
|
||||
* must reject the query cleanly instead. */
|
||||
TEST(cypher_deep_nesting_rejected_not_crash) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("fork crash-isolation is POSIX-only; the depth cap is platform-agnostic");
|
||||
#else
|
||||
enum { NEST = 30000 };
|
||||
fflush(NULL);
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
char *q = malloc(NEST * 2 + 64);
|
||||
if (!q) {
|
||||
_exit(2);
|
||||
}
|
||||
int n = snprintf(q, 64, "MATCH (f:Function) WHERE ");
|
||||
for (int i = 0; i < NEST; i++) {
|
||||
q[n++] = '(';
|
||||
}
|
||||
n += snprintf(q + n, 32, "f.name = \"x\"");
|
||||
for (int i = 0; i < NEST; i++) {
|
||||
q[n++] = ')';
|
||||
}
|
||||
q[n] = '\0';
|
||||
cbm_store_t *s = setup_cypher_store();
|
||||
cbm_cypher_result_t r = {0};
|
||||
/* Any clean outcome is acceptable — success or a parse error. Only a
|
||||
* crash is a failure, and that is what the signal check below catches. */
|
||||
(void)cbm_cypher_execute(s, q, "test", 0, &r);
|
||||
cbm_cypher_result_free(&r);
|
||||
cbm_store_close(s);
|
||||
free(q);
|
||||
_exit(0);
|
||||
}
|
||||
ASSERT_TRUE(pid > 0);
|
||||
int status = 0;
|
||||
(void)waitpid(pid, &status, 0);
|
||||
if (WIFSIGNALED(status)) {
|
||||
char m[96];
|
||||
snprintf(m, sizeof(m), "parser killed by signal %d — unbounded recursion depth",
|
||||
WTERMSIG(status));
|
||||
FAIL(m);
|
||||
}
|
||||
ASSERT_TRUE(WIFEXITED(status));
|
||||
ASSERT_EQ(WEXITSTATUS(status), 0);
|
||||
PASS();
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST(cypher_exec_match_all_functions) {
|
||||
cbm_store_t *s = setup_cypher_store();
|
||||
cbm_cypher_result_t r = {0};
|
||||
@@ -3664,6 +3716,7 @@ SUITE(cypher) {
|
||||
/* Execution */
|
||||
RUN_TEST(cypher_exec_deadline_aborts_runaway_query_issue601);
|
||||
RUN_TEST(cypher_exec_deadline_allows_normal_query_issue601);
|
||||
RUN_TEST(cypher_deep_nesting_rejected_not_crash);
|
||||
RUN_TEST(cypher_exec_match_all_functions);
|
||||
RUN_TEST(cypher_exec_optional_empty_label_no_overflow);
|
||||
RUN_TEST(cypher_cross_join_alloc_rejects_overflow);
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
#include "test_framework.h"
|
||||
#include "discover/discover.h"
|
||||
#include <string.h> /* strdup (test seam) */
|
||||
#ifndef _WIN32
|
||||
#include <sys/wait.h> /* fork/waitpid crash-isolation for the backtracking budget */
|
||||
#include <unistd.h> /* alarm() as the liveness backstop */
|
||||
#endif
|
||||
|
||||
/* ── Basic pattern matching ────────────────────────────────────── */
|
||||
|
||||
@@ -274,9 +278,64 @@ TEST(gi_merge_atomic_on_alloc_failure) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* `**` retries the remainder at every position, so consecutive literal + `**`
|
||||
* groups multiply and cost is exponential in the number of groups. Patterns come
|
||||
* from a committed .gitignore and every discovered path is tested against every
|
||||
* pattern, so a small ignore file can make discovery take unbounded time. The
|
||||
* matcher must give up on its step budget.
|
||||
*
|
||||
* Run in a forked child under an alarm: the alarm is only a liveness backstop —
|
||||
* the assertion is that matching TERMINATES, and the unbounded matcher never
|
||||
* reaches the exit. */
|
||||
TEST(gi_doublestar_backtracking_terminates) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("fork/alarm crash-isolation is POSIX-only; the budget is platform-agnostic");
|
||||
#else
|
||||
fflush(NULL);
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
/* 20 "a**" groups then a literal that cannot match, against a run of
|
||||
* 'a' — the classic catastrophic-backtracking shape. */
|
||||
char pattern[256];
|
||||
int n = 0;
|
||||
for (int i = 0; i < 20; i++) {
|
||||
n += snprintf(pattern + n, sizeof(pattern) - (size_t)n, "a**");
|
||||
}
|
||||
(void)snprintf(pattern + n, sizeof(pattern) - (size_t)n, "X\n");
|
||||
char path[64];
|
||||
memset(path, 'a', sizeof(path) - 1);
|
||||
path[sizeof(path) - 1] = '\0';
|
||||
|
||||
alarm(20);
|
||||
cbm_gitignore_t *gi = cbm_gitignore_parse(pattern);
|
||||
if (!gi) {
|
||||
_exit(2);
|
||||
}
|
||||
/* The pattern cannot match; the only question is whether we come back. */
|
||||
(void)cbm_gitignore_matches(gi, path, false);
|
||||
cbm_gitignore_free(gi);
|
||||
_exit(0);
|
||||
}
|
||||
ASSERT_TRUE(pid > 0);
|
||||
int status = 0;
|
||||
(void)waitpid(pid, &status, 0);
|
||||
if (WIFSIGNALED(status)) {
|
||||
char m[112];
|
||||
snprintf(m, sizeof(m),
|
||||
"glob matching did not terminate (signal %d) — no backtracking budget",
|
||||
WTERMSIG(status));
|
||||
FAIL(m);
|
||||
}
|
||||
ASSERT_TRUE(WIFEXITED(status));
|
||||
ASSERT_EQ(WEXITSTATUS(status), 0);
|
||||
PASS();
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ── Suite ─────────────────────────────────────────────────────── */
|
||||
|
||||
SUITE(gitignore) {
|
||||
RUN_TEST(gi_doublestar_backtracking_terminates);
|
||||
RUN_TEST(gi_empty_pattern);
|
||||
RUN_TEST(gi_exact_file);
|
||||
RUN_TEST(gi_wildcard_star);
|
||||
|
||||
@@ -2030,10 +2030,168 @@ TEST(ui_server_browse_wide_dir_no_overflow) {
|
||||
#endif
|
||||
}
|
||||
|
||||
/* The log endpoint serialises the ring into one heap buffer budgeted at
|
||||
* LOG_LINE_MAX + 10 bytes per line. JSON escaping doubles every '"' and '\\',
|
||||
* so an escape-dense line needs ~2x LOG_LINE_MAX. The inner escape loop stops
|
||||
* at buf_size - 10, but the per-line framing writes (separator comma, opening
|
||||
* quote, closing quote) were raw indexes, so every line past saturation wrote
|
||||
* three bytes beyond the allocation. Fill the ring with escape-dense lines and
|
||||
* read it back in a forked child, so the overflow surfaces as a killing signal
|
||||
* (ASan abort) instead of silent heap corruption. */
|
||||
TEST(ui_server_logs_escape_dense_no_overflow) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("fork crash-isolation is POSIX-only; the clamp is platform-agnostic");
|
||||
#else
|
||||
fflush(NULL);
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
/* Worst case the ring can hold: every byte escapes to two bytes. */
|
||||
char dense[512];
|
||||
for (size_t i = 0; i < sizeof(dense) - 1; i++) {
|
||||
dense[i] = (i % 2 == 0) ? '"' : '\\';
|
||||
}
|
||||
dense[sizeof(dense) - 1] = '\0';
|
||||
for (int i = 0; i < 500; i++) { /* fills the whole 500-entry ring */
|
||||
cbm_ui_log_append(dense);
|
||||
}
|
||||
th_server_t ts;
|
||||
if (th_server_start(&ts) != 0) {
|
||||
_exit(2);
|
||||
}
|
||||
char req[256];
|
||||
int port = cbm_http_server_port(ts.srv);
|
||||
snprintf(req, sizeof(req),
|
||||
"GET /api/logs?lines=500 HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port);
|
||||
size_t cap = 4u * 1024u * 1024u;
|
||||
char *resp = malloc(cap);
|
||||
int n = resp ? th_http(port, req, resp, (int)cap) : 0;
|
||||
int ok = (n > 0 && strstr(resp, "HTTP/1.1 200") != NULL);
|
||||
free(resp);
|
||||
th_server_stop(&ts);
|
||||
_exit(ok ? 0 : 3);
|
||||
}
|
||||
ASSERT_TRUE(pid > 0);
|
||||
int status = 0;
|
||||
(void)waitpid(pid, &status, 0);
|
||||
if (WIFSIGNALED(status)) {
|
||||
char m[96];
|
||||
snprintf(m, sizeof(m), "logs killed by signal %d — response buffer overflow",
|
||||
WTERMSIG(status));
|
||||
FAIL(m);
|
||||
}
|
||||
ASSERT_TRUE(WIFEXITED(status));
|
||||
ASSERT_EQ(WEXITSTATUS(status), 0);
|
||||
PASS();
|
||||
#endif
|
||||
}
|
||||
|
||||
/* 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
|
||||
* 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. */
|
||||
#define MAX_TEST_INDEX_JOBS 4
|
||||
TEST(ui_server_index_status_long_paths_no_overflow) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("fork crash-isolation is POSIX-only; the clamp is platform-agnostic");
|
||||
#else
|
||||
char *base = th_mktempdir("cbm_status");
|
||||
if (!base) {
|
||||
FAIL("mktempdir");
|
||||
}
|
||||
/* Two real directories whose paths are long enough that two rendered job
|
||||
* entries exceed the 2 KB response buffer. */
|
||||
/* Four slots x ~520 chars overflows the 2 KB buffer while every path stays
|
||||
* well inside PATH_MAX — a single pair of ~1 KB paths would reach the
|
||||
* buffer too, but mkdir refuses them once the temp-dir prefix is added. */
|
||||
char comp[200];
|
||||
memset(comp, 'd', sizeof(comp) - 1);
|
||||
comp[sizeof(comp) - 1] = '\0';
|
||||
char deep[MAX_TEST_INDEX_JOBS][800];
|
||||
for (int j = 0; j < MAX_TEST_INDEX_JOBS; j++) {
|
||||
int n = snprintf(deep[j], sizeof(deep[j]), "%s/%d", base, j);
|
||||
while (n < 520) {
|
||||
n += snprintf(deep[j] + n, sizeof(deep[j]) - (size_t)n, "/%s", comp);
|
||||
}
|
||||
th_mkdir_p(deep[j]);
|
||||
}
|
||||
fflush(NULL);
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
/* The blocking executor holds every job open so all four slots are
|
||||
* occupied at once. With the non-blocking stub each job finishes
|
||||
* immediately and handle_index_start recycles the same slot, leaving a
|
||||
* single entry to render — far short of the buffer. */
|
||||
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);
|
||||
if (!ts.srv) {
|
||||
_exit(2);
|
||||
}
|
||||
cbm_http_server_set_index_executor(ts.srv, th_ui_blocking_index_executor, &executor);
|
||||
if (th_server_thread_start(&ts.tid, ts.srv) != 0) {
|
||||
_exit(2);
|
||||
}
|
||||
int port = cbm_http_server_port(ts.srv);
|
||||
for (int j = 0; j < MAX_TEST_INDEX_JOBS; j++) {
|
||||
char body[1200];
|
||||
snprintf(body, sizeof(body), "{\"root_path\":\"%s\",\"project_name\":\"p%d\"}", deep[j],
|
||||
j);
|
||||
char request[1500];
|
||||
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[4096];
|
||||
int rn = th_http(port, request, response, sizeof(response));
|
||||
/* A rejected POST would leave the slot empty and the endpoint would
|
||||
* render nothing — a vacuous pass. Fail loudly instead. */
|
||||
if (rn <= 0 || th_status(response) != 202) {
|
||||
_exit(4);
|
||||
}
|
||||
}
|
||||
/* Wait for the state the assertion depends on — all four jobs actually
|
||||
* running — rather than for a duration. */
|
||||
if (!th_wait_atomic_int(&executor.calls, MAX_TEST_INDEX_JOBS, 5000)) {
|
||||
atomic_store(&executor.release, 1);
|
||||
_exit(5);
|
||||
}
|
||||
char req[256];
|
||||
snprintf(req, sizeof(req), "GET /api/index-status HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n",
|
||||
port);
|
||||
char resp[8192];
|
||||
int n = th_http(port, req, resp, sizeof(resp));
|
||||
int ok = (n > 0 && strstr(resp, "HTTP/1.1 200") != NULL);
|
||||
atomic_store(&executor.release, 1);
|
||||
th_server_stop(&ts);
|
||||
_exit(ok ? 0 : 3);
|
||||
}
|
||||
ASSERT_TRUE(pid > 0);
|
||||
int status = 0;
|
||||
(void)waitpid(pid, &status, 0);
|
||||
th_cleanup(base);
|
||||
if (WIFSIGNALED(status)) {
|
||||
char m[96];
|
||||
snprintf(m, sizeof(m), "index-status killed by signal %d — response buffer overflow",
|
||||
WTERMSIG(status));
|
||||
FAIL(m);
|
||||
}
|
||||
ASSERT_TRUE(WIFEXITED(status));
|
||||
ASSERT_EQ(WEXITSTATUS(status), 0);
|
||||
PASS();
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ── Suite ────────────────────────────────────────────────────── */
|
||||
|
||||
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);
|
||||
/* Parser / helpers */
|
||||
RUN_TEST(httpd_parse_simple_get);
|
||||
RUN_TEST(httpd_parse_security_headers_and_rejects_duplicates);
|
||||
|
||||
@@ -603,6 +603,7 @@ extern void suite_dyn_array(void);
|
||||
extern void suite_str_intern(void);
|
||||
extern void suite_log(void);
|
||||
extern void suite_str_util(void);
|
||||
extern void suite_workspace(void);
|
||||
extern void suite_platform(void);
|
||||
extern void suite_diagnostics(void);
|
||||
extern void suite_subprocess(void);
|
||||
@@ -848,6 +849,7 @@ int main(int argc, char **argv) {
|
||||
RUN_SELECTED_SUITE(str_intern);
|
||||
RUN_SELECTED_SUITE(log);
|
||||
RUN_SELECTED_SUITE(str_util);
|
||||
RUN_SELECTED_SUITE(workspace);
|
||||
RUN_SELECTED_SUITE(platform);
|
||||
RUN_SELECTED_SUITE(diagnostics);
|
||||
RUN_SELECTED_SUITE(subprocess);
|
||||
|
||||
+44
-12
@@ -2940,12 +2940,11 @@ TEST(tool_trace_call_path_prefers_definition) {
|
||||
TEST(trace_evidence_strategy_class_vocabulary_is_closed) {
|
||||
/* Every strategy string assigned anywhere in src/ + internal/ as of this
|
||||
* commit, plus the two literals pass_calls.c writes directly. */
|
||||
static const char *const lsp[] = {"lsp_direct", "lsp_base_dispatch",
|
||||
"lsp_embed_dispatch", "lsp_implicit_this",
|
||||
"lsp_inherited_dispatch", "lsp_method_dispatch",
|
||||
"lsp_proc_macro", "lsp_smart_ptr_dispatch",
|
||||
"lsp_strategy_cross_file", "lsp_trait_dispatch",
|
||||
"lsp_type_dispatch", "lsp_virtual_dispatch"};
|
||||
static const char *const lsp[] = {
|
||||
"lsp_direct", "lsp_base_dispatch", "lsp_embed_dispatch",
|
||||
"lsp_implicit_this", "lsp_inherited_dispatch", "lsp_method_dispatch",
|
||||
"lsp_proc_macro", "lsp_smart_ptr_dispatch", "lsp_strategy_cross_file",
|
||||
"lsp_trait_dispatch", "lsp_type_dispatch", "lsp_virtual_dispatch"};
|
||||
for (size_t i = 0; i < sizeof(lsp) / sizeof(lsp[0]); i++) {
|
||||
const char *cls = cbm_mcp_edge_strategy_class(lsp[i]);
|
||||
ASSERT_NOT_NULL(cls);
|
||||
@@ -6076,8 +6075,8 @@ TEST(tool_index_repository_unknown_project_name_still_requires_repo_path) {
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
ASSERT_NOT_NULL(srv);
|
||||
|
||||
char *resp = cbm_mcp_handle_tool(srv, "index_repository",
|
||||
"{\"project\":\"never-indexed-project\"}");
|
||||
char *resp =
|
||||
cbm_mcp_handle_tool(srv, "index_repository", "{\"project\":\"never-indexed-project\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
ASSERT_NOT_NULL(strstr(resp, "repo_path is required"));
|
||||
free(resp);
|
||||
@@ -6609,13 +6608,13 @@ TEST(detect_changes_seeds_only_touched_symbol_issue1363) {
|
||||
"def bar():\n"
|
||||
" y = 2\n"
|
||||
" return y\n"),
|
||||
0);
|
||||
0);
|
||||
|
||||
/* `git -C` with double quotes, not `cd '<dir>' &&`: single quotes are not
|
||||
* quoting characters for cmd.exe, and identity/branch/signing come from -c
|
||||
* so the fixture does not depend on the machine's global git config. The
|
||||
* assertions below read `base: main`, so pin init.defaultBranch. */
|
||||
#define DC1363_GITCFG \
|
||||
#define DC1363_GITCFG \
|
||||
"-c user.name=t -c user.email=t@t.io -c init.defaultBranch=main -c commit.gpgsign=false"
|
||||
char cmd[1200];
|
||||
const char *steps[] = {"init -q", "add -A", "commit -q -m init"};
|
||||
@@ -6644,7 +6643,7 @@ TEST(detect_changes_seeds_only_touched_symbol_issue1363) {
|
||||
"def bar():\n"
|
||||
" y = 2\n"
|
||||
" return y\n"),
|
||||
0);
|
||||
0);
|
||||
|
||||
char *project = cbm_project_name_from_path(repo);
|
||||
ASSERT_NOT_NULL(project);
|
||||
@@ -6691,7 +6690,7 @@ TEST(detect_changes_zero_overlap_falls_back_issue1363) {
|
||||
" return 2\n"),
|
||||
0);
|
||||
|
||||
#define DC1363B_GITCFG \
|
||||
#define DC1363B_GITCFG \
|
||||
"-c user.name=t -c user.email=t@t.io -c init.defaultBranch=main -c commit.gpgsign=false"
|
||||
char cmd[1200];
|
||||
const char *steps[] = {"init -q", "add -A", "commit -q -m init"};
|
||||
@@ -10003,6 +10002,38 @@ TEST(detect_changes_rejects_windows_cmd_metacharacters_in_project_root) {
|
||||
#endif
|
||||
}
|
||||
|
||||
/* With no boundary configured at all, index_repository must still refuse roots
|
||||
* that are too broad or too sensitive to index as a unit. This is the part that
|
||||
* holds out of the box: the paths the advisories actually demonstrate are refused
|
||||
* without anyone setting an environment variable first. */
|
||||
TEST(index_repository_refuses_overbroad_roots_by_default) {
|
||||
const char *saved = getenv("CBM_ALLOWED_ROOT");
|
||||
char *saved_copy = saved ? strdup(saved) : NULL;
|
||||
cbm_unsetenv("CBM_ALLOWED_ROOT");
|
||||
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
ASSERT_NOT_NULL(srv);
|
||||
|
||||
/* A top-level system tree: refused on breadth, with no configuration. */
|
||||
char *resp = cbm_mcp_handle_tool(srv, "index_repository", "{\"repo_path\":\"/etc\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
ASSERT_TRUE(strstr(resp, "too broad") != NULL);
|
||||
free(resp);
|
||||
|
||||
/* The filesystem root is refused outright and is never overridable. */
|
||||
resp = cbm_mcp_handle_tool(srv, "index_repository", "{\"repo_path\":\"/\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
ASSERT_TRUE(strstr(resp, "cannot be indexed") != NULL);
|
||||
free(resp);
|
||||
|
||||
cbm_mcp_server_free(srv);
|
||||
if (saved_copy) {
|
||||
cbm_setenv("CBM_ALLOWED_ROOT", saved_copy, 1);
|
||||
free(saved_copy);
|
||||
}
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Opt-in workspace boundary: when CBM_ALLOWED_ROOT is set, index_repository
|
||||
* must refuse a repo_path that resolves outside it. Unset (the default) imposes
|
||||
* no restriction. */
|
||||
@@ -10292,6 +10323,7 @@ SUITE(mcp) {
|
||||
RUN_TEST(detect_changes_rejects_option_like_base_branch);
|
||||
RUN_TEST(detect_changes_rejects_windows_cmd_metacharacters_in_base_branch);
|
||||
RUN_TEST(detect_changes_rejects_windows_cmd_metacharacters_in_project_root);
|
||||
RUN_TEST(index_repository_refuses_overbroad_roots_by_default);
|
||||
RUN_TEST(index_repository_honors_allowed_root);
|
||||
/* JSON-RPC parsing */
|
||||
RUN_TEST(jsonrpc_parse_request);
|
||||
|
||||
@@ -220,6 +220,30 @@ TEST(shell_rejects_newline) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Every path that reaches a shell command must go through the path variant, not
|
||||
* the bare one: the three git shell-out sites each wrap the repo path in cmd.exe
|
||||
* double quotes, where %VAR%, !VAR! and ^ stay active. One site used the bare
|
||||
* validator and so accepted them. */
|
||||
TEST(shell_path_arg_rejects_cmd_expansion) {
|
||||
/* Anything the base validator rejects, the path variant rejects too. */
|
||||
ASSERT_FALSE(cbm_validate_shell_path_arg("foo'bar"));
|
||||
ASSERT_FALSE(cbm_validate_shell_path_arg("$(whoami)"));
|
||||
ASSERT_FALSE(cbm_validate_shell_path_arg("foo;rm -rf /"));
|
||||
ASSERT_FALSE(cbm_validate_shell_path_arg(NULL));
|
||||
#ifdef _WIN32
|
||||
ASSERT_FALSE(cbm_validate_shell_path_arg("C:/repo/%USERPROFILE%"));
|
||||
ASSERT_FALSE(cbm_validate_shell_path_arg("C:/repo/!PATH!"));
|
||||
ASSERT_FALSE(cbm_validate_shell_path_arg("C:/repo/a^b"));
|
||||
ASSERT_TRUE(cbm_validate_shell_path_arg("C:/Users/dev/my repo"));
|
||||
#else
|
||||
/* The cmd.exe metacharacters are inert on POSIX, so a path containing them
|
||||
* stays valid here; only the Windows build rejects them. */
|
||||
ASSERT_TRUE(cbm_validate_shell_path_arg("/tmp/repo/100%"));
|
||||
ASSERT_TRUE(cbm_validate_shell_path_arg("/tmp/my repo"));
|
||||
#endif
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(shell_rejects_carriage_return) {
|
||||
ASSERT_FALSE(cbm_validate_shell_arg("foo\rbar"));
|
||||
PASS();
|
||||
@@ -782,6 +806,7 @@ SUITE(security) {
|
||||
RUN_TEST(shell_rejects_ampersand);
|
||||
RUN_TEST(shell_rejects_backslash);
|
||||
RUN_TEST(shell_rejects_newline);
|
||||
RUN_TEST(shell_path_arg_rejects_cmd_expansion);
|
||||
RUN_TEST(shell_rejects_carriage_return);
|
||||
RUN_TEST(shell_rejects_null);
|
||||
RUN_TEST(shell_rejects_double_quote);
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* test_workspace.c — workspace boundary policy.
|
||||
*
|
||||
* The policy is a pure function over an already-canonicalized path, so these are
|
||||
* table tests with no filesystem involved. home_dir and cache_dir are injected.
|
||||
*/
|
||||
#include "../src/foundation/compat.h"
|
||||
#include "test_framework.h"
|
||||
#include "foundation/workspace.h"
|
||||
|
||||
static const char *HOME = "/Users/dev";
|
||||
static const char *CACHE = "/Users/dev/.cache/codebase-memory-mcp";
|
||||
|
||||
TEST(ws_depth_counts_components_below_the_volume) {
|
||||
ASSERT_EQ(cbm_workspace_path_depth("/"), 0);
|
||||
ASSERT_EQ(cbm_workspace_path_depth("/etc"), 1);
|
||||
ASSERT_EQ(cbm_workspace_path_depth("/etc/"), 1);
|
||||
ASSERT_EQ(cbm_workspace_path_depth("/Users/dev"), 2);
|
||||
ASSERT_EQ(cbm_workspace_path_depth("/Users//dev///x"), 3);
|
||||
/* macOS firmlinks: the /private prefix must not inflate depth, or "/etc"
|
||||
* resolves to "/private/etc" and passes a minimum of two. */
|
||||
ASSERT_EQ(cbm_workspace_path_depth("/private/etc"), 1);
|
||||
ASSERT_EQ(cbm_workspace_path_depth("/private/tmp/proj"), 2);
|
||||
ASSERT_EQ(cbm_workspace_path_depth("/private"), 0);
|
||||
/* Drive-relative, so an ordinary Windows workspace is one deep. */
|
||||
ASSERT_EQ(cbm_workspace_path_depth("C:/"), 0);
|
||||
ASSERT_EQ(cbm_workspace_path_depth("D:/repos"), 1);
|
||||
ASSERT_EQ(cbm_workspace_path_depth("D:\\repos\\app"), 2);
|
||||
/* A UNC share root is the share itself. */
|
||||
ASSERT_EQ(cbm_workspace_path_depth("//srv/share"), 0);
|
||||
ASSERT_EQ(cbm_workspace_path_depth("//srv/share/proj"), 1);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(ws_volume_roots_are_absolutely_denied) {
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("C:/", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("C:\\", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("//srv/share", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
|
||||
/* "/private" carries no components of its own once the macOS firmlink prefix
|
||||
* is discounted, so it is a volume root rather than merely shallow. */
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/private", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
|
||||
ASSERT_FALSE(cbm_workspace_verdict_is_overridable(CBM_WS_DENY_ABSOLUTE));
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* A relative or empty path is not a usable root and must not fall through as
|
||||
* allowed just because no rule matched it. */
|
||||
TEST(ws_non_absolute_paths_are_denied) {
|
||||
ASSERT_EQ(cbm_workspace_classify_root("", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("relative/path", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
|
||||
ASSERT_EQ(cbm_workspace_classify_root(NULL, HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* One depth rule refuses every POSIX top-level tree without a list to maintain.
|
||||
* This is the whole reason depth carries its weight. */
|
||||
TEST(ws_posix_top_level_trees_are_too_shallow) {
|
||||
static const char *const shallow[] = {"/etc", "/home", "/Users", "/var",
|
||||
"/opt", "/srv", "/usr", "/private/etc",
|
||||
"/private/var", "/private/tmp"};
|
||||
for (size_t i = 0; i < sizeof(shallow) / sizeof(shallow[0]); i++) {
|
||||
ASSERT_EQ(cbm_workspace_classify_root(shallow[i], HOME, CACHE), CBM_WS_DENY_TOO_SHALLOW);
|
||||
}
|
||||
ASSERT_FALSE(cbm_workspace_verdict_is_overridable(CBM_WS_DENY_TOO_SHALLOW));
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Legitimately shallow project roots must survive: these are the false positives
|
||||
* a blanket depth rule would cause, which is why Windows counts drive-relative. */
|
||||
TEST(ws_legitimate_shallow_roots_are_allowed) {
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/opt/sdk", HOME, CACHE), CBM_WS_ALLOW);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/srv/protos", HOME, CACHE), CBM_WS_ALLOW);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("D:/repos", HOME, CACHE), CBM_WS_ALLOW);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("//srv/share/proj", HOME, CACHE), CBM_WS_ALLOW);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/dev/app", HOME, CACHE), CBM_WS_ALLOW);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* $HOME is depth 2 on both macOS and Linux, so depth cannot see it. */
|
||||
TEST(ws_home_itself_is_sensitive_but_subdirs_are_fine) {
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/projects", HOME, CACHE), CBM_WS_ALLOW);
|
||||
/* A sibling that merely shares a prefix is not the home directory. */
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/Users/developer", HOME, CACHE), CBM_WS_ALLOW);
|
||||
ASSERT_TRUE(cbm_workspace_verdict_is_overridable(CBM_WS_DENY_SENSITIVE));
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* The enumeration bypass: a credential directory passes every breadth rule, so
|
||||
* it has to be named. Matched on any component, so subdirectories go too. */
|
||||
TEST(ws_credential_directories_are_sensitive_at_any_depth) {
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.ssh", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.ssh/keys", HOME, CACHE),
|
||||
CBM_WS_DENY_SENSITIVE);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.aws", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.gnupg", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.kube", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/Library/Keychains", HOME, CACHE),
|
||||
CBM_WS_DENY_SENSITIVE);
|
||||
/* A name that merely contains a listed one is a different directory. */
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.sshconfig", HOME, CACHE), CBM_WS_ALLOW);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(ws_windows_system_trees_are_sensitive) {
|
||||
ASSERT_EQ(cbm_workspace_classify_root("C:/Windows", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("C:/Users", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
|
||||
/* THE regression that matters on Windows: every user's work lives under
|
||||
* C:\Users\<name>, so matching "Users" against every component would refuse
|
||||
* every ordinary project path — including a CI runner's own workspace. Only
|
||||
* the tree root is refused. */
|
||||
ASSERT_EQ(cbm_workspace_classify_root("C:/Users/dev/projects/app", HOME, CACHE), CBM_WS_ALLOW);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("C:/Users/runneradmin/work/repo", HOME, CACHE),
|
||||
CBM_WS_ALLOW);
|
||||
/* System trees are refused at any depth inside them, not just at the root. */
|
||||
ASSERT_EQ(cbm_workspace_classify_root("C:/Windows/System32", HOME, CACHE),
|
||||
CBM_WS_DENY_SENSITIVE);
|
||||
/* A project merely named after one is not one. */
|
||||
ASSERT_EQ(cbm_workspace_classify_root("C:/dev/Windows-app", HOME, CACHE), CBM_WS_ALLOW);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("C:/ProgramData", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("C:/Program Files/app", HOME, CACHE),
|
||||
CBM_WS_DENY_SENSITIVE);
|
||||
/* Case-insensitive: NTFS is, so a case-flipped name must not slip past. */
|
||||
ASSERT_EQ(cbm_workspace_classify_root("C:/WINDOWS", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("C:/users/dev/.SSH", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* POSIX is case-sensitive, so a differently-cased directory is a different one
|
||||
* and must not be refused. */
|
||||
TEST(ws_posix_matching_is_case_sensitive) {
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.SSH", HOME, CACHE), CBM_WS_ALLOW);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Absent injected context, the checks that depend on it simply do not fire —
|
||||
* they must not crash or deny everything. */
|
||||
TEST(ws_null_context_disables_dependent_checks) {
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev", NULL, NULL), CBM_WS_ALLOW);
|
||||
/* Depth and volume rules are context-free and still apply. */
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/etc", NULL, NULL), CBM_WS_DENY_TOO_SHALLOW);
|
||||
ASSERT_EQ(cbm_workspace_classify_root("/", NULL, NULL), CBM_WS_DENY_ABSOLUTE);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(ws_every_verdict_has_a_reason) {
|
||||
ASSERT_NOT_NULL(cbm_workspace_verdict_reason(CBM_WS_ALLOW));
|
||||
ASSERT_NOT_NULL(cbm_workspace_verdict_reason(CBM_WS_DENY_TOO_SHALLOW));
|
||||
ASSERT_NOT_NULL(cbm_workspace_verdict_reason(CBM_WS_DENY_ABSOLUTE));
|
||||
ASSERT_NOT_NULL(cbm_workspace_verdict_reason(CBM_WS_DENY_SENSITIVE));
|
||||
/* Reasons are user-facing; a bare enum name would not help anyone. */
|
||||
ASSERT_TRUE(strlen(cbm_workspace_verdict_reason(CBM_WS_DENY_TOO_SHALLOW)) > 20);
|
||||
PASS();
|
||||
}
|
||||
|
||||
SUITE(workspace) {
|
||||
RUN_TEST(ws_depth_counts_components_below_the_volume);
|
||||
RUN_TEST(ws_volume_roots_are_absolutely_denied);
|
||||
RUN_TEST(ws_non_absolute_paths_are_denied);
|
||||
RUN_TEST(ws_posix_top_level_trees_are_too_shallow);
|
||||
RUN_TEST(ws_legitimate_shallow_roots_are_allowed);
|
||||
RUN_TEST(ws_home_itself_is_sensitive_but_subdirs_are_fine);
|
||||
RUN_TEST(ws_credential_directories_are_sensitive_at_any_depth);
|
||||
RUN_TEST(ws_windows_system_trees_are_sensitive);
|
||||
RUN_TEST(ws_posix_matching_is_case_sensitive);
|
||||
RUN_TEST(ws_null_context_disables_dependent_checks);
|
||||
RUN_TEST(ws_every_verdict_has_a_reason);
|
||||
}
|
||||
Reference in new issue
Block a user