feat(cli): accept --inc-recursive no-op and in-root absolute --temp-dir
This commit is contained in:
@@ -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"
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user