Skip to content

Commit e471723

Browse files
committed
refactor(search): run POSIX unscoped scan as argv stages, no shell
Port of upstream DeusData PR DeusData#2749 (issue DeusData#2634) to the operations layer. The unscoped source search (project root with no indexed file list) used to build a /bin/sh -c script that interpolated the search root and file pattern into find/sort/xargs/grep text. On POSIX it now runs find and sort as supervised argv stages (no shell) that write the NUL-delimited, LC_ALL=C sorted candidate list, then feeds that list to the existing in-process scanner (search_scan_in_process) that scoped projects already use. The root and file pattern reach find as plain arguments (-type f -name PATTERN -print0), so shell metacharacters in file names are ordinary bytes. New helper cbm_operation_run_argv_stage_bounded (command_runner.c/.h, POSIX-only) mirrors the shell runner's contract: cancellation, deadline and cumulative output-budget latches are polled around the child, a latched condition cancels the process tree, and uncontained trees map to SUPERVISION_FAILURE. find/sort failures fail closed; an empty list is a successful empty scan. The shell path remains for runtime->command_override (test seam) and for Windows, whose behaviour is unchanged. validate_search_args is unchanged, so roots and file patterns containing quotes, ';', '|', '$', backticks, etc. are still rejected up front; only discovered file names are unrestricted. Tests (tests/test_integration.c, POSIX only): metacharacter file names (quote, space, ';', backtick, '$(...)', leading '-', '|&') are found and no command executes; no-match is success; file_pattern is honoured and a shell-syntax pattern is rejected; cancellation, deadline and output-limit errors for the unscoped scan. No raw allocator sites added; memory-core baseline unchanged.
1 parent 2efba85 commit e471723

4 files changed

Lines changed: 322 additions & 1 deletion

File tree

‎src/operations/command_runner.c‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,100 @@ cbm_operation_command_cause_t cbm_operation_run_shell_command_bounded(
241241
: CBM_OPERATION_COMMAND_CONTAINED_FAILURE;
242242
}
243243

