Template
feat(gen): add POSIX-sh emitter with quoting
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
#include "sh_emit.h"
|
||||
|
||||
#include "error.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
@@ -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 <stddef.h>
|
||||
#include <stdio.h>
|
||||
|
||||
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=<result>`), 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=<quoted_value>` 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
|
||||
@@ -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=<quoted>; printf '%s' "$v" > <out>`, 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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* 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 "<shell> -n <path>", 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);
|
||||
}
|
||||
Reference in New Issue
Block a user