Files
Martin Vogel 52303157bf fix(test): guard the POSIX-only includes in the gitignore test
sys/wait.h does not exist on the Windows toolchain, so the unguarded include
broke the Windows test build outright — the suites never ran. The test body is
already skipped on Windows, so the includes belong behind the same guard, as
test_cypher.c does for the same pair.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-06 03:30:32 +02:00

361 lines
12 KiB
C

/*
* test_gitignore.c — Tests for gitignore-style pattern matching.
*
* RED phase: These tests define the expected pattern matching behavior.
*/
#include "../src/foundation/compat.h"
#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 ────────────────────────────────────── */
TEST(gi_empty_pattern) {
cbm_gitignore_t *gi = cbm_gitignore_parse("");
ASSERT_NOT_NULL(gi);
ASSERT_FALSE(cbm_gitignore_matches(gi, "foo.txt", false));
cbm_gitignore_free(gi);
PASS();
}
TEST(gi_exact_file) {
cbm_gitignore_t *gi = cbm_gitignore_parse("secret.key\n");
ASSERT_NOT_NULL(gi);
ASSERT_TRUE(cbm_gitignore_matches(gi, "secret.key", false));
ASSERT_FALSE(cbm_gitignore_matches(gi, "other.key", false));
cbm_gitignore_free(gi);
PASS();
}
TEST(gi_wildcard_star) {
cbm_gitignore_t *gi = cbm_gitignore_parse("*.log\n");
ASSERT_NOT_NULL(gi);
ASSERT_TRUE(cbm_gitignore_matches(gi, "error.log", false));
ASSERT_TRUE(cbm_gitignore_matches(gi, "access.log", false));
ASSERT_FALSE(cbm_gitignore_matches(gi, "error.txt", false));
/* Non-rooted pattern matches basename at any depth */
ASSERT_TRUE(cbm_gitignore_matches(gi, "logs/error.log", false));
cbm_gitignore_free(gi);
PASS();
}
TEST(gi_double_star_prefix) {
/* ** matches any number of directories */
cbm_gitignore_t *gi = cbm_gitignore_parse("**/build\n");
ASSERT_NOT_NULL(gi);
ASSERT_TRUE(cbm_gitignore_matches(gi, "build", true));
ASSERT_TRUE(cbm_gitignore_matches(gi, "src/build", true));
ASSERT_TRUE(cbm_gitignore_matches(gi, "a/b/c/build", true));
cbm_gitignore_free(gi);
PASS();
}
TEST(gi_double_star_suffix) {
cbm_gitignore_t *gi = cbm_gitignore_parse("logs/**\n");
ASSERT_NOT_NULL(gi);
ASSERT_TRUE(cbm_gitignore_matches(gi, "logs/debug.log", false));
ASSERT_TRUE(cbm_gitignore_matches(gi, "logs/sub/trace.log", false));
ASSERT_FALSE(cbm_gitignore_matches(gi, "src/logs", false));
cbm_gitignore_free(gi);
PASS();
}
TEST(gi_double_star_middle) {
cbm_gitignore_t *gi = cbm_gitignore_parse("a/**/b\n");
ASSERT_NOT_NULL(gi);
ASSERT_TRUE(cbm_gitignore_matches(gi, "a/b", false));
ASSERT_TRUE(cbm_gitignore_matches(gi, "a/x/b", false));
ASSERT_TRUE(cbm_gitignore_matches(gi, "a/x/y/z/b", false));
ASSERT_FALSE(cbm_gitignore_matches(gi, "c/a/b", false));
cbm_gitignore_free(gi);
PASS();
}
TEST(gi_directory_only) {
/* Trailing slash means match directories only */
cbm_gitignore_t *gi = cbm_gitignore_parse("build/\n");
ASSERT_NOT_NULL(gi);
ASSERT_TRUE(cbm_gitignore_matches(gi, "build", true));
ASSERT_FALSE(cbm_gitignore_matches(gi, "build", false)); /* not a directory */
/* Should match anywhere in tree */
ASSERT_TRUE(cbm_gitignore_matches(gi, "src/build", true));
cbm_gitignore_free(gi);
PASS();
}
TEST(gi_negation) {
/* ! prefix negates a pattern */
cbm_gitignore_t *gi = cbm_gitignore_parse("*.log\n!important.log\n");
ASSERT_NOT_NULL(gi);
ASSERT_TRUE(cbm_gitignore_matches(gi, "error.log", false));
ASSERT_FALSE(cbm_gitignore_matches(gi, "important.log", false));
cbm_gitignore_free(gi);
PASS();
}
TEST(gi_comment_and_blank) {
cbm_gitignore_t *gi = cbm_gitignore_parse("# This is a comment\n\n*.tmp\n");
ASSERT_NOT_NULL(gi);
ASSERT_TRUE(cbm_gitignore_matches(gi, "data.tmp", false));
ASSERT_FALSE(cbm_gitignore_matches(gi, "data.txt", false));
cbm_gitignore_free(gi);
PASS();
}
TEST(gi_rooted_pattern) {
/* Pattern with slash is anchored to the root */
cbm_gitignore_t *gi = cbm_gitignore_parse("/build\n");
ASSERT_NOT_NULL(gi);
ASSERT_TRUE(cbm_gitignore_matches(gi, "build", true));
ASSERT_FALSE(cbm_gitignore_matches(gi, "src/build", true));
cbm_gitignore_free(gi);
PASS();
}
TEST(gi_path_with_slash) {
/* Pattern containing / (not just leading) is rooted */
cbm_gitignore_t *gi = cbm_gitignore_parse("doc/frotz\n");
ASSERT_NOT_NULL(gi);
ASSERT_TRUE(cbm_gitignore_matches(gi, "doc/frotz", false));
ASSERT_FALSE(cbm_gitignore_matches(gi, "src/doc/frotz", false));
cbm_gitignore_free(gi);
PASS();
}
TEST(gi_question_mark) {
cbm_gitignore_t *gi = cbm_gitignore_parse("file?.txt\n");
ASSERT_NOT_NULL(gi);
ASSERT_TRUE(cbm_gitignore_matches(gi, "file1.txt", false));
ASSERT_TRUE(cbm_gitignore_matches(gi, "fileA.txt", false));
ASSERT_FALSE(cbm_gitignore_matches(gi, "file12.txt", false));
cbm_gitignore_free(gi);
PASS();
}
TEST(gi_bracket_range) {
cbm_gitignore_t *gi = cbm_gitignore_parse("file[0-9].txt\n");
ASSERT_NOT_NULL(gi);
ASSERT_TRUE(cbm_gitignore_matches(gi, "file3.txt", false));
ASSERT_FALSE(cbm_gitignore_matches(gi, "fileA.txt", false));
cbm_gitignore_free(gi);
PASS();
}
TEST(gi_multiple_patterns) {
cbm_gitignore_t *gi = cbm_gitignore_parse("*.pyc\n"
"__pycache__/\n"
".env\n"
"*.log\n");
ASSERT_NOT_NULL(gi);
ASSERT_TRUE(cbm_gitignore_matches(gi, "module.pyc", false));
ASSERT_TRUE(cbm_gitignore_matches(gi, "__pycache__", true));
ASSERT_TRUE(cbm_gitignore_matches(gi, ".env", false));
ASSERT_TRUE(cbm_gitignore_matches(gi, "app.log", false));
ASSERT_FALSE(cbm_gitignore_matches(gi, "main.py", false));
cbm_gitignore_free(gi);
PASS();
}
TEST(gi_null_safe_free) {
cbm_gitignore_free(NULL); /* should not crash */
PASS();
}
/* ── Load from file ────────────────────────────────────────────── */
TEST(gi_load_file) {
char path[256]; snprintf(path, sizeof(path), "%s/test_gitignore_file", cbm_tmpdir());
FILE *f = fopen(path, "w");
ASSERT_NOT_NULL(f);
fprintf(f, "*.o\nbuild/\n");
fclose(f);
cbm_gitignore_t *gi = cbm_gitignore_load(path);
ASSERT_NOT_NULL(gi);
ASSERT_TRUE(cbm_gitignore_matches(gi, "main.o", false));
ASSERT_TRUE(cbm_gitignore_matches(gi, "build", true));
cbm_gitignore_free(gi);
remove(path);
PASS();
}
TEST(gi_load_nonexistent) {
char np[256];
snprintf(np, sizeof(np), "%s/nonexistent_gitignore_12345", cbm_tmpdir());
cbm_gitignore_t *gi = cbm_gitignore_load(np);
ASSERT_NULL(gi);
PASS();
}
/* ── Merge ─────────────────────────────────────────────────────── */
TEST(gi_merge_patterns) {
cbm_gitignore_t *base_gi = cbm_gitignore_parse("*.log\n");
cbm_gitignore_t *extra = cbm_gitignore_parse("tmp/\nbuild/\n");
ASSERT_NOT_NULL(base_gi);
ASSERT_NOT_NULL(extra);
cbm_gitignore_merge(base_gi, extra);
cbm_gitignore_free(extra);
ASSERT_TRUE(cbm_gitignore_matches(base_gi, "error.log", false));
ASSERT_TRUE(cbm_gitignore_matches(base_gi, "tmp", true));
ASSERT_TRUE(cbm_gitignore_matches(base_gi, "build", true));
ASSERT_FALSE(cbm_gitignore_matches(base_gi, "main.go", false));
cbm_gitignore_free(base_gi);
PASS();
}
TEST(gi_merge_into_empty) {
cbm_gitignore_t *dst = cbm_gitignore_parse("");
cbm_gitignore_t *src = cbm_gitignore_parse("*.o\n");
ASSERT_NOT_NULL(dst);
ASSERT_NOT_NULL(src);
cbm_gitignore_merge(dst, src);
cbm_gitignore_free(src);
ASSERT_TRUE(cbm_gitignore_matches(dst, "main.o", false));
ASSERT_FALSE(cbm_gitignore_matches(dst, "main.c", false));
cbm_gitignore_free(dst);
PASS();
}
TEST(gi_merge_null_safe) {
cbm_gitignore_t *gi = cbm_gitignore_parse("*.log\n");
cbm_gitignore_merge(gi, NULL); /* should not crash */
cbm_gitignore_merge(NULL, gi); /* should not crash */
cbm_gitignore_free(gi);
PASS();
}
/* Reproduce-first guard for #493: when an allocation fails mid-merge,
* cbm_gitignore_merge must leave dst exactly as it was (atomic) and signal
* failure — never a partial merge that keeps some src patterns and drops
* others. The seam below injects a strdup failure on the 2nd src pattern.
* Without the atomic rollback this test is RED: the first src pattern ("a/")
* leaks into dst, so `matches(dst, "a", true)` is true. */
extern char *(*cbm_gitignore_merge_dup_hook_for_test)(const char *);
static int gi_dup_calls;
static int gi_dup_fail_at;
static char *gi_failing_dup(const char *s) {
if (++gi_dup_calls > gi_dup_fail_at) {
return NULL;
}
return strdup(s);
}
TEST(gi_merge_atomic_on_alloc_failure) {
cbm_gitignore_t *dst = cbm_gitignore_parse("*.log\n"); /* 1 pattern */
cbm_gitignore_t *src = cbm_gitignore_parse("a/\nb/\nc/\n"); /* 3 patterns */
ASSERT_NOT_NULL(dst);
ASSERT_NOT_NULL(src);
gi_dup_calls = 0;
gi_dup_fail_at = 1; /* first src pattern copies; second fails */
cbm_gitignore_merge_dup_hook_for_test = gi_failing_dup;
bool ok = cbm_gitignore_merge(dst, src);
cbm_gitignore_merge_dup_hook_for_test = NULL; /* restore for other tests */
ASSERT_FALSE(ok); /* failure is signalled, not silent */
/* dst unchanged: its own pattern still matches, and the partially copied
* src patterns were rolled back. */
ASSERT_TRUE(cbm_gitignore_matches(dst, "x.log", false));
ASSERT_FALSE(cbm_gitignore_matches(dst, "a", true));
ASSERT_FALSE(cbm_gitignore_matches(dst, "b", true));
cbm_gitignore_free(src);
cbm_gitignore_free(dst);
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);
RUN_TEST(gi_double_star_prefix);
RUN_TEST(gi_double_star_suffix);
RUN_TEST(gi_double_star_middle);
RUN_TEST(gi_directory_only);
RUN_TEST(gi_negation);
RUN_TEST(gi_comment_and_blank);
RUN_TEST(gi_rooted_pattern);
RUN_TEST(gi_path_with_slash);
RUN_TEST(gi_question_mark);
RUN_TEST(gi_bracket_range);
RUN_TEST(gi_multiple_patterns);
RUN_TEST(gi_null_safe_free);
RUN_TEST(gi_load_file);
RUN_TEST(gi_load_nonexistent);
RUN_TEST(gi_merge_patterns);
RUN_TEST(gi_merge_into_empty);
RUN_TEST(gi_merge_null_safe);
RUN_TEST(gi_merge_atomic_on_alloc_failure);
}