diff --git a/src/gen/sh_emit.c b/src/gen/sh_emit.c new file mode 100644 index 0000000..2efb0b3 --- /dev/null +++ b/src/gen/sh_emit.c @@ -0,0 +1,162 @@ +#include "sh_emit.h" + +#include "error.h" + +#include +#include +#include + +/* + * src/gen/sh_emit.c — POSIX-sh text emitter (todo 15). + * + * The quoting rules implemented here are the single-quote rules of the + * POSIX Shell Command Language (XBD 2.2.2 "Single-Quotes"): everything + * between two unescaped `'` characters is literal, and the ONLY way to + * embed a `'` is to close the quoted region, escape the quote with a + * backslash, and reopen it — the four-character sequence `'\''`. + */ + +static struct st_error * +quote_error(const char *message) +{ + return st_error_internal(message); +} + +char * +st_sh_quote_n(const char *value, size_t len, struct st_error **err) +{ + size_t i; + size_t out_len; + char *out; + + if (err != NULL) { + *err = NULL; + } + if (value == NULL) { + if (err != NULL) { + *err = quote_error("st_sh_quote: value is NULL"); + } + return NULL; + } + for (i = 0; i < len; i++) { + if (value[i] == '\0') { + if (err != NULL) { + *err = quote_error("st_sh_quote: value contains a NUL byte"); + } + return NULL; + } + } + /* Worst case: every byte is a `'` and expands to 4. Guard the + * size computation against hostile (huge) lengths. */ + if (len > (SIZE_MAX - 3) / 4) { + if (err != NULL) { + *err = quote_error("st_sh_quote: value too large to quote"); + } + return NULL; + } + out_len = 1; /* opening quote */ + for (i = 0; i < len; i++) { + out_len += (value[i] == '\'') ? 4 : 1; + } + out_len += 1; /* closing quote */ + out = malloc(out_len + 1); + if (out == NULL) { + if (err != NULL) { + *err = quote_error("st_sh_quote: out of memory"); + } + return NULL; + } + out_len = 0; + out[out_len++] = '\''; + for (i = 0; i < len; i++) { + if (value[i] == '\'') { + memcpy(out + out_len, "'\\''", 4); + out_len += 4; + } else { + out[out_len++] = value[i]; + } + } + out[out_len++] = '\''; + out[out_len] = '\0'; + return out; +} + +char * +st_sh_quote_ex(const char *value, struct st_error **err) +{ + if (value == NULL) { + if (err != NULL) { + *err = quote_error("st_sh_quote: value is NULL"); + } + return NULL; + } + return st_sh_quote_n(value, strlen(value), err); +} + +char * +st_sh_quote(const char *value) +{ + return st_sh_quote_ex(value, NULL); +} + +int +st_sh_emit_str(FILE *out, const char *s) +{ + if (out == NULL || s == NULL) { + return -1; + } + return fputs(s, out) == EOF ? -1 : 0; +} + +int +st_sh_emit_comment(FILE *out, const char *text) +{ + const unsigned char *p; + + if (out == NULL || text == NULL) { + return -1; + } + if (fputs("# ", out) == EOF) { + return -1; + } + for (p = (const unsigned char *)text; *p != '\0'; p++) { + if (putc(*p, out) == EOF) { + return -1; + } + if (*p == '\n' && fputs("# ", out) == EOF) { + return -1; + } + } + return putc('\n', out) == EOF ? -1 : 0; +} + +int +st_sh_emit_assign(FILE *out, const char *var, const char *quoted_value) +{ + if (out == NULL || var == NULL || quoted_value == NULL) { + return -1; + } + if (fputs(var, out) == EOF || putc('=', out) == EOF || + fputs(quoted_value, out) == EOF || putc('\n', out) == EOF) { + return -1; + } + return 0; +} + +int +st_sh_emit_assign_q(FILE *out, const char *var, const char *value) +{ + char *quoted; + int rc; + + if (out == NULL || var == NULL) { + return -1; + } + quoted = st_sh_quote(value); + if (quoted == NULL) { + return -1; + } + rc = st_sh_emit_assign(out, var, quoted); + free(quoted); + return rc; +} diff --git a/src/gen/sh_emit.h b/src/gen/sh_emit.h new file mode 100644 index 0000000..d3b7cf0 --- /dev/null +++ b/src/gen/sh_emit.h @@ -0,0 +1,85 @@ +#ifndef ST_GEN_SH_EMIT_H +#define ST_GEN_SH_EMIT_H + +/* + * POSIX-sh text emitter (todo 15). + * + * Everything this module writes is POSIX Shell Command Language only: + * the generated configure scripts must run unmodified under dash, bash + * and zsh. Policy (AGENTS.md §4): no `[[ ]]`, no arrays, no `local`, + * no `==` (use `=` inside `[ ]`), no `<<<`, no `&>`; `set -e` is POSIX + * but is AVOIDED by policy in generated scripts because its failure + * semantics vary across shells. + * + * Trust boundaries (callers MUST honor these): + * - st_sh_quote() is the ONLY function here that makes arbitrary bytes + * shell-safe. Everything written via st_sh_emit_str() and + * st_sh_emit_assign() (3rd arg) is emitted verbatim — the caller + * guarantees it is already safe shell text. + * - `var` arguments to the assign helpers must be valid POSIX shell + * identifiers (name followed by [A-Za-z0-9_]); they are not + * validated or quoted. + */ + +#include +#include + +struct st_error; + +/* + * st_sh_quote(value) — the load-bearing primitive. + * + * Returns a malloc'd single-quoted shell literal that reproduces + * `value` byte-for-byte when assigned inside a shell script + * (e.g. `v=`), or NULL on error. + * + * Form: the value is wrapped in `'…'` and every embedded `'` becomes + * the four-character sequence `'\''` (close quote, escaped quote, open + * quote). Nothing else is touched: inside single quotes NO character + * is special to the shell, so `$`, backticks, `"`, `\`, newlines and + * metacharacters pass through untouched — no injection is possible. + * The empty string quotes as `''`. + * + * Errors (NULL result): `value` is NULL, `value` contains a NUL byte + * (detectable only through the length-bounded variant), the required + * size overflows size_t, or allocation fails. + */ +char *st_sh_quote(const char *value); + +/* As st_sh_quote(), but on error and if `err` is non-NULL, *err + * receives a newly allocated st_error (category ST_ERR_INTERNAL) that + * the caller frees with st_error_free(). On success *err is left + * untouched; if the error itself cannot be allocated, *err is NULL. */ +char *st_sh_quote_ex(const char *value, struct st_error **err); + +/* Length-bounded core: quotes exactly `len` bytes starting at `value`. + * This is the only variant that can DETECT an embedded NUL byte (which + * is rejected with an error rather than silently truncating). Use it + * when the input is a counted buffer, not a C string. */ +char *st_sh_quote_n(const char *value, size_t len, struct st_error **err); + +/* + * Emitter helpers. All return 0 on success, -1 on failure (NULL stream + * or text, or an I/O error). They do not call exit()/abort(). + */ + +/* Writes `s` verbatim. The caller ensures `s` is safe shell text + * (usually it is hand-written code, or output of st_sh_quote()). */ +int st_sh_emit_str(FILE *out, const char *s); + +/* Writes a comment: `# `, then `text` with every embedded newline + * followed by `# `, then a terminating newline. `text` must not be + * NULL. */ +int st_sh_emit_comment(FILE *out, const char *text); + +/* Writes `var=` plus a newline. `quoted_value` must be + * ALREADY shell-safe (typically the result of st_sh_quote()) — it is + * emitted verbatim. */ +int st_sh_emit_assign(FILE *out, const char *var, const char *quoted_value); + +/* Convenience: quotes `value` with st_sh_quote() and assigns it in one + * step. Returns -1 if quoting fails (NULL/NUL input, OOM) or on I/O + * error; the quoted literal is freed internally. */ +int st_sh_emit_assign_q(FILE *out, const char *var, const char *value); + +#endif diff --git a/tests/unit/test_sh_emit.c b/tests/unit/test_sh_emit.c new file mode 100644 index 0000000..55c94be --- /dev/null +++ b/tests/unit/test_sh_emit.c @@ -0,0 +1,613 @@ +/* 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); +}