Merge feat/p4-devices

# Conflicts:
#	src/client/client_send.c
#	src/server/receiver.c
#	src/shared/chunk.c
#	src/shared/file.c
#	src/shared/file_receive.c
#	src/shared/file_receive.h
#	src/shared/file_types.h
#	src/shared/protocol.h
#	tests/test_chunk.c
This commit is contained in:
2026-09-08 22:33:52 +02:00
23 changed files with 910 additions and 29 deletions
+55 -5
View File
@@ -247,11 +247,11 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
| `-A`, `--acls` | Preserve ACLs | ❌ Not Implemented | Removed because it had no effect |
| `-X`, `--xattrs` | Preserve extended attributes | ❌ Not Implemented | Removed because it had no effect |
| `-H`, `--hard-links` | Preserve hard links | ✅ Implemented | Files on the source that share an inode (`st_dev`+`st_ino`, e.g. a `cp -al` tree) are re-created as hard links to one another on the destination, so duplicate links stay deduplicated and only the first member's data is sent (later members are transmitted as payload-less `STATUS_HARDLINK` frames). The receiver links each sibling to the first member's installed file with an atomic link + rename; on `link()` failure it falls back to a byte-identical local copy of the first member, never a partial/corrupt file. Requires the sequential scan for ordering (the first member is always emitted and installed before any sibling is linked). Works single-threaded and under `-m`, `--inplace`, `--delay-updates` (links staged and published by rename) and `--partial`. Crosses the wire (`preserve_hard_links` bool; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0**, peers must match). Incompatible with `-s` (chunk serialization) and `--append`/`--append-verify`, rejected up front with a distinct error. See the Phase-4 hard-links notes below |
| `-D` | Same as --devices --specials | ❌ Not Implemented | Removed because device-file handling is not implemented |
| `--devices` | Preserve device files | ❌ Not Implemented | Removed because it had no effect |
| `--specials` | Preserve special files | ❌ Not Implemented | |
| `--copy-devices` | Copy device contents as file | ❌ Not Implemented | |
| `--write-devices` | Write to devices as files | ❌ Not Implemented | |
| `-D` | Same as --devices --specials | ✅ Implemented | Implies `--devices --specials`. `-D` was unassigned in FastSync (verified: no collision), so it is free to imply both device-node and special-file preservation. See the `--devices`/`--specials` rows and the Phase-4 devices notes below |
| `--devices` | Preserve device files | ⚠️ Partial | Recreates char/block device nodes on the destination via `mknod` instead of transferring content. Type + rdev are validated strictly (S_IFMT from the transmitted mode; major/minor range-checked, non-negative), and creation is **privilege-gated**: `mknod` needs `CAP_MKNOD`, so a non-root receiver (CI runs via setpriv as non-root) logs a warning and **skips the device entry safely** — the whole transfer never aborts just because the node could not be made. The node is created fd-relative below the receive root (`mknodat` on the confined secure parent), so it can never be placed outside the authorized root, never follows a symlink, and never replaces an existing directory. Only a char/block mode is honored. Crosses the wire (a new `STATUS_SPECIAL` frame carries the path + metadata mode + rdev; `PROTOCOL_VERSION` bumped **2.12.0 → 2.13.0**). Divergence: per-entry skip (not a hard error) when the receiver lacks `CAP_MKNOD`, documented in the Phase-4 devices notes |
| `--specials` | Preserve special files | ⚠️ Partial | Recreates **FIFOs** on the destination via `mkfifo` (unprivileged, so this is a real, assertable behavior under CI). Sockets cannot be recreated by any standard filesystem call and are skipped with an explicit note (best-effort / unsupported, matching the plan). FIFO creation is privileged-gated only in the sense of graceful skip on any permission failure. Node creation is confined below the receive root (`mkfifoat` on the secure fd-relative parent; no `..`, no symlink follow). Crosses the wire like `--devices` (the `STATUS_SPECIAL` frame; `PROTOCOL_VERSION` bumped **2.12.0 → 2.13.0**). See the Phase-4 devices notes |
| `--copy-devices` | Copy device contents as file | ⚠️ Partial | Copy a device's CONTENT into an ordinary regular file on the destination instead of recreating the node — non-privileged and safe. FastSync scans a device/FIFO as a regular file: its reported size (`st_size`, typically 0 for char devices and FIFOs) is copied, so a FIFO or a non-readable device becomes an empty (or size-bounded) regular file without ever blocking or reading unbounded pseudo-device streams. The run always succeeds and never crashes on such input. **Deliberate, safe divergence from rsync's dd-like unbounded device read.** See the Phase-4 devices notes |
| `--write-devices` | Write to devices as files | ⚠️ Partial | Write the received data directly into an **existing** device node on the destination instead of creating a regular file. Restricted and best-effort: the destination must already exist and be a char/block device (opened only under the confined receive root, with `O_NOFOLLOW` + `O_NONBLOCK`); a missing, symlinked, FIFO-with-no-reader (`ENXIO`), non-device destination, or any write failure is **skipped with a warning** rather than allowed, so a run can never clobber the system, never blocks on a special-file target, and never aborts on an unusable target. See the Phase-4 devices notes |
| `-U`, `--atimes` | Preserve access times | ✅ Implemented | Captures the source access time (from the scanner's pre-read stat, so it is not clobbered by reading the file for transfer) and transmits it over the wire; the receiver restores it together with the mtime via `futimens`/`utimensat`. Implies metadata transmission (the times travel inside the `-M` metadata payload), but does not enable ownership application (that stays opt-in via the identity flags). Wire: new `atime` fields on the metadata frame + a `preserve_atimes` config boolean; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0** |
| `-N`, `--crtimes` | Preserve create times | ⚠️ Partial | Captures the source birth time via `statx(STATX_BTIME)` on Linux and transmits it (recorded as a wire field), but there is **no portable way to set a birth time** (`utimensat` can only set atime/mtime), so the receiver explicitly does NOT apply it: it logs a debug note and continues — never failing the transfer and never pretending it worked. On platforms without `statx` it parses as a documented no-op (flag accepted; nothing is captured). Implies metadata transmission. Wire: new `crtime` fields + a `preserve_crtimes` config boolean; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0** (see the Phase-4 metadata-time notes) |
| `-O`, `--omit-dir-times` | Omit dirs from --times | 🔄 Compatibility No-op | Accepted and parsed for CLI compatibility, and the config boolean crosses the wire, but it has **no effect**: FastSync never preserves directory mtimes in the first place (directories are created via `mkdir` with no metadata, a documented divergence under `-d`/recursive), so there is nothing for an "omit" to suppress. It never breaks a normal run |
@@ -392,6 +392,56 @@ front with a distinct error on the client, and re-checked on receive): `-H` with
`-s` chunk serialization (the chunk wire has no per-file hard-link info) and `-H`
with `--append`/`--append-verify` (a payload-less sibling cannot be tail-resumed).
**Phase-4 devices notes:** `--devices`, `--specials`, `-D`, `--copy-devices`,
and `--write-devices` are new. They change the wire: the config frame grows three
booleans — `preserve_specials`, `copy_devices`, `write_devices` — that CROSS the
wire (`preserve_devices` already existed), and a new `STATUS_SPECIAL` frame (used
by `--devices`/`--specials`/`-D`) carries a special/device entry: the destination
path, the metadata frame (whose mode's S_IFMT bits carry the node kind, requiring
the flags to imply metadata transmission), and two int32 `rdev` major/minor
fields. The chunk-serialized wire (`-s`) grows a matching per-file special
marker + rdev so `--devices/--specials` also work under `-s`. `PROTOCOL_VERSION`
was bumped **2.12.0 → 2.13.0** (peers must match, exactly as prior phases did).
**Privilege gating (the crux):** making a device node requires `CAP_MKNOD` (root).
CI runs the integration suite as a NON-ROOT user (via setpriv), so `mknod` fails
with `EPERM`. The receiver treats this as a graceful, logged *skip of the entry*
returned as a success/skip outcome — the whole transfer NEVER aborts just because
the environment cannot create the node. `mkfifo` (FIFOs) is unprivileged, so
`--specials` FIFO creation is a real, assertable behavior under CI; sockets cannot
be recreated by any standard filesystem call and are skipped with an explicit
note. The "device actually created" integration assertions are guarded to run
only as root. User-facing expectation: point `--devices` at devices and a
non-root receiver will faithfully skip them while transferring everything else.
**Confinement & validation:** a special/device node is created with
`mknodat`/`mkfifoat` on the parent directory opened fd-relative below the receive
root (`file_open_secure_parent`: `O_NOFOLLOW`, no `..` components, root-checked),
so a node can never be created outside the authorized destination root and never
through a symlinked parent. The transmitted type is derived ONLY from the
validated S_IFMT bits of the metadata mode (char/block/FIFO honored, socket
skipped, regular/dir rejected as an invalid special), and the transmitted rdev is
validated both on the wire (`file_receive_special`, `chunk_deserialize`) and at
the creation site (`file_special_rdev_valid`): a negative, oversize, or
non-device-carrying rdev is rejected outright (receiver aborts the frame), and a
node is never replaced over an existing directory or unrelated entry (a matching
existing node is left in place). `--write-devices` is the deliberately restricted
danger path: it only ever opens an existing char/block node under the confined
root, and every failure mode (missing, non-device, write error, EPERM) is a
warning + skip, never a system-clobbering write or an abort.
**Documented divergences (honest subset):**
- A device entry the receiver cannot create (missing `CAP_MKNOD`) is *skipped*,
not a transfer failure — rsync under the same conditions would error.
- `--copy-devices` copies the device's *reported size* (typically 0 for char
devices/FIFOs) into a regular file and never reads an unbounded pseudo-device;
this is the safe, non-hanging alternative to rsync's dd-like read.
- `--write-devices` requires the device to already exist at the destination and
never creates it; unsupported/inaccessible targets are skipped, not written.
- Ownership is not applied to recreated nodes (identity `fchown` needs an fd and
would require opening the node); permissions and mtime are applied at
creation / via `utimensat`.
## 9. Symlink Handling
| Flag | Rsync Description | FastSync Status | Notes |
+20
View File
@@ -540,6 +540,11 @@ static const OptionEntry OPTION_TABLE[] = {
{"--numeric-ids", NULL, OPT_FLAG, offsetof(Config, numeric_ids)},
{"--atimes", "-U", OPT_FLAG, offsetof(Config, preserve_atimes)},
{"--crtimes", "-N", OPT_FLAG, offsetof(Config, preserve_crtimes)},
/* -D is handled separately (it implies both --devices and --specials). */
{"--devices", NULL, OPT_FLAG, offsetof(Config, preserve_devices)},
{"--specials", NULL, OPT_FLAG, offsetof(Config, preserve_specials)},
{"--copy-devices", NULL, OPT_FLAG, offsetof(Config, copy_devices)},
{"--write-devices", NULL, OPT_FLAG, offsetof(Config, write_devices)},
{"--omit-dir-times", "-O", OPT_FLAG, offsetof(Config, omit_dir_times)},
{"--omit-link-times", "-J", OPT_FLAG, offsetof(Config, omit_link_times)},
{"--open-noatime", NULL, OPT_FLAG, offsetof(Config, open_noatime)},
@@ -834,6 +839,12 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
} else if (opt_is(argv[i], "-V", "--version")) {
printf("fastsync version %s\n", PROTOCOL_VERSION);
return 1;
} else if (opt_is(argv[i], "-D", NULL)) {
/* rsync -D == --devices --specials. -D is otherwise unassigned in
FastSync (verified: no collision), so it is free to imply both. */
config->preserve_devices = true;
config->preserve_specials = true;
log_info_message(LOG_INFO_MISC, "Enabled preservation of device and special files (-D)");
} else if (opt_is(argv[i], "-a", "--archive")) {
config->use_compression =
!config->compress_choice || strcmp(config->compress_choice, "zstd") == 0;
@@ -1203,6 +1214,15 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
config->files_from_set = set;
}
/* Device/special preservation recreates a node from its metadata mode (whose
S_IFMT bits carry the node kind), so --devices/--specials/-D imply metadata
transmission. --copy-devices/--write-devices treat the entry as data but a
mtime/mode-preserving transfer still benefits from metadata, so all four
imply it (FastSync's broad -M bundle; ownership stays opt-in). */
if (config->preserve_devices || config->preserve_specials || config->copy_devices ||
config->write_devices)
config->use_metadata = true;
/* The "unchanged" decision for --compare-dest/--copy-dest/--link-dest must
* be made on the receiver against the basis directories, which requires the
* per-file STATUS_CHECK handshake: basis-dir options therefore imply
+12 -1
View File
@@ -106,6 +106,9 @@ static bool prepare_scanner(const Config* config, int num_threads, PreparedScann
options->munge_links = config->munge_links;
options->checksum = config->checksum;
options->one_file_system = config->one_file_system;
options->preserve_devices = config->preserve_devices;
options->preserve_specials = config->preserve_specials;
options->copy_devices = config->copy_devices;
options->file_list = (const FileListSet*)config->files_from_set;
options->base_filters = out->base_filters;
options->per_dir_filters = config->per_dir_filter;
@@ -1264,13 +1267,21 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config,
change_emit_file_sent(config, f);
continue;
}
/* Symlink entry (-l / -k keep-as-symlink): only the target rides the wire. */
/* Symlink entry (-l / -k keep-as-symlink): only the target rides the wire. */
if (f->is_symlink) {
if (!send_symlink_entry(client, f, config))
return -1;
change_emit_file_sent(config, f);
continue;
}
/* --devices/--specials: a device/special node is recreated on the receiver,
not transferred as content. Send the dedicated STATUS_SPECIAL frame. */
if (f->is_special) {
if (!file_send_special(f, client->file_descriptor, config->use_metadata))
return -1;
change_emit_file_sent(config, f);
continue;
}
bool stream = f->data->data == NULL && f->data->size > 0;
bool use_sendfile =
(config->use_sendfile && !config->use_compression) || (stream && !config->use_compression);
+36
View File
@@ -10,6 +10,7 @@
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/sysmacros.h>
#include <threads.h>
#include <unistd.h>
#include <limits.h>
@@ -199,6 +200,34 @@ static void scanner_assign_hardlink(DirectoryScanner* scanner, HardLinkTable* ta
}
}
/* Phase 4 special/devices: detect a device (char/block), FIFO or socket entry
and, when the matching --devices/--specials flag asks it be preserved,
convert the File into a node to recreate (is_special, empty payload) with its
device rdev captured from the source stat. When the entry is not preserved
(or --copy-devices instead copies its content as an ordinary regular file)
the File is left as a normal data file. Returns true when converted. */
static bool scanner_prepare_special(bool preserve_devices, bool preserve_specials, File* file,
const struct stat* stats) {
if (!file || !stats)
return false;
bool is_device = S_ISCHR(stats->st_mode) || S_ISBLK(stats->st_mode);
bool is_fifo = S_ISFIFO(stats->st_mode);
bool is_socket = S_ISSOCK(stats->st_mode);
if (!is_device && !is_fifo && !is_socket)
return false;
bool preserve = is_device ? preserve_devices : preserve_specials;
if (!preserve)
return false;
file->is_special = true;
file->data->size = 0;
file->data->data = NULL;
if (is_device) {
file->rdev_major = (int32_t)major(stats->st_rdev);
file->rdev_minor = (int32_t)minor(stats->st_rdev);
}
return true;
}
/* Append `rel` to the caller's exclusion sink, taking `mtx` when shared across
parallel worker threads. Returns false on allocation failure (list left
unchanged). */
@@ -420,6 +449,9 @@ DirectoryScanner* directory_scanner_create_with_options(const char* root_directo
scanner->munge_links = options->munge_links;
scanner->checksum = options->checksum;
scanner->one_file_system = options->one_file_system;
scanner->preserve_devices = options->preserve_devices;
scanner->preserve_specials = options->preserve_specials;
scanner->copy_devices = options->copy_devices;
scanner->failed = false;
scanner->root_path = str_dup(root_directory);
if (!scanner->root_path) {
@@ -990,6 +1022,9 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
file->send_path = rel_copy;
rel_copy = NULL;
}
/* --devices/--specials: a device/FIFO/socket entry marked for preservation
becomes a node to recreate (is_special, no data, rdev captured). */
scanner_prepare_special(scanner->preserve_devices, scanner->preserve_specials, file, &stats);
if (scanner->hardlinks && S_ISREG(stats.st_mode))
scanner_assign_hardlink(scanner, scanner->hardlinks, file, &stats);
if (scanner->use_metadata)
@@ -1316,6 +1351,7 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
file->send_path = rel;
rel = NULL;
}
scanner_prepare_special(options->preserve_devices, options->preserve_specials, file, &st);
if (options->hardlinks && S_ISREG(st.st_mode)) {
int gid;
bool is_first;
+10
View File
@@ -42,6 +42,12 @@ typedef struct {
bool munge_links;
bool checksum;
bool one_file_system;
/* Phase 4 special/devices: whether device nodes (--devices) and special files
* (--specials) are preserved via recreation, and whether --copy-devices
* copies a device's content as an ordinary regular file. */
bool preserve_devices;
bool preserve_specials;
bool copy_devices;
/* Phase 2 (files-from / filter layer). All pointers are shared read-only
* across scanner instances and worker threads; ownership stays with the
* caller (client_send). */
@@ -114,6 +120,10 @@ typedef struct {
bool one_file_system;
dev_t root_dev;
bool failed;
/* Phase 4 special/devices (see ScannerOptions). */
bool preserve_devices;
bool preserve_specials;
bool copy_devices;
/* Phase 2 (files-from / filter layer). */
char* root_path; /* transfer root (fs path) for rel computation */
char* current_rel; /* rel path of the open directory ("" == root) */
+9
View File
@@ -185,6 +185,15 @@ void print_usage(void) {
printf(" --munge-links Munge symlink targets on the wire (sender)\n");
printf(" -H, --hard-links Preserve hard-link relationships across the transfer\n");
printf(" -S, --sparse Handle sparse files efficiently\n");
printf(
" -D Preserve device and special files (implies --devices --specials)\n");
printf(
" --devices Recreate device nodes on the destination (privileged; skipped when\n");
printf(" the receiver lacks CAP_MKNOD)\n");
printf(" --specials Recreate special files (FIFOs) on the destination (sockets "
"skipped)\n");
printf(" --copy-devices Copy a source device's content as a regular file instead\n");
printf(" --write-devices Write received data into an existing destination device node\n");
printf(" --inplace Update files in-place (no temp+rename)\n");
printf(
" --preallocate Allocate destination file space up front (fail-fast on full disk)\n");
+8 -3
View File
@@ -155,7 +155,7 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
while (status == STATUS_NEXT || status == STATUS_CHUNK || status == STATUS_CHECK ||
status == STATUS_KEEPALIVE || status == STATUS_ABORT || status == STATUS_CHECK_BATCH ||
status == STATUS_MKDIR || status == STATUS_MANIFEST || status == STATUS_HARDLINK ||
status == STATUS_SYMLINK) {
status == STATUS_SYMLINK || status == STATUS_SPECIAL) {
if (status == STATUS_KEEPALIVE) {
if (!send_status(file_descriptor, STATUS_KEEPALIVE))
goto fail;
@@ -186,10 +186,14 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
File* file = file_receive_hardlink(file_descriptor);
if (!file || !sink->store_file(file, sink->context))
goto receive_error;
} else if (status == STATUS_SYMLINK) {
} else if (status == STATUS_SYMLINK) {
File* sym = file_receive_symlink(file_descriptor, config);
if (!sym || !sink->store_file(sym, sink->context))
goto receive_error;
} else if (status == STATUS_SPECIAL) {
File* file = file_receive_special(file_descriptor);
if (!file || !sink->store_file(file, sink->context))
goto receive_error;
} else if (status == STATUS_MANIFEST) {
DeleteManifest* manifest = receive_manifest_entries(file_descriptor);
if (!manifest)
@@ -314,7 +318,8 @@ static bool receiver_save_file(File* file, void* context_pointer) {
result = file_save_to_disk_full(context->config->receive_root_directory, file, context->config);
}
if (result != FILE_SAVE_ERROR && context->config->remove_source_files && !file->is_dir &&
!file->skip && !receiver_outcomes_append(&context->outcomes, (unsigned char)result)) {
!file->is_special && !file->skip &&
!receiver_outcomes_append(&context->outcomes, (unsigned char)result)) {
file_destroy(file);
return false;
}
+48 -3
View File
@@ -75,10 +75,17 @@ static unsigned long long per_file_serialize_size(File* file, bool use_metadata)
return 0;
size += metadata_size;
/* Entry type marker: 0 = regular file, 1 = explicit directory entry,
2 = symlink entry (carries its target string). */
2 = symlink entry (carries its target string), 3 = special/device node
(recreated by the receiver). */
if (sizeof(int) > ULLONG_MAX - size)
return 0;
size += sizeof(int);
/* A special node also carries its rdev major/minor. */
if (file->is_special) {
if (2 * sizeof(int32_t) > ULLONG_MAX - size)
return 0;
size += 2 * sizeof(int32_t);
}
if (sizeof(size_t) > ULLONG_MAX - size)
return 0;
size += sizeof(size_t);
@@ -128,10 +135,19 @@ Data* chunk_serialize(Chunk* chunk, bool use_metadata) {
memcpy(data_pointer, wire_path, path_len);
data_pointer += path_len;
int entry_type = file->is_symlink ? 2 : (file->is_dir ? 1 : 0);
int entry_type = file->is_dir ? 1 : (file->is_symlink ? 2 : (file->is_special ? 3 : 0));
memcpy(data_pointer, &entry_type, sizeof(int));
data_pointer += sizeof(int);
if (file->is_special) {
int32_t special_major = file->rdev_major;
int32_t special_minor = file->rdev_minor;
memcpy(data_pointer, &special_major, sizeof(special_major));
data_pointer += sizeof(special_major);
memcpy(data_pointer, &special_minor, sizeof(special_minor));
data_pointer += sizeof(special_minor);
}
if (use_metadata)
metadata_to_buf(&data_pointer, file->metadata);
@@ -227,7 +243,7 @@ Chunk* chunk_deserialize(Data* data, bool use_metadata) {
}
int entry_type;
memcpy(&entry_type, data_pointer, sizeof(int));
if (entry_type != 0 && entry_type != 1 && entry_type != 2) {
if (entry_type != 0 && entry_type != 1 && entry_type != 2 && entry_type != 3) {
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: bad entry type");
file_destroy(file);
array_list_delete(files);
@@ -235,9 +251,38 @@ Chunk* chunk_deserialize(Data* data, bool use_metadata) {
}
file->is_dir = entry_type == 1;
file->is_symlink = entry_type == 2;
file->is_special = entry_type == 3;
data_pointer += sizeof(int);
remaining_size -= sizeof(int);
if (file->is_special) {
if (remaining_size < 2 * (int32_t)sizeof(int32_t)) {
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: not enough data for special rdev");
file_destroy(file);
array_list_delete(files);
return NULL;
}
int32_t special_major, special_minor;
memcpy(&special_major, data_pointer, sizeof(special_major));
data_pointer += sizeof(special_major);
memcpy(&special_minor, data_pointer, sizeof(special_minor));
data_pointer += sizeof(special_minor);
remaining_size -= 2 * sizeof(int32_t);
/* Reject an out-of-range/negative rdev here as a malformed chunk (the
same 0xffff / 0x00ffffff bounds file_special_rdev_valid uses), so a
bogus large-but-positive rdev is refused cleanly instead of being
deferred to the creation site where it would abort after the frame. */
if (special_major < 0 || special_minor < 0 || special_major > 0xffff ||
special_minor > 0x00ffffff) {
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: out-of-range special rdev");
file_destroy(file);
array_list_delete(files);
return NULL;
}
file->rdev_major = special_major;
file->rdev_minor = special_minor;
}
if (use_metadata) {
if (remaining_size < sizeof(int)) {
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: not enough data for metadata");
+22 -12
View File
@@ -79,6 +79,9 @@ static void config_set_defaults(Config* config) {
config->preserve_xattrs = false;
config->preserve_devices = false;
config->preserve_sparse = false;
config->preserve_specials = false;
config->copy_devices = false;
config->write_devices = false;
config->itemize_changes = false;
config->out_format = NULL;
config->log_file_format = NULL;
@@ -183,16 +186,18 @@ static bool validate_received_config(const Config* config) {
valid_wire_bool(config->safe_links) && valid_wire_bool(config->copy_unsafe_links) &&
valid_wire_bool(config->preserve_hard_links) && valid_wire_bool(config->preserve_acls) &&
valid_wire_bool(config->preserve_xattrs) && valid_wire_bool(config->preserve_devices) &&
valid_wire_bool(config->preserve_sparse) && valid_wire_bool(config->ignore_existing) &&
valid_wire_bool(config->existing) && valid_wire_bool(config->update) &&
valid_wire_bool(config->inplace) && valid_wire_bool(config->append) &&
valid_wire_bool(config->use_fsync) && valid_wire_bool(config->append_verify) &&
valid_wire_bool(config->delete_excluded) && valid_wire_bool(config->force_delete) &&
valid_wire_bool(config->delete_missing_args) && valid_wire_bool(config->delete_after) &&
valid_wire_bool(config->preallocate) && valid_wire_bool(config->delete_delay) &&
valid_wire_bool(config->delete_during) && valid_wire_bool(config->relative) &&
valid_wire_bool(config->prune_empty_dirs) && valid_wire_bool(config->delay_updates) &&
valid_wire_bool(config->mkpath) && !(config->delay_updates && config->inplace) &&
valid_wire_bool(config->preserve_sparse) && valid_wire_bool(config->preserve_specials) &&
valid_wire_bool(config->copy_devices) && valid_wire_bool(config->write_devices) &&
valid_wire_bool(config->ignore_existing) && valid_wire_bool(config->existing) &&
valid_wire_bool(config->update) && valid_wire_bool(config->inplace) &&
valid_wire_bool(config->append) && valid_wire_bool(config->use_fsync) &&
valid_wire_bool(config->append_verify) && valid_wire_bool(config->delete_excluded) &&
valid_wire_bool(config->force_delete) && valid_wire_bool(config->delete_missing_args) &&
valid_wire_bool(config->delete_after) && valid_wire_bool(config->preallocate) &&
valid_wire_bool(config->delete_delay) && valid_wire_bool(config->delete_during) &&
valid_wire_bool(config->relative) && valid_wire_bool(config->prune_empty_dirs) &&
valid_wire_bool(config->delay_updates) && valid_wire_bool(config->mkpath) &&
!(config->delay_updates && config->inplace) &&
!(config->delay_updates && delay_updates_staging_name_conflict(config->backup_dir)) &&
valid_wire_bool(config->partial) && valid_wire_bool(config->delete_before) &&
valid_wire_bool(config->checksum) && valid_wire_bool(config->eight_bit_output) &&
@@ -448,12 +453,16 @@ static bool send_delta_fields(int fd, const Config* c) {
}
static bool send_file_options(int fd, const Config* c) {
/* Device/special preservation flags cross the wire so the receiver knows a
* special/device entry must be recreated. Trailing fields; protocol 2.13.0. */
return send_int(fd, c->backup) && send_str(fd, c->backup_dir ? c->backup_dir : "") &&
send_int(fd, c->remove_source_files) && send_int(fd, c->follow_symlinks) &&
send_int(fd, c->copy_links) && send_int(fd, c->safe_links) &&
send_int(fd, c->copy_unsafe_links) && send_int(fd, c->preserve_hard_links) &&
send_int(fd, c->preserve_acls) && send_int(fd, c->preserve_xattrs) &&
send_int(fd, c->preserve_devices) && send_int(fd, c->preserve_sparse);
send_int(fd, c->preserve_devices) && send_int(fd, c->preserve_sparse) &&
send_int(fd, c->preserve_specials) && send_int(fd, c->copy_devices) &&
send_int(fd, c->write_devices);
}
static bool send_selection_options(int fd, const Config* c) {
@@ -573,7 +582,8 @@ static bool receive_file_options(int fd, Config* c) {
return false;
bool* flags[] = {&c->follow_symlinks, &c->copy_links, &c->safe_links,
&c->copy_unsafe_links, &c->preserve_hard_links, &c->preserve_acls,
&c->preserve_xattrs, &c->preserve_devices, &c->preserve_sparse};
&c->preserve_xattrs, &c->preserve_devices, &c->preserve_sparse,
&c->preserve_specials, &c->copy_devices, &c->write_devices};
for (size_t i = 0; i < sizeof(flags) / sizeof(flags[0]); i++) {
if (!receive_wire_bool(fd, flags[i]))
return false;
+16
View File
@@ -124,6 +124,22 @@ typedef struct Config {
bool preserve_xattrs;
bool preserve_devices;
bool preserve_sparse;
/* Phase 4 special/devices: preserve special files (FIFOs, sockets) and device
* nodes on the destination by recreating them (mknod/mkfifo) instead of
* transferring content. preserve_specials mirrors rsync --specials (the
* special-file half of -D); preserve_devices mirrors --devices (the device
* half of -D); both CROSS the wire so the receiver knows a special/device
* entry must be recreated rather than written as a regular file. */
bool preserve_specials;
/* --copy-devices: copy the CONTENT of a source device as an ordinary regular
* file on the destination (rsync's non-privileged safe mode), instead of
* recreating the device node. CROSSES the wire (receiver treats the entry as
* a regular file, which is the default, so this is belt-and-braces). */
bool copy_devices;
/* --write-devices: write the received data directly INTO an existing device
* node on the destination instead of creating a regular file. Dangeroud;
* see RSYNC_COMPAT.md for the tight gating. CROSSES the wire. */
bool write_devices;
// Issue #122: Output/logging options
bool itemize_changes;
+3
View File
@@ -116,6 +116,9 @@ File* file_create(const char* path) {
file->hardlink_target = NULL;
file->is_symlink = false;
file->symlink_target = NULL;
file->is_special = false;
file->rdev_major = 0;
file->rdev_minor = 0;
return file;
}
+298
View File
@@ -6,6 +6,7 @@
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/sysmacros.h>
#include <unistd.h>
#include "array_list.h"
@@ -288,6 +289,231 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
}
/* Validate a transmitted special rdev against the node kind implied by `mode`'s
* S_IFMT bits. Char/block devices require a legal major/minor pair (non-negative,
* range-checked); a non-device special (FIFO/socket) must carry an empty rdev.
* Used identically on the wire path and at the secure recreation site so a
* malicious/bogus rdev can never drive a dangerous node. */
bool file_special_rdev_valid(int32_t major, int32_t minor, mode_t mode) {
bool is_device = S_ISCHR(mode) || S_ISBLK(mode);
if (is_device)
return major >= 0 && minor >= 0 && major <= 0xffff && minor <= 0x00ffffff;
/* A non-device entry must actually be a special (FIFO/socket) and carry no
rdev; a regular/dir mode is never a valid special node. */
return (S_ISFIFO(mode) || S_ISSOCK(mode)) && major == 0 && minor == 0;
}
/* ---- Device/special node RECREATION (--devices/--specials), receiver side ----
*
* Privilege gating: making a real device node requires CAP_MKNOD (root); making
* a FIFO works unprivileged (mkfifo). When the receiver lacks the capability,
* mknodat() fails with EPERM and the entry is SKIPPED with a warning -- the
* whole transfer must NOT abort just because the environment cannot make the
* node. CI runs non-root, so device creation is expected to skip there and
* only a FIFO is honestly assertable unprivileged.
*
* Confinement: the parent directory is opened fd-relative below the receive
* root (file_open_secure_parent: O_NOFOLLOW, no "..", root-checked) and the
* node is created with mknodat()/mkfifoat(), so it can never be placed outside
* the confined root and never follows a symlink.
*
* rdev validation: a malicious/bogus rdev (negative, out-of-range) is rejected
* here as well as on the wire (file_receive_special / chunk_deserialize), and a
* non-device entry must carry an empty rdev.
*/
static FileSaveResult file_save_special_to_disk(const char* root_directory, const File* file,
const Config* config) {
if (!root_directory || !file || !file->path || file->path[0] == '\0' ||
has_path_traversal(file->path) || !file->metadata)
return FILE_SAVE_ERROR;
mode_t mode = file->metadata->mode;
bool is_char = S_ISCHR(mode);
bool is_blk = S_ISBLK(mode);
bool is_fifo = S_ISFIFO(mode);
bool is_sock = S_ISSOCK(mode);
if (!is_char && !is_blk && !is_fifo && !is_sock) {
log_message(LOG_LEVEL_ERROR, "Special node has no device/FIFO/socket mode");
return FILE_SAVE_ERROR;
}
if (is_sock) {
/* No standard filesystem call recreates a socket; best-effort unsupported. */
log_message(LOG_LEVEL_WARNING, "socket not recreated: %s (unsupported; skipped)", file->path);
return FILE_SAVE_SKIPPED;
}
if (is_char || is_blk) {
if (!config || !config->preserve_devices)
return FILE_SAVE_SKIPPED;
} else if (is_fifo) {
if (!config || !config->preserve_specials)
return FILE_SAVE_SKIPPED;
}
/* Defense-in-depth rdev/type validation (also done on the wire path). */
if (!file_special_rdev_valid(file->rdev_major, file->rdev_minor, mode)) {
log_message(LOG_LEVEL_ERROR, "Rejected out-of-range device rdev %d:%d", file->rdev_major,
file->rdev_minor);
return FILE_SAVE_ERROR;
}
char* destination = path_cat(root_directory, file->path);
if (!destination)
return FILE_SAVE_ERROR;
char* leaf = NULL;
int parent_fd = file_open_secure_parent(destination, &leaf, true);
if (parent_fd < 0) {
free(destination);
return FILE_SAVE_ERROR;
}
/* --existing / --ignore-existing / --update decide against the node that
would be replaced, mirroring the regular-file path. */
if (config->existing && !file_path_exists_secure(destination)) {
close(parent_fd);
free(leaf);
free(destination);
return FILE_SAVE_SKIPPED;
}
if (config->ignore_existing && file_path_exists_secure(destination)) {
close(parent_fd);
free(leaf);
free(destination);
return FILE_SAVE_SKIPPED;
}
if (config->update && file_destination_is_newer_secure(destination, file->metadata)) {
close(parent_fd);
free(leaf);
free(destination);
return FILE_SAVE_SKIPPED;
}
dev_t rdev = 0;
mode_t create_mode;
if (is_char) {
create_mode = S_IFCHR;
rdev = makedev((unsigned)file->rdev_major, (unsigned)file->rdev_minor);
} else if (is_blk) {
create_mode = S_IFBLK;
rdev = makedev((unsigned)file->rdev_major, (unsigned)file->rdev_minor);
} else {
create_mode = S_IFIFO;
}
mode_t perms = mode & 0777;
int rc = is_fifo ? mkfifoat(parent_fd, leaf, perms)
: mknodat(parent_fd, leaf, create_mode | perms, rdev);
if (rc != 0) {
if (errno == EEXIST) {
/* An entry already exists: only skip when it already is a matching node;
never replace an existing directory or unrelated entry with the node. */
struct stat st;
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) == 0 &&
((is_char && S_ISCHR(st.st_mode)) || (is_blk && S_ISBLK(st.st_mode)) ||
(is_fifo && S_ISFIFO(st.st_mode)))) {
close(parent_fd);
free(leaf);
free(destination);
return FILE_SAVE_SKIPPED;
}
log_message(LOG_LEVEL_WARNING, "refusing to replace existing entry with %s: %s (skipped)",
is_fifo ? "FIFO" : "device", file->path);
} else if (errno == EPERM || errno == EACCES) {
/* Missing CAP_MKNOD / parent write permission: the environment cannot
create the node, so skip instead of failing the whole run. */
log_message(LOG_LEVEL_WARNING,
"skipping %s: cannot create %s node (%s)\n"
" --devices/--specials node creation needs privilege (CAP_MKNOD)",
file->path, is_fifo ? "FIFO" : "device", strerror(errno));
} else {
log_message(LOG_LEVEL_WARNING, "failed to create %s %s: %s (skipped)",
is_fifo ? "FIFO" : "device", file->path, strerror(errno));
}
close(parent_fd);
free(leaf);
free(destination);
return FILE_SAVE_SKIPPED;
}
/* Apply mtime on the fresh node (utimensat, no-follow). Ownership is not
applied -- identity fchown needs an fd and would require opening the node. */
struct timespec times[2] = {
{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
{.tv_sec = file->metadata->mtime_sec, .tv_nsec = file->metadata->mtime_nsec}};
utimensat(parent_fd, leaf, times, AT_SYMLINK_NOFOLLOW);
close(parent_fd);
free(leaf);
free(destination);
return FILE_SAVE_WRITTEN;
}
/* --write-devices (receiver): write the received data directly into an EXISTING
* device node on the destination instead of creating a regular file. The node
* must already exist and be a char/block device (the device itself is opened and
* followed); it is confined to the receive root via file_open_secure_parent.
* Dangerous by nature, so deliberately restricted: a missing/non-device
* destination, or a write failure, is SKIPPED with a warning rather than
* allowed. On environments without device access the run still succeeds (the
* entry is skipped), never aborts. */
static FileSaveResult file_save_write_device(const char* root_directory, const File* file) {
if (!root_directory || !file || !file->path || file->path[0] == '\0' ||
has_path_traversal(file->path))
return FILE_SAVE_ERROR;
if (!file->data)
return FILE_SAVE_ERROR;
char* destination = path_cat(root_directory, file->path);
if (!destination)
return FILE_SAVE_ERROR;
char* leaf = NULL;
int parent_fd = file_open_secure_parent(destination, &leaf, false);
if (parent_fd < 0) {
free(destination);
return FILE_SAVE_SKIPPED;
}
/* O_NONBLOCK: a pre-existing FIFO at the target would otherwise block the
receive thread forever on open(2). With it the open only succeeds for a
readerless FIFO with O_RDWR (which the device fstat gate rejects anyway)
or fails with ENXIO/EAGAIN, both treated as a normal skip below. */
int fd = openat(parent_fd, leaf, O_WRONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
int saved_errno = errno;
free(leaf);
close(parent_fd);
if (fd < 0) {
free(destination);
if (saved_errno == ENXIO || saved_errno == EAGAIN) {
/* A FIFO with no reader / an unreadable special: skip like every other
unusable write-devices target instead of blocking or failing. */
log_message(LOG_LEVEL_WARNING, "write-devices: %s not writable (%s); skipped", file->path,
strerror(saved_errno));
} else {
log_message(LOG_LEVEL_WARNING, "write-devices: cannot open %s (%s); skipped", file->path,
strerror(saved_errno));
}
return FILE_SAVE_SKIPPED;
}
struct stat st;
if (fstat(fd, &st) != 0 || !(S_ISCHR(st.st_mode) || S_ISBLK(st.st_mode))) {
close(fd);
free(destination);
log_message(LOG_LEVEL_WARNING, "write-devices: %s is not a device node; skipped", file->path);
return FILE_SAVE_SKIPPED;
}
bool ok = true;
if (file->data->size > 0) {
size_t total = (size_t)file->data->size;
size_t written = 0;
while (written < total) {
ssize_t n = write(fd, (char*)file->data->data + written, total - written);
if (n <= 0) {
ok = false;
break;
}
written += (size_t)n;
}
}
close(fd);
free(destination);
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_SKIPPED;
}
FileSaveResult file_save_to_disk_full(const char* root_directory, const File* file,
const Config* config) {
/* Backups are incompatible with ignore-existing: moving the entry first
@@ -314,6 +540,14 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
return FILE_SAVE_ERROR;
}
/* Device/special node (--devices/--specials): recreate the node instead of
writing content (privilege-gated, confined, rdev-validated). */
if (file->is_special)
return file_save_special_to_disk(root_directory, file, config);
/* --write-devices: write straight into an existing device node. */
if (config && config->write_devices)
return file_save_write_device(root_directory, file);
/* Explicit directory entries (--dirs) carry an empty payload; the entry is
created as a directory under the receive root, applying the same secure
mkdir-parent semantics as regular writes. Directories are created
@@ -1964,6 +2198,70 @@ File* file_receive_symlink(int file_descriptor, const Config* config) {
return file;
}
/* Receive a device/special node frame (--devices/--specials): the leading
* STATUS_SPECIAL code has already been consumed. Payload: the destination path,
* the metadata frame (whose mode's S_IFMT bits carry the node kind), and two
* int32 rdev major/minor fields. The created File carries no payload and is
* recreated by file_save_to_disk_full (mknod/mkfifo, privilege-gated and
* confined). rdev is validated here (non-negative, range-checked) so a bogus
* value cannot drive a dangerous node on the receiver. */
File* file_receive_special(int file_descriptor) {
char* path = receive_str(file_descriptor);
if (path == NULL)
return NULL;
if (path[0] == '\0' || has_path_traversal(path)) {
char* escaped_path = output_escape(path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid received special path: %s",
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
free(path);
send_status(file_descriptor, STATUS_ERROR);
return NULL;
}
int meta_ok = 1;
FileMetadata* metadata = metadata_receive(file_descriptor, &meta_ok);
if (!meta_ok) {
free(path);
send_status(file_descriptor, STATUS_ERROR);
return NULL;
}
int32_t major = 0;
int32_t minor = 0;
if (!receive_n_data(file_descriptor, &major, sizeof(major)) ||
!receive_n_data(file_descriptor, &minor, sizeof(minor))) {
free(path);
file_metadata_destroy(metadata);
send_status(file_descriptor, STATUS_ERROR);
return NULL;
}
/* A node kind must be present; without metadata mode there is no S_IFMT to
recreate from. */
if (!metadata) {
log_message(LOG_LEVEL_ERROR, "Special node sent without metadata (mode)");
free(path);
send_status(file_descriptor, STATUS_ERROR);
return NULL;
}
if (!file_special_rdev_valid(major, minor, metadata->mode)) {
log_message(LOG_LEVEL_ERROR, "Invalid special rdev received (%d:%d)", (int)major, (int)minor);
free(path);
file_metadata_destroy(metadata);
send_status(file_descriptor, STATUS_ERROR);
return NULL;
}
File* file = file_create(path);
free(path);
if (file == NULL) {
file_metadata_destroy(metadata);
return NULL;
}
file->metadata = metadata;
file->is_special = true;
file->rdev_major = major;
file->rdev_minor = minor;
return file;
}
/* Read a delete-manifest frame (the STATUS_MANIFEST leading code has already
been consumed): a keep-set entry count followed by that many
destination-relative paths, then a protected-prefix count followed by that
+2
View File
@@ -11,6 +11,8 @@ File* file_receive(const Config* config, int file_descriptor);
File* file_receive_directory(int file_descriptor);
File* file_receive_hardlink(int file_descriptor);
File* file_receive_symlink(int file_descriptor, const Config* config);
File* file_receive_special(int file_descriptor);
bool file_special_rdev_valid(int32_t major, int32_t minor, mode_t mode);
File* receive_incremental_check(int fd, const Config* config, bool* skipped);
/* A received delete-manifest frame: the keep-set (`keeps`, destination-relative
+19
View File
@@ -17,6 +17,25 @@
#include "metadata.h"
#include "protocol.h"
/* Transmit a device/special node (--devices / --specials) as a STATUS_SPECIAL
* frame: the destination path, the metadata frame (whose mode's S_IFMT bits
* carry the node kind) and the device rdev major/minor. The receiver validates
* the kind and rdev and recreates the node (privilege-gating the mknod). */
bool file_send_special(File* file, int file_descriptor, bool use_metadata) {
if (!file || !file_wire_path(file))
return false;
if (!send_status(file_descriptor, STATUS_SPECIAL))
return false;
if (!send_str(file_descriptor, file_wire_path(file)))
return false;
if (use_metadata && !metadata_send(file_descriptor, file->metadata))
return false;
int32_t major = file->rdev_major;
int32_t minor = file->rdev_minor;
return send_n_data(file_descriptor, &major, sizeof(major)) &&
send_n_data(file_descriptor, &minor, sizeof(minor));
}
bool file_send_single_calls(File* file, int file_descriptor, bool use_metadata,
int compression_level, bool send_path) {
return file_send_single_calls_with_skip(file, file_descriptor, use_metadata, compression_level,
+1
View File
@@ -6,6 +6,7 @@
/* Client-side file send path. */
bool file_send_special(File* file, int file_descriptor, bool use_metadata);
bool file_send_single_calls(File* file, int file_descriptor, bool use_metadata,
int compression_level, bool send_path);
bool file_send_single_calls_with_skip(File* file, int file_descriptor, bool use_metadata,
+9
View File
@@ -63,6 +63,15 @@ typedef struct {
* data. `data` is empty for a symlink entry. Sender + receiver state. */
bool is_symlink;
char* symlink_target;
/* Phase 4 special/devices: when `is_special` is true this entry is a device
* or special node to be RECREATED on the destination (mknod/mkfifo) rather
* than written from `data`. The concrete node kind is derived from the
* metadata mode's S_IFMT bits (receiver-validated), and rdev_major/minor
* carry the device major/minor numbers for char/block devices. CROSSES the
* wire (protocol 2.13.0). */
bool is_special;
int32_t rdev_major;
int32_t rdev_minor;
} File;
/* The path that should be sent on the wire and used for the receiver-side
+3 -2
View File
@@ -308,8 +308,9 @@ int write_thread(void* pipeline_context) {
}
/* Record the per-file outcome so a --remove-source-files sender learns
which sources were actually written versus skipped on the receiver.
Explicit directory entries have no source and are never acknowledged. */
if (context->config->remove_source_files && !file->is_dir && !file->skip &&
Explicit directory entries and recreated device/special nodes have no
source and are never acknowledged (mirrors receiver.c). */
if (context->config->remove_source_files && !file->is_dir && !file->is_special && !file->skip &&
!receiver_outcomes_append(&context->outcomes, (unsigned char)result)) {
file_destroy(file);
pipeline_context_receiver_note_bytes_released(context, file_bytes);
+8 -1
View File
@@ -96,7 +96,14 @@ enum NET_STATUS {
* if --munge-links) symlink target, and optional metadata; the receiver
* creates a symlink to the unmunged target beneath the receive root (see
* file_receive_symlink). Protocol 2.13.0. */
STATUS_SYMLINK
STATUS_SYMLINK,
/* --devices / --specials (-D): a device or special node the sender wants
* recreated (not written from content). Payload: destination path, the
* metadata frame (whose mode's S_IFMT bits carry the node kind), and two
* int32 rdev major/minor fields. The receiver validates the kind and rdev,
* confines the node below the receive root, and recreates it (mknod/mkfifo),
* privilege-gating the mknod. Protocol 2.13.0. */
STATUS_SPECIAL
};
void io_set_fds(int read_fd, int write_fd);
+154
View File
@@ -3,6 +3,7 @@ import filecmp
import os
import random
import shutil
import stat
import subprocess
import sys
import time
@@ -18,6 +19,159 @@ from common import (
SOURCE_DIR = os.path.join(TEST_DATA_DIR, "feature_source")
DEST_DIR = os.path.join(TEST_DATA_DIR, "feature_dest")
DEVICE_SOURCE = os.path.join(TEST_DATA_DIR, "device_source")
DEVICE_DEST = os.path.join(TEST_DATA_DIR, "device_dest")
class TestDeviceSpecial:
"""Phase 4: --devices / --specials / -D / --copy-devices / --write-devices.
Device node CREATION (mknod) is privileged (CAP_MKNOD); CI runs non-root, so
only the FIFO path (mkfifo, unprivileged) is asserted unconditionally. The
real-device-created assertions are guarded to run only as root. Everything
else must simply succeed / skip without aborting.
"""
def _setup(self):
clean_dir(DEVICE_SOURCE)
clean_dir(DEVICE_DEST)
with open(os.path.join(DEVICE_SOURCE, "plain.txt"), "wb") as f:
f.write(b"regular content\n")
def test_specials_recreates_fifo(self, shared_server):
self._setup()
os.mkfifo(os.path.join(DEVICE_SOURCE, "pipe.fifo"))
result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST,
flags=["--specials"], port=shared_server.port)
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}"
received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE)
fifo = os.path.join(received, "pipe.fifo")
assert os.path.exists(fifo) and stat.S_ISFIFO(os.stat(fifo).st_mode), (
"source FIFO was not recreated as a FIFO on the destination"
)
# The regular file alongside it still transferred normally.
with open(os.path.join(received, "plain.txt")) as f:
assert f.read() == "regular content\n"
def test_D_implies_devices_and_specials_fifo(self, shared_server):
"""-D implies --devices --specials; a FIFO is preserved without a crash
even though no device mknod is attempted on the (non-root) receiver."""
self._setup()
os.mkfifo(os.path.join(DEVICE_SOURCE, "pipe.fifo"))
result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST,
flags=["-D"], port=shared_server.port)
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}"
received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE)
assert stat.S_ISFIFO(os.stat(os.path.join(received, "pipe.fifo")).st_mode)
def test_copy_devices_non_crash(self, shared_server):
"""--copy-devices treats a special/device source as a regular-file copy;
a FIFO (st_size 0) must transfer without hanging or crashing."""
self._setup()
os.mkfifo(os.path.join(DEVICE_SOURCE, "device_copy.fifo"))
result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST,
flags=["--copy-devices"], port=shared_server.port)
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}"
def test_write_devices_non_crash(self, shared_server):
"""--write-devices writes into an existing device only; when the
destination holds no device node the entry is skipped safely and the
run still succeeds (never aborts)."""
self._setup()
# Destination already holds a regular file at the source FIFO's path:
# the receiver must not clobber it and must not crash.
os.mkfifo(os.path.join(DEVICE_SOURCE, "target.fifo"))
result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST,
flags=["--write-devices"], port=shared_server.port)
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}"
@pytest.mark.skipif(os.geteuid() != 0, reason="requires root to create device nodes")
def test_devices_recreates_real_char_device(self, shared_server):
"""Root-only: a source char device node is recreated on the destination
with the same type and rdev (privilege-gated mknod path)."""
self._setup()
src_dev = os.path.join(DEVICE_SOURCE, "realdev")
os.mknod(src_dev, stat.S_IFCHR | 0o666, os.makedev(1, 3))
result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST,
flags=["--devices"], port=shared_server.port)
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}"
received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE)
st = os.lstat(os.path.join(received, "realdev"))
assert stat.S_ISCHR(st.st_mode)
assert os.major(st.st_rdev) == 1 and os.minor(st.st_rdev) == 3
def test_m_remove_source_files_keeps_recreated_fifo(self, shared_server):
"""-m --remove-source-files --specials: a recreated FIFO must NOT be
acknowledged as a removable source (its outcome must not shift the
per-file status stream, which would break the run and mis-remove the
adjacent regular file). The regular file is removed; the FIFO stays."""
self._setup()
os.mkfifo(os.path.join(DEVICE_SOURCE, "pipe.fifo"))
result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST,
flags=["-m", "--remove-source-files", "--specials"],
port=shared_server.port)
assert result.returncode == 0, (
f"Exit {result.returncode}: {result.stderr[:300]}"
)
assert not os.path.exists(os.path.join(DEVICE_SOURCE, "plain.txt")), (
"regular source file should have been removed"
)
assert os.path.exists(os.path.join(DEVICE_SOURCE, "pipe.fifo")), (
"recreated FIFO source must never be removed"
)
@pytest.mark.skipif(os.geteuid() != 0, reason="requires root to create device nodes")
def test_m_remove_source_files_keeps_recreated_device(self, shared_server):
"""Root-only: -m --remove-source-files --devices must not remove a
source device node the receiver recreated (mirrors the single-threaded
behavior; the special is never acknowledged as a removable source)."""
self._setup()
src_dev = os.path.join(DEVICE_SOURCE, "realdev")
os.mknod(src_dev, stat.S_IFCHR | 0o666, os.makedev(1, 3))
result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST,
flags=["-m", "--remove-source-files", "--devices"],
port=shared_server.port)
assert result.returncode == 0, (
f"Exit {result.returncode}: {result.stderr[:300]}"
)
assert not os.path.exists(os.path.join(DEVICE_SOURCE, "plain.txt")), (
"regular source file should have been removed"
)
assert os.path.exists(src_dev) and stat.S_ISCHR(os.lstat(src_dev).st_mode), (
"recreated device source must never be removed"
)
def test_write_devices_fifo_target_skips_not_hangs(self, shared_server):
"""--write-devices must never block on a pre-existing FIFO at the
destination mirror: opening with O_NONBLOCK fails with ENXIO and the
entry is skipped (the FIFO is left untouched and the run succeeds)."""
self._setup()
# Pre-plant a FIFO at the destination mirror of the source file's path.
received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE)
os.makedirs(received, exist_ok=True)
target = os.path.join(received, "plain.txt")
os.mkfifo(target)
result, dur = run_client(DEVICE_SOURCE, DEVICE_DEST,
flags=["--write-devices"], port=shared_server.port)
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}"
assert stat.S_ISFIFO(os.lstat(target).st_mode), "FIFO target was clobbered"
assert dur < 60, "write-devices hung on a FIFO target"
def test_special_confined_to_receive_root(self, shared_server):
"""A special node is created only under the receive root; nothing is
ever materialized outside it (the receiver is confined to its
authorized root)."""
self._setup()
os.mkfifo(os.path.join(DEVICE_SOURCE, "confined.fifo"))
result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST,
flags=["--specials"], port=shared_server.port)
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:200]}"
# The only new FIFO is under the receive tree; its sibling watchers
# confirm the confined dest layout (no stray node at the source root).
source_fifo_escaped = os.path.join(DEVICE_DEST, "confined.fifo")
assert not os.path.lexists(source_fifo_escaped), "special escaped the receive root"
received = get_dest_received_dir(DEVICE_DEST, DEVICE_SOURCE)
assert stat.S_ISFIFO(os.stat(os.path.join(received, "confined.fifo")).st_mode)
@pytest.fixture(scope="module", autouse=True)
+66
View File
@@ -216,9 +216,75 @@ static void test_chunk_symlink_roundtrip() {
rmdir(link_path);
}
/* A --devices/--specials special entry (is_special + rdev) must round-trip
* through the chunk wire with a legal rdev. */
static void test_chunk_special_rdev_roundtrip() {
const char* path = "temp_chunk_special_node";
unlink(path);
File* special = file_create(path);
EXPECT_NOT_NULL(special);
special->is_special = true;
special->rdev_major = 1;
special->rdev_minor = 3;
struct stat st;
EXPECT_EQ_INT(stat("/dev/null", &st), 0);
special->metadata = file_metadata_create(path, &st, false, false);
EXPECT_NOT_NULL(special->metadata);
File* files[1] = {special};
Chunk* chunk = chunk_create(files, 1);
EXPECT_NOT_NULL(chunk);
Data* serialized = chunk_serialize(chunk, true);
EXPECT_NOT_NULL(serialized);
Chunk* deserialized = chunk_deserialize(serialized, true);
EXPECT_NOT_NULL(deserialized);
EXPECT_EQ_INT(deserialized->element_count, 1);
EXPECT_TRUE(deserialized->items[0]->is_special);
EXPECT_FALSE(deserialized->items[0]->is_dir);
EXPECT_EQ_INT((int)deserialized->items[0]->data->size, 0);
EXPECT_EQ_INT(deserialized->items[0]->rdev_major, 1);
EXPECT_EQ_INT(deserialized->items[0]->rdev_minor, 3);
EXPECT_NOT_NULL(deserialized->items[0]->metadata);
data_destroy(serialized);
chunk_destroy(deserialized);
chunk_destroy(chunk);
}
/* A special entry carrying an out-of-range rdev is a malformed chunk and must be
* rejected at deserialize (bounded by the same 0xffff / 0x00ffffff limits
* file_special_rdev_valid uses on the per-file wire), not deferred to the
* creation site. */
static void test_chunk_special_rdev_out_of_range_rejected() {
const char* path = "temp_chunk_special_bad_rdev";
unlink(path);
File* special = file_create(path);
EXPECT_NOT_NULL(special);
special->is_special = true;
special->rdev_major = 0x10000; /* > 0xffff */
special->rdev_minor = 3;
struct stat st;
EXPECT_EQ_INT(stat("/dev/null", &st), 0);
special->metadata = file_metadata_create(path, &st, false, false);
EXPECT_NOT_NULL(special->metadata);
File* files[1] = {special};
Chunk* chunk = chunk_create(files, 1);
EXPECT_NOT_NULL(chunk);
Data* serialized = chunk_serialize(chunk, true);
EXPECT_NOT_NULL(serialized);
Chunk* deserialized = chunk_deserialize(serialized, true);
EXPECT_NULL(deserialized);
data_destroy(serialized);
chunk_destroy(chunk);
}
void test_chunk() {
test_file_operations();
test_chunk_operations();
test_chunk_dir_entry_roundtrip();
test_chunk_symlink_roundtrip();
test_chunk_special_rdev_roundtrip();
test_chunk_special_rdev_out_of_range_rejected();
}
+47 -2
View File
@@ -772,8 +772,6 @@ static void test_parse_args_rejects_unimplemented_options() {
"--acls",
"-X",
"--xattrs",
"-D",
"--devices",
"--delete-excluded",
"--max-delete",
"--prune-empty-dirs",
@@ -2380,6 +2378,52 @@ static void test_parse_args_omit_link_times_long() {
config_delete(cfg);
}
/* --devices / --specials / -D / --copy-devices / --write-devices parse into the
config, and the preserved flags imply metadata transmission. */
static void test_parse_args_devices_specials() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--devices", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->preserve_devices);
EXPECT_FALSE(cfg->preserve_specials);
EXPECT_TRUE(cfg->use_metadata);
config_delete(cfg);
cfg = config_create();
char* argv2[] = {"fastsync", "--specials", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv2, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->preserve_specials);
EXPECT_FALSE(cfg->preserve_devices);
EXPECT_TRUE(cfg->use_metadata);
config_delete(cfg);
cfg = config_create();
char* argv3[] = {"fastsync", "-D", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv3, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->preserve_devices);
EXPECT_TRUE(cfg->preserve_specials);
EXPECT_TRUE(cfg->use_metadata);
config_delete(cfg);
cfg = config_create();
char* argv4[] = {"fastsync", "--copy-devices", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv4, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->copy_devices);
config_delete(cfg);
cfg = config_create();
char* argv5[] = {"fastsync", "--write-devices", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv5, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->write_devices);
config_delete(cfg);
}
void test_client_cli() {
test_validate_config_required_paths();
test_parse_args_numeric_ids();
@@ -2390,6 +2434,7 @@ void test_client_cli() {
test_parse_args_rejects_malformed_identity();
test_parse_args_preallocate();
test_parse_args_metadata_times();
test_parse_args_devices_specials();
test_parse_args_atimes_long_and_short();
test_parse_args_omit_link_times_long();
test_parse_args_append();
+43
View File
@@ -1141,6 +1141,48 @@ static void test_config_preallocate_wire_roundtrip() {
}
}
}
static void test_config_devices_wire_roundtrip() {
if (is_running_under_valgrind())
return;
Config* send_cfg = config_create();
EXPECT_NOT_NULL(send_cfg);
send_cfg->send_directory = str_dup("/send/src");
send_cfg->receive_root_directory = str_dup("/send/dst");
send_cfg->preserve_devices = true;
send_cfg->preserve_specials = true;
send_cfg->copy_devices = true;
send_cfg->write_devices = true;
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) {
close(p[1]);
io_set_fds(p[0], p[0]);
Config* recv = config_receive(p[0]);
bool ok = recv != NULL;
if (ok) {
ok = recv->preserve_devices && recv->preserve_specials && recv->copy_devices &&
recv->write_devices;
}
config_delete(recv);
close(p[0]);
_exit(ok ? 0 : 1);
} else {
close(p[0]);
io_set_fds(p[1], p[1]);
bool sent = config_send(p[1], send_cfg);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(send_cfg);
EXPECT_TRUE(sent);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
void test_config() {
test_config_lifecycle();
test_config_ssh_dest();
@@ -1169,6 +1211,7 @@ void test_config() {
test_config_identity_wire_roundtrip();
test_config_receive_rejects_invalid_identity();
test_config_metadata_times_wire_roundtrip();
test_config_devices_wire_roundtrip();
test_config_preallocate_wire_roundtrip();
}
test_config_delete_timing_early_helper();
+21
View File
@@ -25,6 +25,26 @@ static void test_file_create() {
file_destroy(f);
}
/* rdev/type validation shared by the wire path and the secure recreation site:
* a legal char/block major/minor pair is accepted, out-of-range / negative
* values and non-device entries carrying an rdev are rejected. */
static void test_file_special_rdev_valid() {
mode_t fake_char = S_IFCHR | 0600;
mode_t fake_blk = S_IFBLK | 0600;
mode_t fake_fifo = S_IFIFO | 0600;
/* char/block devices: accept a legal pair, reject negative / oversized. */
EXPECT_TRUE(file_special_rdev_valid(1, 3, fake_char));
EXPECT_TRUE(file_special_rdev_valid(0xffff, 0x00ffffff, fake_blk));
EXPECT_FALSE(file_special_rdev_valid(-1, 3, fake_char));
EXPECT_FALSE(file_special_rdev_valid(1, -1, fake_char));
EXPECT_FALSE(file_special_rdev_valid(0x10000, 3, fake_char));
EXPECT_FALSE(file_special_rdev_valid(1, 0x1000000, fake_char));
/* FIFOs/sockets must carry an empty rdev. */
EXPECT_TRUE(file_special_rdev_valid(0, 0, fake_fifo));
EXPECT_FALSE(file_special_rdev_valid(1, 0, fake_fifo));
EXPECT_FALSE(file_special_rdev_valid(0, 0, (mode_t)(S_IFREG | 0600)));
}
static void test_file_destroy_null() {
file_destroy(NULL);
}
@@ -1004,6 +1024,7 @@ static void test_dir_entry_save_to_disk() {
void test_file() {
test_file_create();
test_file_special_rdev_valid();
test_file_destroy_null();
test_file_destroy_normal();
test_file_load_data();