diff options
| -rw-r--r-- | src/runner.c | 28 | ||||
| -rw-r--r-- | tests/test_runner.c | 135 |
2 files changed, 148 insertions, 15 deletions
diff --git a/src/runner.c b/src/runner.c index 8166e3e..53c38bd 100644 --- a/src/runner.c +++ b/src/runner.c @@ -54,10 +54,8 @@ runner_get_scripts(Runner *r) { /* Single-quote a path for safe shell interpolation. Caller frees. */ static char * _shell_quote(const char *c_path) { - /* Replace each ' with '\'' and wrap in single quotes. */ - char *c_escaped = g_strdup_printf("'%s'", c_path); - /* Simple approach: g_shell_quote does this correctly. */ - g_free(c_escaped); + /* g_shell_quote wraps the path in single quotes and escapes any + * embedded quotes, making it safe to interpolate into a sh -c script. */ return g_shell_quote(c_path); } @@ -102,18 +100,20 @@ runner_run(Runner *r, GFile *p_file, GFile *p_dir, const RunnerScript *p_script, GAsyncReadyCallback p_cb, gpointer p_data, GError **p_err) { (void)r; g_return_val_if_fail(p_script, FALSE); - char *c_cmd = _expand(p_script->c_command, p_file, p_dir); - char *c_full = g_strdup_printf("/bin/sh -c %s", c_cmd); - g_free(c_cmd); - char **argv = NULL; - if (!g_shell_parse_argv(c_full, NULL, &argv, p_err)) { - g_free(c_full); + char *c_cmd = _expand(p_script->c_command, p_file, p_dir); + if (c_cmd == NULL) { + g_set_error(p_err, G_SHELL_ERROR, G_SHELL_ERROR_FAILED, + "runner: failed to expand script command"); return FALSE; } - g_free(c_full); - GSubprocess *p_sub = g_subprocess_newv((const char *const *)argv, - G_SUBPROCESS_FLAGS_NONE, p_err); - g_strfreev(argv); + /* Pass the whole expanded command as a single argv element to sh -c so + * that pipelines, redirections, && and multi-word arguments are parsed + * by the shell as one script (not split by g_shell_parse_argv, which + * would feed sh -c only the first word and treat the rest as $0/$1...). + * %f/%d are already single-quoted by _expand, so paths stay safe. */ + const char *argv[] = {"/bin/sh", "-c", c_cmd, NULL}; + GSubprocess *p_sub = g_subprocess_newv(argv, G_SUBPROCESS_FLAGS_NONE, p_err); + g_free(c_cmd); if (p_sub == NULL) return FALSE; if (p_cb) { diff --git a/tests/test_runner.c b/tests/test_runner.c index 172368b..a3e2b5a 100644 --- a/tests/test_runner.c +++ b/tests/test_runner.c @@ -91,11 +91,144 @@ test_injection_guard(void) { g_free(d); } +/* Observable injection test: a hostile filename containing shell + * metacharacters that, if executed, would create a sentinel file. The + * single-quoted %f must prevent the ';' from being interpreted. */ +static void +test_hostile_filename(void) { + GError *e = NULL; + char *d = g_dir_make_tmp("ggaze-runner-XXXXXX", &e); + g_assert_no_error(e); + + /* Sentinel that would be created if the ';' in the name were executed. */ + char *sentinel_p = g_build_filename(d, "PWNED", NULL); + GFile *sentinel = g_file_new_for_path(sentinel_p); + + /* Hostile filename: ;touch <sentinel>; — if unquoted this runs touch. */ + char *name = g_strdup_printf(";touch %s;", sentinel_p); + char *fp = g_build_filename(d, name, NULL); + GFile *f = g_file_new_for_path(fp); + g_file_replace_contents(f, "x", 1, NULL, FALSE, + G_FILE_CREATE_REPLACE_DESTINATION, NULL, NULL, NULL); + GFile *dd = g_file_new_for_path(d); + + Runner *r = runner_new(); + RunnerScript s = {"true", "true %f"}; + run_and_wait(r, f, dd, &s); + g_assert_cmpint(g_exit_code, ==, 0); + /* The sentinel must NOT exist: the ';' was quoted, not executed. */ + g_assert_false(g_file_query_exists(sentinel, NULL)); + + runner_delete(r); + g_object_unref(sentinel); + g_object_unref(f); + g_object_unref(dd); + g_free(sentinel_p); + g_free(name); + g_free(fp); + GFile *ddd = g_file_new_for_path(d); + GFileEnumerator *en = g_file_enumerate_children( + ddd, "standard::name", G_FILE_QUERY_INFO_NONE, NULL, NULL); + if (en) { + GFileInfo *i; + while ((i = g_file_enumerator_next_file(en, NULL, NULL))) { + GFile *c = g_file_get_child(ddd, g_file_info_get_name(i)); + g_file_delete(c, NULL, NULL); + g_object_unref(c); + g_object_unref(i); + } + g_object_unref(en); + } + g_file_delete(ddd, NULL, NULL); + g_object_unref(ddd); + g_free(d); +} + +/* Run a command that writes to a temp outfile, wait, then read the outfile + * back and compare its contents to c_expected. Cleans up the outfile. */ +static void +_run_and_check_output(const char *c_command, const char *c_expected) { + GError *e = NULL; + char *d = g_dir_make_tmp("ggaze-runner-XXXXXX", &e); + g_assert_no_error(e); + char *out_p = g_build_filename(d, "out.txt", NULL); + GFile *dd = g_file_new_for_path(d); + + /* Interpolate the outfile path via %s wrapped in literal single quotes, + * e.g. `printf 'hello world\n' > '/tmp/.../out.txt'`. Temp paths from + * g_dir_make_tmp contain no quotes, so this is safe for the fixtures. */ + char *cmd = g_strdup_printf(c_command, out_p); + + Runner *r = runner_new(); + RunnerScript s = {"obs", cmd}; + run_and_wait(r, NULL, dd, &s); + g_assert_cmpint(g_exit_code, ==, 0); + + char *contents = NULL; + gsize len = 0; + g_assert_true(g_file_get_contents(out_p, &contents, &len, &e)); + g_assert_no_error(e); + g_assert_cmpstr(contents, ==, c_expected); + g_free(contents); + + g_free(cmd); + runner_delete(r); + g_object_unref(dd); + g_free(out_p); + GFile *ddd = g_file_new_for_path(d); + GFileEnumerator *en = g_file_enumerate_children( + ddd, "standard::name", G_FILE_QUERY_INFO_NONE, NULL, NULL); + if (en) { + GFileInfo *i; + while ((i = g_file_enumerator_next_file(en, NULL, NULL))) { + GFile *c = g_file_get_child(ddd, g_file_info_get_name(i)); + g_file_delete(c, NULL, NULL); + g_object_unref(c); + g_object_unref(i); + } + g_object_unref(en); + } + g_file_delete(ddd, NULL, NULL); + g_object_unref(ddd); + g_free(d); +} + +/* Regression: a multi-word command must reach sh -c intact. The buggy + * g_shell_parse_argv code fed sh -c only the first word ("printf"), so the + * outfile would never contain "hello world". */ +static void +test_multi_word(void) { + _run_and_check_output("printf 'hello world\\n' > '%s'", "hello world\n"); +} + +/* Pipeline: '|' must be parsed by sh, not split into argv words. */ +static void +test_pipeline(void) { + _run_and_check_output("echo pipeline | cat > '%s'", "pipeline\n"); +} + +/* Redirection: '>' must be interpreted by the shell. */ +static void +test_redirection(void) { + _run_and_check_output("echo redir > '%s'", "redir\n"); +} + +/* Multiple space-separated arguments survive intact. */ +static void +test_spaces_in_args(void) { + _run_and_check_output("printf 'a b c\\n' > '%s'", "a b c\n"); +} + int main(int argc, char **argv) { g_test_init(&argc, &argv, NULL); g_test_add_func("/runner/true_exit_zero", test_true_exit_zero); g_test_add_func("/runner/false_exit_nonzero", test_false_exit_nonzero); g_test_add_func("/runner/injection_guard", test_injection_guard); + g_test_add_func("/runner/hostile_filename", test_hostile_filename); + g_test_add_func("/runner/multi_word", test_multi_word); + g_test_add_func("/runner/pipeline", test_pipeline); + g_test_add_func("/runner/redirection", test_redirection); + g_test_add_func("/runner/spaces_in_args", test_spaces_in_args); return g_test_run(); -}
\ No newline at end of file +} |
