feat: implement -R/--relative, --no-implied-dirs, --dirs/-d, --mkpath
CI / lint (pull_request) Successful in 26s
CI / sanitizers (address) (pull_request) Failing after 41s
CI / sanitizers (undefined) (pull_request) Successful in 40s
CI / fuzz-build (pull_request) Successful in 18s
CI / coverage (pull_request) Successful in 35s
CI / valgrind (pull_request) Failing after 37s
CI / build-and-test (pull_request) Failing after 2m35s
CI / lint (pull_request) Successful in 26s
CI / sanitizers (address) (pull_request) Failing after 41s
CI / sanitizers (undefined) (pull_request) Successful in 40s
CI / fuzz-build (pull_request) Successful in 18s
CI / coverage (pull_request) Successful in 35s
CI / valgrind (pull_request) Failing after 37s
CI / build-and-test (pull_request) Failing after 2m35s
RSYNC_COMPAT Phase-2 row M: path-list construction and destination directory creation while preserving traversal safety. - -R/--relative with --files-from: transmit each listed entry under its bare relative destination path (no source-root mirror). Files keep an absolute local read path plus a separate wire/dest path (File.send_path); manifest, incremental quick-check and change output follow the wire path, so --delete and --remove-source-files stay consistent. -R without --files-from is unchanged (full mirror). - --no-implied-dirs: client-only, only meaningful with -R + --files-from. A listed file whose parent dir is not itself (or via an ancestor) explicitly listed cannot be placed; the run fails up front with a clear error. No effect otherwise. - --dirs/-d + --old-dirs/--old-d aliases: -d <dir> transmits the source root as an explicit empty directory entry (STATUS_MKDIR frame); with --files-from listed dirs are created empty and listed files transferred, never descending. Works single-threaded, -m (sequential scanner in the -m scan thread) and chunk-serialization (per-file type marker). Directory entries appear in the delete manifest. - --mkpath: new wire bool; server creates the destination root (and missing leading components under its authorized root) at connection start. A missing destination root is now rejected by default. - Protocol bumped to 2.7.0 (mkpath wire field + STATUS_MKDIR + chunk type marker). All receive paths funnel through file_save_to_disk_full which creates directories via the secure confined mkdir engine; dir entries are excluded from --remove-source-files outcome acknowledgements on both ends. - Unit coverage: CLI parse (relative/dirs aliases/mkpath/no-implied-dirs), config round-trip (relative + mkpath), scanner --dirs non-recursion and -R send_path (sequential + parallel), receiver dir-entry save. - Integration coverage: TestRelativeFilesFrom, TestNoImpliedDirs, TestDirs, TestMkpath (single and -m). - RSYNC_COMPAT: 4 rows move to Implemented (Summary 67/3/5/1/71 = 147).
This commit is contained in:
@@ -1635,6 +1635,254 @@ class TestFilesFrom:
|
||||
transfers its whole subtree. The manifest (and thus --delete) derives from
|
||||
what was actually sent."""
|
||||
|
||||
|
||||
def _make_relative_source(name):
|
||||
"""A small tree used by the -R/--dirs/--no-implied-dirs tests."""
|
||||
source = os.path.join(TEST_DATA_DIR, name)
|
||||
clean_dir(source)
|
||||
entries = {
|
||||
"top.txt": b"top\n",
|
||||
"a/b.txt": b"nested\n",
|
||||
"sub/x.txt": b"x\n",
|
||||
"sub/y.txt": b"y\n",
|
||||
"dir1/keep.txt": b"dir content\n",
|
||||
}
|
||||
for rel, content in entries.items():
|
||||
full = os.path.join(source, rel)
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
with open(full, "wb") as fh:
|
||||
fh.write(content)
|
||||
return source
|
||||
|
||||
|
||||
def _write_rel_list(rel_text):
|
||||
path = os.path.join(TEST_DATA_DIR, "rel_list.txt")
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(rel_text)
|
||||
return path
|
||||
|
||||
|
||||
class TestRelativeFilesFrom:
|
||||
"""-R/--relative with --files-from keeps each listed entry's bare relative
|
||||
destination path below the destination root instead of mirroring the full
|
||||
source path. Without -R the layout is unchanged (full source mirror)."""
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_relative_files_from_keeps_relative_layout(self, shared_server, mt):
|
||||
source = _make_relative_source("rel_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "rel_dst")
|
||||
clean_dir(dest)
|
||||
lst = _write_rel_list(b"top.txt\nsub/x.txt\n")
|
||||
flags = ["--files-from", lst, "-R"] + (["-m"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, f"-R files-from sync failed: {result.stderr[:200]}"
|
||||
assert _read_file(os.path.join(dest, "sub", "x.txt")) == b"x\n", \
|
||||
"listed file must land at <dest>/sub/x.txt"
|
||||
assert _read_file(os.path.join(dest, "top.txt")) == b"top\n", \
|
||||
"top-level listed file must land at <dest>/top.txt"
|
||||
assert not os.path.exists(os.path.join(dest, "sub", "y.txt"))
|
||||
# The source-root mirror must not be reproduced under -R.
|
||||
assert not os.path.exists(get_dest_received_dir(dest, source)), \
|
||||
"-R must not mirror the full source path"
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_relative_without_files_from_has_no_effect(self, shared_server, mt):
|
||||
"""-R alone (no --files-from) must leave the normal full-source mirror
|
||||
layout untouched."""
|
||||
source = _make_relative_source("rel_only_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "rel_only_dst")
|
||||
clean_dir(dest)
|
||||
flags = ["-R"] + (["-m"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, f"-R alone sync failed: {result.stderr[:200]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
mismatches, missing = verify_transfer(source, received)
|
||||
assert not missing and not mismatches
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_without_relative_layout_unchanged(self, shared_server, mt):
|
||||
source = _make_relative_source("rel_noR_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "rel_noR_dst")
|
||||
clean_dir(dest)
|
||||
lst = _write_rel_list(b"sub/x.txt\n")
|
||||
flags = ["--files-from", lst] + (["-m"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, f"files-from sync failed: {result.stderr[:200]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, "sub", "x.txt")) == b"x\n", \
|
||||
"without -R the full source mirror layout is preserved"
|
||||
assert not os.path.exists(os.path.join(dest, "sub")), \
|
||||
"bare relative layout must not appear without -R"
|
||||
|
||||
def test_relative_delete_manifest_stays_consistent(self):
|
||||
"""--delete derives from the sent (-R) relative paths, so a later
|
||||
subset run removes unlisted relative entries but keeps listed ones."""
|
||||
source = _make_relative_source("rel_del_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "rel_del_dst")
|
||||
clean_dir(dest)
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
lst = _write_rel_list(b"sub/x.txt\nsub/y.txt\n")
|
||||
result, _ = run_client(source, dest, flags=["--files-from", lst, "-R"],
|
||||
port=server.port)
|
||||
assert result.returncode == 0, f"seed -R sync failed: {result.stderr[:200]}"
|
||||
assert os.path.isfile(os.path.join(dest, "sub", "y.txt"))
|
||||
|
||||
subset = _write_rel_list(b"sub/x.txt\n")
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["--files-from", subset, "-R", "--delete"],
|
||||
port=server.port)
|
||||
assert result.returncode == 0, f"-R delete sync failed: {result.stderr[:200]}"
|
||||
assert os.path.isfile(os.path.join(dest, "sub", "x.txt")), "listed file was deleted"
|
||||
assert not os.path.exists(os.path.join(dest, "sub", "y.txt")), \
|
||||
"unlisted relative file was not deleted"
|
||||
|
||||
|
||||
class TestNoImpliedDirs:
|
||||
"""--no-implied-dirs (only meaningful with -R + --files-from) refuses to
|
||||
place a listed file whose parent directory is not itself listed."""
|
||||
|
||||
def _make(self):
|
||||
return _make_relative_source("noimplied_src")
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_implied_dir_only_fails_entry(self, shared_server, mt):
|
||||
source = self._make()
|
||||
dest = os.path.join(TEST_DATA_DIR, "noimplied_dst")
|
||||
clean_dir(dest)
|
||||
lst = _write_rel_list(b"a/b.txt\n") # "a" itself is not listed
|
||||
flags = ["--files-from", lst, "-R", "--no-implied-dirs"] + (["-m"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode != 0, "implied parent directory was not rejected"
|
||||
assert "--no-implied-dirs" in (result.stderr or result.stdout)
|
||||
assert not os.path.exists(os.path.join(dest, "a", "b.txt"))
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_listed_dir_allows_file(self, shared_server, mt):
|
||||
source = self._make()
|
||||
dest = os.path.join(TEST_DATA_DIR, "noimplied_ok_dst")
|
||||
clean_dir(dest)
|
||||
lst = _write_rel_list(b"a\na/b.txt\n")
|
||||
flags = ["--files-from", lst, "-R", "--no-implied-dirs"] + (["-m"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, f"listed dir + file sync failed: {result.stderr[:200]}"
|
||||
assert _read_file(os.path.join(dest, "a", "b.txt")) == b"nested\n"
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_no_implied_dirs_without_relative_changes_nothing(self, shared_server, mt):
|
||||
source = self._make()
|
||||
dest = os.path.join(TEST_DATA_DIR, "noimplied_noR_dst")
|
||||
clean_dir(dest)
|
||||
lst = _write_rel_list(b"a/b.txt\n")
|
||||
flags = ["--files-from", lst, "--no-implied-dirs"] + (["-m"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, "--no-implied-dirs without -R changed behavior"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, "a", "b.txt")) == b"nested\n"
|
||||
|
||||
|
||||
class TestDirs:
|
||||
"""-d/--dirs (and the --old-dirs/--old-d aliases) transfer directory entries
|
||||
without recursing into their contents."""
|
||||
|
||||
def _make(self):
|
||||
return _make_relative_source("dirs_src")
|
||||
|
||||
def _assert_only_empty_mirror(self, dest, source):
|
||||
mirror = get_dest_received_dir(dest, source)
|
||||
assert os.path.isdir(mirror), "source-root mirror directory was not created"
|
||||
files = []
|
||||
for root, _dirs, names in os.walk(mirror):
|
||||
files.extend(os.path.relpath(os.path.join(root, n), mirror) for n in names)
|
||||
assert files == [], f"--dirs descended into contents: {files}"
|
||||
|
||||
@pytest.mark.parametrize("flag", ["--dirs", "-d", "--old-dirs", "--old-d"])
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_dirs_transfers_empty_dir_only(self, shared_server, flag, mt):
|
||||
source = self._make()
|
||||
dest = os.path.join(TEST_DATA_DIR, "dirs_dst")
|
||||
clean_dir(dest)
|
||||
flags = [flag] + (["-m"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, f"{flag} sync failed: {result.stderr[:200]}"
|
||||
self._assert_only_empty_mirror(dest, source)
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_dirs_with_files_from(self, shared_server, mt):
|
||||
source = self._make()
|
||||
dest = os.path.join(TEST_DATA_DIR, "dirs_ff_dst")
|
||||
clean_dir(dest)
|
||||
# A listed directory is created empty; a listed file is transferred.
|
||||
lst = _write_rel_list(b"dir1\nsub/x.txt\n")
|
||||
flags = ["--files-from", lst, "--dirs", "-R"] + (["-m"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, f"dirs files-from sync failed: {result.stderr[:200]}"
|
||||
assert os.path.isdir(os.path.join(dest, "dir1")), "listed dir was not created"
|
||||
assert not os.path.exists(os.path.join(dest, "dir1", "keep.txt")), \
|
||||
"--dirs must not descend into a listed directory"
|
||||
assert _read_file(os.path.join(dest, "sub", "x.txt")) == b"x\n", \
|
||||
"listed file content was not transferred"
|
||||
assert not os.path.exists(os.path.join(dest, "sub", "y.txt")), \
|
||||
"unlisted file appeared"
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_dirs_with_files_from_mirror_layout(self, shared_server, mt):
|
||||
"""Without -R the dirs+files-from entries still mirror the source path."""
|
||||
source = self._make()
|
||||
dest = os.path.join(TEST_DATA_DIR, "dirs_ff_noR_dst")
|
||||
clean_dir(dest)
|
||||
lst = _write_rel_list(b"dir1\n")
|
||||
flags = ["--files-from", lst, "--dirs"] + (["-m"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, f"dirs files-from no-R sync failed: {result.stderr[:200]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert os.path.isdir(os.path.join(received, "dir1")), "mirrored dir entry not created"
|
||||
assert not os.path.exists(os.path.join(received, "dir1", "keep.txt")), \
|
||||
"--dirs must not descend into a listed directory"
|
||||
assert not os.path.exists(os.path.join(received, "sub")), \
|
||||
"unlisted subtree appeared"
|
||||
|
||||
|
||||
class TestMkpath:
|
||||
"""--mkpath tells the server to create the destination root directory (and
|
||||
missing leading components) when it does not exist yet; without it a missing
|
||||
destination root fails the transfer."""
|
||||
|
||||
def _transfer(self, dest, mt, mkpath):
|
||||
source = _make_relative_source("mkpath_src")
|
||||
flags = ["--mkpath"] if mkpath else []
|
||||
if mt:
|
||||
flags += ["-m"]
|
||||
result, _ = run_client(source, dest, flags=flags, port=self.server.port)
|
||||
return result
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_missing_root_fails_without_mkpath(self, mt):
|
||||
source = _make_relative_source("mkpath_fail_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "mkpath_missing_dst")
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
with ServerManager() as server:
|
||||
server.start()
|
||||
flags = ["-m"] if mt else []
|
||||
result, _ = run_client(source, dest, flags=flags, port=server.port)
|
||||
assert result.returncode != 0, "missing destination root did not fail without --mkpath"
|
||||
assert not os.path.exists(dest), "missing root was created without --mkpath"
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_mkpath_creates_missing_root(self, mt):
|
||||
source = _make_relative_source("mkpath_ok_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "deep", "mkpath_dst")
|
||||
shutil.rmtree(os.path.join(TEST_DATA_DIR, "deep"), ignore_errors=True)
|
||||
with ServerManager() as server:
|
||||
server.start()
|
||||
flags = ["--mkpath"] + (["-m"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=server.port)
|
||||
assert result.returncode == 0, f"--mkpath sync failed: {result.stderr[:200]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, "sub", "x.txt")) == b"x\n", \
|
||||
"file not transferred into the --mkpath-created root"
|
||||
|
||||
|
||||
class TestFilters:
|
||||
"""--filter/-C/-F rule layer: excludes prune, ordering is first-match-wins,
|
||||
the default with no matching rule is include, and legacy --exclude remains
|
||||
|
||||
+41
-22
@@ -547,34 +547,54 @@ static void test_parse_args_unknown_option() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* Directory aliases must report the unsupported directory-only behavior clearly. */
|
||||
static void test_parse_args_rejects_dirs_aliases() {
|
||||
static const char* const options[] = {"--dirs", "--old-dirs", "--old-d"};
|
||||
|
||||
/* -d/--dirs and the rsync --old-dirs/--old-d aliases all enable directory-only
|
||||
* transfers (--dirs maps every spelling onto the same config field). */
|
||||
static void test_parse_args_dirs_aliases() {
|
||||
static const char* const options[] = {"--dirs", "-d", "--old-dirs", "--old-d"};
|
||||
for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); i++) {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", (char*)options[i], "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
FILE* log_file = tmpfile();
|
||||
char log_buffer[256] = {0};
|
||||
|
||||
EXPECT_NOT_NULL(log_file);
|
||||
log_set_file(log_file);
|
||||
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), -1);
|
||||
fflush(log_file);
|
||||
rewind(log_file);
|
||||
EXPECT_TRUE(fread(log_buffer, 1, sizeof(log_buffer) - 1, log_file) > 0);
|
||||
EXPECT_TRUE(strstr(log_buffer, options[i]) != NULL);
|
||||
EXPECT_TRUE(strstr(log_buffer, "directory-only transfer is not implemented") != NULL);
|
||||
EXPECT_TRUE(strstr(log_buffer, "requires --dirs") == NULL);
|
||||
log_set_file(NULL);
|
||||
fclose(log_file);
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->dirs);
|
||||
config_delete(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
/* -R/--relative, --no-implied-dirs and --mkpath are plain boolean flags. */
|
||||
static void test_parse_args_relative_no_implied_mkpath() {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "-R", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->relative);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
positional_count = 0;
|
||||
char* long_argv[] = {"fastsync", "--relative", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, long_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->relative);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
positional_count = 0;
|
||||
char* noimplied_argv[] = {"fastsync", "--no-implied-dirs", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, noimplied_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->no_implied_dirs);
|
||||
EXPECT_FALSE(cfg->relative);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
positional_count = 0;
|
||||
char* mkpath_argv[] = {"fastsync", "--mkpath", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, mkpath_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->mkpath);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* --del is recognized as the rsync alias, but its timing mode is not implemented. */
|
||||
static void test_parse_args_delete_during_alias_unimplemented() {
|
||||
static const char* const options[] = {"--del", "--delete-during"};
|
||||
@@ -609,8 +629,6 @@ static void test_parse_args_rejects_unimplemented_options() {
|
||||
"--delete-after",
|
||||
"--max-delete",
|
||||
"--prune-empty-dirs",
|
||||
"-R",
|
||||
"--relative",
|
||||
"-e",
|
||||
"--rsh",
|
||||
"--rsync-path",
|
||||
@@ -1522,7 +1540,8 @@ void test_client_cli() {
|
||||
test_parse_args_max_alloc_sizes();
|
||||
test_parse_args_rejects_invalid_max_alloc();
|
||||
test_parse_args_unknown_option();
|
||||
test_parse_args_rejects_dirs_aliases();
|
||||
test_parse_args_dirs_aliases();
|
||||
test_parse_args_relative_no_implied_mkpath();
|
||||
test_parse_args_delete_during_alias_unimplemented();
|
||||
test_parse_args_rejects_unimplemented_options();
|
||||
test_parse_args_quiet();
|
||||
|
||||
@@ -137,6 +137,8 @@ static void test_config_send_receive() {
|
||||
send_cfg->existing = true;
|
||||
send_cfg->ignore_existing = true;
|
||||
send_cfg->delay_updates = true;
|
||||
send_cfg->relative = true;
|
||||
send_cfg->mkpath = true;
|
||||
send_cfg->skip_compress_set = true;
|
||||
send_cfg->skip_compress_count = 1;
|
||||
send_cfg->skip_compress_suffixes = calloc(1, sizeof(char*));
|
||||
@@ -194,6 +196,10 @@ static void test_config_send_receive() {
|
||||
ok = false;
|
||||
if (!recv_cfg->delay_updates)
|
||||
ok = false;
|
||||
if (!recv_cfg->relative)
|
||||
ok = false;
|
||||
if (!recv_cfg->mkpath)
|
||||
ok = false;
|
||||
if (!recv_cfg->skip_compress_set || recv_cfg->skip_compress_count != 1 ||
|
||||
strcmp(recv_cfg->skip_compress_suffixes[0], ".zip") != 0)
|
||||
ok = false;
|
||||
|
||||
@@ -886,6 +886,42 @@ static void test_inplace_overwrite_truncates_shorter_payload() {
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
/* Explicit directory entries (--dirs) create the directory under the receive
|
||||
root through the same save funnel, creating parents as needed, and reject
|
||||
traversal the same way a file path does. */
|
||||
static void test_dir_entry_save_to_disk() {
|
||||
const char* root = "test_dir_entry_root";
|
||||
EXPECT_EQ_INT(mkdir(root, 0755), 0);
|
||||
|
||||
Config* config = config_create();
|
||||
EXPECT_NOT_NULL(config);
|
||||
|
||||
File* dir = file_create("alpha/beta/gamma");
|
||||
EXPECT_NOT_NULL(dir);
|
||||
dir->is_dir = true;
|
||||
EXPECT_EQ_INT(file_save_to_disk_full(root, dir, config), FILE_SAVE_WRITTEN);
|
||||
EXPECT_EQ_INT(file_save_to_disk_full(root, dir, config), FILE_SAVE_WRITTEN);
|
||||
file_destroy(dir);
|
||||
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(stat("test_dir_entry_root/alpha/beta/gamma", &st), 0);
|
||||
EXPECT_TRUE(S_ISDIR(st.st_mode));
|
||||
|
||||
/* The directory-entry save path never follows or escapes. */
|
||||
File* evil = file_create("../dir_entry_escape");
|
||||
EXPECT_NOT_NULL(evil);
|
||||
evil->is_dir = true;
|
||||
EXPECT_EQ_INT(file_save_to_disk_full(root, evil, config), FILE_SAVE_ERROR);
|
||||
file_destroy(evil);
|
||||
EXPECT_EQ_INT(lstat("../dir_entry_escape", &st), -1);
|
||||
|
||||
config_delete(config);
|
||||
rmdir("test_dir_entry_root/alpha/beta/gamma");
|
||||
rmdir("test_dir_entry_root/alpha/beta");
|
||||
rmdir("test_dir_entry_root/alpha");
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
void test_file() {
|
||||
test_file_create();
|
||||
test_file_destroy_null();
|
||||
@@ -906,6 +942,7 @@ void test_file() {
|
||||
test_file_content_to_buffer();
|
||||
test_file_save_to_disk_path_traversal();
|
||||
test_file_save_to_disk_deep_traversal();
|
||||
test_dir_entry_save_to_disk();
|
||||
if (!is_running_under_valgrind()) {
|
||||
// Fork tests are skipped under valgrind because the parent process runs
|
||||
// orders of magnitude slower than the child (parent is instrumented, child
|
||||
|
||||
+190
-2
@@ -397,8 +397,9 @@ static void test_parallel_scanner_root_chunks_without_workers() {
|
||||
create_test_file(file1, "a");
|
||||
create_test_file(file2, "b");
|
||||
|
||||
ScannerOptions options = {false, 1, NULL, 0, NULL, 0, 0, 0, 0, 0,
|
||||
false, false, false, false, false, false, NULL, NULL, false};
|
||||
ScannerOptions options = {false, 1, NULL, 0, NULL, 0, 0,
|
||||
0, 0, 0, false, false, false, false,
|
||||
false, false, NULL, NULL, false, false, false};
|
||||
ParallelScanner* scanner = parallel_scanner_create_with_options(dir, &options, NULL);
|
||||
EXPECT_NOT_NULL(scanner);
|
||||
|
||||
@@ -1078,6 +1079,190 @@ static void test_per_dir_filter_override(bool parallel) {
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
char rel[512];
|
||||
char send[512];
|
||||
bool is_dir;
|
||||
} ScanInfo;
|
||||
|
||||
/* Collect every scanner entry below `root` into `out` (at most `max`), mapping
|
||||
* paths to their root-relative form and capturing send_path and is_dir. */
|
||||
static int collect_scan_info(const char* root, const ScannerOptions* options, ScanInfo out[],
|
||||
int max) {
|
||||
DirectoryScanner* scanner = directory_scanner_create_with_options(root, options);
|
||||
if (!scanner)
|
||||
return -1;
|
||||
size_t root_len = strlen(root);
|
||||
while (root_len > 0 && root[root_len - 1] == '/')
|
||||
root_len--;
|
||||
int count = 0;
|
||||
Chunk* chunk;
|
||||
while ((chunk = directory_scanner_next(scanner)) != NULL) {
|
||||
for (int i = 0; i < chunk->element_count && count < max; i++) {
|
||||
const File* f = chunk->items[i];
|
||||
const char* rel = f->path + root_len;
|
||||
if (*rel == '/')
|
||||
rel++;
|
||||
snprintf(out[count].rel, sizeof(out[count].rel), "%s", rel);
|
||||
snprintf(out[count].send, sizeof(out[count].send), "%s", f->send_path ? f->send_path : "");
|
||||
out[count].is_dir = f->is_dir;
|
||||
count++;
|
||||
}
|
||||
chunk_destroy(chunk);
|
||||
}
|
||||
bool failed = directory_scanner_failed(scanner);
|
||||
directory_scanner_destroy(scanner);
|
||||
return failed ? -1 : count;
|
||||
}
|
||||
|
||||
static bool scan_info_present(const ScanInfo* infos, int count, const char* rel, bool is_dir,
|
||||
const char* send) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (strcmp(infos[i].rel, rel) == 0 && infos[i].is_dir == is_dir &&
|
||||
strcmp(infos[i].send, send ? send : "") == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Parallel variant of collect_scan_info; drains `scanner` fully and destroys
|
||||
* it. */
|
||||
static int collect_scan_info_parallel(ParallelScanner* scanner, const char* root, ScanInfo out[],
|
||||
int max) {
|
||||
if (!scanner)
|
||||
return -1;
|
||||
size_t root_len = strlen(root);
|
||||
while (root_len > 0 && root[root_len - 1] == '/')
|
||||
root_len--;
|
||||
int count = 0;
|
||||
Chunk* chunk;
|
||||
while ((chunk = parallel_scanner_next(scanner)) != NULL) {
|
||||
for (int i = 0; i < chunk->element_count && count < max; i++) {
|
||||
const File* f = chunk->items[i];
|
||||
const char* rel = f->path + root_len;
|
||||
if (*rel == '/')
|
||||
rel++;
|
||||
snprintf(out[count].rel, sizeof(out[count].rel), "%s", rel);
|
||||
snprintf(out[count].send, sizeof(out[count].send), "%s", f->send_path ? f->send_path : "");
|
||||
out[count].is_dir = f->is_dir;
|
||||
count++;
|
||||
}
|
||||
chunk_destroy(chunk);
|
||||
}
|
||||
bool failed = parallel_scanner_failed(scanner);
|
||||
parallel_scanner_destroy(scanner);
|
||||
return failed ? -1 : count;
|
||||
}
|
||||
|
||||
/* -d without --files-from emits exactly the source-root directory (empty) and
|
||||
* never descends. */
|
||||
static void test_dirs_no_descent() {
|
||||
const char* root = "test_scan_dirs_root";
|
||||
EXPECT_EQ_INT(mkdir(root, 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir("test_scan_dirs_root/sub", 0755), 0);
|
||||
create_test_file("test_scan_dirs_root/a.txt", "a");
|
||||
create_test_file("test_scan_dirs_root/sub/b.txt", "b");
|
||||
|
||||
ScannerOptions options = {0};
|
||||
options.dirs = true;
|
||||
ScanInfo infos[8];
|
||||
int count = collect_scan_info(root, &options, infos, 8);
|
||||
EXPECT_EQ_INT(count, 1);
|
||||
EXPECT_TRUE(scan_info_present(infos, count, "", true, NULL));
|
||||
EXPECT_FALSE(scan_info_present(infos, count, "a.txt", false, ""));
|
||||
EXPECT_FALSE(scan_info_present(infos, count, "sub/b.txt", false, ""));
|
||||
|
||||
unlink("test_scan_dirs_root/a.txt");
|
||||
unlink("test_scan_dirs_root/sub/b.txt");
|
||||
rmdir("test_scan_dirs_root/sub");
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
/* -d with --files-from transfers exactly the listed directory (empty) and the
|
||||
* listed file; nothing is descended into. */
|
||||
static void test_dirs_files_from() {
|
||||
const char* root = "test_scan_dirs_ff";
|
||||
const char* list_path = "test_scan_dirs_ff.list";
|
||||
EXPECT_EQ_INT(mkdir(root, 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir("test_scan_dirs_ff/sub", 0755), 0);
|
||||
create_test_file("test_scan_dirs_ff/sub/keep.txt", "keep");
|
||||
create_test_file("test_scan_dirs_ff/sub/skip.bin", "skip");
|
||||
create_test_file("test_scan_dirs_ff/top.txt", "top");
|
||||
|
||||
char err[160];
|
||||
create_test_file(list_path, "sub\nsub/keep.txt\n");
|
||||
FileListSet* set = file_list_load(list_path, false, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(set);
|
||||
|
||||
for (int relative = 0; relative <= 1; relative++) {
|
||||
ScannerOptions options = {0};
|
||||
options.dirs = true;
|
||||
options.file_list = set;
|
||||
options.relative = relative != 0;
|
||||
ScanInfo infos[8];
|
||||
int count = collect_scan_info(root, &options, infos, 8);
|
||||
EXPECT_EQ_INT(count, 2);
|
||||
if (relative) {
|
||||
EXPECT_TRUE(scan_info_present(infos, count, "sub", true, "sub"));
|
||||
EXPECT_TRUE(scan_info_present(infos, count, "sub/keep.txt", false, "sub/keep.txt"));
|
||||
} else {
|
||||
EXPECT_TRUE(scan_info_present(infos, count, "sub", true, NULL));
|
||||
EXPECT_TRUE(scan_info_present(infos, count, "sub/keep.txt", false, NULL));
|
||||
}
|
||||
EXPECT_FALSE(scan_info_present(infos, count, "sub/skip.bin", false, ""));
|
||||
EXPECT_FALSE(scan_info_present(infos, count, "top.txt", false, ""));
|
||||
}
|
||||
file_list_destroy(set);
|
||||
remove(list_path);
|
||||
unlink("test_scan_dirs_ff/sub/keep.txt");
|
||||
unlink("test_scan_dirs_ff/sub/skip.bin");
|
||||
unlink("test_scan_dirs_ff/top.txt");
|
||||
rmdir("test_scan_dirs_ff/sub");
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
/* -R with --files-from (no -d): every file keeps its bare relative path as the
|
||||
* send_path while the local scan path stays absolute-under-root. */
|
||||
static void test_files_from_relative_send_path() {
|
||||
const char* root = "test_scan_rel_ff";
|
||||
const char* list_path = "test_scan_rel_ff.list";
|
||||
EXPECT_EQ_INT(mkdir(root, 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir("test_scan_rel_ff/sub", 0755), 0);
|
||||
create_test_file("test_scan_rel_ff/root.txt", "root");
|
||||
create_test_file("test_scan_rel_ff/sub/keep.txt", "keep");
|
||||
|
||||
char err[160];
|
||||
create_test_file(list_path, "root.txt\nsub/keep.txt\n");
|
||||
FileListSet* set = file_list_load(list_path, false, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(set);
|
||||
|
||||
for (int parallel = 0; parallel <= 1; parallel++) {
|
||||
ScannerOptions options = {0};
|
||||
options.file_list = set;
|
||||
options.relative = true;
|
||||
if (parallel)
|
||||
options.num_threads = 2;
|
||||
ScanInfo infos[8];
|
||||
int count;
|
||||
if (parallel) {
|
||||
ParallelScanner* scanner = parallel_scanner_create_with_options(root, &options, NULL);
|
||||
EXPECT_NOT_NULL(scanner);
|
||||
count = collect_scan_info_parallel(scanner, root, infos, 8);
|
||||
} else {
|
||||
count = collect_scan_info(root, &options, infos, 8);
|
||||
}
|
||||
EXPECT_EQ_INT(count, 2);
|
||||
EXPECT_TRUE(scan_info_present(infos, count, "root.txt", false, "root.txt"));
|
||||
EXPECT_TRUE(scan_info_present(infos, count, "sub/keep.txt", false, "sub/keep.txt"));
|
||||
}
|
||||
file_list_destroy(set);
|
||||
remove(list_path);
|
||||
unlink("test_scan_rel_ff/root.txt");
|
||||
unlink("test_scan_rel_ff/sub/keep.txt");
|
||||
rmdir("test_scan_rel_ff/sub");
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
void test_scanner() {
|
||||
test_scanner_single_file();
|
||||
test_scanner_multiple_files();
|
||||
@@ -1110,4 +1295,7 @@ void test_scanner() {
|
||||
test_scanner_path_relative();
|
||||
test_per_dir_filter_override(false);
|
||||
test_per_dir_filter_override(true);
|
||||
test_dirs_no_descent();
|
||||
test_dirs_files_from();
|
||||
test_files_from_relative_send_path();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user