244+
#ifndef _WIN32
245+
static void operation_argv_stage_poll_latches(const cbm_operation_runtime_t *runtime,
246+
const char *measured_path, uint64_t output_base,
247+
size_t output_limit, uint64_t deadline_ms,
248+
bool deadline_enabled, bool *cancellation_latched,
249+
bool *deadline_latched, bool *output_limit_latched) {
250+
*cancellation_latched = *cancellation_latched || cbm_operation_runtime_cancelled(runtime);
251+
*deadline_latched = *deadline_latched || (deadline_enabled && cbm_now_ms() >= deadline_ms);
252+
if (!*output_limit_latched && output_limit > 0 && measured_path && measured_path[0]) {
253+
int64_t measured_size = cbm_file_size(measured_path);
254+
*output_limit_latched =
255+
measured_size > 0 && output_base + (uint64_t)measured_size > output_limit;
256+
}
257+
}
258+
259+
cbm_operation_command_cause_t cbm_operation_run_argv_stage_bounded(
260+
const cbm_operation_runtime_t *runtime, const char *bin, const char *const *argv,
261+
const char *const *env_overrides, const char *stdout_capture, uint64_t output_base,
262+
size_t output_limit, uint64_t deadline_ms, bool deadline_enabled, bool tolerate_exit_one,
263+
bool *cancellation_latched, bool *deadline_latched, bool *output_limit_latched,
264+
cbm_proc_result_t *result_out) {
265+
if (!bin || !argv || !result_out || !cancellation_latched || !deadline_latched ||
266+
!output_limit_latched) {
267+
return CBM_OPERATION_COMMAND_FAILURE;
268+
}
269+
memset(result_out, 0, sizeof(*result_out));
270+
operation_argv_stage_poll_latches(runtime, stdout_capture, output_base, output_limit,
271+
deadline_ms, deadline_enabled, cancellation_latched,
272+
deadline_latched, output_limit_latched);
273+
if (*cancellation_latched || *deadline_latched || *output_limit_latched) {
274+
result_out->tree_quiesced = true; /* no child was spawned */
275+
if (*cancellation_latched) {
276+
return CBM_OPERATION_COMMAND_CANCELLED;
277+
}
278+
return *deadline_latched ? CBM_OPERATION_COMMAND_DEADLINE
279+
: CBM_OPERATION_COMMAND_OUTPUT_LIMIT;
280+
}
281+
cbm_proc_opts_t options = {
282+
.bin = bin,
283+
.argv = argv,
284+
.env_overrides = env_overrides,
285+
.log_file = NULL,
286+
.quiet_timeout_ms = 0,
287+
.cancel_grace_ms = CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS,
288+
.delete_log_on_exit = false,
289+
.strip_git_repo_env = false,
290+
.stdout_file = stdout_capture,
291+
};
292+
cbm_subprocess_t *process = NULL;
293+
if (cbm_subprocess_spawn(&options, &process) != 0) {
294+
result_out->tree_quiesced = true;
295+
return CBM_OPERATION_COMMAND_FAILURE;
296+
}
297+
cbm_proc_poll_t state;
298+
for (;;) {
299+
operation_argv_stage_poll_latches(runtime, stdout_capture, output_base, output_limit,
300+
deadline_ms, deadline_enabled, cancellation_latched,
301+
deadline_latched, output_limit_latched);
302+
if (*cancellation_latched || *deadline_latched || *output_limit_latched) {
303+
(void)cbm_subprocess_request_cancel(process);
304+
}
305+
state = cbm_subprocess_poll(process, result_out);
306+
if (state != CBM_PROC_POLL_RUNNING) {
307+
break;
308+
}
309+
cbm_usleep(OPERATION_COMMAND_POLL_US);
310+
}
311+
bool contained = state == CBM_PROC_POLL_TERMINAL && result_out->tree_quiesced &&
312+
!result_out->supervision_failed;
313+
cbm_subprocess_destroy(process);
314+
operation_argv_stage_poll_latches(runtime, stdout_capture, output_base, output_limit,
315+
deadline_ms, deadline_enabled, cancellation_latched,
316+
deadline_latched, output_limit_latched);
317+
if (!contained) {
318+
return CBM_OPERATION_COMMAND_SUPERVISION_FAILURE;
319+
}
320+
if (*cancellation_latched) {
321+
return CBM_OPERATION_COMMAND_CANCELLED;
322+
}
323+
if (*deadline_latched) {
324+
return CBM_OPERATION_COMMAND_DEADLINE;
325+
}
326+
if (*output_limit_latched) {
327+
return CBM_OPERATION_COMMAND_OUTPUT_LIMIT;
328+
}
329+
if (tolerate_exit_one && result_out->outcome == CBM_PROC_EXIT_NONZERO &&
330+
result_out->exit_code == 1) {
331+
return CBM_OPERATION_COMMAND_SUCCESS; /* ordinary grep-style no-match */
332+
}
333+
return result_out->outcome == CBM_PROC_CLEAN ? CBM_OPERATION_COMMAND_SUCCESS
334+
: CBM_OPERATION_COMMAND_CONTAINED_FAILURE;
335+
}
336+
#endif
337+
244338
int cbm_operation_run_shell_command(const cbm_operation_runtime_t *runtime, const char *command,
245339
char output_path[CBM_SZ_2K], cbm_proc_result_t *result_out) {
246340
cbm_operation_command_cause_t cause = cbm_operation_run_shell_command_bounded(

‎src/operations/command_runner.h‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,19 @@ cbm_operation_command_cause_t cbm_operation_run_shell_command_bounded(
2626
size_t output_limit, uint64_t deadline_ms, bool deadline_enabled, bool deadline_latched,
2727
bool exit_one_is_no_match, cbm_proc_result_t *result_out);
2828

29+
#ifndef _WIN32
30+
/* POSIX-only: supervised run of one argv stage (no shell). Same latch and
31+
* containment contract as the shell runner. The measured stdout_capture file's
32+
* size plus output_base counts toward output_limit so chained stages share one
33+
* budget. tolerate_exit_one maps a plain exit-1 (grep's no-match) to SUCCESS. */
34+
cbm_operation_command_cause_t cbm_operation_run_argv_stage_bounded(
35+
const cbm_operation_runtime_t *runtime, const char *bin, const char *const *argv,
36+
const char *const *env_overrides, const char *stdout_capture, uint64_t output_base,
37+
size_t output_limit, uint64_t deadline_ms, bool deadline_enabled, bool tolerate_exit_one,
38+
bool *cancellation_latched, bool *deadline_latched, bool *output_limit_latched,
39+
cbm_proc_result_t *result_out);
40+
#endif
41+
2942
/* Compatibility shape used by detect-changes style callers: any contained
3043
* terminal process tree is considered wrapper success and the caller inspects
3144
* result_out for exit/cancellation classification. */

‎src/operations/source_search.c‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2294,6 +2294,51 @@ int cbm_source_search_scan_for_test(const cbm_operation_runtime_t *runtime,
22942294
}
22952295
#endif
22962296

2297+
#ifndef _WIN32
2298+
/* The unscoped scan with no shell anywhere: find writes the NUL-delimited
2299+
* candidate list and LC_ALL=C sort orders it in place, both as supervised argv
2300+
* stages, then the list feeds the same in-process scanner scoped projects use.
2301+
* The search root and file pattern reach find as plain arguments, never as
2302+
* shell text. find/sort failures fail closed; an empty list is a successful
2303+
* empty scan. */
2304+
static cbm_operation_command_cause_t search_run_unscoped_argv(
2305+
const cbm_operation_runtime_t *runtime, const char *root_path, const char *file_pattern,
2306+
const char *pattern, bool use_regex, const char *filelist, FILE *out, size_t output_limit,
2307+
uint64_t deadline_ms, bool deadline_latched, cbm_proc_result_t *result_out) {
2308+
bool cancellation_latched = false;
2309+
bool output_limit_latched = false;
2310+
enum { FIND_ARGV_MAX = 9 };
2311+
const char *find_argv[FIND_ARGV_MAX];
2312+
int fa = 0;
2313+
find_argv[fa++] = "find";
2314+
find_argv[fa++] = root_path;
2315+
find_argv[fa++] = "-type";
2316+
find_argv[fa++] = "f";
2317+
if (file_pattern) {
2318+
find_argv[fa++] = "-name";
2319+
find_argv[fa++] = file_pattern;
2320+
}
2321+
find_argv[fa++] = "-print0";
2322+
find_argv[fa] = NULL;
2323+
cbm_operation_command_cause_t cause = cbm_operation_run_argv_stage_bounded(
2324+
runtime, "find", find_argv, NULL, filelist, 0, 0, deadline_ms, true, false,
2325+
&cancellation_latched, &deadline_latched, &output_limit_latched, result_out);
2326+
if (cause != CBM_OPERATION_COMMAND_SUCCESS) {
2327+
return cause;
2328+
}
2329+
const char *sort_argv[] = {"sort", "-z", "-o", filelist, filelist, NULL};
2330+
const char *sort_env[] = {"LC_ALL=C", NULL};
2331+
cause = cbm_operation_run_argv_stage_bounded(
2332+
runtime, "sort", sort_argv, sort_env, NULL, 0, 0, deadline_ms, true, false,
2333+
&cancellation_latched, &deadline_latched, &output_limit_latched, result_out);
2334+
if (cause != CBM_OPERATION_COMMAND_SUCCESS) {
2335+
return cause;
2336+
}
2337+
return search_scan_in_process(runtime, filelist, pattern, use_regex, out, output_limit,
2338+
deadline_ms);
2339+
}
2340+
#endif
2341+
22972342
/* Open the scratch directory, write `pattern` to the grep -f file, and leave the
22982343
* file list open for write_scoped_filelist. Returns true on success; on failure
22992344
* everything already created is removed before returning. */
@@ -2791,13 +2836,20 @@ cbm_operation_result_t cbm_source_search_operation_execute(const char *args,
27912836
* command overrides, and Windows patterns that need PowerShell's
27922837
* post-filter semantics. */
27932838
bool scan_in_process = scoped && !(runtime && runtime->command_override);
2839+
bool scan_argv = false;
2840+
#ifndef _WIN32
2841+
/* POSIX unscoped scans run as find/sort argv stages feeding the
2842+
* in-process scanner: no shell parses the root or file pattern. The
2843+
* command override keeps the shell runner for tests. */
2844+
scan_argv = !scoped && !(runtime && runtime->command_override);
2845+
#endif
27942846
#ifdef _WIN32
27952847
scan_in_process =
27962848
scan_in_process &&
27972849
(!file_pattern || cbm_search_code_file_pattern_can_prefilter(file_pattern));
27982850
#endif
27992851
cbm_operation_command_cause_t scan_cause;
2800-
if (scan_in_process) {
2852+
if (scan_in_process || scan_argv) {
28012853
FILE *scan_out = NULL;
28022854
if (scan_cancellation_latched || cbm_operation_runtime_cancelled(runtime)) {
28032855
scan_cause = CBM_OPERATION_COMMAND_CANCELLED;
@@ -2806,6 +2858,12 @@ cbm_operation_result_t cbm_source_search_operation_execute(const char *args,
28062858
} else if (!(scan_out = search_scratch_file(scratch.dir, "scan", output_path,
28072859
sizeof(output_path)))) {
28082860
scan_cause = CBM_OPERATION_COMMAND_FAILURE;
2861+
} else if (scan_argv) {
2862+
#ifndef _WIN32
2863+
scan_cause = search_run_unscoped_argv(
2864+
runtime, root_path, file_pattern, pattern, use_regex, filelist, scan_out,
2865+
scan_output_limit, scan_deadline_ms, scan_deadline_latched, &scan_result);
2866+
#endif
28092867
} else {
28102868
scan_cause = search_scan_in_process(runtime, filelist, pattern, use_regex, scan_out,
28112869
scan_output_limit, scan_deadline_ms);

‎tests/test_integration.c‎

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2429,6 +2429,158 @@ TEST(port_source_search_drops_walker_hits_that_resolve_outside_root) {
24292429
ASSERT_TRUE(total_counts_inside_only);
24302430
PASS();
24312431
}
2432+
2433+
/* #2634: the POSIX unscoped scan runs find/sort as argv stages with no shell,
2434+
* feeding the in-process scanner. Run one query against a project whose root
2435+
* has no indexed files, so the scan walks the tree. */
2436+
typedef struct {
2437+
port_cache_fixture_t cache;
2438+
char root[CBM_SZ_512];
2439+
} port_argv_fixture_t;
2440+
2441+
static bool port_argv_fixture_begin(port_argv_fixture_t *fx, const char *project) {
2442+
memset(fx, 0, sizeof(*fx));
2443+
if (!port_cache_begin(&fx->cache)) {
2444+
return false;
2445+
}
2446+
snprintf(fx->root, sizeof(fx->root), "%s/cbm-search-argv-XXXXXX", cbm_tmpdir());
2447+
return cbm_mkdtemp(fx->root) != NULL && port_create_project(fx->cache.cache, project, fx->root);
2448+
}
2449+
2450+
static void port_argv_fixture_end(port_argv_fixture_t *fx) {
2451+
if (fx->root[0]) {
2452+
th_rmtree(fx->root);
2453+
}
2454+
port_cache_end(&fx->cache);
2455+
}
2456+
2457+
static bool port_argv_write(const port_argv_fixture_t *fx, const char *name, const char *content) {
2458+
char path[CBM_SZ_1K];
2459+
int written = snprintf(path, sizeof(path), "%s/%s", fx->root, name);
2460+
return written > 0 && (size_t)written < sizeof(path) && th_write_file(path, content) == 0;
2461+
}
2462+
2463+
TEST(port_source_search_unscoped_argv_scans_metachar_filenames_issue2634) {
2464+
port_argv_fixture_t fx;
2465+
ASSERT_TRUE(port_argv_fixture_begin(&fx, "argv-meta"));
2466+
ASSERT_TRUE(port_argv_write(&fx, "we'ird ;na`me$.go", "func HandleHostile2634() {}\n"));
2467+
ASSERT_TRUE(port_argv_write(&fx, "$(touch pwned).c", "int HandleHostile2634;\n"));
2468+
ASSERT_TRUE(port_argv_write(&fx, "-lead.c", "int HandleHostile2634;\n"));
2469+
ASSERT_TRUE(port_argv_write(&fx, "a|b&c.c", "int HandleHostile2634;\n"));
2470+
2471+
char *resp = port_operation_execute(
2472+
"search_code",
2473+
"{\"pattern\":\"HandleHostile2634\",\"project\":\"argv-meta\",\"format\":\"json\"}", NULL);
2474+
ASSERT_NOT_NULL(resp);
2475+
ASSERT_NOT_NULL(strstr(resp, "we'ird ;na`me$.go"));
2476+
ASSERT_NOT_NULL(strstr(resp, "$(touch pwned).c"));
2477+
ASSERT_NOT_NULL(strstr(resp, "-lead.c"));
2478+
ASSERT_NOT_NULL(strstr(resp, "a|b&c.c"));
2479+
ASSERT_NOT_NULL(strstr(resp, "\"total_grep_matches\":4"));
2480+
free(resp);
2481+
2482+
char pwned[CBM_SZ_1K];
2483+
snprintf(pwned, sizeof(pwned), "%s/pwned", fx.root);
2484+
ASSERT_FALSE(access(pwned, F_OK) == 0);
2485+
ASSERT_FALSE(access("pwned", F_OK) == 0);
2486+
port_argv_fixture_end(&fx);
2487+
PASS();
2488+
}
2489+
2490+
TEST(port_source_search_unscoped_argv_no_match_is_success_issue2634) {
2491+
port_argv_fixture_t fx;
2492+
ASSERT_TRUE(port_argv_fixture_begin(&fx, "argv-nomatch"));
2493+
ASSERT_TRUE(port_argv_write(&fx, "handler.go", "func HandlePresent2634() {}\n"));
2494+
2495+
char *resp = port_operation_execute(
2496+
"search_code",
2497+
"{\"pattern\":\"NoSuchSymbol2634\",\"project\":\"argv-nomatch\",\"format\":\"json\"}",
2498+
NULL);
2499+
ASSERT_NOT_NULL(resp);
2500+
ASSERT_NOT_NULL(strstr(resp, "\"total_grep_matches\":0"));
2501+
ASSERT_NOT_NULL(strstr(resp, "\"total_relation\":\"eq\""));
2502+
free(resp);
2503+
port_argv_fixture_end(&fx);
2504+
PASS();
2505+
}
2506+
2507+
TEST(port_source_search_unscoped_argv_honours_file_pattern_issue2634) {
2508+
port_argv_fixture_t fx;
2509+
ASSERT_TRUE(port_argv_fixture_begin(&fx, "argv-glob"));
2510+
ASSERT_TRUE(port_argv_write(&fx, "handler.py", "def handle_python2634():\n return 1\n"));
2511+
2512+
char *resp =
2513+
port_operation_execute("search_code",
2514+
"{\"pattern\":\"handle_python2634\",\"project\":\"argv-glob\","
2515+
"\"file_pattern\":\"*.go\",\"format\":\"json\"}",
2516+
NULL);
2517+
ASSERT_NOT_NULL(resp);
2518+
ASSERT_NULL(strstr(resp, "handler.py"));
2519+
ASSERT_NOT_NULL(strstr(resp, "\"total_grep_matches\":0"));
2520+
free(resp);
2521+
2522+
resp = port_operation_execute(
2523+
"search_code",
2524+
"{\"pattern\":\"handle_python2634\",\"project\":\"argv-glob\",\"format\":\"json\"}", NULL);
2525+
ASSERT_NOT_NULL(resp);
2526+
ASSERT_NOT_NULL(strstr(resp, "handler.py"));
2527+
ASSERT_NOT_NULL(strstr(resp, "\"total_grep_matches\":1"));
2528+
free(resp);
2529+
2530+
/* A file_pattern carrying shell syntax is rejected before any process runs. */
2531+
resp = port_operation_execute("search_code",
2532+
"{\"pattern\":\"handle_python2634\",\"project\":\"argv-glob\","
2533+
"\"file_pattern\":\"*.py;touch pwned\"}",
2534+
NULL);
2535+
ASSERT_NOT_NULL(resp);
2536+
ASSERT_NOT_NULL(strstr(resp, "invalid characters"));
2537+
free(resp);
2538+
port_argv_fixture_end(&fx);
2539+
PASS();
2540+
}
2541+
2542+
static bool port_argv_always_cancelled(void *context) {
2543+
(void)context;
2544+
return true;
2545+
}
2546+
2547+
TEST(port_source_search_unscoped_argv_cancel_deadline_and_output_limit) {
2548+
port_argv_fixture_t fx;
2549+
ASSERT_TRUE(port_argv_fixture_begin(&fx, "argv-bounds"));
2550+
ASSERT_TRUE(port_argv_write(&fx, "a.go", "func Bound2634() {}\nfunc Bound2634b() {}\n"));
2551+
ASSERT_TRUE(port_argv_write(&fx, "b.go", "func Bound2634c() {}\n"));
2552+
static const char args[] = "{\"pattern\":\"Bound2634\",\"project\":\"argv-bounds\"}";
2553+
2554+
cbm_operation_runtime_t cancel = {0};
2555+
cancel.cancelled = port_argv_always_cancelled;
2556+
char *resp = port_operation_execute("search_code", args, &cancel);
2557+
ASSERT_NOT_NULL(resp);
2558+
ASSERT_NOT_NULL(strstr(resp, "cancelled"));
2559+
free(resp);
2560+
2561+
cbm_operation_runtime_t deadline = {0};
2562+
deadline.command_timeout_override_set = true;
2563+
deadline.command_timeout_override_ms = 0;
2564+
resp = port_operation_execute("search_code", args, &deadline);
2565+
ASSERT_NOT_NULL(resp);
2566+
ASSERT_NOT_NULL(strstr(resp, "request_timeout"));
2567+
free(resp);
2568+
2569+
cbm_operation_runtime_t limit = {0};
2570+
limit.command_output_limit_override = 16;
2571+
resp = port_operation_execute("search_code", args, &limit);
2572+
ASSERT_NOT_NULL(resp);
2573+
ASSERT_NOT_NULL(strstr(resp, "safety limit"));
2574+
free(resp);
2575+
2576+
/* Same query without bounds still succeeds and the scratch was cleaned. */
2577+
resp = port_operation_execute("search_code", args, NULL);
2578+
ASSERT_NOT_NULL(resp);
2579+
ASSERT_NOT_NULL(strstr(resp, "Bound2634"));
2580+
free(resp);
2581+
port_argv_fixture_end(&fx);
2582+
PASS();
2583+
}
24322584
#endif /* !_WIN32 */
24332585

24342586
/* #2346 (cross-repo-status): every index_status answer carries a read-only
@@ -3248,6 +3400,10 @@ SUITE(integration) {
32483400
#if !defined(_WIN32)
32493401
RUN_TEST(port_source_search_matches_stay_inside_project_root);
32503402
RUN_TEST(port_source_search_drops_walker_hits_that_resolve_outside_root);
3403+
RUN_TEST(port_source_search_unscoped_argv_scans_metachar_filenames_issue2634);
3404+
RUN_TEST(port_source_search_unscoped_argv_no_match_is_success_issue2634);
3405+
RUN_TEST(port_source_search_unscoped_argv_honours_file_pattern_issue2634);
3406+
RUN_TEST(port_source_search_unscoped_argv_cancel_deadline_and_output_limit);
32513407
#endif
32523408
RUN_TEST(port_status_reports_cross_repo_never_run);
32533409
RUN_TEST(port_search_graph_infra_filters_pin_the_bm25_mirror);

0 commit comments

Comments
 (0)