Merge branch 'fix/sec-receiver' into fix/sec-integration
This commit is contained in:
@@ -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) {
|
||||
if (!scanner || !file || !(scanner->options.preserve_xattrs || scanner->options.preserve_acls))
|
||||
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
|
||||
@@ -1445,7 +1445,7 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
|
||||
}
|
||||
if ((options->preserve_xattrs || options->preserve_acls) &&
|
||||
!(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)) {
|
||||
free(rel);
|
||||
file_destroy(file);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <limits.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -20,6 +21,33 @@
|
||||
#define MAX_FILE_DATA_SIZE (64ULL * 1024 * 1024)
|
||||
#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) {
|
||||
if (element_count < 0 || (element_count > 0 && items == NULL))
|
||||
return NULL;
|
||||
@@ -370,6 +398,15 @@ Chunk* chunk_deserialize(Data* data, bool use_metadata) {
|
||||
Data* replacement = data_create(file_data, file_data_size);
|
||||
if (replacement == NULL)
|
||||
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);
|
||||
file->data = replacement;
|
||||
data_pointer += file_data_size;
|
||||
@@ -466,12 +503,21 @@ Chunk* receive_chunk_data(int fd, const Config* config) {
|
||||
}
|
||||
Data* data_to_process = chunk_data;
|
||||
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_destroy(chunk_data);
|
||||
if (data_to_process == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to decompress chunk");
|
||||
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.
|
||||
|
||||
@@ -23,4 +23,15 @@ Data* chunk_compress_with_threads(Chunk* chunk, int compression_level, bool use_
|
||||
int compression_threads);
|
||||
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
|
||||
|
||||
+38
-8
@@ -890,13 +890,35 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
|
||||
if (inplace) {
|
||||
/* --inplace writes directly into the destination; a scratch --temp-dir
|
||||
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) {
|
||||
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;
|
||||
if (update && metadata && fstat(fd, &destination_stat) == 0 &&
|
||||
S_ISREG(destination_stat.st_mode)) {
|
||||
newer = stat_is_newer(&destination_stat, metadata);
|
||||
if (update && metadata && stat_is_newer(&destination_stat, metadata)) {
|
||||
newer = true;
|
||||
}
|
||||
if (newer) {
|
||||
ok = true;
|
||||
@@ -1226,11 +1248,19 @@ static bool file_to_disk_secure_link_impl(const char* path, const char* basis_pa
|
||||
if (linked) {
|
||||
int target_dirfd = scratch_dirfd >= 0 ? scratch_dirfd : dirfd;
|
||||
if (use_fsync) {
|
||||
int tfd = openat(target_dirfd, tmp, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (tfd < 0 || fsync(tfd) != 0) {
|
||||
/* O_NONBLOCK: the freshly linked temp is normally the basis's regular
|
||||
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;
|
||||
if (tfd >= 0)
|
||||
close(tfd);
|
||||
close(tfd);
|
||||
} else {
|
||||
close(tfd);
|
||||
}
|
||||
|
||||
+110
-23
@@ -12,6 +12,7 @@
|
||||
#include "array_list.h"
|
||||
#include "charset.h"
|
||||
#include "chmod.h"
|
||||
#include "chunk.h"
|
||||
#include "compression.h"
|
||||
#include "config.h"
|
||||
#include "data.h"
|
||||
@@ -27,6 +28,11 @@
|
||||
|
||||
#define MAX_SERVER_DELETE_COUNT 100000U
|
||||
#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) {
|
||||
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;
|
||||
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;
|
||||
free(leaf);
|
||||
close(parent_fd);
|
||||
@@ -254,7 +263,8 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
|
||||
free(destination_path);
|
||||
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(
|
||||
staged_sibling, staged_first, content, content_size, preallocate, file->metadata,
|
||||
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;
|
||||
}
|
||||
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(
|
||||
destination_path, first_disk, content, content_size, preallocate, file->metadata,
|
||||
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)
|
||||
return true;
|
||||
int xok = 0;
|
||||
FileXattrList* list = xattr_receive(fd, &xok);
|
||||
FileXattrList* list = xattr_receive(fd, &xok, config->preserve_acls);
|
||||
if (!xok) {
|
||||
xattr_list_free(list);
|
||||
return false;
|
||||
@@ -1009,6 +1020,7 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_
|
||||
!compression_should_skip_with_suffixes(
|
||||
check_path, config->skip_compress_suffixes,
|
||||
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);
|
||||
data_destroy(delta_data);
|
||||
if (!raw_delta) {
|
||||
@@ -1017,6 +1029,15 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_
|
||||
*failed = true;
|
||||
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);
|
||||
@@ -1131,12 +1152,20 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_
|
||||
file->path, config->skip_compress_suffixes,
|
||||
config->skip_compress_set ? config->skip_compress_count : -1)) {
|
||||
Data* uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
|
||||
ProtocolSession* owner = file_data->owner;
|
||||
data_destroy(file_data);
|
||||
if (uncompressed == NULL) {
|
||||
file_destroy(file);
|
||||
*failed = true;
|
||||
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) {
|
||||
data_destroy(uncompressed);
|
||||
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);
|
||||
if (parent_fd < 0)
|
||||
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);
|
||||
close(parent_fd);
|
||||
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
|
||||
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,
|
||||
unsigned long long check_size, time_t check_mtime,
|
||||
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));
|
||||
if (!config || !config_has_basis(config) || config->ignore_times)
|
||||
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++) {
|
||||
const BasisDest* entry = &config->basis_dirs[i];
|
||||
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
|
||||
: -1)) {
|
||||
Data* uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
|
||||
ProtocolSession* owner = file_data->owner;
|
||||
data_destroy(file_data);
|
||||
if (uncompressed == NULL) {
|
||||
file_destroy(file);
|
||||
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) {
|
||||
data_destroy(uncompressed);
|
||||
file_destroy(file);
|
||||
@@ -1776,7 +1826,9 @@ static IncrementalCheckOutcome incremental_check_open_destination(IncrementalChe
|
||||
char* leaf = NULL;
|
||||
int parent_fd = file_open_secure_parent(full_path, &leaf, false);
|
||||
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);
|
||||
close(parent_fd);
|
||||
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 &&
|
||||
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 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;
|
||||
|
||||
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;
|
||||
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];
|
||||
size_t old_len = 0;
|
||||
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
|
||||
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 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
|
||||
reports as a skip (STATUS_OK) exactly as the full basis path below would.
|
||||
Everything read here (destination file, basis candidates) is read-only. */
|
||||
|
||||
The basis lookup is deliberately content-blind: a real run would only accept
|
||||
a --compare-dest exact hit after hashing the basis file and comparing it with
|
||||
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,
|
||||
bool* skipped,
|
||||
bool* would_transfer) {
|
||||
@@ -1875,9 +1943,12 @@ static IncrementalCheckOutcome incremental_check_dry_run_shortcut(IncrementalChe
|
||||
bool skip_via_compare = false;
|
||||
if (config_has_basis(config) && !config->ignore_times) {
|
||||
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,
|
||||
(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)
|
||||
skip_via_compare = true;
|
||||
basis_match_free(&basis);
|
||||
@@ -1905,7 +1976,7 @@ static IncrementalCheckOutcome incremental_check_try_basis(IncrementalCheckState
|
||||
BasisMatch basis;
|
||||
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,
|
||||
true, &basis);
|
||||
true, true, &basis);
|
||||
if (basis.hit) {
|
||||
if (basis.type == BASIS_DEST_COMPARE) {
|
||||
basis_match_free(&basis);
|
||||
@@ -2050,7 +2121,7 @@ static IncrementalCheckOutcome incremental_check_try_append_resume(IncrementalCh
|
||||
}
|
||||
if (config->use_xattrs) {
|
||||
int xok = 0;
|
||||
append_xattrs = xattr_receive(fd, &xok);
|
||||
append_xattrs = xattr_receive(fd, &xok, config->preserve_acls);
|
||||
if (!xok) {
|
||||
xattr_list_free(append_xattrs);
|
||||
return INCREMENTAL_ERROR;
|
||||
@@ -2066,11 +2137,17 @@ static IncrementalCheckOutcome incremental_check_try_append_resume(IncrementalCh
|
||||
config->skip_compress_set ? config->skip_compress_count
|
||||
: -1)) {
|
||||
Data* uncompressed = data_decompress_limited(tail, MAX_RECEIVE_WHOLE_FILE_SIZE);
|
||||
ProtocolSession* owner = tail->owner;
|
||||
data_destroy(tail);
|
||||
if (uncompressed == NULL) {
|
||||
xattr_list_free(append_xattrs);
|
||||
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) {
|
||||
data_destroy(uncompressed);
|
||||
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
|
||||
: -1)) {
|
||||
Data* file_data_uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
|
||||
ProtocolSession* owner = file_data->owner;
|
||||
data_destroy(file_data);
|
||||
if (file_data_uncompressed == NULL) {
|
||||
file_destroy(file);
|
||||
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) {
|
||||
data_destroy(file_data_uncompressed);
|
||||
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
|
||||
when the frame is malformed (bad count, empty/absolute path, path traversal,
|
||||
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;
|
||||
if (!receive_int(fd, &count)) {
|
||||
send_status(fd, STATUS_ERROR);
|
||||
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);
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < count; i++) {
|
||||
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) ||
|
||||
entry_size > MAX_MANIFEST_BYTES - *manifest_bytes ||
|
||||
(*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;
|
||||
}
|
||||
}
|
||||
*manifest_entries += (size_t)count;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2733,9 +2819,10 @@ DeleteManifest* receive_manifest_entries(int fd) {
|
||||
return NULL;
|
||||
}
|
||||
size_t manifest_bytes = 0;
|
||||
if (!receive_manifest_section(fd, manifest->keeps, &manifest_bytes) ||
|
||||
!receive_manifest_section(fd, manifest->protected, &manifest_bytes) ||
|
||||
!receive_manifest_section(fd, manifest->missing, &manifest_bytes)) {
|
||||
size_t manifest_entries = 0;
|
||||
if (!receive_manifest_section(fd, manifest->keeps, &manifest_bytes, &manifest_entries) ||
|
||||
!receive_manifest_section(fd, manifest->protected, &manifest_bytes, &manifest_entries) ||
|
||||
!receive_manifest_section(fd, manifest->missing, &manifest_bytes, &manifest_entries)) {
|
||||
delete_manifest_free(manifest);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
+47
-17
@@ -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.",
|
||||
* "system.", "security.", "user.", or "trusted." prefix. We only ever touch
|
||||
* the unprivileged "user.*" namespace and the two POSIX ACL xattrs carried in
|
||||
* the "system." namespace. Everything else -- especially "security.*" (ACLs,
|
||||
* capabilities, SELinux labels) and "trusted.*" -- is refused so a client can
|
||||
* never compel the receiver to apply a privileged attribute it would not
|
||||
* otherwise be able to set (and which would be a local privilege escalation if
|
||||
* it could). */
|
||||
bool xattr_name_appliable(const char* name) {
|
||||
* the unprivileged "user.*" namespace and, only when --acls/-A was negotiated,
|
||||
* the two POSIX ACL xattrs carried in the "system." namespace. Everything else
|
||||
* -- especially "security.*" (ACLs, capabilities, SELinux labels) and
|
||||
* "trusted.*" -- is refused so a client can never compel the receiver to apply a
|
||||
* privileged attribute it would not otherwise be able to set (and which would be
|
||||
* a local privilege escalation if it could).
|
||||
*
|
||||
* 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')
|
||||
return false;
|
||||
size_t len = strlen(name);
|
||||
@@ -95,15 +99,24 @@ bool xattr_name_appliable(const char* name) {
|
||||
if (strncmp(name, "user.", 5) == 0)
|
||||
return name[5] != '\0';
|
||||
if (strcmp(name, "system.posix_acl_access") == 0)
|
||||
return true;
|
||||
return preserve_acls;
|
||||
if (strcmp(name, "system.posix_acl_default") == 0)
|
||||
return true;
|
||||
return preserve_acls;
|
||||
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 ---- */
|
||||
|
||||
FileXattrList* xattr_capture_path(const char* path) {
|
||||
FileXattrList* xattr_capture_path(const char* path, bool preserve_acls) {
|
||||
if (!path)
|
||||
return NULL;
|
||||
ssize_t list_size = listxattr(path, NULL, 0);
|
||||
@@ -130,7 +143,10 @@ FileXattrList* xattr_capture_path(const char* path) {
|
||||
if (name_len == 0)
|
||||
break; /* trailing double NUL not expected; stop */
|
||||
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;
|
||||
ssize_t value_size = getxattr(path, name, NULL, 0);
|
||||
if (value_size < 0)
|
||||
@@ -190,7 +206,7 @@ bool xattr_send(int fd, const FileXattrList* list) {
|
||||
return true;
|
||||
}
|
||||
|
||||
FileXattrList* xattr_receive(int fd, int* ok) {
|
||||
FileXattrList* xattr_receive(int fd, int* ok, bool preserve_acls) {
|
||||
if (ok)
|
||||
*ok = 0;
|
||||
int count;
|
||||
@@ -232,11 +248,19 @@ FileXattrList* xattr_receive(int fd, int* ok) {
|
||||
xattr_list_free(list);
|
||||
return NULL;
|
||||
}
|
||||
if (!xattr_name_appliable(name)) {
|
||||
log_message(LOG_LEVEL_ERROR, "rejected xattr block: disallowed namespace for '%s'", name);
|
||||
free(name);
|
||||
xattr_list_free(list);
|
||||
return NULL;
|
||||
bool skip = false;
|
||||
if (!xattr_name_appliable(name, preserve_acls)) {
|
||||
if (!preserve_acls && xattr_name_is_posix_acl(name)) {
|
||||
/* -X without -A: the sender may still carry ACLs, but the receiver must
|
||||
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;
|
||||
if (!receive_n_data(fd, &value_len32, sizeof(value_len32))) {
|
||||
@@ -273,6 +297,12 @@ FileXattrList* xattr_receive(int fd, int* ok) {
|
||||
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)) {
|
||||
free(value);
|
||||
free(name);
|
||||
|
||||
+17
-9
@@ -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);
|
||||
|
||||
/* 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
|
||||
* both capture and receiver-side validation. */
|
||||
bool xattr_name_appliable(const char* name);
|
||||
* build is authorized to apply. `user.*` is always accepted for -X; the two
|
||||
* POSIX ACL xattrs are accepted only when `preserve_acls` (--acls/-A) is set, so
|
||||
* 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
|
||||
* 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);
|
||||
/* Sender: read the whitelisted xattrs of `path` into a new list. The POSIX ACL
|
||||
* names are captured only when `preserve_acls` (--acls/-A) is set, so a plain
|
||||
* -X run never carries an ACL it was not asked to preserve; `user.*` is
|
||||
* 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
|
||||
* 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);
|
||||
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
|
||||
* descriptor. A per-attribute failure (e.g. ACL set refused for non-root on a
|
||||
|
||||
Reference in New Issue
Block a user