Release v2.26.0 #284

Merged
TapTap merged 210 commits from dev into main 2026-09-18 19:05:52 +02:00
8 changed files with 240 additions and 23 deletions
Showing only changes of commit 5893de4a34 - Show all commits
+2 -2
View File
@@ -179,7 +179,7 @@ static bool entry_passes_selection(const FileListSet* file_list, const FilterRul
static void scanner_capture_xattrs(const DirectoryScanner* scanner, File* file) { static void scanner_capture_xattrs(const DirectoryScanner* scanner, File* file) {
if (!scanner || !file || !(scanner->options.preserve_xattrs || scanner->options.preserve_acls)) if (!scanner || !file || !(scanner->options.preserve_xattrs || scanner->options.preserve_acls))
return; return;
file->xattrs = xattr_capture_path(file->path); file->xattrs = xattr_capture_path(file->path, scanner->options.preserve_acls);
} }
/* Apply --hard-links (-H) detection to one regular File. On a sibling (a /* Apply --hard-links (-H) detection to one regular File. On a sibling (a
@@ -1434,7 +1434,7 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
} }
if ((options->preserve_xattrs || options->preserve_acls) && if ((options->preserve_xattrs || options->preserve_acls) &&
!(file->link_group != 0 && !file->link_first)) !(file->link_group != 0 && !file->link_first))
file->xattrs = xattr_capture_path(file->path); file->xattrs = xattr_capture_path(file->path, options->preserve_acls);
if (!array_list_add(root_files, file)) { if (!array_list_add(root_files, file)) {
free(rel); free(rel);
file_destroy(file); file_destroy(file);
+11 -3
View File
@@ -1248,10 +1248,18 @@ static bool file_to_disk_secure_link_impl(const char* path, const char* basis_pa
if (linked) { if (linked) {
int target_dirfd = scratch_dirfd >= 0 ? scratch_dirfd : dirfd; int target_dirfd = scratch_dirfd >= 0 ? scratch_dirfd : dirfd;
if (use_fsync) { if (use_fsync) {
int tfd = openat(target_dirfd, tmp, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); /* O_NONBLOCK: the freshly linked temp is normally the basis's regular
if (tfd < 0 || fsync(tfd) != 0) { file, but a raced-in FIFO at the name must not block this reopen
forever. With O_NONBLOCK such an open fails with ENXIO instead of
blocking, which is treated as a benign fsync-skip (the link itself
is still installed); any other open/fsync failure falls back to the
byte-copy path as before. */
int tfd = openat(target_dirfd, tmp, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK);
if (tfd < 0) {
if (errno != ENXIO)
linked = false;
} else if (fsync(tfd) != 0) {
linked = false; linked = false;
if (tfd >= 0)
close(tfd); close(tfd);
} else { } else {
close(tfd); close(tfd);
+31 -10
View File
@@ -263,7 +263,8 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
free(destination_path); free(destination_path);
return absent_result; return absent_result;
} }
FileXattrList* sibling_xattrs = cfg->use_xattrs ? xattr_capture_path(staged_first) : NULL; FileXattrList* sibling_xattrs =
cfg->use_xattrs ? xattr_capture_path(staged_first, cfg->preserve_acls) : NULL;
bool ok = file_to_disk_secure_link_attrs( bool ok = file_to_disk_secure_link_attrs(
staged_sibling, staged_first, content, content_size, preallocate, file->metadata, staged_sibling, staged_first, content, content_size, preallocate, file->metadata,
preserve_executability, use_fsync, sibling_xattrs, cfg ? cfg->fake_super : false, NULL); preserve_executability, use_fsync, sibling_xattrs, cfg ? cfg->fake_super : false, NULL);
@@ -295,7 +296,8 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
return absent_result; return absent_result;
} }
const char* temp_dir = (cfg && cfg->temp_dir) ? cfg->temp_dir : NULL; const char* temp_dir = (cfg && cfg->temp_dir) ? cfg->temp_dir : NULL;
FileXattrList* sibling_xattrs = cfg->use_xattrs ? xattr_capture_path(first_disk) : NULL; FileXattrList* sibling_xattrs =
cfg->use_xattrs ? xattr_capture_path(first_disk, cfg->preserve_acls) : NULL;
bool ok = file_to_disk_secure_link_attrs( bool ok = file_to_disk_secure_link_attrs(
destination_path, first_disk, content, content_size, preallocate, file->metadata, destination_path, first_disk, content, content_size, preallocate, file->metadata,
preserve_executability, use_fsync, sibling_xattrs, cfg ? cfg->fake_super : false, temp_dir); preserve_executability, use_fsync, sibling_xattrs, cfg ? cfg->fake_super : false, temp_dir);
@@ -1276,14 +1278,27 @@ static bool basis_quick_matches(const Config* config, const struct stat* st, tim
/* Search the basis-dir list in command-line order and return the first exact /* Search the basis-dir list in command-line order and return the first exact
match. When load_content is true the matched bytes are kept in out->content match. When load_content is true the matched bytes are kept in out->content
so the caller can materialize the file without re-reading it. */ so the caller can materialize the file without re-reading it.
An exact match ALSO requires the basis bytes' digest to equal the source's,
so `hash_content` gates the content read/hash itself. A server-contacting
--dry-run passes hash_content=false: no basis file may be read or hashed
(that would be a 1-bit content oracle against a client-supplied digest), so a
metadata-only pass can never confirm a hit and declines it. The real path
always passes hash_content=true, keeping its behavior byte-for-byte. */
static bool basis_match_find(const Config* config, const char* check_path, static bool basis_match_find(const Config* config, const char* check_path,
unsigned long long check_size, time_t check_mtime, unsigned long long check_size, time_t check_mtime,
long check_mtime_nsec, const uint8_t* check_digest, long check_mtime_nsec, const uint8_t* check_digest,
size_t check_digest_len, bool load_content, BasisMatch* out) { size_t check_digest_len, bool load_content, bool hash_content,
BasisMatch* out) {
memset(out, 0, sizeof(*out)); memset(out, 0, sizeof(*out));
if (!config || !config_has_basis(config) || config->ignore_times) if (!config || !config_has_basis(config) || config->ignore_times)
return false; return false;
/* Dry-run: never read/hash basis content. A hit cannot be decided from
metadata alone, so report no match (the caller treats it as would-transfer)
without touching the file's contents. */
if (!hash_content)
return false;
for (int i = 0; i < config->basis_count; i++) { for (int i = 0; i < config->basis_count; i++) {
const BasisDest* entry = &config->basis_dirs[i]; const BasisDest* entry = &config->basis_dirs[i];
char* basis_dir = path_cat(config->receive_root_directory, entry->path); char* basis_dir = path_cat(config->receive_root_directory, entry->path);
@@ -1911,10 +1926,13 @@ static IncrementalCheckOutcome incremental_check_quick_skip(IncrementalCheckStat
When dry_run is set and the file is not already up to date the receiver must When dry_run is set and the file is not already up to date the receiver must
materialize nothing (no basis link/copy, no append/delta/full transfer) and materialize nothing (no basis link/copy, no append/delta/full transfer) and
the sender must send no data, so answer STATUS_DRY_RUN_TRANSFER and stop. the sender must send no data, so answer STATUS_DRY_RUN_TRANSFER and stop.
The one exception is a --compare-dest exact hit with no destination copy: a
real run would suppress the data without changing the destination, so it The basis lookup is deliberately content-blind: a real run would only accept
reports as a skip (STATUS_OK) exactly as the full basis path below would. a --compare-dest exact hit after hashing the basis file and comparing it with
Everything read here (destination file, basis candidates) is read-only. */ the client-supplied digest, which in a dry-run is a 1-bit content oracle.
Under dry_run no basis bytes may be read, so an otherwise-matching entry is
treated as would-transfer instead of a skip. Everything read here (the
destination file's metadata, basis candidates' metadata) is read-only. */
static IncrementalCheckOutcome incremental_check_dry_run_shortcut(IncrementalCheckState* state, static IncrementalCheckOutcome incremental_check_dry_run_shortcut(IncrementalCheckState* state,
bool* skipped, bool* skipped,
bool* would_transfer) { bool* would_transfer) {
@@ -1925,9 +1943,12 @@ static IncrementalCheckOutcome incremental_check_dry_run_shortcut(IncrementalChe
bool skip_via_compare = false; bool skip_via_compare = false;
if (config_has_basis(config) && !config->ignore_times) { if (config_has_basis(config) && !config->ignore_times) {
BasisMatch basis; BasisMatch basis;
/* hash_content=false: a dry-run must not read or hash the basis file. No
content comparison is possible, so no compare-dest hit can be confirmed
and an otherwise-matching file is reported as would-transfer. */
basis_match_find(config, state->check_path, state->check_size, (time_t)state->check_mtime, basis_match_find(config, state->check_path, state->check_size, (time_t)state->check_mtime,
(long)state->check_mtime_nsec, state->check_digest, state->check_digest_len, (long)state->check_mtime_nsec, state->check_digest, state->check_digest_len,
false, &basis); false, false, &basis);
if (basis.hit && basis.type == BASIS_DEST_COMPARE && !state->has_old_file) if (basis.hit && basis.type == BASIS_DEST_COMPARE && !state->has_old_file)
skip_via_compare = true; skip_via_compare = true;
basis_match_free(&basis); basis_match_free(&basis);
@@ -1955,7 +1976,7 @@ static IncrementalCheckOutcome incremental_check_try_basis(IncrementalCheckState
BasisMatch basis; BasisMatch basis;
basis_match_find(config, state->check_path, state->check_size, (time_t)state->check_mtime, basis_match_find(config, state->check_path, state->check_size, (time_t)state->check_mtime,
(long)state->check_mtime_nsec, state->check_digest, state->check_digest_len, (long)state->check_mtime_nsec, state->check_digest, state->check_digest_len,
true, &basis); true, true, &basis);
if (basis.hit) { if (basis.hit) {
if (basis.type == BASIS_DEST_COMPARE) { if (basis.type == BASIS_DEST_COMPARE) {
basis_match_free(&basis); basis_match_free(&basis);
+4 -3
View File
@@ -116,7 +116,7 @@ static bool xattr_name_is_posix_acl(const char* name) {
/* ---- SENDER: capture ---- */ /* ---- SENDER: capture ---- */
FileXattrList* xattr_capture_path(const char* path) { FileXattrList* xattr_capture_path(const char* path, bool preserve_acls) {
if (!path) if (!path)
return NULL; return NULL;
ssize_t list_size = listxattr(path, NULL, 0); ssize_t list_size = listxattr(path, NULL, 0);
@@ -144,8 +144,9 @@ FileXattrList* xattr_capture_path(const char* path) {
break; /* trailing double NUL not expected; stop */ break; /* trailing double NUL not expected; stop */
offset += (ssize_t)name_len + 1; offset += (ssize_t)name_len + 1;
/* Capture is sender-side: the scanner has already gated on -X/-A, so the /* Capture is sender-side: the scanner has already gated on -X/-A, so the
per-name whitelist here allows the ACL names (true). */ per-name whitelist here allows the ACL names only when --acls was
if (!xattr_name_appliable(name, true)) negotiated. Without it a plain -X capture never carries an ACL. */
if (!xattr_name_appliable(name, preserve_acls))
continue; continue;
ssize_t value_size = getxattr(path, name, NULL, 0); ssize_t value_size = getxattr(path, name, NULL, 0);
if (value_size < 0) if (value_size < 0)
+7 -4
View File
@@ -65,10 +65,13 @@ bool xattr_list_append(FileXattrList* list, const char* name, const void* value,
* Used for both capture and receiver-side validation. */ * Used for both capture and receiver-side validation. */
bool xattr_name_appliable(const char* name, bool preserve_acls); bool xattr_name_appliable(const char* name, bool preserve_acls);
/* Sender: read the whitelisted xattrs of `path` into a new list. Returns NULL /* Sender: read the whitelisted xattrs of `path` into a new list. The POSIX ACL
* when the path has no appliable xattrs (or the filesystem has no xattr * names are captured only when `preserve_acls` (--acls/-A) is set, so a plain
* support); an empty-but-valid list is never returned distinct from NULL. */ * -X run never carries an ACL it was not asked to preserve; `user.*` is
FileXattrList* xattr_capture_path(const char* path); * unaffected. Returns NULL when the path has no appliable xattrs (or the
* filesystem has no xattr support); an empty-but-valid list is never returned
* distinct from NULL. */
FileXattrList* xattr_capture_path(const char* path, bool preserve_acls);
/* Wire: bounded serialization. xattr_send returns false on write failure; an /* Wire: bounded serialization. xattr_send returns false on write failure; an
* empty/NULL list transmits a zero-count block. xattr_receive returns NULL and * empty/NULL list transmits a zero-count block. xattr_receive returns NULL and
+21
View File
@@ -3813,6 +3813,27 @@ class TestBasisDestDirs:
assert _read_file(os.path.join(received, self.ADDED)) == \ assert _read_file(os.path.join(received, self.ADDED)) == \
self._source_tree("c")[self.ADDED], "added file not transferred" self._source_tree("c")[self.ADDED], "added file not transferred"
@pytest.mark.ci
def test_dry_run_compare_dest_does_not_read_basis(self, shared_server):
# A dry-run --compare-dest must never read/hash the basis file: doing so
# is a 1-bit content oracle against the client-supplied digest. Even a
# byte-identical basis with a matching size+mtime is therefore reported
# as would-transfer, and nothing is created.
source = self._make_source("basis_dry_src", {self.UNCHANGED: b"stable content v1\n"})
dest = os.path.join(TEST_DATA_DIR, "basis_dry_dst")
clean_dir(dest)
self._seed_basis(dest, source, "drybasis", {self.UNCHANGED: b"stable content v1\n"})
before = _snapshot_tree(dest)
result, _ = run_client(source, dest,
flags=["--compare-dest=drybasis", "--dry-run"],
port=shared_server.port)
assert result.returncode == 0, \
f"dry-run compare-dest failed: {result.stderr[:300]}"
assert self.UNCHANGED in result.stdout, (
"dry-run compare-dest silently skipped: receiver read the basis content"
)
assert _snapshot_tree(dest) == before, "dry-run compare-dest mutated the destination"
def test_compare_dest_content_mismatch_forces_transfer(self, shared_server): def test_compare_dest_content_mismatch_forces_transfer(self, shared_server):
# The basis holds a file with a DIFFERENT body: even though it shares # The basis holds a file with a DIFFERENT body: even though it shares
# the mtime pin, the xxHash check fails and the data must be sent. # the mtime pin, the xxHash check fails and the data must be sent.
+88
View File
@@ -1,4 +1,5 @@
#include "test_server.h" #include "test_server.h"
#include "checksum.h"
#include "config.h" #include "config.h"
#include "delta.h" #include "delta.h"
#include "file.h" #include "file.h"
@@ -910,6 +911,92 @@ static void test_incremental_check_fifo_destination_does_not_hang() {
} }
} }
/* A server-contacting --dry-run with an alternate basis dir must never read or
hash the basis file. An exact (size+mtime+content) basis match would
otherwise let a client probe the basis bytes against its own supplied digest
(a 1-bit content oracle). The dry-run decision is metadata-only, so even a
byte-identical basis is reported as would-transfer, not a compare-dest skip. */
static void test_incremental_check_dry_run_basis_does_not_read_content() {
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
cfg->dry_run = true;
char* root = make_check_root("dryb");
EXPECT_NOT_NULL(root);
cfg->receive_root_directory = str_dup(root);
char basis_dir[1024];
char basis_path[2048];
snprintf(basis_dir, sizeof(basis_dir), "%s/basis", root);
EXPECT_EQ_INT(mkdir(basis_dir, 0700), 0);
const char* content = "basis content that matches\n";
write_check_file(basis_dir, "file.txt", content);
snprintf(basis_path, sizeof(basis_path), "%s/file.txt", basis_dir);
struct stat bst;
EXPECT_EQ_INT(stat(basis_path, &bst), 0);
EXPECT_EQ_INT(config_basis_append(cfg, BASIS_DEST_COMPARE, "basis"), 0);
/* The (correct) source digest for the basis bytes: an unfixed dry-run would
read+hash the basis and treat this as an exact compare-dest hit. */
uint8_t digest[CHECKSUM_MAX_DIGEST_LEN];
size_t digest_len = 0;
EXPECT_TRUE(checksum_digest((ChecksumAlgo)cfg->checksum_algo, cfg->checksum_seed, content,
strlen(content), digest, sizeof(digest), &digest_len));
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) {
alarm(30);
close(p[1]);
io_set_fds(p[0], p[0]);
bool skipped = false;
bool would_transfer = false;
File* file = receive_incremental_check_ex(p[0], cfg, &skipped, &would_transfer);
bool ok = file == NULL && !skipped && would_transfer;
file_destroy(file);
config_delete(cfg);
close(p[0]);
_exit(ok ? 0 : 1);
} else {
close(p[0]);
io_set_fds(p[1], p[1]);
EXPECT_TRUE(send_str(p[1], "file.txt"));
unsigned long long size = (unsigned long long)bst.st_size;
long long mtime = (long long)bst.st_mtime;
long long mtime_nsec = 0;
#ifdef __linux__
mtime_nsec = (long long)bst.st_mtim.tv_nsec;
#endif
EXPECT_TRUE(send_n_data(p[1], &size, sizeof(size)));
EXPECT_TRUE(send_n_data(p[1], &mtime, sizeof(mtime)));
EXPECT_TRUE(send_n_data(p[1], &mtime_nsec, sizeof(mtime_nsec)));
uint8_t wire_len = (uint8_t)digest_len;
EXPECT_TRUE(send_n_data(p[1], &wire_len, sizeof(wire_len)));
EXPECT_TRUE(send_n_data(p[1], digest, digest_len));
Status s;
EXPECT_TRUE(receive_status(p[1], &s));
/* A skip here would mean the receiver read+hashed the basis file. */
EXPECT_EQ_INT(s, STATUS_DRY_RUN_TRANSFER);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(cfg);
/* The dry-run must not have materialized anything in the receive root. */
char dest_path[2048];
snprintf(dest_path, sizeof(dest_path), "%s/file.txt", root);
EXPECT_FALSE(file_path_exists_secure(dest_path));
unlink(basis_path);
rmdir(basis_dir);
rmdir(root);
free(root);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
/* B1: a FIFO planted in a --link-dest basis directory must not block /* B1: a FIFO planted in a --link-dest basis directory must not block
* basis_open_regular() either; the basis match is simply declined. */ * basis_open_regular() either; the basis match is simply declined. */
static void test_incremental_check_basis_fifo_does_not_hang() { static void test_incremental_check_basis_fifo_does_not_hang() {
@@ -1024,6 +1111,7 @@ void test_server() {
test_incremental_check_delta_oversize_reports_failure(); test_incremental_check_delta_oversize_reports_failure();
test_incremental_check_fifo_destination_does_not_hang(); test_incremental_check_fifo_destination_does_not_hang();
test_incremental_check_basis_fifo_does_not_hang(); test_incremental_check_basis_fifo_does_not_hang();
test_incremental_check_dry_run_basis_does_not_read_content();
test_receive_manifest_total_entry_cap(); test_receive_manifest_total_entry_cap();
test_late_manifest_abort_frees_keepset(); test_late_manifest_abort_frees_keepset();
test_late_manifest_eof_frees_keepset(); test_late_manifest_eof_frees_keepset();
+75
View File
@@ -273,6 +273,80 @@ static void test_link_copy_fallback_preserves_xattrs() {
rmdir(basis_dir); rmdir(basis_dir);
} }
/* Capture must honor --acls: xattr_capture_path(path, false) (plain -X) must
* never return the POSIX ACL names, while xattr_capture_path(path, true) (-A)
* does; user.* is captured either way. This is the capture-side counterpart of
* the receiver's --acls gate and must not depend on the caller having checked
* the flag. Guarded on filesystem/ACL support. */
static void test_xattr_capture_filters_acls() {
const char* path = "test_xattr_capture_acls.txt";
unlink(path);
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0)
return;
bool has_xattr = setxattr(path, "user.fastsync.xprobe", "p", 1, 0) == 0;
if (has_xattr)
removexattr(path, "user.fastsync.xprobe");
if (!has_xattr) {
close(fd);
unlink(path);
return; /* filesystem without xattr support */
}
if (setxattr(path, "user.keep", "yes", 3, 0) != 0) {
close(fd);
unlink(path);
return;
}
/* Synthesize a valid non-trivial POSIX access ACL blob (little-endian):
version 2 followed by USER_OBJ/USER/GROUP_OBJ/MASK/OTHER entries. */
uint32_t acl_uid = geteuid() == 0 ? 65534u : (uint32_t)geteuid();
unsigned char blob[4 + 5 * 8];
uint32_t version = 2;
memcpy(blob, &version, 4);
const uint16_t tags[5] = {0x01, 0x02, 0x04, 0x10, 0x20}; /* OBJ/USER/GROUP/MASK/OTHER */
const uint16_t perms[5] = {0x04, 0x04, 0x04, 0x04, 0x00};
const uint32_t ids[5] = {0xFFFFFFFFu, acl_uid, 0xFFFFFFFFu, 0xFFFFFFFFu, 0xFFFFFFFFu};
size_t off = 4;
for (int i = 0; i < 5; i++) {
memcpy(blob + off, &tags[i], sizeof(tags[i]));
off += sizeof(tags[i]);
memcpy(blob + off, &perms[i], sizeof(perms[i]));
off += sizeof(perms[i]);
memcpy(blob + off, &ids[i], sizeof(ids[i]));
off += sizeof(ids[i]);
}
if (setxattr(path, "system.posix_acl_access", blob, off, 0) != 0) {
close(fd);
unlink(path);
return; /* no unprivileged ACL support: skip silently */
}
close(fd);
FileXattrList* plain = xattr_capture_path(path, false);
FileXattrList* with_acls = xattr_capture_path(path, true);
bool plain_user = false, plain_acl = false, acl_user = false, acl_acl = false;
for (int i = 0; plain && i < plain->count; i++) {
if (strcmp(plain->items[i].name, "user.keep") == 0)
plain_user = true;
if (strcmp(plain->items[i].name, "system.posix_acl_access") == 0)
plain_acl = true;
}
for (int i = 0; with_acls && i < with_acls->count; i++) {
if (strcmp(with_acls->items[i].name, "user.keep") == 0)
acl_user = true;
if (strcmp(with_acls->items[i].name, "system.posix_acl_access") == 0)
acl_acl = true;
}
EXPECT_TRUE(plain_user);
EXPECT_FALSE(plain_acl);
EXPECT_TRUE(acl_user);
EXPECT_TRUE(acl_acl);
xattr_list_free(plain);
xattr_list_free(with_acls);
unlink(path);
}
/* --fake-super replay: fake_super_store_fd records the source stat into the /* --fake-super replay: fake_super_store_fd records the source stat into the
* reserved xattr, and fake_super_restore_fd re-applies mode/mtime (and owner, * reserved xattr, and fake_super_restore_fd re-applies mode/mtime (and owner,
* when the process may) fd-relative. Restore must also be a safe no-op with no * when the process may) fd-relative. Restore must also be a safe no-op with no
@@ -409,6 +483,7 @@ void test_xattr() {
test_xattr_reject_oversized_value(); test_xattr_reject_oversized_value();
test_xattr_count_bound(); test_xattr_count_bound();
test_xattr_capture_and_appliable(); test_xattr_capture_and_appliable();
test_xattr_capture_filters_acls();
test_xattr_receive_drops_acl_without_preserve_acls(); test_xattr_receive_drops_acl_without_preserve_acls();
test_link_copy_fallback_preserves_xattrs(); test_link_copy_fallback_preserves_xattrs();
test_fake_super_restore(); test_fake_super_restore();