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 | | `-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 | | `-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 | | `-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 | | `-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 | ❌ Not Implemented | Removed because it had no effect | | `--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 | ❌ Not Implemented | | | `--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 | ❌ Not Implemented | | | `--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 | ❌ Not Implemented | | | `--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** | | `-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) | | `-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 | | `-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` `-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). 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 ## 9. Symlink Handling
| Flag | Rsync Description | FastSync Status | Notes | | 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)}, {"--numeric-ids", NULL, OPT_FLAG, offsetof(Config, numeric_ids)},
{"--atimes", "-U", OPT_FLAG, offsetof(Config, preserve_atimes)}, {"--atimes", "-U", OPT_FLAG, offsetof(Config, preserve_atimes)},
{"--crtimes", "-N", OPT_FLAG, offsetof(Config, preserve_crtimes)}, {"--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-dir-times", "-O", OPT_FLAG, offsetof(Config, omit_dir_times)},
{"--omit-link-times", "-J", OPT_FLAG, offsetof(Config, omit_link_times)}, {"--omit-link-times", "-J", OPT_FLAG, offsetof(Config, omit_link_times)},
{"--open-noatime", NULL, OPT_FLAG, offsetof(Config, open_noatime)}, {"--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")) { } else if (opt_is(argv[i], "-V", "--version")) {
printf("fastsync version %s\n", PROTOCOL_VERSION); printf("fastsync version %s\n", PROTOCOL_VERSION);
return 1; 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")) { } else if (opt_is(argv[i], "-a", "--archive")) {
config->use_compression = config->use_compression =
!config->compress_choice || strcmp(config->compress_choice, "zstd") == 0; !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; 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 /* The "unchanged" decision for --compare-dest/--copy-dest/--link-dest must
* be made on the receiver against the basis directories, which requires the * be made on the receiver against the basis directories, which requires the
* per-file STATUS_CHECK handshake: basis-dir options therefore imply * per-file STATUS_CHECK handshake: basis-dir options therefore imply
+11
View File
@@ -106,6 +106,9 @@ static bool prepare_scanner(const Config* config, int num_threads, PreparedScann
options->munge_links = config->munge_links; options->munge_links = config->munge_links;
options->checksum = config->checksum; options->checksum = config->checksum;
options->one_file_system = config->one_file_system; 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->file_list = (const FileListSet*)config->files_from_set;
options->base_filters = out->base_filters; options->base_filters = out->base_filters;
options->per_dir_filters = config->per_dir_filter; options->per_dir_filters = config->per_dir_filter;
@@ -1271,6 +1274,14 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config,
change_emit_file_sent(config, f); change_emit_file_sent(config, f);
continue; 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 stream = f->data->data == NULL && f->data->size > 0;
bool use_sendfile = bool use_sendfile =
(config->use_sendfile && !config->use_compression) || (stream && !config->use_compression); (config->use_sendfile && !config->use_compression) || (stream && !config->use_compression);
+36
View File
@@ -10,6 +10,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/sysmacros.h>
#include <threads.h> #include <threads.h>
#include <unistd.h> #include <unistd.h>
#include <limits.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 /* Append `rel` to the caller's exclusion sink, taking `mtx` when shared across
parallel worker threads. Returns false on allocation failure (list left parallel worker threads. Returns false on allocation failure (list left
unchanged). */ unchanged). */
@@ -420,6 +449,9 @@ DirectoryScanner* directory_scanner_create_with_options(const char* root_directo
scanner->munge_links = options->munge_links; scanner->munge_links = options->munge_links;
scanner->checksum = options->checksum; scanner->checksum = options->checksum;
scanner->one_file_system = options->one_file_system; 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->failed = false;
scanner->root_path = str_dup(root_directory); scanner->root_path = str_dup(root_directory);
if (!scanner->root_path) { if (!scanner->root_path) {
@@ -990,6 +1022,9 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
file->send_path = rel_copy; file->send_path = rel_copy;
rel_copy = NULL; 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)) if (scanner->hardlinks && S_ISREG(stats.st_mode))
scanner_assign_hardlink(scanner, scanner->hardlinks, file, &stats); scanner_assign_hardlink(scanner, scanner->hardlinks, file, &stats);
if (scanner->use_metadata) if (scanner->use_metadata)
@@ -1316,6 +1351,7 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
file->send_path = rel; file->send_path = rel;
rel = NULL; rel = NULL;
} }
scanner_prepare_special(options->preserve_devices, options->preserve_specials, file, &st);
if (options->hardlinks && S_ISREG(st.st_mode)) { if (options->hardlinks && S_ISREG(st.st_mode)) {
int gid; int gid;
bool is_first; bool is_first;
+10
View File
@@ -42,6 +42,12 @@ typedef struct {
bool munge_links; bool munge_links;
bool checksum; bool checksum;
bool one_file_system; 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 /* Phase 2 (files-from / filter layer). All pointers are shared read-only
* across scanner instances and worker threads; ownership stays with the * across scanner instances and worker threads; ownership stays with the
* caller (client_send). */ * caller (client_send). */
@@ -114,6 +120,10 @@ typedef struct {
bool one_file_system; bool one_file_system;
dev_t root_dev; dev_t root_dev;
bool failed; bool failed;
/* Phase 4 special/devices (see ScannerOptions). */
bool preserve_devices;
bool preserve_specials;
bool copy_devices;
/* Phase 2 (files-from / filter layer). */ /* Phase 2 (files-from / filter layer). */
char* root_path; /* transfer root (fs path) for rel computation */ char* root_path; /* transfer root (fs path) for rel computation */
char* current_rel; /* rel path of the open directory ("" == root) */ 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(" --munge-links Munge symlink targets on the wire (sender)\n");
printf(" -H, --hard-links Preserve hard-link relationships across the transfer\n"); printf(" -H, --hard-links Preserve hard-link relationships across the transfer\n");
printf(" -S, --sparse Handle sparse files efficiently\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(" --inplace Update files in-place (no temp+rename)\n");
printf( printf(
" --preallocate Allocate destination file space up front (fail-fast on full disk)\n"); " --preallocate Allocate destination file space up front (fail-fast on full disk)\n");
+7 -2
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 || while (status == STATUS_NEXT || status == STATUS_CHUNK || status == STATUS_CHECK ||
status == STATUS_KEEPALIVE || status == STATUS_ABORT || status == STATUS_CHECK_BATCH || 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_SYMLINK) { status == STATUS_SYMLINK || status == STATUS_SPECIAL) {
if (status == STATUS_KEEPALIVE) { if (status == STATUS_KEEPALIVE) {
if (!send_status(file_descriptor, STATUS_KEEPALIVE)) if (!send_status(file_descriptor, STATUS_KEEPALIVE))
goto fail; goto fail;
@@ -190,6 +190,10 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
File* sym = file_receive_symlink(file_descriptor, config); File* sym = file_receive_symlink(file_descriptor, config);
if (!sym || !sink->store_file(sym, sink->context)) if (!sym || !sink->store_file(sym, sink->context))
goto receive_error; 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) { } else if (status == STATUS_MANIFEST) {
DeleteManifest* manifest = receive_manifest_entries(file_descriptor); DeleteManifest* manifest = receive_manifest_entries(file_descriptor);
if (!manifest) 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); 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 && 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); file_destroy(file);
return false; 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; return 0;
size += metadata_size; size += metadata_size;
/* Entry type marker: 0 = regular file, 1 = explicit directory entry, /* 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) if (sizeof(int) > ULLONG_MAX - size)
return 0; return 0;
size += sizeof(int); 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) if (sizeof(size_t) > ULLONG_MAX - size)
return 0; return 0;
size += sizeof(size_t); size += sizeof(size_t);
@@ -128,10 +135,19 @@ Data* chunk_serialize(Chunk* chunk, bool use_metadata) {
memcpy(data_pointer, wire_path, path_len); memcpy(data_pointer, wire_path, path_len);
data_pointer += 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)); memcpy(data_pointer, &entry_type, sizeof(int));
data_pointer += 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) if (use_metadata)
metadata_to_buf(&data_pointer, file->metadata); metadata_to_buf(&data_pointer, file->metadata);
@@ -227,7 +243,7 @@ Chunk* chunk_deserialize(Data* data, bool use_metadata) {
} }
int entry_type; int entry_type;
memcpy(&entry_type, data_pointer, sizeof(int)); 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"); log_message(LOG_LEVEL_ERROR, "Invalid chunk format: bad entry type");
file_destroy(file); file_destroy(file);
array_list_delete(files); array_list_delete(files);
@@ -235,9 +251,38 @@ Chunk* chunk_deserialize(Data* data, bool use_metadata) {
} }
file->is_dir = entry_type == 1; file->is_dir = entry_type == 1;
file->is_symlink = entry_type == 2; file->is_symlink = entry_type == 2;
file->is_special = entry_type == 3;
data_pointer += sizeof(int); data_pointer += sizeof(int);
remaining_size -= 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 (use_metadata) {
if (remaining_size < sizeof(int)) { if (remaining_size < sizeof(int)) {
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: not enough data for metadata"); 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_xattrs = false;
config->preserve_devices = false; config->preserve_devices = false;
config->preserve_sparse = false; config->preserve_sparse = false;
config->preserve_specials = false;
config->copy_devices = false;
config->write_devices = false;
config->itemize_changes = false; config->itemize_changes = false;
config->out_format = NULL; config->out_format = NULL;
config->log_file_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->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_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_xattrs) && valid_wire_bool(config->preserve_devices) &&
valid_wire_bool(config->preserve_sparse) && valid_wire_bool(config->ignore_existing) && valid_wire_bool(config->preserve_sparse) && valid_wire_bool(config->preserve_specials) &&
valid_wire_bool(config->existing) && valid_wire_bool(config->update) && valid_wire_bool(config->copy_devices) && valid_wire_bool(config->write_devices) &&
valid_wire_bool(config->inplace) && valid_wire_bool(config->append) && valid_wire_bool(config->ignore_existing) && valid_wire_bool(config->existing) &&
valid_wire_bool(config->use_fsync) && valid_wire_bool(config->append_verify) && valid_wire_bool(config->update) && valid_wire_bool(config->inplace) &&
valid_wire_bool(config->delete_excluded) && valid_wire_bool(config->force_delete) && valid_wire_bool(config->append) && valid_wire_bool(config->use_fsync) &&
valid_wire_bool(config->delete_missing_args) && valid_wire_bool(config->delete_after) && valid_wire_bool(config->append_verify) && valid_wire_bool(config->delete_excluded) &&
valid_wire_bool(config->preallocate) && valid_wire_bool(config->delete_delay) && valid_wire_bool(config->force_delete) && valid_wire_bool(config->delete_missing_args) &&
valid_wire_bool(config->delete_during) && valid_wire_bool(config->relative) && valid_wire_bool(config->delete_after) && valid_wire_bool(config->preallocate) &&
valid_wire_bool(config->prune_empty_dirs) && valid_wire_bool(config->delay_updates) && valid_wire_bool(config->delete_delay) && valid_wire_bool(config->delete_during) &&
valid_wire_bool(config->mkpath) && !(config->delay_updates && config->inplace) && 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)) && !(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->partial) && valid_wire_bool(config->delete_before) &&
valid_wire_bool(config->checksum) && valid_wire_bool(config->eight_bit_output) && 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) { 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 : "") && 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->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_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->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_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) { 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; return false;
bool* flags[] = {&c->follow_symlinks, &c->copy_links, &c->safe_links, bool* flags[] = {&c->follow_symlinks, &c->copy_links, &c->safe_links,
&c->copy_unsafe_links, &c->preserve_hard_links, &c->preserve_acls, &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++) { for (size_t i = 0; i < sizeof(flags) / sizeof(flags[0]); i++) {
if (!receive_wire_bool(fd, flags[i])) if (!receive_wire_bool(fd, flags[i]))
return false; return false;
+16
View File
@@ -124,6 +124,22 @@ typedef struct Config {
bool preserve_xattrs; bool preserve_xattrs;
bool preserve_devices; bool preserve_devices;
bool preserve_sparse; 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 // Issue #122: Output/logging options
bool itemize_changes; bool itemize_changes;
+3
View File
@@ -116,6 +116,9 @@ File* file_create(const char* path) {
file->hardlink_target = NULL; file->hardlink_target = NULL;
file->is_symlink = false; file->is_symlink = false;
file->symlink_target = NULL; file->symlink_target = NULL;
file->is_special = false;
file->rdev_major = 0;
file->rdev_minor = 0;
return file; return file;
} }
+298
View File
@@ -6,6 +6,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/sysmacros.h>
#include <unistd.h> #include <unistd.h>
#include "array_list.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; 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, FileSaveResult file_save_to_disk_full(const char* root_directory, const File* file,
const Config* config) { const Config* config) {
/* Backups are incompatible with ignore-existing: moving the entry first /* 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; 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 /* Explicit directory entries (--dirs) carry an empty payload; the entry is
created as a directory under the receive root, applying the same secure created as a directory under the receive root, applying the same secure
mkdir-parent semantics as regular writes. Directories are created 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; 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 /* Read a delete-manifest frame (the STATUS_MANIFEST leading code has already
been consumed): a keep-set entry count followed by that many been consumed): a keep-set entry count followed by that many
destination-relative paths, then a protected-prefix count followed by that 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_directory(int file_descriptor);
File* file_receive_hardlink(int file_descriptor); File* file_receive_hardlink(int file_descriptor);
File* file_receive_symlink(int file_descriptor, const Config* config); 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); File* receive_incremental_check(int fd, const Config* config, bool* skipped);
/* A received delete-manifest frame: the keep-set (`keeps`, destination-relative /* A received delete-manifest frame: the keep-set (`keeps`, destination-relative
+19
View File
@@ -17,6 +17,25 @@
#include "metadata.h" #include "metadata.h"
#include "protocol.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, bool file_send_single_calls(File* file, int file_descriptor, bool use_metadata,
int compression_level, bool send_path) { int compression_level, bool send_path) {
return file_send_single_calls_with_skip(file, file_descriptor, use_metadata, compression_level, 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. */ /* 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, bool file_send_single_calls(File* file, int file_descriptor, bool use_metadata,
int compression_level, bool send_path); int compression_level, bool send_path);
bool file_send_single_calls_with_skip(File* file, int file_descriptor, bool use_metadata, 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. */ * data. `data` is empty for a symlink entry. Sender + receiver state. */
bool is_symlink; bool is_symlink;
char* symlink_target; 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; } File;
/* The path that should be sent on the wire and used for the receiver-side /* 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 /* Record the per-file outcome so a --remove-source-files sender learns
which sources were actually written versus skipped on the receiver. which sources were actually written versus skipped on the receiver.
Explicit directory entries have no source and are never acknowledged. */ Explicit directory entries and recreated device/special nodes have no
if (context->config->remove_source_files && !file->is_dir && !file->skip && 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)) { !receiver_outcomes_append(&context->outcomes, (unsigned char)result)) {
file_destroy(file); file_destroy(file);
pipeline_context_receiver_note_bytes_released(context, file_bytes); 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 * if --munge-links) symlink target, and optional metadata; the receiver
* creates a symlink to the unmunged target beneath the receive root (see * creates a symlink to the unmunged target beneath the receive root (see
* file_receive_symlink). Protocol 2.13.0. */ * 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); void io_set_fds(int read_fd, int write_fd);
+154
View File
@@ -3,6 +3,7 @@ import filecmp
import os import os
import random import random
import shutil import shutil
import stat
import subprocess import subprocess
import sys import sys
import time import time
@@ -18,6 +19,159 @@ from common import (
SOURCE_DIR = os.path.join(TEST_DATA_DIR, "feature_source") SOURCE_DIR = os.path.join(TEST_DATA_DIR, "feature_source")
DEST_DIR = os.path.join(TEST_DATA_DIR, "feature_dest") 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) @pytest.fixture(scope="module", autouse=True)
+66
View File
@@ -216,9 +216,75 @@ static void test_chunk_symlink_roundtrip() {
rmdir(link_path); 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() { void test_chunk() {
test_file_operations(); test_file_operations();
test_chunk_operations(); test_chunk_operations();
test_chunk_dir_entry_roundtrip(); test_chunk_dir_entry_roundtrip();
test_chunk_symlink_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", "--acls",
"-X", "-X",
"--xattrs", "--xattrs",
"-D",
"--devices",
"--delete-excluded", "--delete-excluded",
"--max-delete", "--max-delete",
"--prune-empty-dirs", "--prune-empty-dirs",
@@ -2380,6 +2378,52 @@ static void test_parse_args_omit_link_times_long() {
config_delete(cfg); 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() { void test_client_cli() {
test_validate_config_required_paths(); test_validate_config_required_paths();
test_parse_args_numeric_ids(); test_parse_args_numeric_ids();
@@ -2390,6 +2434,7 @@ void test_client_cli() {
test_parse_args_rejects_malformed_identity(); test_parse_args_rejects_malformed_identity();
test_parse_args_preallocate(); test_parse_args_preallocate();
test_parse_args_metadata_times(); test_parse_args_metadata_times();
test_parse_args_devices_specials();
test_parse_args_atimes_long_and_short(); test_parse_args_atimes_long_and_short();
test_parse_args_omit_link_times_long(); test_parse_args_omit_link_times_long();
test_parse_args_append(); 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() { void test_config() {
test_config_lifecycle(); test_config_lifecycle();
test_config_ssh_dest(); test_config_ssh_dest();
@@ -1169,6 +1211,7 @@ void test_config() {
test_config_identity_wire_roundtrip(); test_config_identity_wire_roundtrip();
test_config_receive_rejects_invalid_identity(); test_config_receive_rejects_invalid_identity();
test_config_metadata_times_wire_roundtrip(); test_config_metadata_times_wire_roundtrip();
test_config_devices_wire_roundtrip();
test_config_preallocate_wire_roundtrip(); test_config_preallocate_wire_roundtrip();
} }
test_config_delete_timing_early_helper(); test_config_delete_timing_early_helper();
+21
View File
@@ -25,6 +25,26 @@ static void test_file_create() {
file_destroy(f); 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() { static void test_file_destroy_null() {
file_destroy(NULL); file_destroy(NULL);
} }
@@ -1004,6 +1024,7 @@ static void test_dir_entry_save_to_disk() {
void test_file() { void test_file() {
test_file_create(); test_file_create();
test_file_special_rdev_valid();
test_file_destroy_null(); test_file_destroy_null();
test_file_destroy_normal(); test_file_destroy_normal();
test_file_load_data(); test_file_load_data();