Release v2.26.0 #284

Merged
TapTap merged 210 commits from dev into main 2026-09-18 19:05:52 +02:00
13 changed files with 873 additions and 84 deletions
Showing only changes of commit 7c24a365cf - 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
@@ -1445,7 +1445,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);
+46
View File
@@ -1,6 +1,7 @@
#include <stddef.h> #include <stddef.h>
#include <stdint.h> #include <stdint.h>
#include <limits.h> #include <limits.h>
#include <stdatomic.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
@@ -20,6 +21,33 @@
#define MAX_FILE_DATA_SIZE (64ULL * 1024 * 1024) #define MAX_FILE_DATA_SIZE (64ULL * 1024 * 1024)
#define MAX_FILES_PER_CHUNK 65536U #define MAX_FILES_PER_CHUNK 65536U
/* Reserve `charge` against `session`'s connection budget. This mirrors the
static protocol_reserve_memory() in protocol.c: the receive-side call sites
only have the Data.owner pointer (a ProtocolSession*), and protocol.c is out
of scope for this fix, so the same atomic CAS accounting is reproduced here.
The matching release always goes through data_destroy()'s Data.owner path. */
static bool chunk_session_reserve(ProtocolSession* session, size_t charge) {
unsigned long long allocated = atomic_load(&session->total_allocated_bytes);
while (true) {
if (allocated > MAX_CONNECTION_MEMORY ||
(unsigned long long)charge > MAX_CONNECTION_MEMORY - allocated)
return false;
if (atomic_compare_exchange_weak(&session->total_allocated_bytes, &allocated,
allocated + (unsigned long long)charge))
return true;
}
}
bool data_charge_session(Data* data, ProtocolSession* session, size_t charge) {
if (!data || charge == 0 || session == NULL)
return true;
if (!chunk_session_reserve(session, charge))
return false;
data->owner = session;
data->protocol_charge = charge;
return true;
}
Chunk* chunk_create(File** items, int element_count) { Chunk* chunk_create(File** items, int element_count) {
if (element_count < 0 || (element_count > 0 && items == NULL)) if (element_count < 0 || (element_count > 0 && items == NULL))
return NULL; return NULL;
@@ -370,6 +398,15 @@ Chunk* chunk_deserialize(Data* data, bool use_metadata) {
Data* replacement = data_create(file_data, file_data_size); Data* replacement = data_create(file_data, file_data_size);
if (replacement == NULL) if (replacement == NULL)
goto error; goto error;
/* Charge the retained per-file copy to the connection budget (when the
inbound chunk carries an owning session) so the queued copies are not
held outside MAX_CONNECTION_MEMORY (B6). A NULL owner (e.g. a local
batch apply) leaves the copy uncharged. */
if (!data_charge_session(replacement, data->owner, allocation_size)) {
log_message(LOG_LEVEL_ERROR, "Per-connection memory limit exceeded for chunk file data");
data_destroy(replacement);
goto error;
}
data_destroy(file->data); data_destroy(file->data);
file->data = replacement; file->data = replacement;
data_pointer += file_data_size; data_pointer += file_data_size;
@@ -466,12 +503,21 @@ Chunk* receive_chunk_data(int fd, const Config* config) {
} }
Data* data_to_process = chunk_data; Data* data_to_process = chunk_data;
if (config->use_compression) { if (config->use_compression) {
/* Preserve the inbound session across decompression so the (larger)
decompressed chunk is charged to the same connection budget; the
compressed buffer's own charge is released by data_destroy below. */
ProtocolSession* owner = chunk_data->owner;
data_to_process = data_decompress_limited(chunk_data, MAX_CHUNK_SIZE); data_to_process = data_decompress_limited(chunk_data, MAX_CHUNK_SIZE);
data_destroy(chunk_data); data_destroy(chunk_data);
if (data_to_process == NULL) { if (data_to_process == NULL) {
log_message(LOG_LEVEL_ERROR, "Failed to decompress chunk"); log_message(LOG_LEVEL_ERROR, "Failed to decompress chunk");
return NULL; return NULL;
} }
if (!data_charge_session(data_to_process, owner, data_to_process->size)) {
log_message(LOG_LEVEL_ERROR, "Per-connection memory limit exceeded for decompressed chunk");
data_destroy(data_to_process);
return NULL;
}
} }
// Reject chunks larger than the maximum allowed size to prevent OOM. // Reject chunks larger than the maximum allowed size to prevent OOM.
+11
View File
@@ -23,4 +23,15 @@ Data* chunk_compress_with_threads(Chunk* chunk, int compression_level, bool use_
int compression_threads); int compression_threads);
Chunk* receive_chunk_data(int fd, const Config* config); Chunk* receive_chunk_data(int fd, const Config* config);
/* Charge `charge` retained bytes of `data` against `session`'s per-connection
* budget (MAX_CONNECTION_MEMORY), mirroring the protocol layer's accounting, and
* record them on `data` so data_destroy() returns the charge through the
* Data.owner path. Returns false (leaving `data` uncharged) when the ceiling
* would be exceeded. A NULL/zero-size charge or a NULL session is a no-op
* success. The receive-side decompression and chunk-copy paths know the owning
* session only through the Data.owner of the buffer they are processing, so
* this is the entry point that lets them participate in the connection budget
* without a session handle (B6). */
bool data_charge_session(Data* data, ProtocolSession* session, size_t charge);
#endif #endif
+38 -8
View File
@@ -890,13 +890,35 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
if (inplace) { if (inplace) {
/* --inplace writes directly into the destination; a scratch --temp-dir /* --inplace writes directly into the destination; a scratch --temp-dir
does not apply and must never redirect these writes. */ does not apply and must never redirect these writes. */
fd = openat(dirfd, leaf, O_WRONLY | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0644); /* Type gate BEFORE opening: an existing destination entry that is not a
regular file (FIFO, socket, char/block device, directory) must never be
opened for writing. Opening a FIFO would block the receive thread
forever and writing into a device would bypass the --write-devices /
super-mode gate (a client-controlled device write). fstatat with
AT_SYMLINK_NOFOLLOW does not follow a symlink and does not block. */
struct stat pre_stat;
if (fstatat(dirfd, leaf, &pre_stat, AT_SYMLINK_NOFOLLOW) == 0 && !S_ISREG(pre_stat.st_mode)) {
close(dirfd);
free(leaf);
return false;
}
/* O_NONBLOCK: a no-op for a regular file, but a raced-in FIFO cannot block
the open before the post-open S_ISREG re-check rejects it. */
fd = openat(dirfd, leaf, O_WRONLY | O_CREAT | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK, 0644);
if (fd >= 0) { if (fd >= 0) {
struct stat destination_stat; struct stat destination_stat;
/* Re-check the opened descriptor: a concurrent replacement between the
fstatat probe and the open (or a device/FIFO raced in) must never be
written through. */
if (fstat(fd, &destination_stat) != 0 || !S_ISREG(destination_stat.st_mode)) {
close(fd);
close(dirfd);
free(leaf);
return false;
}
bool newer = false; bool newer = false;
if (update && metadata && fstat(fd, &destination_stat) == 0 && if (update && metadata && stat_is_newer(&destination_stat, metadata)) {
S_ISREG(destination_stat.st_mode)) { newer = true;
newer = stat_is_newer(&destination_stat, metadata);
} }
if (newer) { if (newer) {
ok = true; ok = true;
@@ -1226,11 +1248,19 @@ 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);
} }
+110 -23
View File
@@ -12,6 +12,7 @@
#include "array_list.h" #include "array_list.h"
#include "charset.h" #include "charset.h"
#include "chmod.h" #include "chmod.h"
#include "chunk.h"
#include "compression.h" #include "compression.h"
#include "config.h" #include "config.h"
#include "data.h" #include "data.h"
@@ -27,6 +28,11 @@
#define MAX_SERVER_DELETE_COUNT 100000U #define MAX_SERVER_DELETE_COUNT 100000U
#define MAX_FILE_DATA_SIZE MAX_RECEIVE_WHOLE_FILE_SIZE #define MAX_FILE_DATA_SIZE MAX_RECEIVE_WHOLE_FILE_SIZE
/* Retained cost of one delete-manifest entry beyond its path bytes: the
ArrayList pointer slot plus an approximate malloc header/rounding for the
heap copy. Charged against MAX_MANIFEST_BYTES so a frame full of tiny paths
cannot retain far more than the byte budget (B5). */
#define MANIFEST_ENTRY_OVERHEAD (sizeof(char*) + 16)
bool file_save_to_disk(const char* root_directory, const File* file, const Config* config) { bool file_save_to_disk(const char* root_directory, const File* file, const Config* config) {
return file_save_to_disk_full(root_directory, file, config) != FILE_SAVE_ERROR; return file_save_to_disk_full(root_directory, file, config) != FILE_SAVE_ERROR;
@@ -127,7 +133,10 @@ static bool hardlink_read_source(const char* path, void** out_buf, unsigned long
*source_absent = errno == ENOENT || errno == ENOTDIR; *source_absent = errno == ENOENT || errno == ENOTDIR;
return false; return false;
} }
int fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); /* O_NONBLOCK is a no-op for a regular file but makes openat() fail/succeed
immediately for a client-planted FIFO instead of blocking the receive
thread forever; the post-open S_ISREG gate below is the actual type check. */
int fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
int saved_errno = errno; int saved_errno = errno;
free(leaf); free(leaf);
close(parent_fd); close(parent_fd);
@@ -254,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);
@@ -286,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);
@@ -944,7 +955,7 @@ static bool receive_file_xattrs(File* file, int fd, const Config* config) {
if (!config->use_xattrs) if (!config->use_xattrs)
return true; return true;
int xok = 0; int xok = 0;
FileXattrList* list = xattr_receive(fd, &xok); FileXattrList* list = xattr_receive(fd, &xok, config->preserve_acls);
if (!xok) { if (!xok) {
xattr_list_free(list); xattr_list_free(list);
return false; return false;
@@ -1009,6 +1020,7 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_
!compression_should_skip_with_suffixes( !compression_should_skip_with_suffixes(
check_path, config->skip_compress_suffixes, check_path, config->skip_compress_suffixes,
config->skip_compress_set ? config->skip_compress_count : -1)) { config->skip_compress_set ? config->skip_compress_count : -1)) {
ProtocolSession* owner = delta_data->owner;
raw_delta = data_decompress_limited(delta_data, MAX_RECEIVE_WHOLE_FILE_SIZE); raw_delta = data_decompress_limited(delta_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
data_destroy(delta_data); data_destroy(delta_data);
if (!raw_delta) { if (!raw_delta) {
@@ -1017,6 +1029,15 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_
*failed = true; *failed = true;
return NULL; return NULL;
} }
/* Charge the decompressed delta to the connection budget (the paired
wire buffer's charge was just released). */
if (!data_charge_session(raw_delta, owner, raw_delta->size)) {
data_destroy(raw_delta);
free(old_data);
delta_signature_destroy(sig);
*failed = true;
return NULL;
}
} }
Delta* delta = delta_deserialize(raw_delta); Delta* delta = delta_deserialize(raw_delta);
@@ -1131,12 +1152,20 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_
file->path, config->skip_compress_suffixes, file->path, config->skip_compress_suffixes,
config->skip_compress_set ? config->skip_compress_count : -1)) { config->skip_compress_set ? config->skip_compress_count : -1)) {
Data* uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE); Data* uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
ProtocolSession* owner = file_data->owner;
data_destroy(file_data); data_destroy(file_data);
if (uncompressed == NULL) { if (uncompressed == NULL) {
file_destroy(file); file_destroy(file);
*failed = true; *failed = true;
return NULL; return NULL;
} }
if (!data_charge_session(uncompressed, owner, uncompressed->size)) {
data_destroy(uncompressed);
file_destroy(file);
send_status(fd, STATUS_ERROR);
*failed = true;
return NULL;
}
if (uncompressed->size > MAX_FILE_DATA_SIZE) { if (uncompressed->size > MAX_FILE_DATA_SIZE) {
data_destroy(uncompressed); data_destroy(uncompressed);
file_destroy(file); file_destroy(file);
@@ -1193,7 +1222,9 @@ static bool basis_open_regular(const char* path, unsigned long long expected_siz
int parent_fd = file_open_secure_parent(path, &leaf, false); int parent_fd = file_open_secure_parent(path, &leaf, false);
if (parent_fd < 0) if (parent_fd < 0)
return false; return false;
int fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); /* O_NONBLOCK: a client-planted FIFO must not block the receiver's openat()
forever; the fstat()/S_ISREG gate below rejects it immediately. */
int fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
free(leaf); free(leaf);
close(parent_fd); close(parent_fd);
if (fd < 0) if (fd < 0)
@@ -1247,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);
@@ -1637,11 +1681,17 @@ static File* receive_full_file(int fd, const Config* config, const char* path) {
config->skip_compress_set ? config->skip_compress_count config->skip_compress_set ? config->skip_compress_count
: -1)) { : -1)) {
Data* uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE); Data* uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
ProtocolSession* owner = file_data->owner;
data_destroy(file_data); data_destroy(file_data);
if (uncompressed == NULL) { if (uncompressed == NULL) {
file_destroy(file); file_destroy(file);
return NULL; return NULL;
} }
if (!data_charge_session(uncompressed, owner, uncompressed->size)) {
data_destroy(uncompressed);
file_destroy(file);
return NULL;
}
if (uncompressed->size > MAX_FILE_DATA_SIZE) { if (uncompressed->size > MAX_FILE_DATA_SIZE) {
data_destroy(uncompressed); data_destroy(uncompressed);
file_destroy(file); file_destroy(file);
@@ -1776,7 +1826,9 @@ static IncrementalCheckOutcome incremental_check_open_destination(IncrementalChe
char* leaf = NULL; char* leaf = NULL;
int parent_fd = file_open_secure_parent(full_path, &leaf, false); int parent_fd = file_open_secure_parent(full_path, &leaf, false);
if (parent_fd >= 0) { if (parent_fd >= 0) {
state->old_fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); /* O_NONBLOCK: an existing FIFO at the destination must not block this
openat(); the S_ISREG gate below rejects the non-regular entry. */
state->old_fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
free(leaf); free(leaf);
close(parent_fd); close(parent_fd);
state->has_old_file = state->old_fd >= 0 && fstat(state->old_fd, &state->old_st) == 0 && state->has_old_file = state->old_fd >= 0 && fstat(state->old_fd, &state->old_st) == 0 &&
@@ -1815,7 +1867,15 @@ static IncrementalCheckOutcome incremental_check_quick_skip(IncrementalCheckStat
bool try_delta = config->use_delta && !config->whole_file && has_old_file && bool try_delta = config->use_delta && !config->whole_file && has_old_file &&
delta_should_attempt(old_size, state->check_size, config->delta_max_file_size); delta_should_attempt(old_size, state->check_size, config->delta_max_file_size);
bool checksum_needs_read = size_equal && !config->ignore_times && config->checksum; bool checksum_needs_read = size_equal && !config->ignore_times && config->checksum;
bool need_old_data = checksum_needs_read || try_delta; /* --dry-run must never read the destination file's CONTENTS: a client could
otherwise use `--dry-run --checksum` against a read-only module as a
1-bit content oracle (hash match / mismatch) and force arbitrary reads.
Decide from metadata alone; when metadata is inconclusive (checksum or
delta would have required the body) report would-transfer. The real
(non-dry-run) behavior below is unchanged. */
bool need_old_data = !config->dry_run && (checksum_needs_read || try_delta);
if (config->dry_run)
try_delta = false;
*out_try_delta = try_delta; *out_try_delta = try_delta;
if (need_old_data && has_old_file && old_size > 0 && old_size <= MAX_RECEIVE_WHOLE_FILE_SIZE && if (need_old_data && has_old_file && old_size > 0 && old_size <= MAX_RECEIVE_WHOLE_FILE_SIZE &&
@@ -1836,7 +1896,12 @@ static IncrementalCheckOutcome incremental_check_quick_skip(IncrementalCheckStat
} }
bool match = false; bool match = false;
if (checksum_needs_read) { if (config->dry_run) {
/* Metadata-only decision: a size match plus a matching mtime is treated as
up to date; --checksum/--delta cannot be verified without reading, so an
otherwise inconclusive comparison is a would-transfer. */
match = size_equal && !config->ignore_times && (config->size_only || match_by_metadata);
} else if (checksum_needs_read) {
uint8_t old_digest[CHECKSUM_MAX_DIGEST_LEN]; uint8_t old_digest[CHECKSUM_MAX_DIGEST_LEN];
size_t old_len = 0; size_t old_len = 0;
bool hashed = checksum_digest((ChecksumAlgo)config->checksum_algo, config->checksum_seed, bool hashed = checksum_digest((ChecksumAlgo)config->checksum_algo, config->checksum_seed,
@@ -1861,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) {
@@ -1875,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);
@@ -1905,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);
@@ -2050,7 +2121,7 @@ static IncrementalCheckOutcome incremental_check_try_append_resume(IncrementalCh
} }
if (config->use_xattrs) { if (config->use_xattrs) {
int xok = 0; int xok = 0;
append_xattrs = xattr_receive(fd, &xok); append_xattrs = xattr_receive(fd, &xok, config->preserve_acls);
if (!xok) { if (!xok) {
xattr_list_free(append_xattrs); xattr_list_free(append_xattrs);
return INCREMENTAL_ERROR; return INCREMENTAL_ERROR;
@@ -2066,11 +2137,17 @@ static IncrementalCheckOutcome incremental_check_try_append_resume(IncrementalCh
config->skip_compress_set ? config->skip_compress_count config->skip_compress_set ? config->skip_compress_count
: -1)) { : -1)) {
Data* uncompressed = data_decompress_limited(tail, MAX_RECEIVE_WHOLE_FILE_SIZE); Data* uncompressed = data_decompress_limited(tail, MAX_RECEIVE_WHOLE_FILE_SIZE);
ProtocolSession* owner = tail->owner;
data_destroy(tail); data_destroy(tail);
if (uncompressed == NULL) { if (uncompressed == NULL) {
xattr_list_free(append_xattrs); xattr_list_free(append_xattrs);
return INCREMENTAL_ERROR; return INCREMENTAL_ERROR;
} }
if (!data_charge_session(uncompressed, owner, uncompressed->size)) {
data_destroy(uncompressed);
xattr_list_free(append_xattrs);
return INCREMENTAL_ERROR;
}
if (uncompressed->size > MAX_FILE_DATA_SIZE) { if (uncompressed->size > MAX_FILE_DATA_SIZE) {
data_destroy(uncompressed); data_destroy(uncompressed);
xattr_list_free(append_xattrs); xattr_list_free(append_xattrs);
@@ -2301,11 +2378,17 @@ File* file_receive(const Config* config, int file_descriptor) {
config->skip_compress_set ? config->skip_compress_count config->skip_compress_set ? config->skip_compress_count
: -1)) { : -1)) {
Data* file_data_uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE); Data* file_data_uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
ProtocolSession* owner = file_data->owner;
data_destroy(file_data); data_destroy(file_data);
if (file_data_uncompressed == NULL) { if (file_data_uncompressed == NULL) {
file_destroy(file); file_destroy(file);
return NULL; return NULL;
} }
if (!data_charge_session(file_data_uncompressed, owner, file_data_uncompressed->size)) {
data_destroy(file_data_uncompressed);
file_destroy(file);
return NULL;
}
if (file_data_uncompressed->size > MAX_FILE_DATA_SIZE) { if (file_data_uncompressed->size > MAX_FILE_DATA_SIZE) {
data_destroy(file_data_uncompressed); data_destroy(file_data_uncompressed);
file_destroy(file); file_destroy(file);
@@ -2694,19 +2777,21 @@ File* file_receive_special(int file_descriptor) {
manifest). Returns an owned DeleteManifest, or NULL after sending STATUS_ERROR manifest). Returns an owned DeleteManifest, or NULL after sending STATUS_ERROR
when the frame is malformed (bad count, empty/absolute path, path traversal, when the frame is malformed (bad count, empty/absolute path, path traversal,
or an aggregate size beyond MAX_MANIFEST_BYTES). */ or an aggregate size beyond MAX_MANIFEST_BYTES). */
static bool receive_manifest_section(int fd, ArrayList* list, size_t* manifest_bytes) { static bool receive_manifest_section(int fd, ArrayList* list, size_t* manifest_bytes,
size_t* manifest_entries) {
int count; int count;
if (!receive_int(fd, &count)) { if (!receive_int(fd, &count)) {
send_status(fd, STATUS_ERROR); send_status(fd, STATUS_ERROR);
return false; return false;
} }
if (count < 0 || count > MAX_MANIFEST_ENTRIES) { if (count < 0 || count > MAX_MANIFEST_ENTRIES ||
(size_t)count > MAX_MANIFEST_ENTRIES - *manifest_entries) {
send_status(fd, STATUS_ERROR); send_status(fd, STATUS_ERROR);
return false; return false;
} }
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
char* s = receive_wire_str(fd); char* s = receive_wire_str(fd);
size_t entry_size = s ? strlen(s) : 0; size_t entry_size = s ? strlen(s) + MANIFEST_ENTRY_OVERHEAD : 0;
if (!s || s[0] == '\0' || s[0] == '/' || has_path_traversal(s) || if (!s || s[0] == '\0' || s[0] == '/' || has_path_traversal(s) ||
entry_size > MAX_MANIFEST_BYTES - *manifest_bytes || entry_size > MAX_MANIFEST_BYTES - *manifest_bytes ||
(*manifest_bytes += entry_size) > MAX_MANIFEST_BYTES || !array_list_add(list, s)) { (*manifest_bytes += entry_size) > MAX_MANIFEST_BYTES || !array_list_add(list, s)) {
@@ -2715,6 +2800,7 @@ static bool receive_manifest_section(int fd, ArrayList* list, size_t* manifest_b
return false; return false;
} }
} }
*manifest_entries += (size_t)count;
return true; return true;
} }
@@ -2733,9 +2819,10 @@ DeleteManifest* receive_manifest_entries(int fd) {
return NULL; return NULL;
} }
size_t manifest_bytes = 0; size_t manifest_bytes = 0;
if (!receive_manifest_section(fd, manifest->keeps, &manifest_bytes) || size_t manifest_entries = 0;
!receive_manifest_section(fd, manifest->protected, &manifest_bytes) || if (!receive_manifest_section(fd, manifest->keeps, &manifest_bytes, &manifest_entries) ||
!receive_manifest_section(fd, manifest->missing, &manifest_bytes)) { !receive_manifest_section(fd, manifest->protected, &manifest_bytes, &manifest_entries) ||
!receive_manifest_section(fd, manifest->missing, &manifest_bytes, &manifest_entries)) {
delete_manifest_free(manifest); delete_manifest_free(manifest);
return NULL; return NULL;
} }
+47 -17
View File
@@ -73,13 +73,17 @@ bool xattr_list_append(FileXattrList* list, const char* name, const void* value,
/* A Linux xattr name is "namespace.name" with an optional leading "trusted.", /* A Linux xattr name is "namespace.name" with an optional leading "trusted.",
* "system.", "security.", "user.", or "trusted." prefix. We only ever touch * "system.", "security.", "user.", or "trusted." prefix. We only ever touch
* the unprivileged "user.*" namespace and the two POSIX ACL xattrs carried in * the unprivileged "user.*" namespace and, only when --acls/-A was negotiated,
* the "system." namespace. Everything else -- especially "security.*" (ACLs, * the two POSIX ACL xattrs carried in the "system." namespace. Everything else
* capabilities, SELinux labels) and "trusted.*" -- is refused so a client can * -- especially "security.*" (ACLs, capabilities, SELinux labels) and
* never compel the receiver to apply a privileged attribute it would not * "trusted.*" -- is refused so a client can never compel the receiver to apply a
* otherwise be able to set (and which would be a local privilege escalation if * privileged attribute it would not otherwise be able to set (and which would be
* it could). */ * a local privilege escalation if it could).
bool xattr_name_appliable(const char* name) { *
* The ACL gate is deliberate: --xattrs/-X alone derives use_xattrs but must NOT
* authorize the ACL names, otherwise a -X client could plant an ACL the
* receiver never opted into (B4). */
bool xattr_name_appliable(const char* name, bool preserve_acls) {
if (!name || name[0] == '\0') if (!name || name[0] == '\0')
return false; return false;
size_t len = strlen(name); size_t len = strlen(name);
@@ -95,15 +99,24 @@ bool xattr_name_appliable(const char* name) {
if (strncmp(name, "user.", 5) == 0) if (strncmp(name, "user.", 5) == 0)
return name[5] != '\0'; return name[5] != '\0';
if (strcmp(name, "system.posix_acl_access") == 0) if (strcmp(name, "system.posix_acl_access") == 0)
return true; return preserve_acls;
if (strcmp(name, "system.posix_acl_default") == 0) if (strcmp(name, "system.posix_acl_default") == 0)
return true; return preserve_acls;
return false; return false;
} }
/* The two POSIX ACL xattr names: the only names whose applicablity is
* conditional (they require --acls). Used by the receiver to distinguish "not
* negotiated" (drop the entry, keep user.* working for -X) from a genuinely
* disallowed namespace (hard reject). */
static bool xattr_name_is_posix_acl(const char* name) {
return name != NULL && (strcmp(name, "system.posix_acl_access") == 0 ||
strcmp(name, "system.posix_acl_default") == 0);
}
/* ---- 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);
@@ -130,7 +143,10 @@ FileXattrList* xattr_capture_path(const char* path) {
if (name_len == 0) if (name_len == 0)
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;
if (!xattr_name_appliable(name)) /* Capture is sender-side: the scanner has already gated on -X/-A, so the
per-name whitelist here allows the ACL names only when --acls was
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)
@@ -190,7 +206,7 @@ bool xattr_send(int fd, const FileXattrList* list) {
return true; return true;
} }
FileXattrList* xattr_receive(int fd, int* ok) { FileXattrList* xattr_receive(int fd, int* ok, bool preserve_acls) {
if (ok) if (ok)
*ok = 0; *ok = 0;
int count; int count;
@@ -232,11 +248,19 @@ FileXattrList* xattr_receive(int fd, int* ok) {
xattr_list_free(list); xattr_list_free(list);
return NULL; return NULL;
} }
if (!xattr_name_appliable(name)) { bool skip = false;
log_message(LOG_LEVEL_ERROR, "rejected xattr block: disallowed namespace for '%s'", name); if (!xattr_name_appliable(name, preserve_acls)) {
free(name); if (!preserve_acls && xattr_name_is_posix_acl(name)) {
xattr_list_free(list); /* -X without -A: the sender may still carry ACLs, but the receiver must
return NULL; never apply an ACL it was not asked to preserve. Consume and drop the
entry (keeping -X compatibility) rather than failing the transfer. */
skip = true;
} else {
log_message(LOG_LEVEL_ERROR, "rejected xattr block: disallowed namespace for '%s'", name);
free(name);
xattr_list_free(list);
return NULL;
}
} }
int32_t value_len32; int32_t value_len32;
if (!receive_n_data(fd, &value_len32, sizeof(value_len32))) { if (!receive_n_data(fd, &value_len32, sizeof(value_len32))) {
@@ -273,6 +297,12 @@ FileXattrList* xattr_receive(int fd, int* ok) {
return NULL; return NULL;
} }
} }
if (skip) {
free(value);
free(name);
budget += (size_t)name_len32 + (size_t)value_len32;
continue;
}
if (!xattr_list_append(list, name, value, (size_t)value_len32)) { if (!xattr_list_append(list, name, value, (size_t)value_len32)) {
free(value); free(value);
free(name); free(name);
+17 -9
View File
@@ -59,20 +59,28 @@ void xattr_list_free(FileXattrList* list);
bool xattr_list_append(FileXattrList* list, const char* name, const void* value, size_t value_len); bool xattr_list_append(FileXattrList* list, const char* name, const void* value, size_t value_len);
/* True when `name` is a well-formed xattr name AND belongs to a namespace this /* True when `name` is a well-formed xattr name AND belongs to a namespace this
* build is authorized to apply (user.* or the two POSIX ACL xattrs). Used for * build is authorized to apply. `user.*` is always accepted for -X; the two
* both capture and receiver-side validation. */ * POSIX ACL xattrs are accepted only when `preserve_acls` (--acls/-A) is set, so
bool xattr_name_appliable(const char* name); * a plain -X run can never carry or apply an ACL the receiver did not ask for.
* Used for both capture and receiver-side validation. */
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
* sets *ok = 0 on any malformed / oversized / non-whitelisted entry. */ * sets *ok = 0 on any malformed / oversized / non-whitelisted entry. When
* `preserve_acls` is false, any POSIX ACL entries are consumed and DROPPED (so
* a -X transfer still succeeds and never applies an ACL it did not negotiate);
* a genuinely disallowed namespace is still rejected. */
bool xattr_send(int fd, const FileXattrList* list); bool xattr_send(int fd, const FileXattrList* list);
FileXattrList* xattr_receive(int fd, int* ok); FileXattrList* xattr_receive(int fd, int* ok, bool preserve_acls);
/* Receiver: apply every entry fd-relative (fsetxattr) to the just-written file /* Receiver: apply every entry fd-relative (fsetxattr) to the just-written file
* descriptor. A per-attribute failure (e.g. ACL set refused for non-root on a * descriptor. A per-attribute failure (e.g. ACL set refused for non-root on a
+13 -9
View File
@@ -75,7 +75,7 @@ static void write_best_effort(int fd, const void* data, size_t size) {
} }
static void receive_stream(const unsigned char* prefix, size_t prefix_len, const uint8_t* data, static void receive_stream(const unsigned char* prefix, size_t prefix_len, const uint8_t* data,
size_t size) { size_t size, bool preserve_acls) {
int sv[2]; int sv[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0)
return; return;
@@ -91,7 +91,7 @@ static void receive_stream(const unsigned char* prefix, size_t prefix_len, const
shutdown(sv[0], SHUT_WR); shutdown(sv[0], SHUT_WR);
int ok = 0; int ok = 0;
FileXattrList* list = xattr_receive(sv[1], &ok); FileXattrList* list = xattr_receive(sv[1], &ok, preserve_acls);
xattr_list_free(list); xattr_list_free(list);
close(sv[0]); close(sv[0]);
@@ -102,14 +102,18 @@ int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
if (!g_block_ready) if (!g_block_ready)
build_canonical_block(); build_canonical_block();
/* Raw bytes as the whole block. */ /* Raw bytes as the whole block. Exercise both the -X-only (no ACLs) and the
receive_stream(NULL, 0, data, size); * -A (ACL names accepted) receiver gates. */
for (int acls = 0; acls < 2; acls++) {
bool preserve_acls = acls != 0;
receive_stream(NULL, 0, data, size, preserve_acls);
/* Valid framing so the fuzzer mutates the entry list, the first value and /* Valid framing so the fuzzer mutates the entry list, the first value and
* the second entry respectively instead of stopping at the count. */ * the second entry respectively instead of stopping at the count. */
receive_stream(g_block, g_off_after_entry0, data, size); receive_stream(g_block, g_off_after_entry0, data, size, preserve_acls);
receive_stream(g_block, g_off_value0, data, size); receive_stream(g_block, g_off_value0, data, size, preserve_acls);
receive_stream(g_block, g_off_after_count, data, size); receive_stream(g_block, g_off_after_count, data, size, preserve_acls);
}
return 0; return 0;
} }
+58 -6
View File
@@ -444,9 +444,10 @@ class TestRemoteDryRun:
self._seed(source) self._seed(source)
clean_dir(dest) clean_dir(dest)
# Populate the destination with a real transfer, then make exactly one # Populate the destination with a real transfer that preserves mtimes
# file differ (content+size) and add a brand-new file. # (--preserve), then make exactly one file differ (content+size) and add
result, _ = run_client(source, dest, port=shared_server.port) # a brand-new file.
result, _ = run_client(source, dest, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0, f"seed transfer failed: {result.stderr[:200]}" assert result.returncode == 0, f"seed transfer failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
@@ -456,9 +457,11 @@ class TestRemoteDryRun:
f.write(b"newly added\n") f.write(b"newly added\n")
before = _snapshot_tree(received) before = _snapshot_tree(received)
# --checksum makes the up-to-date decision content-based (the seed # --checksum must NOT read destination contents in a dry-run (B3), so
# transfer did not preserve mtimes), so keep.txt/deep.txt report skip. # the up-to-date decision is metadata-only. The --preserve seed made
result, _ = run_client(source, dest, flags=["--dry-run", "--checksum"], # keep.txt and deep.txt size+mtime-identical; the dry-run must also
# transmit metadata (--preserve) for that metadata to be comparable.
result, _ = run_client(source, dest, flags=["--dry-run", "--checksum", "--preserve"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, f"remote dry-run failed: {result.stderr[:300]}" assert result.returncode == 0, f"remote dry-run failed: {result.stderr[:300]}"
assert "Dry run:" in result.stdout, result.stdout[:200] assert "Dry run:" in result.stdout, result.stdout[:200]
@@ -470,6 +473,34 @@ class TestRemoteDryRun:
assert "deep.txt" not in result.stdout, result.stdout assert "deep.txt" not in result.stdout, result.stdout
assert _snapshot_tree(received) == before, "remote dry-run mutated the destination" assert _snapshot_tree(received) == before, "remote dry-run mutated the destination"
@pytest.mark.ci
def test_remote_dry_run_checksum_does_not_read_destination(self, shared_server):
"""B3: --dry-run --checksum against a read-only module must not read the
destination file's content (a 1-bit hash oracle). A same-size/same-content
file whose mtime differs is therefore reported as would-transfer because
the metadata-only decision is inconclusive, instead of being hashed and
silently skipped."""
source = os.path.join(TEST_DATA_DIR, "remote_dry_oracle_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_oracle_dst")
self._seed(source)
clean_dir(dest)
result, _ = run_client(source, dest, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
received = get_dest_received_dir(dest, source)
target = os.path.join(received, "keep.txt")
# Identical size and content, but a deliberately different mtime.
os.utime(target, (1000000000, 1000000000))
before = _snapshot_tree(received)
result, _ = run_client(source, dest, flags=["--dry-run", "--checksum", "--preserve"],
port=shared_server.port)
assert result.returncode == 0, result.stderr[:300]
assert "keep.txt" in result.stdout, (
f"dry-run --checksum must not read the destination to prove equality: {result.stdout}"
)
assert _snapshot_tree(received) == before, "dry-run mutated the destination"
@pytest.mark.ci @pytest.mark.ci
def test_remote_dry_run_into_empty_dest_creates_nothing(self, shared_server): def test_remote_dry_run_into_empty_dest_creates_nothing(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_empty_src") source = os.path.join(TEST_DATA_DIR, "remote_dry_empty_src")
@@ -3782,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.
+48
View File
@@ -1,8 +1,10 @@
#include "chunk.h" #include "chunk.h"
#include "protocol.h"
#include "test_utils.h" #include "test_utils.h"
#include "utils.h" #include "utils.h"
#include <string.h> #include <string.h>
#include <sys/socket.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.h> #include <unistd.h>
@@ -279,6 +281,51 @@ static void test_chunk_special_rdev_out_of_range_rejected() {
chunk_destroy(chunk); chunk_destroy(chunk);
} }
/* B6: chunk_deserialize() charges each retained per-file copy to the owning
* session's connection budget (MAX_CONNECTION_MEMORY) so queued chunk payloads
* are not held outside the per-connection ceiling; destroying the chunk returns
* the charge through the Data.owner path. */
static void test_chunk_deserialize_charges_session_budget() {
const char* path = "temp_chunk_charge.txt";
const char* content = "charge me to the connection budget";
unlink(path);
file_write_to_disk(path, content, strlen(content), false, false);
struct stat st;
EXPECT_EQ_INT(stat(path, &st), 0);
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
ProtocolSession session;
protocol_session_init(&session, p[0], p[1]);
protocol_session_set_max_alloc(&session, 4ULL * 1024 * 1024);
File* f = file_create(path);
EXPECT_NOT_NULL(f);
f->data->size = (unsigned long long)st.st_size;
EXPECT_TRUE(file_load_data(f));
File* files[1] = {f};
Chunk* chunk = chunk_create(files, 1);
EXPECT_NOT_NULL(chunk);
Data* serialized = chunk_serialize(chunk, false);
EXPECT_NOT_NULL(serialized);
/* Simulate a received buffer carrying its owning session. */
serialized->owner = &session;
Chunk* deserialized = chunk_deserialize(serialized, false);
EXPECT_NOT_NULL(deserialized);
unsigned long long charged = atomic_load(&session.total_allocated_bytes);
EXPECT_EQ_INT((int)charged, (int)strlen(content));
chunk_destroy(deserialized);
/* The copy's charge is released with the File/Data on destroy. */
EXPECT_EQ_INT((int)atomic_load(&session.total_allocated_bytes), 0);
data_destroy(serialized);
chunk_destroy(chunk);
close(p[0]);
close(p[1]);
unlink(path);
}
void test_chunk() { void test_chunk() {
test_file_operations(); test_file_operations();
test_chunk_operations(); test_chunk_operations();
@@ -286,4 +333,5 @@ void test_chunk() {
test_chunk_symlink_roundtrip(); test_chunk_symlink_roundtrip();
test_chunk_special_rdev_roundtrip(); test_chunk_special_rdev_roundtrip();
test_chunk_special_rdev_out_of_range_rejected(); test_chunk_special_rdev_out_of_range_rejected();
test_chunk_deserialize_charges_session_budget();
} }
+91
View File
@@ -14,6 +14,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/sysmacros.h>
#include <sys/wait.h> #include <sys/wait.h>
#include <time.h> #include <time.h>
#include <unistd.h> #include <unistd.h>
@@ -994,6 +995,94 @@ static void test_inplace_overwrite_truncates_shorter_payload() {
rmdir(root); rmdir(root);
} }
/* B2: --inplace must refuse an existing non-regular destination entry. A FIFO
would block open(O_WRONLY) forever and a device node would be written
directly, bypassing the --write-devices/super gate. Forked with an alarm so
a regression is a prompt failure instead of a hung suite. */
static void test_inplace_refuses_fifo_destination() {
const char* root = "test_inplace_fifo_tmp";
const char* path = "test_inplace_fifo_tmp/fifo";
unlink(path);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0700), 0);
EXPECT_EQ_INT(mkfifo(path, 0600), 0);
pid_t pid = fork();
if (pid == 0) {
alarm(10);
File* f = file_create("fifo");
if (!f)
_exit(1);
const char* content = "payload";
f->data->data = malloc(strlen(content));
if (!f->data->data)
_exit(1);
memcpy(f->data->data, content, strlen(content));
f->data->size = strlen(content);
Config* cfg = config_create();
if (!cfg)
_exit(1);
cfg->inplace = true;
bool written = file_save_to_disk(root, f, cfg);
file_destroy(f);
config_delete(cfg);
_exit(written ? 1 : 0); /* must be refused */
}
int status;
waitpid(pid, &status, 0);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
struct stat st;
EXPECT_EQ_INT(lstat(path, &st), 0);
EXPECT_TRUE(S_ISFIFO(st.st_mode)); /* left untouched */
unlink(path);
rmdir(root);
}
/* B2: an existing char device must not be written by --inplace. mknod needs
privilege, so a non-root run skips gracefully. /dev/null's (1:3) rdev makes
the negative case harmless if it ever regresses. */
static void test_inplace_refuses_device_destination() {
const char* root = "test_inplace_dev_tmp";
const char* path = "test_inplace_dev_tmp/dev";
unlink(path);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0700), 0);
if (mknod(path, S_IFCHR | 0600, makedev(1, 3)) != 0) {
rmdir(root);
return; /* no privilege to create a device node: skip */
}
pid_t pid = fork();
if (pid == 0) {
alarm(10);
File* f = file_create("dev");
if (!f)
_exit(1);
const char* content = "payload";
f->data->data = malloc(strlen(content));
if (!f->data->data)
_exit(1);
memcpy(f->data->data, content, strlen(content));
f->data->size = strlen(content);
Config* cfg = config_create();
if (!cfg)
_exit(1);
cfg->inplace = true;
bool written = file_save_to_disk(root, f, cfg);
file_destroy(f);
config_delete(cfg);
_exit(written ? 1 : 0); /* must be refused */
}
int status;
waitpid(pid, &status, 0);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
struct stat st;
EXPECT_EQ_INT(lstat(path, &st), 0);
EXPECT_TRUE(S_ISCHR(st.st_mode)); /* still a device, not replaced */
unlink(path);
rmdir(root);
}
/* Explicit directory entries (--dirs) create the directory under the receive /* Explicit directory entries (--dirs) create the directory under the receive
root through the same save funnel, creating parents as needed, and reject root through the same save funnel, creating parents as needed, and reject
traversal the same way a file path does. */ traversal the same way a file path does. */
@@ -1606,4 +1695,6 @@ void test_file() {
test_inplace_overwrite_clears_special_mode_bits(); test_inplace_overwrite_clears_special_mode_bits();
test_inplace_overwrite_metadata_strips_special_bits(); test_inplace_overwrite_metadata_strips_special_bits();
test_inplace_overwrite_truncates_shorter_payload(); test_inplace_overwrite_truncates_shorter_payload();
test_inplace_refuses_fifo_destination();
test_inplace_refuses_device_destination();
} }
+257
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"
@@ -844,6 +845,258 @@ static void test_special_socket_path_log_escaped() {
EXPECT_NOT_NULL(strstr(output, "socket not recreated: evil\\#012path")); EXPECT_NOT_NULL(strstr(output, "socket not recreated: evil\\#012path"));
} }
/* B1: a client-planted FIFO at the destination must not block the receiver's
* incremental-check open. With the O_NONBLOCK open plus the post-open S_ISREG
* gate the FIFO is simply "no existing regular file", so the receiver proceeds
* to a full transfer; without O_NONBLOCK the child blocks in openat() and the
* alarm(30) kills it. */
static void test_incremental_check_fifo_destination_does_not_hang() {
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
char* root = make_check_root("qffo");
EXPECT_NOT_NULL(root);
cfg->receive_root_directory = str_dup(root);
char path[1024];
snprintf(path, sizeof(path), "%s/file.txt", root);
EXPECT_EQ_INT(mkfifo(path, 0600), 0);
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;
File* file = receive_incremental_check(p[0], cfg, &skipped);
bool ok = file != NULL && !skipped;
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 = 4;
long long mtime = 42;
long long mtime_nsec = 0;
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)));
Status s;
EXPECT_TRUE(receive_status(p[1], &s));
EXPECT_EQ_INT(s, STATUS_NEXT);
Data* body = data_create_reserve(4);
EXPECT_NOT_NULL(body);
body->data = malloc(4);
EXPECT_NOT_NULL(body->data);
memcpy(body->data, "data", 4);
body->size = 4;
EXPECT_TRUE(send_data(p[1], body));
data_destroy(body);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(cfg);
unlink(path);
rmdir(root);
free(root);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
/* 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
* basis_open_regular() either; the basis match is simply declined. */
static void test_incremental_check_basis_fifo_does_not_hang() {
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
char* root = make_check_root("qbfi");
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);
snprintf(basis_path, sizeof(basis_path), "%s/file.txt", basis_dir);
EXPECT_EQ_INT(mkfifo(basis_path, 0600), 0);
EXPECT_EQ_INT(config_basis_append(cfg, BASIS_DEST_LINK, "basis"), 0);
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;
File* file = receive_incremental_check(p[0], cfg, &skipped);
bool ok = file != NULL && !skipped;
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 = 4;
long long mtime = 42;
long long mtime_nsec = 0;
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)));
/* config_has_basis() makes the request carry the source digest. */
uint8_t wire_len = 8;
uint8_t digest[8] = {0};
EXPECT_TRUE(send_n_data(p[1], &wire_len, sizeof(wire_len)));
EXPECT_TRUE(send_n_data(p[1], digest, sizeof(digest)));
Status s;
EXPECT_TRUE(receive_status(p[1], &s));
EXPECT_EQ_INT(s, STATUS_NEXT);
Data* body = data_create_reserve(4);
EXPECT_NOT_NULL(body);
body->data = malloc(4);
EXPECT_NOT_NULL(body->data);
memcpy(body->data, "data", 4);
body->size = 4;
EXPECT_TRUE(send_data(p[1], body));
data_destroy(body);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(cfg);
unlink(basis_path);
rmdir(basis_dir);
rmdir(root);
free(root);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
/* B5: the aggregate entry count across the three manifest sections is capped at
* MAX_MANIFEST_ENTRIES, and a section that would push the total over the cap is
* rejected before its entries are read (so a tiny first section followed by a
* huge claimed second section fails fast). */
static void test_receive_manifest_total_entry_cap() {
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
cfg->receive_root_directory = str_dup("/tmp/dst");
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
io_set_fds(p[0], p[1]);
EXPECT_TRUE(send_int(p[1], 1));
EXPECT_TRUE(send_str(p[1], "keep.txt"));
/* The second section alone is within its per-section cap, but 1 + it exceeds
the cross-section cap; the receiver must reject at the count. */
EXPECT_TRUE(send_int(p[1], MAX_MANIFEST_ENTRIES));
EXPECT_NULL(receive_manifest_entries(p[0]));
Status status;
EXPECT_TRUE(receive_status(p[1], &status));
EXPECT_EQ_INT(status, STATUS_ERROR);
close(p[0]);
close(p[1]);
config_delete(cfg);
}
void test_server() { void test_server() {
test_special_socket_path_log_escaped(); test_special_socket_path_log_escaped();
if (!is_running_under_valgrind()) { if (!is_running_under_valgrind()) {
@@ -856,6 +1109,10 @@ void test_server() {
test_incremental_check_size_mismatch_full_transfer(); test_incremental_check_size_mismatch_full_transfer();
test_incremental_check_dry_run_reports_transfer_without_writing(); test_incremental_check_dry_run_reports_transfer_without_writing();
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_basis_fifo_does_not_hang();
test_incremental_check_dry_run_basis_does_not_read_content();
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();
test_late_second_manifest_frees_both(); test_late_second_manifest_frees_both();
+135 -10
View File
@@ -17,7 +17,7 @@
static void run_recv_helper(int fd) { static void run_recv_helper(int fd) {
int ok = 0; int ok = 0;
FileXattrList* list = xattr_receive(fd, &ok); FileXattrList* list = xattr_receive(fd, &ok, false);
if (!ok) if (!ok)
_exit(1); _exit(1);
if (!list) { if (!list) {
@@ -64,7 +64,7 @@ static void test_xattr_wire_roundtrip() {
static void run_recv_must_fail(int fd) { static void run_recv_must_fail(int fd) {
int ok = 0; int ok = 0;
FileXattrList* list = xattr_receive(fd, &ok); FileXattrList* list = xattr_receive(fd, &ok, false);
/* A NULL list with ok==0 is the expected rejection. */ /* A NULL list with ok==0 is the expected rejection. */
if (ok == 0 && list == NULL) if (ok == 0 && list == NULL)
_exit(0); _exit(0);
@@ -148,15 +148,64 @@ static void test_xattr_count_bound() {
/* The captured list on a plain file reflects only whitelisted namespaces /* The captured list on a plain file reflects only whitelisted namespaces
* (Linux only; skipped when the filesystem has no xattr support). */ * (Linux only; skipped when the filesystem has no xattr support). */
static void test_xattr_capture_and_appliable() { static void test_xattr_capture_and_appliable() {
EXPECT_FALSE(xattr_name_appliable(NULL)); EXPECT_FALSE(xattr_name_appliable(NULL, false));
EXPECT_FALSE(xattr_name_appliable("")); EXPECT_FALSE(xattr_name_appliable("", false));
EXPECT_FALSE(xattr_name_appliable("security.selinux")); EXPECT_FALSE(xattr_name_appliable("security.selinux", false));
EXPECT_FALSE(xattr_name_appliable("trusted.blob")); EXPECT_FALSE(xattr_name_appliable("trusted.blob", false));
EXPECT_TRUE(xattr_name_appliable("user.foo")); EXPECT_TRUE(xattr_name_appliable("user.foo", false));
EXPECT_TRUE(xattr_name_appliable("user.foo", true));
/* The reserved fake-super key is receiver-only and never forwarded/applied. */ /* The reserved fake-super key is receiver-only and never forwarded/applied. */
EXPECT_FALSE(xattr_name_appliable("user.fastsync.stat")); EXPECT_FALSE(xattr_name_appliable("user.fastsync.stat", false));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_access")); EXPECT_FALSE(xattr_name_appliable("user.fastsync.stat", true));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_default")); /* B4: the ACL names require --acls; -X alone must not authorize them. */
EXPECT_FALSE(xattr_name_appliable("system.posix_acl_access", false));
EXPECT_FALSE(xattr_name_appliable("system.posix_acl_default", false));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_access", true));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_default", true));
}
/* B4: a `-X`-only receiver (preserve_acls false) must NOT apply an incoming
* ACL xattr, while a user.* attribute in the same block still survives. The
* ACL entry is dropped, not applied (and the -X transfer is not failed). */
static void run_recv_drops_acl_keeps_user(int fd) {
int ok = 0;
FileXattrList* list = xattr_receive(fd, &ok, false);
if (!ok || list == NULL)
_exit(1);
bool saw_user = false;
for (int i = 0; i < list->count; i++) {
if (strcmp(list->items[i].name, "system.posix_acl_access") == 0)
_exit(1); /* ACL must have been dropped */
if (strcmp(list->items[i].name, "user.keep") == 0)
saw_user = true;
}
xattr_list_free(list);
_exit(saw_user ? 0 : 1);
}
static void test_xattr_receive_drops_acl_without_preserve_acls() {
int p[2];
EXPECT_EQ_INT(pipe(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]);
run_recv_drops_acl_keeps_user(p[0]);
}
close(p[0]);
io_set_fds(p[1], p[1]);
FileXattrList* list = xattr_list_new();
EXPECT_NOT_NULL(list);
EXPECT_TRUE(xattr_list_append(list, "system.posix_acl_access", "\x02\x00\x00\x00", 4));
EXPECT_TRUE(xattr_list_append(list, "user.keep", "yes", 3));
xattr_send(p[1], list);
xattr_list_free(list);
int status;
waitpid(pid, &status, 0);
close(p[1]);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
} }
/* MINOR-2: a --link-dest / -H copy fallback (linkat refused) must still apply /* MINOR-2: a --link-dest / -H copy fallback (linkat refused) must still apply
@@ -224,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
@@ -360,6 +483,8 @@ 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_link_copy_fallback_preserves_xattrs(); test_link_copy_fallback_preserves_xattrs();
test_fake_super_restore(); test_fake_super_restore();
test_fake_super_owner_gate(); test_fake_super_owner_gate();