diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 738e83b..dabcfdb 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -41,7 +41,7 @@ This document maps rsync's full feature set to FastSync's current implementation | `-h`, `--human-readable` | Human-readable numbers | ✅ Implemented | Formats transfer byte sizes using binary units | | `-i`, `--itemize-changes` | Per-file change summary | ✅ Implemented | Prints rsync-style `>f+++++++++` lines to stdout only for files actually sent (also under `-j`/`--threads`); unchanged files print nothing, matching single-`-i` behavior | | `--progress` | Show progress | ✅ Implemented | Progress callback in sender | -| `-P` | Same as --partial --progress | ✅ Implemented | Phase 7 Wave B: `-P` parses to `--partial` + `--progress`. On a failed/interrupted write the receiver now retains the already-written temp file at the destination path (best-effort rename instead of unlink when configured), so a later `--append`/`--append-verify` run can resume it; `--partial-dir` still stages completed files under the confined partial dir and installs them atomically. The retention never runs when `--partial` is off and only ever renames the already-written temp (never a corrupt blend) | +| `-P` | Same as --partial --progress | ✅ Implemented | Phase 7 Wave B: `-P` parses to `--partial` + `--progress`. On a failed/interrupted write the receiver now retains the already-written temp file at the destination path (best-effort rename instead of unlink when configured), so a later `--append`/`--append-verify` run can resume it; `--partial-dir` still stages completed files under the confined partial dir and installs them atomically. The retention never runs when `--partial` is off, when no data was actually written, or under `--ignore-existing`/`--existing` (the destination is not ours to overwrite), and it only ever renames the already-written temp (never a corrupt blend; a failed rename falls back to the normal unlink). See the `-S`/`--sparse` interplay note (a retained sparse temp has full logical size) | | `--out-format=FORMAT` | Custom output format | ✅ Implemented | Per-transfer template on stdout; tokens `%f` `%n` `%l` `%b` `%M` `%%` (`%b` is the source length, always `== %l`; post-compression/delta wire bytes are not counted); unknown escapes preserved | | `--log-file=FILE` | Log to file | ✅ Implemented | `log_file` config field | | `--log-file-format=FMT` | Log format | ✅ Implemented | Requires `--log-file`; writes one template line per transferred file using the same token set as `--out-format` (including `%b` `==` source length) | @@ -258,7 +258,7 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`. | `-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 | | `-J`, `--omit-link-times` | Omit symlinks 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 sets symlink times (`-l`/`--links` copies symlinks as symlinks but the receiver does not apply timestamps/owner to symlink entries), so there is nothing for an "omit" to suppress. It never breaks a normal run | | `--super` | Receiver attempts super-user activities | ❌ Not Implemented | | -| `--fake-super` | Store/recover privileged attrs via xattrs | ✅ Implemented | Phase 7 Wave B: full record **and replay**. The receiver writes the source `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative, format unchanged), then immediately re-applies it via `fake_super_restore_fd`: `fchown` (only where privileged — a non-root EPERM is logged-and-skipped, matching FastSync's identity philosophy), `fchmod`, and `futimens`. Absence or a malformed record is a silent no-op, never fatal. The recording format diverges from rsync's `user.rsync.%stat%`; no cross-tool conversion is attempted. Implies metadata transmission so the source uid/gid/mode/mtime are available. Both it and `-X`/`-A` are incompatible with `-s` (chunk serialization), rejected up front | +| `--fake-super` | Store/recover privileged attrs via xattrs | ✅ Implemented | Phase 7 Wave B: full record **and replay**. The receiver writes the source `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative, format unchanged), then immediately re-applies it via `fake_super_restore_fd`: `fchown` (only where privileged — a non-root EPERM/EACCES is skipped silently, matching FastSync's identity philosophy), `fchmod`, and `futimens`. The restored mode goes through the same sanitization as the normal metadata path (group/other write bits are never granted, so a recorded 0666 restores as 0644), so fake-super replay can never grant group/other-write that plain `--preserve` would refuse. Absence or a malformed record is a silent no-op, never fatal. The recording format diverges from rsync's `user.rsync.%stat%`; no cross-tool conversion is attempted. Implies metadata transmission so the source uid/gid/mode/mtime are available. Both it and `-X`/`-A` are incompatible with `-s` (chunk serialization), rejected up front | | `--open-noatime` | Avoid changing access time when opening files | ✅ Implemented | Sender-side policy: the sender opens source files with `O_NOATIME` (Linux) when reading them for transfer, so the open/read does NOT bump the source's on-disk access time. Degrades safely when `O_NOATIME` is unavailable (not defined) or refused (`EPERM`, since it needs `CAP_FOWNER` or file ownership): the code falls back to a normal open, so the data always transfers — only the atime-bump is skipped. It does not itself capture/preserve atime; it only avoids modifying it. **Client-only, never crosses the wire.** Exposed as `file_open_for_read()` and applied to both the buffered data path and the sendfile path | | `--numeric-ids` | Do not map uid/gid by name | ✅ Implemented | Ownership is applied through FastSync's opt-in identity path (see the Phase-4 identity notes below). `--numeric-ids` is a mapping-policy modifier: when applying ownership it uses the transmitted numeric uid/gid directly, skipping the name lookup. Without an ownership-affecting option it is inert (FastSync only applies ownership when the user opts in). It does not need `-M` to be parsed, but ownership is only applied when metadata (hence the source uid/gid) is actually transmitted (see the notes) | | `--usermap=STRING` | Map usernames | ✅ Implemented | Opt-in ownership application. rsync subset implemented: comma-separated `FROM:TO` rules evaluated in order, first match wins; `FROM`/`TO` are group/user names (resolved on the SOURCE machine at parse time), `*` (FROM matches any id / TO = the receiving process's current euid), and an `@N` or bare `N` numeric id. Rules are carried over the wire as resolved numeric id pairs; the receiver applies a matching rule (else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup) via an fd-relative `fchown`. Malformed/unresolvable specs are rejected with a clear error, never a silent no-op. Implies metadata preservation so the source uid/gid travel. Only effective when the receiver can actually change ownership (root or membership); otherwise it warns and continues | @@ -577,8 +577,8 @@ now transmits targets (the prior behavior was broken/partial); its status moved | Flag | Rsync Description | FastSync Status | Notes | |------|-------------------|-----------------|-------| -| `-S`, `--sparse` | Sparse block handling | ✅ Implemented | Phase 7 Wave B: real hole preservation with no wire change. The receiver's sparse-aware writer (`write_all_sparse`, next to `write_all` in `src/shared/file.c` and `src/shared/file_store.c`) walks the in-memory file image and emits any all-zero run ≥ 4096 bytes as a hole via `lseek(SEEK_CUR)` (the pre-size `ftruncate` guarantees the offset bookkeeping and logical size), `ftruncate(size)` after the last run pins the final size even with a hole tail. Wired into both the atomic temp+rename store and `--inplace` when `sparse` is set; the non-sparse path is byte-identical to before | -| `--preallocate` | Allocate dest files before writing | ✅ Implemented | The receiver preallocates the destination file's full expected space before any data is written, so a transfer that would overflow disk fails fast at allocation time (a clean error, not a half-written file) and the file is laid out contiguously, avoiding fragmentation. Crosses the wire (the config frame carries a `preallocate` boolean; `PROTOCOL_VERSION` bumped **2.10.0 → 2.11.0**, peers must match) so the sender knows the receiver will preallocate and the receiver performs it. **Allocation approach:** `posix_fallocate()` is preferred because it reserves *real* disk blocks (true fail-fast on ENOSPC), falling back to plain `ftruncate()` only when the filesystem reports the allocation is unsupported (`EOPNOTSUPP`/`ENOSYS`); `ftruncate` still extends the logical size so the intent degrades gracefully. **Fallback/error semantics:** `EOPNOTSUPP`/`ENOSYS` → clean fallback to `ftruncate` (best-effort, preallocates the logical size and never fails a transfer on filesystems that lack `posix_fallocate`); a genuine allocation failure (`ENOSPC`/`EDQUOT`/`EFBIG`/…) aborts the file/receive with a distinct `preallocate failed ... transfer aborted` error — it does **not** fall back to a normal non-preallocated write, preserving the fail-fast purpose. **Size-known requirement:** preallocation only runs when the final size is already known up front (the normal regular-file case); unknown-length data is skipped (never failed). **Orthogonality:** applies uniformly across the atomic temp+rename store path, `--inplace`, `--partial`/`--partial-dir`, `--delay-updates` (the staged temp file is preallocated before data flows) and the `--link-dest` copy fallback; it neither implies nor conflicts with `-s`, `--append`, or delta. rsync-divergence: rsync signals that `--preallocate` is ignored with `--sparse`; FastSync simply preallocates first and still honours `--sparse`'s `ftruncate` sizing/trim, so the two combine rather than one being silently ignored. See the Phase-4 preallocate notes below | +| `-S`, `--sparse` | Sparse block handling | ✅ Implemented | Phase 7 Wave B: real hole preservation with no wire change. The receiver's sparse-aware writer (`write_all_sparse`, next to `write_all` in `src/shared/file.c` and `src/shared/file_store.c`) walks the in-memory file image and emits any all-zero run ≥ 4096 bytes as a hole via `lseek(SEEK_CUR)` (the pre-size `ftruncate` guarantees the offset bookkeeping and logical size), `ftruncate(size)` after the last run pins the final size even with a hole tail. Wired into both the atomic temp+rename store and `--inplace` when `sparse` is set; the non-sparse path is byte-identical to before. **Sparse wins over `--preallocate`** (posix_fallocate is skipped when sparse is set, so the holes are not re-allocated). Interplay note: under `--partial` a retained sparse temp already has the full logical size (trailing content is holes), so `--append`'s "shorter destination" resume does not re-run; the retained file is still valid and a normal re-transfer (or `-W`/delta) repairs it — documented so the combination is never surprising | +| `--preallocate` | Allocate dest files before writing | ✅ Implemented | The receiver preallocates the destination file's full expected space before any data is written, so a transfer that would overflow disk fails fast at allocation time (a clean error, not a half-written file) and the file is laid out contiguously, avoiding fragmentation. Crosses the wire (the config frame carries a `preallocate` boolean; `PROTOCOL_VERSION` bumped **2.10.0 → 2.11.0**, peers must match) so the sender knows the receiver will preallocate and the receiver performs it. **Allocation approach:** `posix_fallocate()` is preferred because it reserves *real* disk blocks (true fail-fast on ENOSPC), falling back to plain `ftruncate()` only when the filesystem reports the allocation is unsupported (`EOPNOTSUPP`/`ENOSYS`); `ftruncate` still extends the logical size so the intent degrades gracefully. **Fallback/error semantics:** `EOPNOTSUPP`/`ENOSYS` → clean fallback to `ftruncate` (best-effort, preallocates the logical size and never fails a transfer on filesystems that lack `posix_fallocate`); a genuine allocation failure (`ENOSPC`/`EDQUOT`/`EFBIG`/…) aborts the file/receive with a distinct `preallocate failed ... transfer aborted` error — it does **not** fall back to a normal non-preallocated write, preserving the fail-fast purpose. **Size-known requirement:** preallocation only runs when the final size is already known up front (the normal regular-file case); unknown-length data is skipped (never failed). **Orthogonality:** applies uniformly across the atomic temp+rename store path, `--inplace`, `--partial`/`--partial-dir`, `--delay-updates` (the staged temp file is preallocated before data flows) and the `--link-dest` copy fallback; it neither implies nor conflicts with `-s`, `--append`, or delta. rsync-divergence: rsync signals that `--preallocate` is ignored with `--sparse`; FastSync gives **sparse precedence** — when both are set, `posix_fallocate` is skipped so the holes the sparse writer creates are not re-allocated (the `ftruncate` presize sizing stays), matching the intent of "sparse wins". See the Phase-4 preallocate notes below | **Preallocate notes (Phase 4, preallocate wave):** `--preallocate` is implemented as a real receiver-side allocation of the destination file's space before data is written. It is a plain boolean config flag that crosses the wire (serialized in the config frame's selection-options block, mirroring `--inplace`/`--append`/`--force`), so the run requires matching ends: `PROTOCOL_VERSION` was bumped **2.10.0 → 2.11.0** (peers must match or the version check fails). The allocation is performed on the exact destination fd, immediately after it is opened, before any bytes are streamed; `posix_fallocate` (and the `ftruncate` fallback) leave the fd's file offset untouched, so the subsequent data write at offset 0 is unaffected and complete. Because FastSync writes each file's byte payload in one in-memory batch, the "full expected size" is exactly the known `data_size`, which is what gets preallocated. Unknown-length/streamed payloads are skipped rather than failed. A failed allocation logs a distinct `preallocate failed` error and aborts the file (the atomic temp is unlinked, the inplace target is left untrimmed) so the run fails cleanly and never silently degrades to a non-preallocated write — preserving rsync's fail-fast intent on a full disk. @@ -812,7 +812,7 @@ These are the last compatibility items and the closing phase toward rsync flag p | `-T` / `--timeout` | `-T` = `--temp-dir` | → `--timeout` (long-only) | | `-a` / `--archive` (= `-c -m -M`) | `-a` = `-rlptgoD` | → becomes **real rsync `-a`** after the renames | -**Wave B — Output & filesystem completion (✅ implemented).** `-S`/`--sparse` (`⚠️→✅`): real hole preservation — a sparse-aware writer (`write_all_sparse`) skips all-zero runs ≥ 4096 bytes with `lseek(SEEK_CUR)` and `ftruncate`s the final size, wired into both the atomic temp+rename store and `--inplace` receiver-side with **no wire change** (the full file image is already in memory; the ftruncate presize is kept). `-P` (`⚠️→✅`): interrupted-write retention — on a save failure after data reached the temp fd, `--partial` now renames the already-written temp to the destination path (best-effort; falls through to the normal unlink on failure, never retains when `--partial` is off) so a later `--append`/`--append-verify` run can resume. `--block-size=SIZE` (`⚠️→✅`): promoted after verification — `--block-size` is now an alias for `--delta-block`, both set `config->delta_block_size`, which the delta engine already honored end-to-end (`delta_signature_create_seeded` + `delta_apply`); out-of-range values keep the default. `--fake-super` (`⚠️→✅`): added `fake_super_restore_fd` to parse and re-apply the recorded `user.fastsync.stat` record fd-relative (fchown best-effort/non-root skipped, fchmod, futimens); a save under `--fake-super` now re-applies the recorded attrs instead of only recording them, with the recording format unchanged. `--stderr=client` (`⚠️→⛔ Impossible/Divergence`): FastSync has no rsync client-message channel, and `client` is rejected at CLI parse — the rejection is the documented behavior (unit-tested). `-N`/`--crtimes` (`⚠️→⛔ Impossible/Divergence`): birth-times cannot be set by any portable fs call (`utimensat` sets only atime/mtime); capture/transmit stays, setting is impossible, the flag is accepted and safely inert. +**Wave B — Output & filesystem completion (✅ implemented).** `-S`/`--sparse` (`⚠️→✅`): real hole preservation — a sparse-aware writer (`write_all_sparse`) skips all-zero runs ≥ 4096 bytes with `lseek(SEEK_CUR)` and `ftruncate`s the final size, wired into both the atomic temp+rename store and `--inplace` receiver-side with **no wire change** (the full file image is already in memory; the ftruncate presize is kept). `-P` (`⚠️→✅`): interrupted-write retention — on a save failure after data reached the temp fd, `--partial` now renames the already-written temp to the destination path (best-effort; falls through to the normal unlink on failure, never retains when `--partial` is off) so a later `--append`/`--append-verify` run can resume. `--block-size=SIZE` (`⚠️→✅`): promoted after verification — `--block-size` is now an alias for `--delta-block`, both set `config->delta_block_size`, which the delta engine already honored end-to-end (`delta_signature_create_seeded` + `delta_apply`); out-of-range values keep the default. `--fake-super` (`⚠️→✅`): added `fake_super_restore_fd` to parse and re-apply the recorded `user.fastsync.stat` record fd-relative (fchown best-effort/non-root skipped, fchmod, futimens); a save under `--fake-super` now re-applies the recorded attrs instead of only recording them, with the recording format unchanged. `--stderr=client` (`⚠️→⛔ Impossible/Divergence`): FastSync has no rsync client-message channel, and `client` is rejected at CLI parse — the rejection is the documented behavior (unit-tested). `-N`/`--crtimes` (`⚠️→⛔ Impossible/Divergence`): birth-times cannot be set by any portable fs call (`utimensat` sets only atime/mtime); capture/transmit stays, setting is impossible, the flag is accepted and safely inert. Review-hardening (post-eval): fake-super replay applies the mode through the same sanitization as the normal metadata path (group/other write bits are never granted); `--sparse` takes precedence over `--preallocate` (posix_fallocate skipped so holes survive); `--partial` retention is disabled for `--no_replace` (ignore/existing) and only marks a write-attempt after the actual write begins; `--block-size=SIZE`/`--delta-block=SIZE` inline forms are accepted. **Wave C — Devices & special files (finalize statuses + tests).** `--devices`, `--specials`, `--copy-devices`, `--write-devices` (`⚠️`) are already functionally implemented with documented, safety-driven divergences (CAP_MKNOD per-entry skip; FIFO-recreate-with-no-socket; size-bounded content copy; confined best-effort device write). During this wave each is promoted to its final status with coverage tests: `--specials` **sockets** cannot be recreated by any standard filesystem call → mark **Impossible/Divergence**; the rest are complete → **✅**. diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 3b831f9..78cbb3f 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -1079,18 +1079,34 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args, if (config_add_pattern(&config->include_patterns, &config->include_count, argv[++i], "--include") != 0) return -1; + } else if (strncmp(argv[i], "--delta-block=", 14) == 0) { + unsigned long long val; + if (parse_ull_arg(argv[i] + 14, &val, "--block-size/--delta-block") != 0) + return -1; + if (val >= DELTA_BLOCK_SIZE_MIN && val <= DELTA_BLOCK_SIZE_MAX) + config->delta_block_size = (uint32_t)val; + else + log_message(LOG_LEVEL_WARNING, "block size value %llu out of range, using default", val); + } else if (strncmp(argv[i], "--block-size=", 13) == 0) { + unsigned long long val; + if (parse_ull_arg(argv[i] + 13, &val, "--block-size/--delta-block") != 0) + return -1; + if (val >= DELTA_BLOCK_SIZE_MIN && val <= DELTA_BLOCK_SIZE_MAX) + config->delta_block_size = (uint32_t)val; + else + log_message(LOG_LEVEL_WARNING, "block size value %llu out of range, using default", val); } else if (opt_is(argv[i], "--delta-block", "--block-size")) { if (i + 1 >= argc) { log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); return -1; } unsigned long long val; - if (parse_ull_arg(argv[++i], &val, "--delta-block") != 0) + if (parse_ull_arg(argv[++i], &val, "--block-size/--delta-block") != 0) return -1; if (val >= DELTA_BLOCK_SIZE_MIN && val <= DELTA_BLOCK_SIZE_MAX) config->delta_block_size = (uint32_t)val; else - log_message(LOG_LEVEL_WARNING, "--delta-block value %llu out of range, using default", val); + log_message(LOG_LEVEL_WARNING, "block size value %llu out of range, using default", val); } else if (opt_is(argv[i], "--delta-max", NULL)) { if (i + 1 >= argc) { log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); diff --git a/src/shared/file.c b/src/shared/file.c index ae5ed5f..e772926 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -882,9 +882,12 @@ static bool file_to_disk_secure_impl(const char* path, const void* data, ok = true; } else { /* Preallocate the expected payload size before writing so an - out-of-space condition fails cleanly up front (--preallocate). */ + out-of-space condition fails cleanly up front (--preallocate). + --sparse takes precedence: posix_fallocate would allocate every + block, defeating the holes the sparse writer would create, so the + two never combine here (the ftruncate presize below stays). */ int prealloc_rc = 0; - if (preallocate && data_size > 0) { + if (preallocate && !sparse && data_size > 0) { prealloc_rc = preallocate_fd(fd, data_size); if (prealloc_rc != 0) log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted", path, @@ -991,20 +994,24 @@ static bool file_to_disk_secure_impl(const char* path, const void* data, if (fd < 0) continue; /* EEXIST (or a transient open error): try a fresh name. */ int prealloc_rc = 0; - if (preallocate && data_size > 0) { + if (preallocate && !sparse && data_size > 0) { prealloc_rc = preallocate_fd(fd, data_size); if (prealloc_rc != 0) log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted", path, strerror(prealloc_rc)); } if (prealloc_rc == 0) { - write_attempted = true; lseek(fd, 0, SEEK_SET); if (sparse && data_size > 0) ok = ftruncate(fd, (off_t)data_size) == 0; - if (ok || (!sparse || data_size == 0)) + /* A real write attempt begins here (the ftruncate presize succeeded or + no presize applies): a later mid-write / metadata / fsync / install + failure may leave partial data that --partial retention can rename. */ + if (ok || (!sparse || data_size == 0)) { + write_attempted = true; ok = sparse && data_size > 0 ? write_all_sparse(fd, (const unsigned char*)data, data_size) : write_all(fd, data, data_size); + } if (ok && metadata) ok = file_restore_metadata_fd(fd, metadata, preserve_executability); if (ok) @@ -1047,8 +1054,10 @@ static bool file_to_disk_secure_impl(const char* path, const void* data, This only ever renames the already-written temp (never a corrupt blend); the rename can fail (cross-device, permissions) and we then fall through to the normal unlink cleanup. Never retains when - keep_partial is off. */ - if (!keep_partial || !write_attempted || + keep_partial is off, when nothing was actually written, or under + --ignore-existing/--existing (no_replace), where the destination is + not ours to overwrite. */ + if (!keep_partial || !write_attempted || no_replace || renameat(scratch_dirfd >= 0 ? scratch_dirfd : dirfd, tmp, dirfd, leaf) != 0) unlinkat(scratch_dirfd >= 0 ? scratch_dirfd : dirfd, tmp, 0); } diff --git a/src/shared/xattr.c b/src/shared/xattr.c index a4d1287..be4c3d0 100644 --- a/src/shared/xattr.c +++ b/src/shared/xattr.c @@ -352,13 +352,19 @@ bool fake_super_restore_fd(int fd) { 5) return false; /* malformed record: skip, never fatal */ - /* Owner is applied best-effort only: a non-root process cannot chown and must - not abort the transfer for that reason (FastSync identity philosophy). */ - if (fchown(fd, (uid_t)ul_uid, (gid_t)ul_gid) != 0 && errno != EPERM && errno != EACCES && - errno != EINVAL) + /* Owner is applied best-effort only: a non-root process cannot chown and + must not abort the transfer for that reason (FastSync identity philosophy). + EPERM/EACCES (expected for a non-root receiver) are skipped silently; a + genuine EINVAL (an impossible stored id) is logged so the corruption is + not hidden. */ + if (fchown(fd, (uid_t)ul_uid, (gid_t)ul_gid) != 0 && errno != EPERM && errno != EACCES) log_message(LOG_LEVEL_WARNING, "--fake-super: could not restore owner on destination file: %s", strerror(errno)); - if (fchmod(fd, (mode_t)(ul_mode & 07777U)) != 0) + /* Mode is applied through the same sanitization the normal metadata path + uses (metadata_mode): group/other write bits are never granted, so a + recorded source mode of 0666 restores as 0644 — identical to a non-fake- + super --preserve run, never a privilege-granting regression. */ + if (fchmod(fd, (mode_t)(ul_mode & 0777U & ~(S_IWGRP | S_IWOTH))) != 0) log_message(LOG_LEVEL_WARNING, "--fake-super: could not restore mode on destination file: %s", strerror(errno)); struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT}, diff --git a/src/shared/xattr.h b/src/shared/xattr.h index 6f98d7c..f615fbe 100644 --- a/src/shared/xattr.h +++ b/src/shared/xattr.h @@ -89,9 +89,11 @@ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, int6 /* --fake-super replay: parse the FAKESUPER_XATTR record previously written on * `fd` by fake_super_store_fd and re-apply uid/gid/mode/mtime fd-relative. * Best-effort: absence of the xattr or a malformed record is a silent no-op - * that never fails the transfer, and fchown is applied only when permitted - * (a non-root EPERM is logged and skipped, matching FastSync's identity - * philosophy). Returns true when the xattr was present and parsed. */ + * that never fails the transfer; fchown is applied only when permitted (a + * non-root EPERM/EACCES is skipped silently, matching FastSync's identity + * philosophy), and the mode is sanitized exactly like the normal metadata path + * (group/other write bits never granted). Returns true when the xattr was + * present and parsed. */ bool fake_super_restore_fd(int fd); #endif \ No newline at end of file diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index bfa7b39..30cbc3d 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -4350,22 +4350,23 @@ class TestBlockSize: payload = os.urandom(300 * 1024) # enough for several 1 KiB blocks with open(os.path.join(source, "big.bin"), "wb") as f: f.write(payload) - # First run installs the file; second run with delta + a small block size. - result, _ = run_client(source, dest, flags=["-S"], - port=shared_server.port) + # First run installs the file as the destination basis (do NOT wipe it + # afterwards: the second run's delta must be computed against it). + result, _ = run_client(source, dest, port=shared_server.port) assert result.returncode == 0 received = get_dest_received_dir(dest, source) - # Change the source, then delta-transfer with a non-default block size. + # Extend the source so it differs from the installed basis: the second + # run with --delta must compute a real delta against that basis. with open(os.path.join(source, "big.bin"), "ab") as f: f.write(os.urandom(4096)) - clean_dir(dest) result, _ = run_client(source, dest, flags=["--incremental", "--delta", flag, "1024"], port=shared_server.port) assert result.returncode == 0, \ f"{flag} 1024 delta transfer failed: {(result.stderr or result.stdout)[:300]}" with open(os.path.join(received, "big.bin"), "rb") as f: - assert f.read() == open(os.path.join(source, "big.bin"), "rb").read() + with open(os.path.join(source, "big.bin"), "rb") as expect: + assert f.read() == expect.read() class TestOmitTimes: """-O/--omit-dir-times and -J/--omit-link-times are recognized and cross the diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index ffce540..567445a 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -2930,6 +2930,13 @@ static void test_parse_args_block_size() { EXPECT_EQ_INT(parse_args(cfg, 5, argv_delta, positional_args, &positional_count), 0); EXPECT_EQ_INT((int)cfg->delta_block_size, 2048); + /* Inline =SIZE forms (the documented rsync spelling) are accepted too. */ + cfg->delta_block_size = DELTA_BLOCK_SIZE_DEFAULT; + char* argv_eq[] = {"fastsync", "--block-size=8192", "/src", "/dst"}; + positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 4, argv_eq, positional_args, &positional_count), 0); + EXPECT_EQ_INT((int)cfg->delta_block_size, 8192); + /* Out of range: parsed, warned, and the default is kept. */ cfg->delta_block_size = DELTA_BLOCK_SIZE_DEFAULT; char* argv_bad[] = {"fastsync", "--block-size", "1", "/src", "/dst"}; diff --git a/tests/test_file.c b/tests/test_file.c index ad1d002..08f0257 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -1244,14 +1244,15 @@ static void test_file_write_to_disk_sparse_preserves_holes() { } EXPECT_TRUE(file_store_write_secure(path, buf, size, false, true, NULL, false)); - free(buf); /* Logical size must equal data_size exactly. */ struct stat st; EXPECT_EQ_INT(stat(path, &st), 0); EXPECT_EQ_INT((int)st.st_size, (int)size); - /* Content must round-trip exactly. */ + /* Content must round-trip exactly: the full readback must equal the original + buffer byte-for-byte (header, the hole region staying zero, and tail) — + a writer bug in the lseek-offset bookkeeping would show up here. */ int fd = open(path, O_RDONLY); EXPECT_TRUE(fd >= 0); /* cppcheck-suppress knownConditionTrueFalse -- EXPECT_TRUE above asserts, @@ -1267,14 +1268,8 @@ static void test_file_write_to_disk_sparse_preserves_holes() { got += (unsigned long long)n; } EXPECT_EQ_INT((int)got, (int)size); - if (got == size) { - /* The middle hole region stays all-zero. */ - for (unsigned long long i = 4096; i < size - 4096; i++) - if (readback[i] != 0) { - EXPECT_EQ_INT(0, 1); - break; - } - } + if (got == size) + EXPECT_EQ_INT(memcmp(readback, buf, size), 0); free(readback); } /* Tolerant sparseness check: seek for holes; skip if unsupported. */ @@ -1288,6 +1283,7 @@ static void test_file_write_to_disk_sparse_preserves_holes() { } close(fd); } + free(buf); unlink(path); } diff --git a/tests/test_xattr.c b/tests/test_xattr.c index ed26996..60d430b 100644 --- a/tests/test_xattr.c +++ b/tests/test_xattr.c @@ -250,6 +250,14 @@ static void test_fake_super_restore() { EXPECT_EQ_INT(fstat(fd, &st), 0); EXPECT_EQ_INT((int)(st.st_mode & 07777), 0751); + /* Mode sanitization: the normal metadata path never grants group/other write + bits, and fake-super replay must not re-add them (a recorded 0666 restores + as 0644, never as world-writable). */ + fake_super_store_fd(fd, 1001, 1002, 0666, 1700000000, 0); + EXPECT_TRUE(fake_super_restore_fd(fd)); + EXPECT_EQ_INT(fstat(fd, &st), 0); + EXPECT_EQ_INT((int)(st.st_mode & 0777), 0644); + /* Restore with a malformed record must skip without failing. */ time_t before = st.st_mtime; int wfd = open(path, O_RDONLY);