feat(p5-remote-option): --remote-option (probe 2.14.0), --trust-sender
This commit is contained in:
@@ -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]}"
|
||||
|
||||
@@ -142,3 +142,48 @@ class TestSSHFeatures:
|
||||
def test_preallocate(self):
|
||||
r = _run_ssh_test("SSH Preallocate (--preallocate)", ["--preallocate"])
|
||||
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"))
|
||||
|
||||
@@ -2480,6 +2480,109 @@ static void test_parse_args_devices_specials() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* --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();
|
||||
@@ -2608,4 +2711,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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
static void test_ssh_connect_invalid_dest_no_colon() {
|
||||
/* cppcheck-suppress constVariablePointer */
|
||||
Client* client = client_connect_ssh("invalid-destination-no-colon", 22, NULL, false);
|
||||
Client* client = client_connect_ssh("invalid-destination-no-colon", 22, 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);
|
||||
Client* client = client_connect_ssh("", 22, NULL, false, NULL, 0);
|
||||
EXPECT_NULL(client);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ static void test_ssh_connect_malformed() {
|
||||
setenv("PATH", "", 1);
|
||||
|
||||
/* cppcheck-suppress constVariablePointer */
|
||||
Client* client = client_connect_ssh(":", 22, NULL, false);
|
||||
Client* client = client_connect_ssh(":", 22, NULL, false, NULL, 0);
|
||||
|
||||
if (saved_path) {
|
||||
setenv("PATH", saved_path, 1);
|
||||
@@ -36,7 +36,7 @@ static void test_ssh_connect_malformed() {
|
||||
/* Test client_connect_ssh with valid format but unreachable host.
|
||||
* 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);
|
||||
Client* client = client_connect_ssh("nonexistent.invalid:/remote/path", 22, NULL, false, NULL, 0);
|
||||
if (client != NULL) {
|
||||
client_disconnect(client);
|
||||
client_delete(client);
|
||||
@@ -45,23 +45,71 @@ 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);
|
||||
}
|
||||
|
||||
/* --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();
|
||||
test_ssh_connect_malformed();
|
||||
test_ssh_connect_unreachable();
|
||||
test_ssh_remote_command_argument_modes();
|
||||
test_ssh_remote_command_with_remote_options();
|
||||
test_ssh_remote_command_rejects_bad_options();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user