feat(cli): accept --inc-recursive no-op and in-root absolute --temp-dir

This commit is contained in:
2026-09-22 21:58:54 +02:00
parent d780a4625e
commit f96f1764af
8 changed files with 257 additions and 46 deletions
+120
View File
@@ -0,0 +1,120 @@
"""Parity coverage for an absolute ``--temp-dir`` that lies inside the receive root.
FastSync confines the ``--temp-dir`` scratch directory to the receive root. It
previously rejected *every* absolute path; it now canonicalizes an absolute path
with ``realpath(3)`` and accepts it when it resolves inside the canonical receive
root (the destination is identical, so this is a pure parity win), while an
absolute path that escapes the root stays rejected with a clear error.
Two paths exercise the same receiver-side resolution:
* the local ``--read-batch`` apply (no network; the batch destination is the
receive root), and
* a real TCP transfer against the shared server (the destination root is the
client-supplied absolute path).
The out-of-root case asserts the run fails without writing a single scratch
file, so the confinement invariant is preserved.
"""
import os
import subprocess
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
from common import CLIENT_CMD, TEST_DATA_DIR, clean_dir, get_dest_received_dir, run_client
FILES = {
"top.txt": b"top level\n",
"sub/nested.txt": b"nested file\n" * 16,
}
def _run(args):
return subprocess.run(CLIENT_CMD + args, capture_output=True, text=True, timeout=180)
def _seed_source(root):
clean_dir(root)
for rel, data in FILES.items():
full = os.path.join(root, rel)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "wb") as fh:
fh.write(data)
def _make_batch(tmp, source):
batch = os.path.join(tmp, "tree.batch")
result = _run(["--only-write-batch", batch, source])
assert result.returncode == 0, (result.stdout, result.stderr)
return batch
def _read(path):
with open(path, "rb") as fh:
return fh.read()
@pytest.mark.ci
def test_read_batch_absolute_temp_dir_inside_root_accepted(tmp_path):
source = os.path.join(tmp_path, "src")
dest = os.path.join(tmp_path, "dst")
_seed_source(source)
clean_dir(dest)
scratch = os.path.join(dest, "scratch")
os.makedirs(scratch)
batch = _make_batch(str(tmp_path), source)
result = _run(["--read-batch", batch, dest, "--temp-dir", scratch])
assert result.returncode == 0, (result.stdout, result.stderr)
received = get_dest_received_dir(dest, source)
for rel, data in FILES.items():
assert _read(os.path.join(received, rel)) == data, f"content mismatch for {rel}"
assert os.listdir(scratch) == [], "scratch dir was not left clean"
@pytest.mark.ci
def test_read_batch_absolute_temp_dir_outside_root_rejected(tmp_path):
source = os.path.join(tmp_path, "src")
dest = os.path.join(tmp_path, "dst")
_seed_source(source)
clean_dir(dest)
outside = os.path.join(tmp_path, "outside")
os.makedirs(outside)
batch = _make_batch(str(tmp_path), source)
result = _run(["--read-batch", batch, dest, "--temp-dir", outside])
assert result.returncode != 0, "an absolute temp dir outside the receive root must be rejected"
assert os.listdir(outside) == [], "receiver wrote into an unconfined temp dir"
assert "temp-dir" in (result.stdout + result.stderr), (result.stdout, result.stderr)
def test_tcp_absolute_temp_dir_inside_root_accepted(shared_server):
source = os.path.join(TEST_DATA_DIR, "tempdir_abs_in_src")
dest = os.path.join(TEST_DATA_DIR, "tempdir_abs_in_dst")
_seed_source(source)
clean_dir(dest)
scratch = os.path.join(dest, "scratch")
os.makedirs(scratch)
result, _ = run_client(source, dest, flags=["--temp-dir", scratch], port=shared_server.port)
assert result.returncode == 0, (result.stdout, result.stderr)[:300]
received = get_dest_received_dir(dest, source)
for rel, data in FILES.items():
assert _read(os.path.join(received, rel)) == data, f"content mismatch for {rel}"
assert os.listdir(scratch) == [], "scratch dir was not left clean"
def test_tcp_absolute_temp_dir_outside_root_rejected(shared_server):
source = os.path.join(TEST_DATA_DIR, "tempdir_abs_out_src")
dest = os.path.join(TEST_DATA_DIR, "tempdir_abs_out_dst")
_seed_source(source)
clean_dir(dest)
outside = os.path.join(TEST_DATA_DIR, "tempdir_abs_out_scratch")
clean_dir(outside)
result, _ = run_client(source, dest, flags=["--temp-dir", outside], port=shared_server.port)
assert result.returncode != 0, "an absolute temp dir outside the receive root must be rejected"
assert os.listdir(outside) == [], "receiver wrote into an unconfined temp dir"
+5 -3
View File
@@ -5040,10 +5040,12 @@ static void test_parse_args_include_exclude_order() {
config_delete(cfg3);
}
/* OPT_NOOP compatibility flags (-s/--secluded-args, -r/--recursive) must never
* swallow the next argv: `fastsync -s SRC DST` keeps both positionals. */
/* OPT_NOOP compatibility flags (-s/--secluded-args, -r/--recursive, and the
* --inc-recursive/--no-inc-recursive scan-mode pair) must never swallow the
* next argv: `fastsync -s SRC DST` keeps both positionals. */
static void test_parse_args_noop_does_not_consume_argv() {
static const char* const noops[] = {"-s", "--secluded-args", "-r", "--recursive"};
static const char* const noops[] = {"-s", "--secluded-args", "-r",
"--recursive", "--inc-recursive", "--no-inc-recursive"};
for (size_t i = 0; i < sizeof(noops) / sizeof(noops[0]); i++) {
Config* cfg = config_create();
int positional_args[2];
+12
View File
@@ -339,12 +339,16 @@ static void test_file_save_to_disk_temp_dir_confined() {
const char* root = "test_temp_confine_tmp";
const char* dest_file = "test_temp_confine_tmp/file.txt";
char outside[PATH_MAX];
char inside_abs[PATH_MAX];
snprintf(outside, sizeof(outside), "/tmp/fastsync_temp_outside_%d", (int)getpid());
unlink(dest_file);
rmdir("test_temp_confine_tmp/scratch");
rmdir("test_temp_confine_tmp/abs_scratch");
rmdir(root);
mkdir(root, 0755);
mkdir("test_temp_confine_tmp/scratch", 0755);
mkdir("test_temp_confine_tmp/abs_scratch", 0755);
EXPECT_NOT_NULL(realpath("test_temp_confine_tmp/abs_scratch", inside_abs));
mkdir(outside, 0755);
File* f = file_create("file.txt");
@@ -364,6 +368,13 @@ static void test_file_save_to_disk_temp_dir_confined() {
config->temp_dir = str_dup("../escape");
EXPECT_EQ_INT(file_save_to_disk_full(root, f, config), FILE_SAVE_ERROR);
EXPECT_EQ_INT(access(dest_file, F_OK), -1);
/* An absolute temp dir that canonicalizes INSIDE the receive root is
accepted and used (the parity win); destination is still written. */
free(config->temp_dir);
config->temp_dir = str_dup(inside_abs);
EXPECT_EQ_INT(file_save_to_disk_full(root, f, config), FILE_SAVE_WRITTEN);
EXPECT_EQ_INT(access(dest_file, F_OK), 0);
unlink(dest_file);
free(config->temp_dir);
config->temp_dir = str_dup("scratch");
EXPECT_EQ_INT(file_save_to_disk_full(root, f, config), FILE_SAVE_WRITTEN);
@@ -373,6 +384,7 @@ static void test_file_save_to_disk_temp_dir_confined() {
config_delete(config);
unlink(dest_file);
rmdir("test_temp_confine_tmp/scratch");
rmdir("test_temp_confine_tmp/abs_scratch");
rmdir(root);
rmdir(outside);
}