From 007e8f90f2f7e203e501c152b4f486771c9e8082 Mon Sep 17 00:00:00 2001 From: TapTap Date: Tue, 8 Sep 2026 22:27:53 +0200 Subject: [PATCH] devices: --devices/--specials/-D/--copy-devices/--write-devices Recreate char/block nodes via mknodat (privilege-gated, EPERM->warn+skip) and FIFOs via mkfifoat; new STATUS_SPECIAL frame + validated rdev; sockets skipped; -copy-devices copies st_size; -write-devices O_NOFOLLOW+O_NONBLOCK warn+skip. preserve_specials/copy_devices/write_devices cross the wire. PROTOCOL_VERSION 2.12.0->2.13.0. Review fixes: -m source-removal keeps recreated specials, FIFO ENXIO skip, rdev bounds at chunk_deserialize, STATUS_ERROR on receive branch, scanner_prepare_special dedup. --- RSYNC_COMPAT.md | 60 +++++- src/client/client_cli.c | 20 ++ src/client/client_send.c | 11 ++ src/client/scanner.c | 36 ++++ src/client/scanner.h | 10 + src/client/usage.c | 9 + src/server/receiver.c | 10 +- src/shared/chunk.c | 51 ++++- src/shared/config.c | 34 ++-- src/shared/config.h | 18 +- src/shared/file.c | 3 + src/shared/file_receive.c | 298 +++++++++++++++++++++++++++++ src/shared/file_receive.h | 2 + src/shared/file_send.c | 19 ++ src/shared/file_send.h | 1 + src/shared/file_types.h | 9 + src/shared/multiprocessing.c | 5 +- src/shared/protocol.h | 9 +- tests/integration/test_features.py | 154 +++++++++++++++ tests/test_chunk.c | 66 +++++++ tests/test_client_cli.c | 49 ++++- tests/test_config.c | 43 +++++ tests/test_file.c | 21 ++ 23 files changed, 910 insertions(+), 28 deletions(-) diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index f88c863..37d2c8c 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -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 | diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 5ff1a78..e7f23df 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -537,6 +537,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)}, @@ -831,6 +836,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; @@ -1200,6 +1211,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 diff --git a/src/client/client_send.c b/src/client/client_send.c index 3dbf2a7..cbc4547 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -104,6 +104,9 @@ static bool prepare_scanner(const Config* config, int num_threads, PreparedScann options->copy_unsafe_links = config->copy_unsafe_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; @@ -1248,6 +1251,14 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config, 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); diff --git a/src/client/scanner.c b/src/client/scanner.c index 0662823..d598fd2 100644 --- a/src/client/scanner.c +++ b/src/client/scanner.c @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -193,6 +194,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). */ @@ -365,6 +394,9 @@ DirectoryScanner* directory_scanner_create_with_options(const char* root_directo scanner->copy_unsafe_links = options->copy_unsafe_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) { @@ -925,6 +957,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) @@ -1243,6 +1278,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; diff --git a/src/client/scanner.h b/src/client/scanner.h index 4f755f4..765a029 100644 --- a/src/client/scanner.h +++ b/src/client/scanner.h @@ -34,6 +34,12 @@ typedef struct { bool copy_unsafe_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). */ @@ -104,6 +110,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) */ diff --git a/src/client/usage.c b/src/client/usage.c index 453e5c1..c93e891 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -182,6 +182,15 @@ void print_usage(void) { printf(" --copy-unsafe-links Only transform unsafe symlinks into referent files\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"); diff --git a/src/server/receiver.c b/src/server/receiver.c index 548b090..8d3ba13 100644 --- a/src/server/receiver.c +++ b/src/server/receiver.c @@ -154,7 +154,8 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver DeleteManifest* deferred_manifest = NULL; 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_MKDIR || status == STATUS_MANIFEST || status == STATUS_HARDLINK || + status == STATUS_SPECIAL) { if (status == STATUS_KEEPALIVE) { if (!send_status(file_descriptor, STATUS_KEEPALIVE)) goto fail; @@ -185,6 +186,10 @@ 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_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) @@ -309,7 +314,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; } diff --git a/src/shared/chunk.c b/src/shared/chunk.c index 4a40487..b384e02 100644 --- a/src/shared/chunk.c +++ b/src/shared/chunk.c @@ -74,10 +74,17 @@ static unsigned long long per_file_serialize_size(File* file, bool use_metadata) if (metadata_size > ULLONG_MAX - size) return 0; size += metadata_size; - /* Entry type marker: 0 = regular file, 1 = explicit directory entry. */ + /* Entry type marker: 0 = regular file, 1 = explicit directory entry, + * 2 = 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); @@ -116,10 +123,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_dir ? 1 : 0; + int entry_type = file->is_dir ? 1 : (file->is_special ? 2 : 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); @@ -206,16 +222,45 @@ 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) { + if (entry_type != 0 && entry_type != 1 && entry_type != 2) { log_message(LOG_LEVEL_ERROR, "Invalid chunk format: bad entry type"); file_destroy(file); array_list_delete(files); return NULL; } file->is_dir = entry_type == 1; + file->is_special = entry_type == 2; 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"); diff --git a/src/shared/config.c b/src/shared/config.c index a524a25..bf2b844 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -76,6 +76,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; @@ -180,16 +183,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) && @@ -444,12 +449,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) { @@ -569,7 +578,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; diff --git a/src/shared/config.h b/src/shared/config.h index 15d5aae..c812aa0 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -115,6 +115,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; @@ -312,7 +328,7 @@ typedef struct Config { bool open_noatime; } Config; -#define PROTOCOL_VERSION "2.12.0" +#define PROTOCOL_VERSION "2.13.0" #define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024) /* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */ #define MAX_BASIS_DIRS 64 diff --git a/src/shared/file.c b/src/shared/file.c index f80e5e3..a94bd22 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -114,6 +114,9 @@ File* file_create(const char* path) { file->link_group = 0; file->link_first = false; file->hardlink_target = NULL; + file->is_special = false; + file->rdev_major = 0; + file->rdev_minor = 0; return file; } diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index 07754a0..747f7b6 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -6,6 +6,7 @@ #include #include #include +#include #include #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 @@ -1868,6 +2102,70 @@ File* file_receive_hardlink(int file_descriptor) { 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 : ""); + 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 diff --git a/src/shared/file_receive.h b/src/shared/file_receive.h index 3ecee9c..bdbaf9a 100644 --- a/src/shared/file_receive.h +++ b/src/shared/file_receive.h @@ -10,6 +10,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_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 diff --git a/src/shared/file_send.c b/src/shared/file_send.c index e94e178..82234f3 100644 --- a/src/shared/file_send.c +++ b/src/shared/file_send.c @@ -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, diff --git a/src/shared/file_send.h b/src/shared/file_send.h index 67585fc..515abf1 100644 --- a/src/shared/file_send.h +++ b/src/shared/file_send.h @@ -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, diff --git a/src/shared/file_types.h b/src/shared/file_types.h index 8e3548f..158ad20 100644 --- a/src/shared/file_types.h +++ b/src/shared/file_types.h @@ -56,6 +56,15 @@ typedef struct { int link_group; bool link_first; char* hardlink_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 diff --git a/src/shared/multiprocessing.c b/src/shared/multiprocessing.c index 2179c09..a77ffb6 100644 --- a/src/shared/multiprocessing.c +++ b/src/shared/multiprocessing.c @@ -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); diff --git a/src/shared/protocol.h b/src/shared/protocol.h index 92e32f4..9dcc8a2 100644 --- a/src/shared/protocol.h +++ b/src/shared/protocol.h @@ -90,7 +90,14 @@ enum NET_STATUS { * first (data-carrying) member's destination-relative wire path; the receiver * creates this entry as a hard link to the first member's installed file * (falling back to a byte-identical copy if link() fails). Protocol 2.12.0. */ - STATUS_HARDLINK + STATUS_HARDLINK, + /* --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); diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index e21c8d4..c90b05f 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -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) diff --git a/tests/test_chunk.c b/tests/test_chunk.c index 8271b8f..18eb570 100644 --- a/tests/test_chunk.c +++ b/tests/test_chunk.c @@ -159,8 +159,74 @@ static void test_chunk_dir_entry_roundtrip() { rmdir(dir_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_special_rdev_roundtrip(); + test_chunk_special_rdev_out_of_range_rejected(); } diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index 1aa40fb..6c6b490 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -772,8 +772,6 @@ static void test_parse_args_rejects_unimplemented_options() { "--acls", "-X", "--xattrs", - "-D", - "--devices", "--delete-excluded", "--max-delete", "--prune-empty-dirs", @@ -2346,6 +2344,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(); @@ -2356,6 +2400,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(); diff --git a/tests/test_config.c b/tests/test_config.c index 9770b84..9c9e920 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -1090,6 +1090,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(); @@ -1117,6 +1159,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(); diff --git a/tests/test_file.c b/tests/test_file.c index 0495bd2..206e2b4 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -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); } @@ -960,6 +980,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();