summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/runner.c28
-rw-r--r--tests/test_runner.c135
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
+}