Merge feat/p5-remote-option: --remote-option, --trust-sender

# Conflicts:
#	RSYNC_COMPAT.md
#	src/client/client_cli.c
#	src/client/client_send.c
#	src/shared/transport_ssh.c
#	src/shared/transport_ssh.h
#	tests/integration/test_ssh.py
#	tests/test_client_cli.c
#	tests/test_transport_ssh.c
This commit is contained in:
2026-09-09 14:35:26 +02:00
21 changed files with 769 additions and 57 deletions
+14
View File
@@ -187,6 +187,20 @@ def setup_test_data():
class TestDryRun:
def test_trust_sender_transfer_completes(self, shared_server):
"""--trust-sender is a receiver-local policy (never sent to the peer).
A transfer run with it must still complete and produce byte-identical
results: the receiver keeps its low-level root confinement, so a normal
trusted transfer is unchanged."""
clean_dir(DEST_DIR)
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR,
flags=["--trust-sender"], port=shared_server.port)
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}"
mismatches, missing = verify_transfer(SOURCE_DIR, received)
assert not missing, f"Missing files: {missing[:5]}"
assert not mismatches, f"Mismatched files: {mismatches[:5]}"
def test_human_readable_dry_run(self):
result, dur = run_client(SOURCE_DIR, DEST_DIR, flags=["-h", "--dry-run"])
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}"
+45 -1
View File
@@ -144,7 +144,6 @@ class TestSSHFeatures:
r = _run_ssh_test("SSH Preallocate (--preallocate)", ["--preallocate"])
assert r["status"] == "Success", r["error"]
class TestSSHConnectivity:
"""Phase 5 connectivity options: -e/--rsh, --rsync-path, --blocking-io,
--outbuf. These are client-side launch concerns, so each must parse and
@@ -181,3 +180,48 @@ class TestSSHConnectivity:
def test_blocking_io_with_compression(self):
r = _run_ssh_test("SSH --blocking-io -c", ["--blocking-io", "-c"])
assert r["status"] == "Success", r["error"]
def test_trust_sender(self):
r = _run_ssh_test("SSH Trust Sender (--trust-sender)", ["--trust-sender"])
assert r["status"] == "Success", r["error"]
def test_remote_option_reaches_server(self):
"""--remote-option=OPT appends OPT to the remote server command line and
the server honors it. Over SSH the server is launched without
--allow-delete, so a bare --delete is inert (nothing is removed). If
--remote-option=--allow-delete really reaches the remote server, the
receiver's deletion policy becomes permissive and the stale destination
file IS removed. Asserting the file is gone is therefore a positive
proof the forwarded option was honored by the server."""
src = SOURCE_DIR
if os.path.exists(src):
shutil.rmtree(src)
os.makedirs(src)
with open(os.path.join(src, "keep.txt"), "w") as f:
f.write("kept\n")
with open(os.path.join(src, "stale.txt"), "w") as f:
f.write("stale\n")
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
# Initial push so the destination mirrors the source.
clean_dir(DEST_DIR)
ssh_dest = f"localhost:{DEST_DIR}"
base = CLIENT_CMD + [src, ssh_dest, "--save-to-disk",
"--fastsync-server-path", os.path.join(BUILD_DIR, "server")]
first = subprocess.run(base, text=True, capture_output=True)
assert first.returncode == 0, f"initial push failed: {(first.stderr or first.stdout)[:200]}"
assert os.path.exists(os.path.join(received, "stale.txt"))
# Remove stale.txt from the source and re-push with --delete +
# --remote-option=--allow-delete. Forwarding --allow-delete to the
# server is what makes the deletion actually happen.
os.remove(os.path.join(src, "stale.txt"))
second = subprocess.run(base + ["--delete", "--remote-option=--allow-delete"],
text=True, capture_output=True)
assert second.returncode == 0, \
f"second push failed: {(second.stderr or second.stdout)[:200]}"
assert not os.path.exists(os.path.join(received, "stale.txt")), (
"stale.txt still present: --allow-delete (forwarded via "
"--remote-option) did not reach the remote server"
)
assert os.path.exists(os.path.join(received, "keep.txt"))
+1
View File
@@ -55,6 +55,7 @@ int main() {
RUN_TEST(test_metadata);
RUN_TEST(test_glob);
RUN_TEST(test_file);
RUN_TEST(test_trust_sender);
RUN_TEST(test_delay_updates);
RUN_TEST(test_file_sendfile);
RUN_TEST(test_multiprocessing);
+110
View File
@@ -2669,6 +2669,109 @@ static void test_parse_args_sockopts() {
}
}
/* --trust-sender parses; default is false (receiver-local policy, off). */
static void test_parse_args_trust_sender_default_false() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv[] = {"fastsync", "--source-dir", "/src", "--dest-dir", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
EXPECT_FALSE(cfg->trust_sender);
config_delete(cfg);
}
static void test_parse_args_trust_sender() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv[] = {"fastsync", "--trust-sender", "--source-dir", "/src", "--dest-dir", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->trust_sender);
config_delete(cfg);
}
/* --remote-option=OPT is repeatable and stores each value in order. */
static void test_parse_args_remote_option_multiple() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
EXPECT_EQ_INT(cfg->remote_option_count, 0);
char* argv[] = {"fastsync",
"--source-dir",
"/src",
"--dest-dir",
"/dst",
"--remote-option=--allow-delete",
"--remote-option=--verbose"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->remote_option_count, 2);
EXPECT_EQ_STR(cfg->remote_options[0], "--allow-delete");
EXPECT_EQ_STR(cfg->remote_options[1], "--verbose");
config_delete(cfg);
}
/* Space-separated form "--remote-option OPT" also parses. */
static void test_parse_args_remote_option_space_form() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
"/dst", "--remote-option", "-v"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->remote_option_count, 1);
EXPECT_EQ_STR(cfg->remote_options[0], "-v");
config_delete(cfg);
}
/* A missing argument bare --remote-option is rejected. */
static void test_parse_args_remote_option_missing_value() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv[] = {"fastsync", "--source-dir", "/src", "--dest-dir", "/dst", "--remote-option"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv, positional_args, &positional_count), -1);
config_delete(cfg);
}
/* An empty --remote-option value and a value with control characters is
* rejected (the value would break the remote shell quoting). */
static void test_parse_args_remote_option_rejects_bad_values() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv[] = {"fastsync", "--source-dir", "/src", "--dest-dir", "/dst", "--remote-option="};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv, positional_args, &positional_count), -1);
EXPECT_EQ_INT(cfg->remote_option_count, 0);
char* argv2[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
"/dst", "--remote-option", "--bad\noption"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv2, positional_args, &positional_count), -1);
EXPECT_EQ_INT(cfg->remote_option_count, 0);
config_delete(cfg);
}
/* A short -M form must NOT be accepted as --remote-option: -M stays FastSync
* metadata mode (documented divergence). */
static void test_parse_args_remote_option_no_short_M() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
/* -M followed by a remote-option-looking word still means metadata mode. */
char* argv[] = {"fastsync", "-M", "-v", "--source-dir", "/src", "--dest-dir", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->use_metadata);
EXPECT_EQ_INT(cfg->remote_option_count, 0);
config_delete(cfg);
}
void test_client_cli() {
test_validate_config_required_paths();
test_parse_args_numeric_ids();
@@ -2804,4 +2907,11 @@ void test_client_cli() {
test_parse_args_delete_policy_invalid_values();
test_parse_args_max_delete_inert_without_delete();
test_parse_args_missing_args_flags();
test_parse_args_trust_sender_default_false();
test_parse_args_trust_sender();
test_parse_args_remote_option_multiple();
test_parse_args_remote_option_space_form();
test_parse_args_remote_option_missing_value();
test_parse_args_remote_option_rejects_bad_values();
test_parse_args_remote_option_no_short_M();
}
+55
View File
@@ -1226,15 +1226,70 @@ static void test_config_phase4_xattr_wire_roundtrip() {
}
}
/* --trust-sender defaults to OFF (a receiver-local policy). */
static void test_config_trust_sender_default_false() {
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
EXPECT_FALSE(cfg->trust_sender);
EXPECT_NULL(cfg->remote_options);
EXPECT_EQ_INT(cfg->remote_option_count, 0);
config_delete(cfg);
}
/* --trust-sender and --remote-option are LOCAL to the process that sets them:
* they must never cross the wire. After a round-trip the receiver observes the
* neutral defaults (trust_sender=false, no remote options), even when the
* sender had them set. */
static void test_config_local_only_fields_not_serialized() {
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
pid_t pid = fork();
if (pid == 0) {
close(p[1]);
io_set_fds(p[0], p[0]);
Config* recv = config_receive(p[0]);
bool ok = recv != NULL && !recv->trust_sender && recv->remote_options == NULL &&
recv->remote_option_count == 0;
config_delete(recv);
close(p[0]);
_exit(ok ? 0 : 1);
}
close(p[0]);
io_set_fds(p[1], p[1]);
Config* send_cfg = config_create();
EXPECT_NOT_NULL(send_cfg);
send_cfg->trust_sender = true;
/* remote_options is client-side state; populate it like the CLI would. */
send_cfg->remote_options = malloc(sizeof(char*));
send_cfg->remote_options[0] = str_dup("--allow-delete");
send_cfg->remote_option_count = 1;
send_cfg->send_directory = str_dup("/src");
send_cfg->receive_root_directory = str_dup("/dst");
bool sent = config_send(p[1], send_cfg);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(send_cfg);
EXPECT_TRUE(sent);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
void test_config() {
test_config_lifecycle();
test_config_ssh_dest();
test_config_ssh_dest_local_path();
test_config_ssh_dest_no_user();
test_config_trust_sender_default_false();
test_pipeline_sender_lifecycle();
test_pipeline_receiver_lifecycle();
if (!is_running_under_valgrind()) {
test_config_send_receive();
test_config_local_only_fields_not_serialized();
test_config_send_receive_version_mismatch();
test_config_receive_truncated();
test_config_string_null_vs_empty_roundtrip();
+194
View File
@@ -6,6 +6,7 @@
#include "protocol.h"
#include "test_utils.h"
#include <fcntl.h>
#include <limits.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
@@ -1022,6 +1023,199 @@ static void test_dir_entry_save_to_disk() {
rmdir(root);
}
/* ---- Phase 5 (--trust-sender) safety-floor tests ----
*
* --trust-sender is a receiver-local policy that never crosses the wire: a real
* receiver enables it from its own process (the standalone server's --trust-
* sender CLI switch, which a client forwards as --remote-option=--trust-sender),
* so these tests force file_set_trust_sender(true) directly. Trust must RELAX
* only the redundant list-level re-validation (an escaping symlink TARGET is
* copied verbatim, rsync -l parity) and must NEVER disable the low-level
* fd-relative confinement floor: file_open_secure_parent's ".." rejection, the
* O_NOFOLLOW parent walk, leaf/destination confinement, and the ungated
* has_path_traversal on the link's own placement path in file_symlink_at_secure
* stay hard. A hostile sender therefore still cannot place a file, directory
* or symlink outside the receive root even with trust on. */
static void test_trust_sender_relaxes_symlink_target() {
const char* root = "test_trust_sender_root";
const char* link = "test_trust_sender_root/escape_link";
unlink(link);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0755), 0);
/* Control: without trust an absolute (escaping) target is refused and the
link is never placed. */
file_set_trust_sender(false);
EXPECT_FALSE(file_symlink_at_secure(link, "/etc/passwd"));
struct stat st;
EXPECT_EQ_INT(lstat(link, &st), -1);
/* Trust ON: the escaping target is copied verbatim (rsync -l parity) ... */
file_set_trust_sender(true);
EXPECT_TRUE(file_symlink_at_secure(link, "/etc/passwd"));
EXPECT_EQ_INT(lstat(link, &st), 0);
EXPECT_TRUE(S_ISLNK(st.st_mode));
/* ...but the link itself still lands beneath the receive root. */
char target[128];
ssize_t target_len = readlink(link, target, sizeof(target) - 1);
EXPECT_TRUE(target_len > 0);
// cppcheck-suppress knownConditionTrueFalse
if (target_len > 0) {
target[target_len] = '\0';
EXPECT_EQ_STR(target, "/etc/passwd");
}
unlink(link);
/* Same relaxation through the real save funnel (file_save_to_disk_full). */
Config* config = config_create();
EXPECT_NOT_NULL(config);
const char* save_link = "test_trust_sender_root/save_link";
unlink(save_link);
File* sym = file_create("save_link");
EXPECT_NOT_NULL(sym);
sym->is_symlink = true;
sym->symlink_target = str_dup("/etc/passwd");
EXPECT_NOT_NULL(sym->symlink_target);
file_set_trust_sender(false);
EXPECT_EQ_INT(file_save_to_disk_full(root, sym, config), FILE_SAVE_SKIPPED);
EXPECT_EQ_INT(lstat(save_link, &st), -1);
file_set_trust_sender(true);
EXPECT_EQ_INT(file_save_to_disk_full(root, sym, config), FILE_SAVE_WRITTEN);
EXPECT_EQ_INT(lstat(save_link, &st), 0);
EXPECT_TRUE(S_ISLNK(st.st_mode));
file_destroy(sym);
config_delete(config);
unlink(save_link);
rmdir(root);
}
static void test_trust_sender_confines_hostile_paths() {
const char* root = "test_trust_sender_root";
const char* escaped_file = "../test_trust_sender_escaped_file.txt";
const char* escaped_dir = "../test_trust_sender_escaped_dir";
const char* escaped_link = "../test_trust_sender_escaped_link";
unlink(escaped_file);
rmdir(escaped_dir);
unlink(escaped_link);
unlink(root);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0755), 0);
Config* config = config_create();
EXPECT_NOT_NULL(config);
file_set_trust_sender(true);
struct stat st;
/* A hostile regular-file path that would escape the root is contained: the
save-layer ".." re-check is relaxed under trust, so the attempt reaches the
secure floor, which refuses the walk -- nothing appears outside. */
File* file = file_create(escaped_file);
EXPECT_NOT_NULL(file);
file->data->data = malloc(5);
EXPECT_NOT_NULL(file->data->data);
memcpy(file->data->data, "evil", 4);
file->data->size = 4;
EXPECT_EQ_INT(file_save_to_disk_full(root, file, config), FILE_SAVE_ERROR);
file_destroy(file);
EXPECT_EQ_INT(lstat(escaped_file, &st), -1);
/* A hostile directory entry is contained the same way. */
File* dir = file_create(escaped_dir);
EXPECT_NOT_NULL(dir);
dir->is_dir = true;
EXPECT_EQ_INT(file_save_to_disk_full(root, dir, config), FILE_SAVE_ERROR);
file_destroy(dir);
EXPECT_EQ_INT(lstat(escaped_dir, &st), -1);
/* A hostile symlink whose OWN placement path escapes the root is refused even
under trust: the ungated has_path_traversal in file_symlink_at_secure never
turns off. */
EXPECT_FALSE(file_symlink_at_secure("test_trust_sender_root/../escaped_link", "/etc/passwd"));
EXPECT_EQ_INT(lstat(escaped_link, &st), -1);
/* file_open_secure_parent still refuses a ".." component outright. */
char* leaf = NULL;
EXPECT_EQ_INT(file_open_secure_parent("test_trust_sender_root/../../etc/passwd", &leaf, true),
-1);
free(leaf);
config_delete(config);
rmdir(root);
}
/* The same guarantees under a configured authorized root: a within-root link
with an escaping target is created (relaxed), while a placement path that is
a clean absolute path OUTSIDE the authorized root (no ".." anywhere) is
refused by the leaf/destination confinement. */
static void test_trust_sender_authorized_root_confinement() {
const char* root = "test_trust_sender_root";
const char* sibling = "test_trust_sender_sibling";
unlink(root);
rmdir(root);
rmdir(sibling);
EXPECT_EQ_INT(mkdir(root, 0755), 0);
EXPECT_EQ_INT(mkdir(sibling, 0755), 0);
char root_abs[PATH_MAX];
char sibling_abs[PATH_MAX];
EXPECT_NOT_NULL(realpath(root, root_abs));
EXPECT_NOT_NULL(realpath(sibling, sibling_abs));
int root_fd = open(root_abs, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
EXPECT_TRUE(root_fd >= 0);
// cppcheck-suppress knownConditionTrueFalse
if (root_fd < 0) {
rmdir(root);
rmdir(sibling);
return;
}
EXPECT_TRUE(file_set_authorized_root(root_fd, root_abs));
file_set_trust_sender(true);
struct stat st;
/* Within the authorized root, an escaping symlink TARGET is copied verbatim. */
char* inside_link = path_cat(root_abs, "authorized_escape_link");
EXPECT_NOT_NULL(inside_link);
unlink(inside_link);
EXPECT_TRUE(file_symlink_at_secure(inside_link, "/etc/passwd"));
EXPECT_EQ_INT(lstat(inside_link, &st), 0);
EXPECT_TRUE(S_ISLNK(st.st_mode));
unlink(inside_link);
/* A clean absolute path in a sibling directory (outside the authorized root)
is still refused even under trust. */
char* outside_link = path_cat(sibling_abs, "test_trust_sender_outside_link");
EXPECT_NOT_NULL(outside_link);
unlink(outside_link);
EXPECT_FALSE(file_symlink_at_secure(outside_link, "/etc/passwd"));
EXPECT_EQ_INT(lstat(outside_link, &st), -1);
free(outside_link);
free(inside_link);
file_set_authorized_root(-1, NULL);
close(root_fd);
unlink("test_trust_sender_outside_link");
rmdir(sibling);
rmdir(root);
}
void test_trust_sender() {
/* The final reset lines always run (a failing EXPECT only returns from the
helper), so a later group never inherits a stray trust/authorized-root
policy. */
file_set_trust_sender(false);
test_trust_sender_relaxes_symlink_target();
test_trust_sender_confines_hostile_paths();
test_trust_sender_authorized_root_confinement();
file_set_trust_sender(false);
file_set_authorized_root(-1, NULL);
}
void test_file() {
test_file_create();
test_file_special_rdev_valid();
+1
View File
@@ -2,5 +2,6 @@
#define TEST_FILE_H
void test_file();
void test_trust_sender();
#endif
+57 -8
View File
@@ -4,13 +4,14 @@
static void test_ssh_connect_invalid_dest_no_colon() {
/* cppcheck-suppress constVariablePointer */
Client* client = client_connect_ssh("invalid-destination-no-colon", 22, NULL, false, NULL, false);
Client* client =
client_connect_ssh("invalid-destination-no-colon", 22, NULL, false, NULL, false, NULL, 0);
EXPECT_NULL(client);
}
static void test_ssh_connect_invalid_dest_empty() {
/* cppcheck-suppress constVariablePointer */
Client* client = client_connect_ssh("", 22, NULL, false, NULL, false);
Client* client = client_connect_ssh("", 22, NULL, false, NULL, false, NULL, 0);
EXPECT_NULL(client);
}
@@ -21,7 +22,7 @@ static void test_ssh_connect_malformed() {
setenv("PATH", "", 1);
/* cppcheck-suppress constVariablePointer */
Client* client = client_connect_ssh(":", 22, NULL, false, NULL, false);
Client* client = client_connect_ssh(":", 22, NULL, false, NULL, false, NULL, 0);
if (saved_path) {
setenv("PATH", saved_path, 1);
@@ -37,7 +38,7 @@ static void test_ssh_connect_malformed() {
* The function launches ssh which will fail to connect, returns a Client. */
static void test_ssh_connect_unreachable() {
Client* client =
client_connect_ssh("nonexistent.invalid:/remote/path", 22, NULL, false, NULL, false);
client_connect_ssh("nonexistent.invalid:/remote/path", 22, NULL, false, NULL, false, NULL, 0);
if (client != NULL) {
client_disconnect(client);
client_delete(client);
@@ -46,15 +47,15 @@ static void test_ssh_connect_unreachable() {
}
static void test_ssh_remote_command_argument_modes() {
char* command = ssh_build_remote_command("fast sync; touch /tmp/pwned", false);
char* command = ssh_build_remote_command("fast sync; touch /tmp/pwned", false, NULL, 0);
EXPECT_EQ_STR(command, "'fast sync; touch /tmp/pwned' --stdio");
free(command);
command = ssh_build_remote_command("fast'sync", false);
command = ssh_build_remote_command("fast'sync", false, NULL, 0);
EXPECT_EQ_STR(command, "'fast'\\''sync' --stdio");
free(command);
command = ssh_build_remote_command("fast sync; touch /tmp/pwned", true);
command = ssh_build_remote_command("fast sync; touch /tmp/pwned", true, NULL, 0);
EXPECT_EQ_STR(command, "fast sync; touch /tmp/pwned --stdio");
free(command);
}
@@ -104,6 +105,52 @@ static void test_ssh_build_client_argv_whitespace_command_and_port() {
ssh_free_client_argv(argv);
}
/* --remote-option=OPT appends OPT to the remote command line after " --stdio",
* each escaped as its own single-quoted shell word. Metacharacters that could
* break out of the quoting are neutralized (never injected), matching the
* ssh_build_remote_command safety boundary for the server path. */
static void test_ssh_remote_command_with_remote_options() {
char* noop[] = {"--allow-delete"};
char* command = ssh_build_remote_command("fastsync-server", false, noop, 1);
EXPECT_EQ_STR(command, "'fastsync-server' --stdio '--allow-delete'");
free(command);
/* Multiple options append in order, each as its own quoted word. */
char* multi[] = {"-v", "--allow-delete"};
command = ssh_build_remote_command("srv", false, multi, 2);
EXPECT_EQ_STR(command, "'srv' --stdio '-v' '--allow-delete'");
free(command);
/* A remote option containing a single quote and shell metacharacters is
escaped with the same "'\''" boundary, so it stays one word and cannot
break out into an arbitrary remote command. */
char* val = strdup("--x=un'der; touch /tmp/pwned");
char* dangerous[1] = {val};
command = ssh_build_remote_command("srv", false, dangerous, 1);
EXPECT_EQ_STR(command, "'srv' --stdio '--x=un'\\''der; touch /tmp/pwned'");
free(command);
free(val);
/* --old-args leaves the server path unquoted but still quotes remote options. */
command = ssh_build_remote_command("srv", true, multi, 2);
EXPECT_EQ_STR(command, "srv --stdio '-v' '--allow-delete'");
free(command);
}
/* The remote command builder refuses to forward an empty or control-character
* remote option (defense-in-depth independent of the CLI validation). */
static void test_ssh_remote_command_rejects_bad_options() {
char* empty[] = {""};
EXPECT_NULL(ssh_build_remote_command("srv", false, empty, 1));
char nl = '\n';
char* newline[] = {&nl};
EXPECT_NULL(ssh_build_remote_command("srv", false, newline, 1));
char* with_null[] = {NULL};
EXPECT_NULL(ssh_build_remote_command("srv", false, with_null, 1));
}
void test_transport_ssh() {
test_ssh_connect_invalid_dest_no_colon();
test_ssh_connect_invalid_dest_empty();
@@ -113,4 +160,6 @@ void test_transport_ssh() {
test_ssh_build_client_argv_default_is_ssh();
test_ssh_build_client_argv_uses_custom_rsh();
test_ssh_build_client_argv_whitespace_command_and_port();
}
test_ssh_remote_command_with_remote_options();
test_ssh_remote_command_rejects_bad_options();
}