Files
codebase-memory-mcp/src/foundation/str_util.c
T
Martin Vogel 886a8856a4 fix: bound response buffers, parse depth and glob matching
A pass over places where a bound was either miscalculated or missing.

UI response builders (src/ui/http_server.c) mixed the clamping helper
http_appendf with raw cursor indexing. Route the per-line separators and
quotes through the helper too, so the cursor cannot leave the buffer once
output saturates it, and terminate explicitly afterwards — the helper pins
the cursor and then writes nothing, which would otherwise leave the "%s"
reply with no terminator in range. The log buffer was budgeted at
LOG_LINE_MAX + 10 per line while JSON escaping can double every byte, so
size it for the escaped worst case. Job status entries now escape their path
and error fields, which were interpolated raw.

Cypher (src/cypher/cypher.c): the WHERE grammar descends once per nested '('
and once per NOT, so parse depth followed the query text rather than
anything bounded. Track depth on parser_t and refuse past 256 levels with a
parse error. Bounding parse depth also bounds the resulting tree, so the
recursive evaluator inherits the limit.

Gitignore (src/discover/gitignore.c): '**' retries the remainder at every
position and consecutive groups multiply, so match cost is exponential in
the number of groups. Thread a step budget through the matcher and give up
past 20000 steps, reporting no-match so a pathological pattern fails to
ignore rather than ignoring the wrong files. glob_match keeps its name so
recursion_whitelist.h still describes the functions that recurse; the new
non-recursive wrapper that seeds the budget is glob_match_bounded.

Call extraction (internal/cbm/extract_calls.c): extract_fp_callee recursed
once per applied argument, so stack use followed the parse-tree depth of the
indexed file. Rewritten as a left-spine loop. Reassigning node/nk before
continuing leaves the fall-through cases where the recursive form left them,
so behaviour is unchanged and the extraction suite is untouched.

CALLS props (src/pipeline/pass_calls.c): `cap - pos - PAIR_LEN` wraps once
pos reaches cap - PAIR_LEN and stops bounding the copy below. Guard
additively, matching the closing write in the same function.

Shell-bound paths (src/foundation/str_util.*): the three git shell-out sites
each wrap a repo path in cmd.exe-compatible double quotes, where %VAR%,
!VAR! and ^ remain active. Two carried private copies of the check that
rejects those three on Windows and the third used the bare validator.
Promote the stricter form to cbm_validate_shell_path_arg and route all three
through it, so the copies cannot drift apart again.

Tests cover the buffer bounds, the parse-depth refusal, matcher termination
and the validator. The bounds tests run in a forked child so a violation
surfaces as a signal instead of silently.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-05 23:27:48 +02:00

361 lines
8.9 KiB
C

