Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion scripts/security-allowlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ src/cli/cli.c:popen:sha256 checksum computation via shasum
# ── Watcher: git status polling (repo paths validated via cbm_validate_shell_arg) ──
src/watcher/watcher.c:system:git repo detection (is_git_repo)
src/watcher/watcher.c:cbm_popen:git HEAD hash (git_head)
src/watcher/watcher.c:cbm_popen:git working tree status (git_is_dirty)
src/watcher/watcher.c:cbm_popen:git working tree status signature (git_status_signature)
src/watcher/watcher.c:cbm_popen:git file count (git_file_count)
src/watcher/watcher.c:cbm_popen:git toplevel resolution (git_show_toplevel)
src/watcher/watcher.c:popen:via cbm_popen wrapper calls

# ── Git context: git metadata resolution (repo paths validated via cbm_validate_shell_arg) ──
Expand Down
87 changes: 87 additions & 0 deletions src/discover/discover.c
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,93 @@ void cbm_discover_free_excluded(char **excluded, int count) {
free(excluded);
}

/* ── Bounded file counting (#713) ─────────────────────────────────
* Count the indexable files under repo_path without building a result list,
* stopping as soon as the count exceeds `limit`. Serves as the auto-index
* OOM guard for non-git roots, where `git ls-files` cannot provide a count.
* Honors the same hardcoded skip dirs, suffix filters, and language gate as
* full discovery (mode FULL) but deliberately NOT gitignore: over-counting
* only makes the guard more conservative, and this path must stay cheap. */
int cbm_discover_count_files(const char *repo_path, int limit) {
if (!repo_path || !repo_path[0] || limit < 0) {
return 0;
}

struct stat st;
if (wide_stat(repo_path, &st) != 0 || !S_ISDIR(st.st_mode)) {
return 0;
}

/* Dynamic stack of absolute directory paths (heap-owned). */
int cap = CBM_SZ_64;
char **stack = malloc((size_t)cap * sizeof(char *));
if (!stack) {
return 0;
}
stack[0] = strdup(repo_path);
if (!stack[0]) {
free(stack);
return 0;
}
int top = 1;

int count = 0;
while (top > 0 && count <= limit) {
char *dir = stack[--top];
cbm_dir_t *d = cbm_opendir(dir);
if (!d) {
free(dir);
continue;
}

cbm_dirent_t *entry;
while (count <= limit && (entry = cbm_readdir(d)) != NULL) {
if (entry->is_dir) {
if (cbm_should_skip_dir(entry->name, CBM_MODE_FULL)) {
continue;
}
char sub[CBM_SZ_4K];
snprintf(sub, sizeof(sub), "%s/%s", dir, entry->name);
/* safe_stat skips symlinks / junctions so the walk cannot
* cycle or escape the root — same policy as discovery. */
struct stat sst;
if (safe_stat(sub, &sst) != 0 || !S_ISDIR(sst.st_mode)) {
continue;
}
if (top >= cap) {
int new_cap = cap * PAIR_LEN;
char **grown = realloc(stack, (size_t)new_cap * sizeof(char *));
if (!grown) {
continue; /* OOM: skip subtree — count stays a lower bound */
}
stack = grown;
cap = new_cap;
}
char *copy = strdup(sub);
if (copy) {
stack[top++] = copy;
}
} else {
if (cbm_has_ignored_suffix(entry->name, CBM_MODE_FULL)) {
continue;
}
if (cbm_language_for_filename(entry->name) == CBM_LANG_COUNT) {
continue;
}
count++;
}
}
cbm_closedir(d);
free(dir);
}

for (int i = 0; i < top; i++) {
free(stack[i]);
}
free(stack);
return count;
}

void cbm_discover_free_ignored(cbm_ignored_file_t *ignored, int count) {
if (!ignored) {
return;
Expand Down
8 changes: 8 additions & 0 deletions src/discover/discover.h
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ int cbm_discover_ex2(const char *repo_path, const cbm_discover_opts_t *opts, cbm
cbm_ignored_file_t **ignored_out, int *ignored_count_out,
int *ignored_total_out);

/* Count indexable files under repo_path with an early exit: stops walking as
* soon as the count exceeds `limit` and returns limit + 1 (#713). Honors the
* hardcoded skip dirs, suffix filters, and language gate (mode FULL), but not
* gitignore — over-counting only makes a size guard more conservative. Used
* as the auto-index OOM guard for roots where `git ls-files` has no answer
* (non-git folders). Returns 0 on a missing/invalid root. */
int cbm_discover_count_files(const char *repo_path, int limit);

/* Free an array of file info results. NULL-safe. */
void cbm_discover_free(cbm_file_info_t *files, int count);

Expand Down
49 changes: 38 additions & 11 deletions src/mcp/mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ enum {
#include "pipeline/pipeline.h"
#include "pipeline/pass_cross_repo.h"
#include "git/git_context.h"
#include "discover/discover.h"
#include "cli/cli.h"
#include "watcher/watcher.h"
#include "foundation/mem.h"
Expand Down Expand Up @@ -6280,26 +6281,52 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) {
return;
}

/* Quick file count check to avoid OOM on massive repos */
/* File-count guard against OOM on massive roots (#713). For git repos,
* count tracked files via `git ls-files`, tallying newlines in C — the
* old `| wc -l` pipe used single quotes (passed through literally by
* cmd.exe) and `wc` (absent on Windows), so it silently returned nothing
* there. For non-git roots — where ls-files reports 0 files and the old
* guard could NEVER fire, letting auto-index eat tens of GB on large
* code folders — fall back to a bounded native walk that honors the same
* skip dirs and language gate as discovery and stops at the limit. */
if (!cbm_validate_shell_arg(srv->session_root)) {
cbm_log_warn("autoindex.skip", "reason", "path contains shell metacharacters");
return;
}
#ifdef _WIN32
const char *ai_null_dev = "NUL";
#else
const char *ai_null_dev = "/dev/null";
#endif
int count = 0;
char cmd[CBM_SZ_1K];
snprintf(cmd, sizeof(cmd), "git -C '%s' ls-files 2>/dev/null | wc -l", srv->session_root);
snprintf(cmd, sizeof(cmd), "git -C \"%s\" ls-files 2>%s", srv->session_root, ai_null_dev);
FILE *fp = cbm_popen(cmd, "r");
if (fp) {
char line[CBM_SZ_64];
if (fgets(line, sizeof(line), fp)) {
int count = (int)strtol(line, NULL, CBM_DECIMAL_BASE);
if (count > file_limit) {
cbm_log_warn("autoindex.skip", "reason", "too_many_files", "files", line, "limit",
CBM_CONFIG_AUTO_INDEX_LIMIT);
cbm_pclose(fp);
return;
char buf[CBM_SZ_1K];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) {
for (size_t i = 0; i < n; i++) {
if (buf[i] == '\n') {
count++;
}
}
}
cbm_pclose(fp);
if (cbm_pclose(fp) != 0) {
count = 0; /* not a git repo (or git failed) → use the walk below */
}
}
if (count == 0) {
count = cbm_discover_count_files(srv->session_root, file_limit);
}
if (count > file_limit) {
char count_str[CBM_SZ_32];
char limit_str[CBM_SZ_32];
snprintf(count_str, sizeof(count_str), "%d", count);
snprintf(limit_str, sizeof(limit_str), "%d", file_limit);
cbm_log_warn("autoindex.skip", "reason", "too_many_files", "files", count_str, "limit",
limit_str);
return;
}

/* Launch auto-index in background */
Expand Down
Loading
Loading