/* LINK: ../../src/gen/sh_emit.c ../../src/error.c ../../src/span.c */ #define _POSIX_C_SOURCE 200809L /* mkdtemp(3) under -std=c23 */ /* tests/unit/test_sh_emit.c * * Unit tests for the POSIX-sh text emitter (todo 15). * * The magic LINK comment on line 1 is REQUIRED by tests/run.sh: it lists * the extra .c sources to compile into this test binary (paths relative * to tests/unit/, space-separated). munit.c and the include dirs are * added automatically by the harness. * * Strategy: st_sh_quote() is the load-bearing primitive, so the core * proof is the ROUND-TRIP — the quoted literal, dropped into a real * shell script as `v=; printf '%s' "$v" > `, must reproduce * the exact input bytes when the script is executed by sh, bash and zsh. * The same machinery proves the injection attempt stays inert. * * No command substitution is used to capture output anywhere here: it * strips trailing newlines, which would mask a broken round-trip. */ #include "munit.h" #include "error.h" #include "gen/sh_emit.h" #include #include #include #include #include #include /* Per-test temp dir, created in setup, removed in teardown. */ static char temp_dir[128]; static void * setup(const MunitParameter params[], void *user_data) { (void)params; (void)user_data; if (snprintf(temp_dir, sizeof temp_dir, "/tmp/st_emit_XXXXXX") < 0) { return NULL; } if (mkdtemp(temp_dir) == NULL) { return NULL; } return (void *)1; } static void teardown(void *fixture) { char cmd[160]; if (fixture == NULL || temp_dir[0] == '\0') { return; } if (snprintf(cmd, sizeof cmd, "rm -rf -- '%s'", temp_dir) < 0) { return; } (void)system(cmd); /* best-effort; mkdtemp names are [A-Za-z0-9_]* */ temp_dir[0] = '\0'; } /* Run " -n ", return the shell's exit status, or -1 if the * command could not even be spawned. */ static int syntax_check(const char *shell, const char *path) { char cmd[512]; int rc; if (snprintf(cmd, sizeof cmd, "%s -n %s", shell, path) < 0) { return -1; } rc = system(cmd); if (rc == -1) { return -1; } return WEXITSTATUS(rc); } /* Round-trip `value` through one shell: write a script that assigns * st_sh_quote(value) to a variable and printf's it into a file, execute * it, and byte-compare the file with the original. Returns the shell's * exit status, or -1 on setup failure. */ static int roundtrip_one(const char *shell, const char *value, size_t len, const char *pathbase) { char script_path[512]; char out_path[512]; char *q, *qout; FILE *f; FILE *in; char *got; long n; int rc; q = st_sh_quote_n(value, len, NULL); if (q == NULL) { return -1; } if (snprintf(script_path, sizeof script_path, "%s/%s.sh", temp_dir, pathbase) < 0) { free(q); return -1; } if (snprintf(out_path, sizeof out_path, "%s/%s.out", temp_dir, pathbase) < 0) { free(q); return -1; } qout = st_sh_quote(out_path); if (qout == NULL) { free(q); return -1; } f = fopen(script_path, "w"); if (f == NULL) { free(q); free(qout); return -1; } /* q and qout are st_sh_quote() output: shell-safe by construction. */ if (fprintf(f, "v=%s\nprintf '%%s' \"$v\" > %s\n", q, qout) < 0) { fclose(f); free(q); free(qout); return -1; } fclose(f); free(q); free(qout); { char cmd[1200]; int spawned; if (snprintf(cmd, sizeof cmd, "%s %s", shell, script_path) < 0) { return -1; } spawned = system(cmd); if (spawned == -1) { return -1; } rc = WEXITSTATUS(spawned); } if (rc != 0) { return rc; } in = fopen(out_path, "rb"); if (in == NULL) { return -1; } if (fseek(in, 0, SEEK_END) != 0) { fclose(in); return -1; } n = ftell(in); if (n < 0 || fseek(in, 0, SEEK_SET) != 0) { fclose(in); return -1; } got = munit_malloc((size_t)n + 1); if (fread(got, 1, (size_t)n, in) != (size_t)n) { free(got); fclose(in); return -1; } fclose(in); got[n] = '\0'; if ((size_t)n != len || memcmp(got, value, len) != 0) { free(got); return -2; /* round-trip mismatch */ } free(got); return 0; } /* Round-trip through sh, bash and zsh; -2 means content mismatch. */ static void roundtrip_assert(const char *value, size_t len, const char *pathbase) { munit_assert_int(roundtrip_one("sh", value, len, pathbase), ==, 0); munit_assert_int(roundtrip_one("bash", value, len, pathbase), ==, 0); munit_assert_int(roundtrip_one("zsh", value, len, pathbase), ==, 0); } static void roundtrip_assert_str(const char *value, const char *pathbase) { roundtrip_assert(value, strlen(value), pathbase); } /* --- exact literal format ------------------------------------------------- */ static MunitResult test_quote_empty(const MunitParameter params[], void *data) { (void)params; (void)data; char *q = st_sh_quote(""); munit_assert_not_null(q); munit_assert_string_equal(q, "''"); free(q); return MUNIT_OK; } static MunitResult test_quote_plain(const MunitParameter params[], void *data) { (void)params; (void)data; char *q = st_sh_quote("abc"); munit_assert_not_null(q); munit_assert_string_equal(q, "'abc'"); free(q); return MUNIT_OK; } /* The canonical escape shape is locked byte-for-byte: each `'` becomes * the 4-char sequence '\'' between the two surrounding quotes. */ static MunitResult test_quote_literal_format(const MunitParameter params[], void *data) { (void)params; (void)data; char *q; q = st_sh_quote("'"); munit_assert_not_null(q); munit_assert_string_equal(q, "''\\'''"); free(q); q = st_sh_quote("a'b"); munit_assert_not_null(q); munit_assert_string_equal(q, "'a'\\''b'"); free(q); return MUNIT_OK; } /* --- round-trips through real shells -------------------------------------- */ static MunitResult test_quote_roundtrip_fixed(const MunitParameter params[], void *data) { (void)params; (void)data; static const char *const hostile[] = { "a'b\"c $d", "''", "'''", "$(rm -rf /)", "`rm -rf /`", "\\", "$", "\"", "'", "`", "line1\nline2\n", "\nleading", "trailing\n", "\t tab\t$HOME\t", "$PATH ${HOME} ~ *.txt ?[a] ; | & > <", "#! /bin/sh", "--prefix=/tmp/x y", "=;:,%^(){}[]<>!#~", "h\xC3\xA9llo w\xC3\xB6rld \xE2\x9C\x93", /* UTF-8 */ "", }; size_t i; for (i = 0; i < sizeof hostile / sizeof hostile[0]; i++) { char base[64]; if (snprintf(base, sizeof base, "fixed%zu", i) < 0) { return MUNIT_ERROR; } roundtrip_assert_str(hostile[i], base); } return MUNIT_OK; } /* xorshift32 with a fixed seed: deterministic property test. */ static uint32_t rng_state = 0x2E3A5B7Fu; static uint32_t rng_next(void) { uint32_t x = rng_state; x ^= x << 13; x ^= x >> 17; x ^= x << 5; rng_state = x; return x; } static MunitResult test_quote_roundtrip_random(const MunitParameter params[], void *data) { (void)params; (void)data; /* Hostile alphabet: every byte class that matters to a shell. */ static const char alphabet[] = "'\"\\$`();|&><*?[]{}!~#%^=:,./- \t\n" "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; char buf[80]; int iter; for (iter = 0; iter < 96; iter++) { size_t len = (size_t)(rng_next() % sizeof buf); size_t i; char base[64]; for (i = 0; i < len; i++) { buf[i] = alphabet[rng_next() % (sizeof alphabet - 1)]; } if (snprintf(base, sizeof base, "rnd%d", iter) < 0) { return MUNIT_ERROR; } roundtrip_assert(buf, len, base); } return MUNIT_OK; } static MunitResult test_quote_long_single_quotes(const MunitParameter params[], void *data) { (void)params; (void)data; char *big = munit_malloc(1001); size_t i; for (i = 0; i < 1000; i++) { big[i] = '\''; } big[1000] = '\0'; roundtrip_assert_str(big, "big"); free(big); return MUNIT_OK; } /* --- injection proof ------------------------------------------------------ */ static MunitResult test_quote_injection_inert(const MunitParameter params[], void *data) { (void)params; (void)data; char marker[512]; char script_path[512]; char out_path[512]; char *payload, *q, *qout; FILE *f; FILE *in; char *got; long n; int rc; (void)params; (void)data; if (snprintf(marker, sizeof marker, "%s/PWNED", temp_dir) < 0) { return MUNIT_ERROR; } /* Classic break-out: close the quote, run a command, reopen it. */ payload = munit_malloc(512); if (snprintf(payload, 512, "'; echo PWNED > %s; echo '", marker) < 0) { free(payload); return MUNIT_ERROR; } q = st_sh_quote(payload); munit_assert_not_null(q); if (snprintf(script_path, sizeof script_path, "%s/inject.sh", temp_dir) < 0) { free(payload); free(q); return MUNIT_ERROR; } if (snprintf(out_path, sizeof out_path, "%s/inject.out", temp_dir) < 0) { free(payload); free(q); return MUNIT_ERROR; } qout = st_sh_quote(out_path); munit_assert_not_null(qout); f = fopen(script_path, "w"); munit_assert_not_null(f); { int wr = fprintf(f, "v=%s\nprintf '%%s' \"$v\" > %s\n", q, qout); munit_assert_int(wr, >=, 0); } fclose(f); free(q); free(qout); { char cmd[1200]; int spawned; int snrc = snprintf(cmd, sizeof cmd, "sh %s", script_path); munit_assert_int(snrc, >=, 0); spawned = system(cmd); munit_assert_int(spawned, !=, -1); rc = WEXITSTATUS(spawned); } munit_assert_int(rc, ==, 0); /* The attack command must NOT have run. */ munit_assert_int(access(marker, F_OK), ==, -1); /* And the output must be the exact payload bytes. */ in = fopen(out_path, "rb"); munit_assert_not_null(in); munit_assert_int(fseek(in, 0, SEEK_END), ==, 0); n = ftell(in); munit_assert_int(n, ==, (long)strlen(payload)); munit_assert_int(fseek(in, 0, SEEK_SET), ==, 0); got = munit_malloc((size_t)n + 1); munit_assert_size(fread(got, 1, (size_t)n, in), ==, (size_t)n); fclose(in); got[n] = '\0'; munit_assert_memory_equal((size_t)n, got, payload); free(got); free(payload); return MUNIT_OK; } /* --- NUL / NULL input handling ------------------------------------------- */ static MunitResult test_quote_null_input(const MunitParameter params[], void *data) { (void)params; (void)data; struct st_error *err = NULL; char *q; q = st_sh_quote(NULL); munit_assert_null(q); q = st_sh_quote_ex(NULL, &err); munit_assert_null(q); munit_assert_not_null(err); munit_assert_int(st_error_category_of(err), ==, ST_ERR_INTERNAL); munit_assert_int(strlen(st_error_message(err)), >, 0); st_error_free(err); return MUNIT_OK; } static MunitResult test_quote_nul_byte(const MunitParameter params[], void *data) { (void)params; (void)data; struct st_error *err = NULL; char *q; /* embedded NUL detected through the length-bounded variant */ q = st_sh_quote_n("a\0b", 3, &err); munit_assert_null(q); munit_assert_not_null(err); munit_assert_int(st_error_category_of(err), ==, ST_ERR_INTERNAL); st_error_free(err); /* same input, truncated to the bytes before the NUL: fine */ q = st_sh_quote_n("a\0b", 1, NULL); munit_assert_not_null(q); munit_assert_string_equal(q, "'a'"); free(q); return MUNIT_OK; } /* --- emitter helpers: the sample script ----------------------------------- */ /* Assembles a small representative configure-style script exclusively * through the emitter API, then proves on the ACTUAL emitted bytes: * - sh -n, bash -n and zsh -n all accept it, * - none of the banned constructs appear, * - it actually runs and prints the expected line. */ static MunitResult test_emit_sample_script(const MunitParameter params[], void *data) { (void)params; (void)data; static const char *const banned[] = { "[[ ", "]]", "local ", "==", "<<<", "&>", "set -e", }; char script_path[512]; FILE *f; char *q; FILE *in; char *bytes; long n; size_t i; if (snprintf(script_path, sizeof script_path, "%s/sample.sh", temp_dir) < 0) { return MUNIT_ERROR; } f = fopen(script_path, "w"); munit_assert_not_null(f); munit_assert_int(st_sh_emit_str(f, "#!/bin/sh\n"), ==, 0); munit_assert_int(st_sh_emit_comment(f, "generated sample\nline two"), ==, 0); munit_assert_int(st_sh_emit_assign_q(f, "prefix", "/usr/local"), ==, 0); q = st_sh_quote("it's fine"); munit_assert_not_null(q); munit_assert_int(st_sh_emit_assign(f, "greeting", q), ==, 0); free(q); munit_assert_int(st_sh_emit_str(f, "if [ \"$1\" = \"--help\" ]; then\n" " echo 'usage: configure [--prefix=DIR] [VAR=VALUE]'\n" " exit 0\n" "fi\n" "case \"$1\" in\n" " --prefix=*)\n" " prefix=${1#--prefix=}\n" " ;;\n" " '')\n" " ;;\n" " *)\n" " echo \"unrecognized option: $1\" >&2\n" " exit 1\n" " ;;\n" "esac\n" "echo \"prefix is $prefix; greeting is $greeting\"\n"), ==, 0); fclose(f); /* lock the comment/assign byte shapes */ in = fopen(script_path, "rb"); munit_assert_not_null(in); munit_assert_int(fseek(in, 0, SEEK_END), ==, 0); n = ftell(in); munit_assert_int(n, >, 0); munit_assert_int(fseek(in, 0, SEEK_SET), ==, 0); bytes = munit_malloc((size_t)n + 1); munit_assert_size(fread(bytes, 1, (size_t)n, in), ==, (size_t)n); fclose(in); bytes[n] = '\0'; munit_assert_not_null(strstr(bytes, "# generated sample\n# line two\n")); munit_assert_not_null(strstr(bytes, "prefix='/usr/local'\n")); munit_assert_not_null(strstr(bytes, "greeting='it'\\''s fine'\n")); /* the banned-construct sweep on the real emitted bytes */ for (i = 0; i < sizeof banned / sizeof banned[0]; i++) { munit_assert_null(strstr(bytes, banned[i])); } /* syntax: sh -n / bash -n / zsh -n on the actual file */ munit_assert_int(syntax_check("sh", script_path), ==, 0); munit_assert_int(syntax_check("bash", script_path), ==, 0); munit_assert_int(syntax_check("zsh", script_path), ==, 0); /* behavior: the script must actually run (exit 0, expected echo) */ { char cmd[1200]; int spawned; int snrc = snprintf(cmd, sizeof cmd, "sh %s", script_path); munit_assert_int(snrc, >=, 0); spawned = system(cmd); munit_assert_int(spawned, !=, -1); munit_assert_int(WEXITSTATUS(spawned), ==, 0); } free(bytes); return MUNIT_OK; } /* defensive behavior of the emitters (never write on bad input) */ static MunitResult test_emit_null_guards(const MunitParameter params[], void *data) { (void)params; (void)data; FILE *f = tmpfile(); munit_assert_not_null(f); munit_assert_int(st_sh_emit_str(NULL, "x"), ==, -1); munit_assert_int(st_sh_emit_str(f, NULL), ==, -1); munit_assert_int(st_sh_emit_comment(NULL, "x"), ==, -1); munit_assert_int(st_sh_emit_comment(f, NULL), ==, -1); munit_assert_int(st_sh_emit_assign(NULL, "a", "b"), ==, -1); munit_assert_int(st_sh_emit_assign(f, NULL, "b"), ==, -1); munit_assert_int(st_sh_emit_assign(f, "a", NULL), ==, -1); munit_assert_int(st_sh_emit_assign_q(NULL, "a", "b"), ==, -1); munit_assert_int(st_sh_emit_assign_q(f, "a", NULL), ==, -1); fclose(f); return MUNIT_OK; } static MunitTest tests[] = { { "/quote/empty", test_quote_empty, setup, teardown, MUNIT_TEST_OPTION_NONE, NULL }, { "/quote/plain", test_quote_plain, setup, teardown, MUNIT_TEST_OPTION_NONE, NULL }, { "/quote/literal-format", test_quote_literal_format, setup, teardown, MUNIT_TEST_OPTION_NONE, NULL }, { "/quote/roundtrip-fixed", test_quote_roundtrip_fixed, setup, teardown, MUNIT_TEST_OPTION_NONE, NULL }, { "/quote/roundtrip-random", test_quote_roundtrip_random, setup, teardown, MUNIT_TEST_OPTION_NONE, NULL }, { "/quote/long-single-quotes", test_quote_long_single_quotes, setup, teardown, MUNIT_TEST_OPTION_NONE, NULL }, { "/quote/injection-inert", test_quote_injection_inert, setup, teardown, MUNIT_TEST_OPTION_NONE, NULL }, { "/quote/null-input", test_quote_null_input, setup, teardown, MUNIT_TEST_OPTION_NONE, NULL }, { "/quote/nul-byte", test_quote_nul_byte, setup, teardown, MUNIT_TEST_OPTION_NONE, NULL }, { "/emit/sample-script", test_emit_sample_script, setup, teardown, MUNIT_TEST_OPTION_NONE, NULL }, { "/emit/null-guards", test_emit_null_guards, setup, teardown, MUNIT_TEST_OPTION_NONE, NULL }, { NULL, NULL, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, }; static const MunitSuite suite = { "/sh_emit", tests, NULL, 1, MUNIT_SUITE_OPTION_NONE, }; int main(int argc, char *argv[MUNIT_ARRAY_PARAM(argc + 1)]) { return munit_suite_main(&suite, NULL, argc, argv); }