/*
* str_util.c — Safe string operations (arena-allocated).
*/
#include "str_util.h"
#include "arena.h" // CBMArena, cbm_arena_alloc/strdup/strndup
#include "foundation/constants.h"
#include <string.h>
#include <ctype.h>
#include <stdio.h>
enum {
JSON_ESC_LEN = 2, /* escaped char takes 2 bytes (backslash + char) */
JSON_NUL_RESERVE = 1, /* reserve 1 byte for NUL terminator */
JSON_CTRL_LIMIT = 0x20, /* ASCII control character upper bound */
};
char *cbm_path_join(CBMArena *a, const char *base, const char *name) {
if (!base || !name) {
return NULL;
}
size_t blen = strlen(base);
size_t nlen = strlen(name);
/* Handle empty components */
if (blen == 0) {
return cbm_arena_strdup(a, name);
}
if (nlen == 0) {
return cbm_arena_strdup(a, base);
}
/* Strip trailing slash from base */
while (blen > 0 && base[blen - SKIP_ONE] == '/') {
blen--;
}
/* Strip leading slash from name */
while (nlen > 0 && *name == '/') {
name++;
nlen--;
}
if (blen == 0) {
return cbm_arena_strndup(a, name, nlen);
}
if (nlen == 0) {
return cbm_arena_strndup(a, base, blen);
}
char *result = (char *)cbm_arena_alloc(a, blen + SKIP_ONE + nlen + SKIP_ONE);
if (!result) {
return NULL;
}
memcpy(result, base, blen);
result[blen] = '/';
memcpy(result + blen + SKIP_ONE, name, nlen);
result[blen + SKIP_ONE + nlen] = '\0';
return result;
}
char *cbm_path_join_n(CBMArena *a, const char **parts, int n) {
if (n <= 0 || !parts) {
return cbm_arena_strdup(a, "");
}
if (n == SKIP_ONE) {
return cbm_arena_strdup(a, parts[0]);
}
char *result = cbm_arena_strdup(a, parts[0]);
for (int i = SKIP_ONE; i < n; i++) {
result = cbm_path_join(a, result, parts[i]);
}
return result;
}
const char *cbm_path_ext(const char *path) {
if (!path) {
return "";
}
const char *dot = NULL;
const char *slash = NULL;
for (const char *p = path; *p; p++) {
if (*p == '.') {
dot = p;
}
if (*p == '/') {
slash = p;
}
}
/* dot must be after last slash and not at start of basename */
if (!dot) {
return "";
}
if (slash && dot < slash) {
return "";
}
return dot + SKIP_ONE;
}
const char *cbm_path_base(const char *path) {
if (!path) {
return "";
}
const char *last_slash = NULL;
for (const char *p = path; *p; p++) {
if (*p == '/') {
last_slash = p;
}
}
return last_slash ? last_slash + SKIP_ONE : path;
}
char *cbm_path_dir(CBMArena *a, const char *path) {
if (!path) {
return cbm_arena_strdup(a, ".");
}
const char *last_slash = NULL;
for (const char *p = path; *p; p++) {
if (*p == '/') {
last_slash = p;
}
}
if (!last_slash) {
return cbm_arena_strdup(a, ".");
}
return cbm_arena_strndup(a, path, (size_t)(last_slash - path));
}
bool cbm_str_starts_with(const char *s, const char *prefix) {
if (!s || !prefix) {
return false;
}
size_t plen = strlen(prefix);
return strncmp(s, prefix, plen) == 0;
}
bool cbm_str_ends_with(const char *s, const char *suffix) {
if (!s || !suffix) {
return false;
}
size_t slen = strlen(s);
size_t xlen = strlen(suffix);
if (xlen > slen) {
return false;
}
return strcmp(s + slen - xlen, suffix) == 0;
}
bool cbm_str_contains(const char *s, const char *sub) {
if (!s || !sub) {
return false;
}
if (sub[0] == '\0') {
return true;
}
return strstr(s, sub) != NULL;
}
char *cbm_str_tolower(CBMArena *a, const char *s) {
if (!s) {
return NULL;
}
size_t len = strlen(s);
char *result = (char *)cbm_arena_alloc(a, len + SKIP_ONE);
if (!result) {
return NULL;
}
for (size_t i = 0; i < len; i++) {
result[i] = (char)tolower((unsigned char)s[i]);
}
result[len] = '\0';
return result;
}
char *cbm_str_replace_char(CBMArena *a, const char *s, char from, char to) {
if (!s) {
return NULL;
}
size_t len = strlen(s);
char *result = (char *)cbm_arena_alloc(a, len + SKIP_ONE);
if (!result) {
return NULL;
}
for (size_t i = 0; i < len; i++) {
result[i] = (s[i] == from) ? to : s[i];
}
result[len] = '\0';
return result;
}
char *cbm_str_strip_ext(CBMArena *a, const char *path) {
if (!path) {
return NULL;
}
const char *dot = NULL;
const char *slash = NULL;
for (const char *p = path; *p; p++) {
if (*p == '.') {
dot = p;
}
if (*p == '/') {
slash = p;
}
}
if (!dot || (slash && dot < slash)) {
return cbm_arena_strdup(a, path);
}
return cbm_arena_strndup(a, path, (size_t)(dot - path));
}
char **cbm_str_split(CBMArena *a, const char *s, char delim, int *out_count) {
if (!s || !out_count) {
return NULL;
}
/* Count parts */
int count = SKIP_ONE;
for (const char *p = s; *p; p++) {
if (*p == delim) {
count++;
}
}
char **result = (char **)cbm_arena_alloc(a, (size_t)(count + SKIP_ONE) * sizeof(char *));
if (!result) {
return NULL;
}
int idx = 0;
const char *start = s;
for (const char *p = s;; p++) {
if (*p == delim || *p == '\0') {
size_t part_len = (size_t)(p - start);
result[idx++] = cbm_arena_strndup(a, start, part_len);
if (*p == '\0') {
break;
}
start = p + SKIP_ONE;
}
}
result[idx] = NULL;
*out_count = count;
return result;
}
bool cbm_validate_shell_arg(const char *s) {
if (!s) {
return false;
}
for (const char *p = s; *p; p++) {
switch (*p) {
case '\'':
case '"':
case ';':
case '|':
case '&':
case '$':
case '`':
case '<':
case '>':
case '\n':
case '\r':
#ifndef _WIN32
case '\\':
#endif
return false;
default:
break;
}
}
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;
/* Reject directory traversal */
if (strcmp(name, "..") == 0 || strstr(name, "..") != NULL)
return false;
/* Reject path separators */
if (strchr(name, '/') || strchr(name, '\\'))
return false;
/* Reject leading dot (hidden files / relative refs) */
if (name[0] == '.')
return false;
/* Allow only alphanumeric, dash, underscore, dot */
for (const char *p = name; *p; p++) {
if (!(((*p >= 'a') && (*p <= 'z')) || ((*p >= 'A') && (*p <= 'Z')) ||
((*p >= '0') && (*p <= '9')) || *p == '-' || *p == '_' || *p == '.')) {
return false;
}
}
return true;
}
int cbm_json_escape(char *buf, int bufsize, const char *src) {
if (!buf || bufsize <= 0) {
return 0;
}
if (!src) {
buf[0] = '\0';
return 0;
}
int pos = 0;
for (int i = 0; src[i] && pos < bufsize - JSON_NUL_RESERVE; i++) {
unsigned char c = (unsigned char)src[i];
if (c == '"' || c == '\\') {
if (pos + JSON_ESC_LEN > bufsize - JSON_NUL_RESERVE) {
break;
}
buf[pos++] = '\\';
buf[pos++] = (char)c;
} else if (c == '\n') {
if (pos + JSON_ESC_LEN > bufsize - JSON_NUL_RESERVE) {
break;
}
buf[pos++] = '\\';
buf[pos++] = 'n';
} else if (c == '\r') {
if (pos + JSON_ESC_LEN > bufsize - JSON_NUL_RESERVE) {
break;
}
buf[pos++] = '\\';
buf[pos++] = 'r';
} else if (c == '\t') {
if (pos + JSON_ESC_LEN > bufsize - JSON_NUL_RESERVE) {
break;
}
buf[pos++] = '\\';
buf[pos++] = 't';
} else if (c < JSON_CTRL_LIMIT) {
/* Other control chars: escape as \u00XX */
if (pos + 6 > bufsize - JSON_NUL_RESERVE) {
break;
}
pos += snprintf(buf + pos, 7, "\\u%04x", c);
} else {
buf[pos++] = (char)c;
}
}
buf[pos] = '\0';
return pos;
}