Merge feat/p7-cli-namespace: Phase 7 Wave A CLI-namespace parity (renames colliding short flags to rsync parity)
CI / lint (push) Successful in 1m17s
CI / sanitizers (address) (push) Successful in 54s
CI / sanitizers (undefined) (push) Successful in 54s
CI / fuzz-build (push) Successful in 22s
CI / coverage (push) Successful in 46s
CI / valgrind (push) Successful in 38s
CI / build-and-test (push) Successful in 4m30s

This commit is contained in:
2026-09-11 14:44:58 +02:00
12 changed files with 359 additions and 302 deletions
+31 -26
View File
@@ -88,16 +88,18 @@ partial, alternate, and planned behavior.
| Argument | Description | | Argument | Description |
|----------|-------------| |----------|-------------|
| Positional | `<source> <dest>` — automatic SSH detection if dest contains `:` | | Positional | `<source> <dest>` — automatic SSH detection if dest contains `:` |
| `-c [level]` | Compression with optional level (1–22, default 5) | | `-c, --checksum` | Verify content by checksum instead of size+mtime |
| `-z [level]` | Alias for `-c` | | `-z, --compress [level]` | Enable streaming zstd compression (level 1–22, default 5) |
| `-a, --archive` | Archive mode: enables `-c -m -M` (no `-s`) | | `-a, --archive` | rsync archive mode (`-rlptgoD`): links, metadata, devices and specials (not compression/multithreading) |
| `-m` | Multithreading mode | | `-j, --threads` | Multithreading mode |
| `-s` | Chunk serialization (batch all files per chunk) | | `-m` | rsync `--prune-empty-dirs` (short form now rsync-parity) |
| `--secluded-args` | Accepted as an rsync compatibility option with no effect; `-s` remains chunk serialization. | | `--chunk-serialization` | Chunk serialization (batch all files per chunk; long form only) |
| `-f, --sendfile` | Sendfile zero-copy. Incompatible with `-c` / `-s`. TCP only. | | `-s` | rsync `--secluded-args` compatibility no-op (remote SSH argv is already injection-safe) |
| `-M, --preserve` | Preserve supported file metadata (mode and mtime; ownership and atime are unsupported) | | `--sendfile` | Sendfile zero-copy. Incompatible with compression / chunk serialization. TCP only. Long form only. |
| `--preserve` | Preserve supported file metadata (mode and mtime; ownership and atime are unsupported) |
| `-n, --dry-run` | Scan and print what would be transferred | | `-n, --dry-run` | Scan and print what would be transferred |
| `-p <port>` | SSH port (default: 22) | | `-p, --perms` | Preserve permission bits (part of the metadata bundle) |
| `--ssh-port <port>` | SSH port (default: 22) |
| `-v, --verbose` | Enable debug logging | | `-v, --verbose` | Enable debug logging |
| `-q, --quiet` | Suppress non-error output | | `-q, --quiet` | Suppress non-error output |
| `--progress` | Show real-time transfer speed | | `--progress` | Show real-time transfer speed |
@@ -113,7 +115,7 @@ partial, alternate, and planned behavior.
| `--max-size <n>` | Skip files larger than n bytes | | `--max-size <n>` | Skip files larger than n bytes |
| `--min-size <n>` | Skip files smaller than n bytes | | `--min-size <n>` | Skip files smaller than n bytes |
| `--max-alloc <SIZE>` | Maximum single allocation (binary units: B, K, M, G, T, P, E; default 1G) | | `--max-alloc <SIZE>` | Maximum single allocation (binary units: B, K, M, G, T, P, E; default 1G) |
| `--incremental` | Skip files unchanged since last transfer (size + mtime). Auto-enables `--preserve`. Incompatible with `-s`. | | `--incremental` | Skip files unchanged since last transfer (size + mtime). Auto-enables `--preserve`. Incompatible with `--chunk-serialization`. |
| `--existing` | Skip files not already present at the destination; update existing files normally. | | `--existing` | Skip files not already present at the destination; update existing files normally. |
| `--bwlimit <KB/s>` | Bandwidth limit in kilobytes per second | | `--bwlimit <KB/s>` | Bandwidth limit in kilobytes per second |
| `--chunk-size <n>` | Chunk size in bytes (default: 10485760) | | `--chunk-size <n>` | Chunk size in bytes (default: 10485760) |
@@ -350,16 +352,16 @@ features without changing the meaning of ordinary compatibility options.
| Option | Purpose | | Option | Purpose |
|---|---| |---|---|
| `-m` | Enable the multithreaded scanner/loader/sender pipeline. | | `-j`, `--threads` | Enable the multithreaded scanner/loader/sender pipeline. |
| `-c [level]`, `-z [level]` | Enable streaming zstd compression, levels 1-22. | | `-z [level]`, `--compress [level]` | Enable streaming zstd compression, levels 1-22. |
| `--compress-level <n>` | Set the zstd compression level. | | `--compress-level <n>` | Set the zstd compression level. |
| `--zc <alg>` | Alias for `--compress-choice`. FastSync supports `zstd` and `none`. | | `--zc <alg>` | Alias for `--compress-choice`. FastSync supports `zstd` and `none`. |
| `--zl <n>` | Alias for `--compress-level`. | | `--zl <n>` | Alias for `--compress-level`. |
| `--skip-compress <list>` | Skip compression for comma-separated suffixes; incompatible with `-s`. | | `--skip-compress <list>` | Skip compression for comma-separated suffixes; incompatible with `--chunk-serialization`. |
| `--compress-threads <n>` | Use `n` zstd compression workers. Requires compression and a zstd build with threaded support; the setting affects sender CPU work only. | | `--compress-threads <n>` | Use `n` zstd compression workers. Requires compression and a zstd build with threaded support; the setting affects sender CPU work only. |
| `--chunk-size <bytes>` | Set the transfer chunk size. | | `--chunk-size <bytes>` | Set the transfer chunk size. |
| `-s` | Enable FastSync chunk serialization. | | `--chunk-serialization` | Enable FastSync chunk serialization (long form only; `-s` is rsync's `--secluded-args`). |
| `-f`, `--sendfile` | Use TCP `sendfile()` zero-copy transfer. Incompatible with compression and chunk serialization. | | `--sendfile` | Use TCP `sendfile()` zero-copy transfer. Incompatible with compression and chunk serialization. Long form only. |
| `--delta` | Use FastSync-native block delta transfer. Requires `--incremental`. | | `--delta` | Use FastSync-native block delta transfer. Requires `--incremental`. |
| `--delta-block <bytes>` | Set the FastSync delta block size. | | `--delta-block <bytes>` | Set the FastSync delta block size. |
| `--delta-max <bytes>` | Limit files eligible for FastSync delta transfer. | | `--delta-max <bytes>` | Limit files eligible for FastSync delta transfer. |
@@ -372,15 +374,18 @@ features without changing the meaning of ordinary compatibility options.
| `--timeout <seconds>` | Set I/O timeout. | | `--timeout <seconds>` | Set I/O timeout. |
| `--contimeout <seconds>` | Set connection timeout. | | `--contimeout <seconds>` | Set connection timeout. |
Current short-option conflicts are tracked as compatibility work. In Short-option conflicts with rsync have been resolved for the CLI namespace
particular, FastSync currently uses `-p` for SSH port, `-s` for chunk (Phase 7): `-c` is now rsync's `--checksum`, `-m` is `--prune-empty-dirs`, `-M`
serialization, and `-S` for sparse handling. These meanings must be reconciled is `--remote-option`, `-f` is `--filter`, `-s` is `--secluded-args`, `-p` is
before FastSync can claim full rsync CLI compatibility. `--perms`, and `-T` is `--temp-dir`. FastSync's own flags were renamed to
long-form-only or new shorts: multithreading is `-j`/`--threads`, metadata
is `--preserve`, sendfile is `--sendfile`, chunk serialization is
`--chunk-serialization`, timeout is `--timeout`, and SSH port is `--ssh-port`.
`-a`/`--archive` is now real rsync archive (`-rlptgoD`).
`--secluded-args` is accepted as a long-form compatibility no-op. It does not `--secluded-args` (and its short form `-s`) is accepted as a compatibility
change FastSync's transport or protocol behavior. The rsync short form `-s` is no-op. It does not change FastSync's transport or protocol behavior, because
intentionally not aliased because it remains FastSync's chunk-serialization remote SSH argv is already built injection-safe.
option.
## Client Options ## Client Options
@@ -388,7 +393,7 @@ option.
| Option | Description | | Option | Description |
|---|---| |---|---|
| `-a`, `--archive` | Enable current archive preset. Full rsync archive semantics are planned. | | `-a`, `--archive` | rsync archive mode (`-rlptgoD`): links, metadata, devices and specials. |
| `-n`, `--dry-run` | Scan and report without writing files. | | `-n`, `--dry-run` | Scan and report without writing files. |
| `--delete` | Request removal of destination entries absent from the source. The server must allow deletion. Default timing is delete-after: extras are removed only after the whole transfer succeeded. | | `--delete` | Request removal of destination entries absent from the source. The server must allow deletion. Default timing is delete-after: extras are removed only after the whole transfer succeeded. |
| `--delete-before` | Delete extras before the transfer starts (implies `--delete`). | | `--delete-before` | Delete extras before the transfer starts (implies `--delete`). |
@@ -417,7 +422,7 @@ use with `--partial`. |
| Option | Description | | Option | Description |
|---|---| |---|---|
| `-M`, `--preserve` | Preserve supported file metadata, currently mode and modification time. | | `--preserve` | Preserve supported file metadata, currently mode and modification time (long form only). |
| `-l`, `--links` | Request symlink preservation; | `-l`, `--links` | Request symlink preservation;
link-target transfer remains incomplete. | link-target transfer remains incomplete. |
| `--copy-links` | Copy symlink referents. | | `--copy-links` | Copy symlink referents. |
@@ -440,7 +445,7 @@ link-target transfer remains incomplete. |
| Option | Description | | Option | Description |
|---|---| |---|---|
| `-p <port>` | SSH port in the current CLI. This conflicts with rsync's `-p` permissions option and is planned for correction. | | `--ssh-port <port>` | SSH port for the SSH transport (default: 22). Note the short `-p` is now rsync's `--perms`. |
| `--fastsync-server-path <path>` | Remote FastSync server path for SSH mode. | | `--fastsync-server-path <path>` | Remote FastSync server path for SSH mode. |
| `--source-dir <path>` | Set the source directory explicitly. | | `--source-dir <path>` | Set the source directory explicitly. |
| `--dest-dir <path>` | Set the destination directory explicitly. | | `--dest-dir <path>` | Set the destination directory explicitly. |
+27 -27
View File
@@ -6,8 +6,8 @@ This document maps rsync's full feature set to FastSync's current implementation
| Status | Count | Description | | Status | Count | Description |
|--------|-------|-------------| |--------|-------|-------------|
| ✅ Implemented | 129 | Feature works end-to-end | | ✅ Implemented | 132 | Feature works end-to-end |
| 🔀 Alt Arg | 3 | Functionality exists but under different flag/semantics | | 🔀 Alt Arg | 0 | Functionality exists but under different flag/semantics |
| ⚠️ Partial | 10 | Flag parsed/stored but behavior incomplete | | ⚠️ Partial | 10 | Flag parsed/stored but behavior incomplete |
| 🔄 Compatibility No-op | 3 | Flag is accepted for CLI compatibility but has no effect | | 🔄 Compatibility No-op | 3 | Flag is accepted for CLI compatibility but has no effect |
| ❌ Not Implemented | 2 | Flag not recognized or no behavior | | ❌ Not Implemented | 2 | Flag not recognized or no behavior |
@@ -19,7 +19,7 @@ This document maps rsync's full feature set to FastSync's current implementation
| Flag | Rsync Description | FastSync Status | Notes | | Flag | Rsync Description | FastSync Status | Notes |
|------|-------------------|-----------------|-------| |------|-------------------|-----------------|-------|
| `-a`, `--archive` | Archive mode is -rlptgoD | 🔀 Alt Arg | Maps to -c -m -M (compression + multithread + metadata) | | `-a`, `--archive` | Archive mode is -rlptgoD | ✅ Implemented | Phase 7 Wave A: real rsync archive. `-a`/`--archive` now implies `--links` + metadata (perms/times/group/owner as FastSync's broad bundle) + `--devices` + `--specials`. FastSync is always recursive, so no `-r` is needed. It no longer implies compression or multithreading (those moved to `-z`/`-j`). The short-option namespace is now rsync-parity (see the Phase 7 note) |
| `-v`, `--verbose` | Increase verbosity | ✅ Implemented | Sets `log_level=DEBUG` | | `-v`, `--verbose` | Increase verbosity | ✅ Implemented | Sets `log_level=DEBUG` |
| `-q`, `--quiet` | Suppress non-error messages | ✅ Implemented | Suppresses client output while preserving errors | | `-q`, `--quiet` | Suppress non-error messages | ✅ Implemented | Suppresses client output while preserving errors |
| `--help` | Show help | ✅ Implemented | Prints usage and exits; `-h` is not accepted | | `--help` | Show help | ✅ Implemented | Prints usage and exits; `-h` is not accepted |
@@ -38,7 +38,7 @@ This document maps rsync's full feature set to FastSync's current implementation
|------|-------------------|-----------------|-------| |------|-------------------|-----------------|-------|
| `--stats` | Give transfer stats | ✅ Implemented | Prints file/byte counts | | `--stats` | Give transfer stats | ✅ Implemented | Prints file/byte counts |
| `-h`, `--human-readable` | Human-readable numbers | ✅ Implemented | Formats transfer byte sizes using binary units | | `-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 `-m`); unchanged files print nothing, matching single-`-i` behavior | | `-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 | | `--progress` | Show progress | ✅ Implemented | Progress callback in sender |
| `-P` | Same as --partial --progress | ⚠️ Partial | Parses and enables progress, but interrupted files are not retained for resumable transfers | | `-P` | Same as --partial --progress | ⚠️ Partial | Parses and enables progress, but interrupted files are not retained for resumable transfers |
| `--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 | | `--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 |
@@ -62,7 +62,7 @@ This document maps rsync's full feature set to FastSync's current implementation
| `--size-only` | Skip based on size only | ✅ Implemented | With `--incremental`, ignores mtime | | `--size-only` | Skip based on size only | ✅ Implemented | With `--incremental`, ignores mtime |
| `-@`, `--modify-window=NUM` | Mod-time comparison accuracy | ✅ Implemented | Whole-second tolerance with nanosecond-aware comparisons | | `-@`, `--modify-window=NUM` | Mod-time comparison accuracy | ✅ Implemented | Whole-second tolerance with nanosecond-aware comparisons |
| `--existing` | Skip creating new files on receiver | ✅ Implemented | Existing destination files continue through normal update handling | | `--existing` | Skip creating new files on receiver | ✅ Implemented | Existing destination files continue through normal update handling |
| `--ignore-existing` | Skip updating existing files | ✅ Implemented | `ignore_existing` config field (crosses the wire; receiver-side policy). For a destination entry that already exists, the receiver skips the write: in the regular-file path, existing/delay-updates-staged, hardlink-sibling, and special/device handlers all return `FILE_SAVE_SKIPPED` without overwriting (passed as `no_replace` to the write engine), and `--backup` is disabled for skipped files. Note: it is applied at write time, so an existing dest whose size+mtime differ still has its data (or delta) transmitted before the write is discarded — functionally correct, bandwidth-suboptimal vs rsync, which short-circuits earlier. Like rsync, it does not apply to directories/symlinks (those return before the block). Combines with `-m` and `--delay-updates`. See Phase-4/— notes below | | `--ignore-existing` | Skip updating existing files | ✅ Implemented | `ignore_existing` config field (crosses the wire; receiver-side policy). For a destination entry that already exists, the receiver skips the write: in the regular-file path, existing/delay-updates-staged, hardlink-sibling, and special/device handlers all return `FILE_SAVE_SKIPPED` without overwriting (passed as `no_replace` to the write engine), and `--backup` is disabled for skipped files. Note: it is applied at write time, so an existing dest whose size+mtime differ still has its data (or delta) transmitted before the write is discarded — functionally correct, bandwidth-suboptimal vs rsync, which short-circuits earlier. Like rsync, it does not apply to directories/symlinks (those return before the block). Combines with `-j`/`--threads` and `--delay-updates`. See Phase-4/— notes below |
| `--remove-source-files` | Sender removes regular files after confirmed transfer | ✅ Implemented | | | `--remove-source-files` | Sender removes regular files after confirmed transfer | ✅ Implemented | |
| `-x`, `--one-file-system` | Do not cross filesystem boundaries | ✅ Implemented | Sender scanner captures the root device and skips descending into mount-point crossings (`st_dev` differs); cross-filesystem mount-point subdirectories are dropped entirely, matching rsync | | `-x`, `--one-file-system` | Do not cross filesystem boundaries | ✅ Implemented | Sender scanner captures the root device and skips descending into mount-point crossings (`st_dev` differs); cross-filesystem mount-point subdirectories are dropped entirely, matching rsync |
| `-F` | Add the default `.rsync-filter` rules | ✅ Implemented | Reads one filter rule per line from each directory's `.rsync-filter` file during traversal and applies it to that directory's subtree; the current directory's rules are evaluated before its ancestors', so deeper files override shallower ones and per-directory files override the command-line `--filter`/`-C` base by default (matching rsync's first-match-wins precedence); `.rsync-filter` files are never transferred. The rsync `-FF` behavior (also `.cvsignore`) is out of scope; unsupported rule types inside the file abort with a clear error | | `-F` | Add the default `.rsync-filter` rules | ✅ Implemented | Reads one filter rule per line from each directory's `.rsync-filter` file during traversal and applies it to that directory's subtree; the current directory's rules are evaluated before its ancestors', so deeper files override shallower ones and per-directory files override the command-line `--filter`/`-C` base by default (matching rsync's first-match-wins precedence); `.rsync-filter` files are never transferred. The rsync `-FF` behavior (also `.cvsignore`) is out of scope; unsupported rule types inside the file abort with a clear error |
@@ -72,9 +72,9 @@ This document maps rsync's full feature set to FastSync's current implementation
| Flag | Rsync Description | FastSync Status | Notes | | Flag | Rsync Description | FastSync Status | Notes |
|------|-------------------|-----------------|-------| |------|-------------------|-----------------|-------|
| `-r`, `--recursive` | Recurse into directories | ✅ Implemented | Default behavior | | `-r`, `--recursive` | Recurse into directories | ✅ Implemented | Default behavior |
| `-R`, `--relative` | Use relative path names | ✅ Implemented | Meaningful together with `--files-from` (FastSync's default full-tree scan always mirrors the full source argument path below the destination root, so -R does not change it). With `-R` + `--files-from` each listed entry is transmitted under its bare relative destination path: an entry `sub/x.txt` lands at `<dest>/sub/x.txt` (its leading components preserved) instead of under the `<dest>/<full source path>` mirror. Only the path sent on the wire changes; the client still reads the absolute source path, and the delete manifest derives from the sent (relative) paths so `--delete` and `--remove-source-files` stay consistent in both layouts. Works single-threaded and under `-m` (including chunk serialization) | | `-R`, `--relative` | Use relative path names | ✅ Implemented | Meaningful together with `--files-from` (FastSync's default full-tree scan always mirrors the full source argument path below the destination root, so -R does not change it). With `-R` + `--files-from` each listed entry is transmitted under its bare relative destination path: an entry `sub/x.txt` lands at `<dest>/sub/x.txt` (its leading components preserved) instead of under the `<dest>/<full source path>` mirror. Only the path sent on the wire changes; the client still reads the absolute source path, and the delete manifest derives from the sent (relative) paths so `--delete` and `--remove-source-files` stay consistent in both layouts. Works single-threaded and under `-j`/`--threads` (including chunk serialization) |
| `--no-implied-dirs` | Don't send implied dirs with -R | ✅ Implemented | Client-side, meaningful only with `-R` + `--files-from`. rsync would normally create the ancestor directories implied by a listed file so it can be written; with `--no-implied-dirs` a listed file whose parent directory is not itself (or via an ancestor) explicitly listed cannot be placed, and FastSync fails the whole run up front with a clear error (`--no-implied-dirs: cannot place file '...': parent directory '...' is not explicitly listed`). Listing the directory (or an ancestor of it, or the whole tree `.`) permits the file. In every other mode the option has no effect. FastSync has no per-entry skip channel, so the rsync "omit the file" case is surfaced as a hard pre-transfer error | | `--no-implied-dirs` | Don't send implied dirs with -R | ✅ Implemented | Client-side, meaningful only with `-R` + `--files-from`. rsync would normally create the ancestor directories implied by a listed file so it can be written; with `--no-implied-dirs` a listed file whose parent directory is not itself (or via an ancestor) explicitly listed cannot be placed, and FastSync fails the whole run up front with a clear error (`--no-implied-dirs: cannot place file '...': parent directory '...' is not explicitly listed`). Listing the directory (or an ancestor of it, or the whole tree `.`) permits the file. In every other mode the option has no effect. FastSync has no per-entry skip channel, so the rsync "omit the file" case is surfaced as a hard pre-transfer error |
| `-d`, `--dirs`, `--old-dirs`, `--old-d` | Transfer dirs without recursing | ✅ Implemented | `-d <dir>` transmits an explicit directory entry for the source-root directory, so the destination mirror is created empty and nothing is descended into. With `--files-from` exactly the listed items are transferred: a listed directory is created empty (no descent) and a listed file is transferred with its content; the dest layout follows the same -R rules as plain files. A new wire frame (`STATUS_MKDIR`) carries each directory entry (path only); the receiver creates it with the same confined mkdir-parent semantics as regular writes, in single-threaded and `-m` receivers (chunk serialization carries a per-entry type marker). Directory entries appear in the delete manifest so `--delete` prunes correctly. FastSync divergences: directory mtimes/modes are not transmitted, filter/`--exclude` rules are not re-applied to the listed dirs mode (there is no descent during which they would apply), and `-d` never creates the intermediate directories between the destination root and a listed file beyond the usual on-demand parent creation. Under `--delay-updates` only regular files are staged: directory entries are created immediately, so a delayed run that fails part way can leave the already-created empty directories behind (matching rsync, which also creates directories as it processes the file list and only delays regular-file data) | | `-d`, `--dirs`, `--old-dirs`, `--old-d` | Transfer dirs without recursing | ✅ Implemented | `-d <dir>` transmits an explicit directory entry for the source-root directory, so the destination mirror is created empty and nothing is descended into. With `--files-from` exactly the listed items are transferred: a listed directory is created empty (no descent) and a listed file is transferred with its content; the dest layout follows the same -R rules as plain files. A new wire frame (`STATUS_MKDIR`) carries each directory entry (path only); the receiver creates it with the same confined mkdir-parent semantics as regular writes, in single-threaded and `-j`/`--threads` receivers (chunk serialization carries a per-entry type marker). Directory entries appear in the delete manifest so `--delete` prunes correctly. FastSync divergences: directory mtimes/modes are not transmitted, filter/`--exclude` rules are not re-applied to the listed dirs mode (there is no descent during which they would apply), and `-d` never creates the intermediate directories between the destination root and a listed file beyond the usual on-demand parent creation. Under `--delay-updates` only regular files are staged: directory entries are created immediately, so a delayed run that fails part way can leave the already-created empty directories behind (matching rsync, which also creates directories as it processes the file list and only delays regular-file data) |
| `--mkpath` | Create missing path components | ✅ Implemented | Wire option (client → server). At connection start the server creates the client's destination root directory (and any missing leading components below its own authorized root) when `--mkpath` is set, failing the connection cleanly if it cannot. Without `--mkpath` a destination root that does not exist yet is rejected up front (rsync semantics), so the flag is the only way to transfer into a not-yet-created destination directory. Creation is confined by the same secure mkdir walk as file writes (`O_NOFOLLOW`, no `..`) | | `--mkpath` | Create missing path components | ✅ Implemented | Wire option (client → server). At connection start the server creates the client's destination root directory (and any missing leading components below its own authorized root) when `--mkpath` is set, failing the connection cleanly if it cannot. Without `--mkpath` a destination root that does not exist yet is rejected up front (rsync semantics), so the flag is the only way to transfer into a not-yet-created destination directory. Creation is confined by the same secure mkdir walk as file writes (`O_NOFOLLOW`, no `..`) |
## 5. Transfer Modifications ## 5. Transfer Modifications
@@ -96,8 +96,8 @@ This document maps rsync's full feature set to FastSync's current implementation
| `-b`, `--backup` | Make backups of overwritten files | ✅ Implemented | Backup before overwrite | | `-b`, `--backup` | Make backups of overwritten files | ✅ Implemented | Backup before overwrite |
| `--backup-dir=DIR` | Backup directory hierarchy | ✅ Implemented | `backup_dir` config field | | `--backup-dir=DIR` | Backup directory hierarchy | ✅ Implemented | `backup_dir` config field |
| `--suffix=SUFFIX` | Backup suffix (default ~) | ✅ Implemented | `suffix` config field | | `--suffix=SUFFIX` | Backup suffix (default ~) | ✅ Implemented | `suffix` config field |
| `--delay-updates` | Put updated files in place at end | ✅ Implemented | Successfully received files are staged under a private 0700 `.fastsync-stage` dir inside the receive root and atomically renamed into their final destinations only after the whole transfer (manifest/delete handling included) succeeds, just before the success/outcome frame is sent. The delete walker deliberately skips the staging dir at the receive root, so `--delete` removes genuine extras but never the staged files (deletion runs before publication; rsync's delete-after ordering is not implemented). `--existing`/`--ignore-existing`/`--update` decide against the final destination path at stage time; `--backup` moves the old file aside at publication. Incompatible with `--inplace` and with `--backup-dir=.fastsync-stage` (the internal staging name is reserved; both are rejected). The staging dir name is fixed, so two simultaneous delayed transfers to the same destination root are serialized with an exclusive advisory lock held for the whole transfer: the second session fails cleanly instead of corrupting the first. Aborting or failing before publication installs nothing and removes the staging tree; a crash between stage and publish leaves staged leftovers that the next delayed run wipes at start (process death releases the lock). A stage→publish failure aborts the transfer (best-effort cleanup of the not-yet-published staged files; already-published files are not rolled back). Works in single-threaded and `-m` modes | | `--delay-updates` | Put updated files in place at end | ✅ Implemented | Successfully received files are staged under a private 0700 `.fastsync-stage` dir inside the receive root and atomically renamed into their final destinations only after the whole transfer (manifest/delete handling included) succeeds, just before the success/outcome frame is sent. The delete walker deliberately skips the staging dir at the receive root, so `--delete` removes genuine extras but never the staged files (deletion runs before publication; rsync's delete-after ordering is not implemented). `--existing`/`--ignore-existing`/`--update` decide against the final destination path at stage time; `--backup` moves the old file aside at publication. Incompatible with `--inplace` and with `--backup-dir=.fastsync-stage` (the internal staging name is reserved; both are rejected). The staging dir name is fixed, so two simultaneous delayed transfers to the same destination root are serialized with an exclusive advisory lock held for the whole transfer: the second session fails cleanly instead of corrupting the first. Aborting or failing before publication installs nothing and removes the staging tree; a crash between stage and publish leaves staged leftovers that the next delayed run wipes at start (process death releases the lock). A stage→publish failure aborts the transfer (best-effort cleanup of the not-yet-published staged files; already-published files are not rolled back). Works in single-threaded and `-j`/`--threads` modes |
| `-T`, `--temp-dir=DIR` | Create temporary files in DIR | ✅ Implemented | `--temp-dir` only; `-T` stays FastSync's `--timeout` alias. Scratch dir is resolved under the receive root; temp copies use a unique name there and are atomically renamed into place. If the scratch dir and destination are on different filesystems the atomic rename fails with EXDEV and the file save fails, which aborts the whole transfer (FastSync has no per-file skip/resume on a save error; rsync's non-atomic copy fallback is deliberately not used). `--inplace` and `--partial-dir` writes bypass the scratch dir | | `-T`, `--temp-dir=DIR` | Create temporary files in DIR | ✅ Implemented | `--temp-dir` with the rsync short `-T` (Phase 7 Wave A; the timeout alias moved to long-only `--timeout`). Scratch dir is resolved under the receive root; temp copies use a unique name there and are atomically renamed into place. If the scratch dir and destination are on different filesystems the atomic rename fails with EXDEV and the file save fails, which aborts the whole transfer (FastSync has no per-file skip/resume on a save error; rsync's non-atomic copy fallback is deliberately not used). `--inplace` and `--partial-dir` writes bypass the scratch dir |
## 7. Deletion ## 7. Deletion
@@ -112,7 +112,7 @@ This document maps rsync's full feature set to FastSync's current implementation
| `--max-delete=NUM` | Max files to delete | ✅ Implemented | `max_delete` config field (default -1 = no client limit; 0 = delete nothing). NUM bounds a `--delete` run with rsync's all-or-nothing semantics: the receiver rehearses the deletion first and, if the destination holds more than NUM extras, deletes NOTHING and fails the transfer with a distinct `--max-delete` error. A run at or below NUM deletes exactly the extras. NUM only applies together with `--delete` (it is inert otherwise, matching rsync). The hard server bound `MAX_SERVER_DELETE_COUNT` (100000) still caps the walk; a NUM above it never raises that cap, and exceeding the server bound is its own all-or-nothing error. Directories count toward the limit (each removed empty directory is one deletion), like rsync | | `--max-delete=NUM` | Max files to delete | ✅ Implemented | `max_delete` config field (default -1 = no client limit; 0 = delete nothing). NUM bounds a `--delete` run with rsync's all-or-nothing semantics: the receiver rehearses the deletion first and, if the destination holds more than NUM extras, deletes NOTHING and fails the transfer with a distinct `--max-delete` error. A run at or below NUM deletes exactly the extras. NUM only applies together with `--delete` (it is inert otherwise, matching rsync). The hard server bound `MAX_SERVER_DELETE_COUNT` (100000) still caps the walk; a NUM above it never raises that cap, and exceeding the server bound is its own all-or-nothing error. Directories count toward the limit (each removed empty directory is one deletion), like rsync |
| `--ignore-errors` | Delete even with I/O errors | ✅ Implemented | Sender-side, client-only config field. rsync suppresses `--delete` when the transfer had I/O errors; FastSync's equivalent is a source-scan I/O error (an unreadable directory, e.g. EACCES): by default the scan aborts the run so no deletion happens. With `--ignore-errors` the scan continues past the unreadable directory, the readable tree is transferred and the deletion still runs (the mirror of the unreadable directory is treated as an extra). The run still exits non-zero (the error is reported, matching rsync's error status). Divergence: without the flag FastSync aborts the whole run on the scan error, whereas rsync transfers the rest of the tree and merely skips the deletion; both leave the deletion undone | | `--ignore-errors` | Delete even with I/O errors | ✅ Implemented | Sender-side, client-only config field. rsync suppresses `--delete` when the transfer had I/O errors; FastSync's equivalent is a source-scan I/O error (an unreadable directory, e.g. EACCES): by default the scan aborts the run so no deletion happens. With `--ignore-errors` the scan continues past the unreadable directory, the readable tree is transferred and the deletion still runs (the mirror of the unreadable directory is treated as an extra). The run still exits non-zero (the error is reported, matching rsync's error status). Divergence: without the flag FastSync aborts the whole run on the scan error, whereas rsync transfers the rest of the tree and merely skips the deletion; both leave the deletion undone |
| `--force` | Force deletion of non-empty dirs | ✅ Implemented | `force_delete` receiver config field (crosses the wire). rsync's `--force` lets an incoming non-directory replace a destination directory; FastSync implements exactly that: when a regular file is written to a path that is currently a (possibly non-empty) destination directory, `--force` removes that directory tree first — confined to the receive root and symlink-safe (O_NOFOLLOW fd walk, symlinks removed by name, never followed) — so the atomic install can place the file. Without `--force` such a write fails and the run aborts. Divergence: `--force` acts on the immediate-install path only; under `--delay-updates` a blocking directory is not cleared (publication renames over regular files) | | `--force` | Force deletion of non-empty dirs | ✅ Implemented | `force_delete` receiver config field (crosses the wire). rsync's `--force` lets an incoming non-directory replace a destination directory; FastSync implements exactly that: when a regular file is written to a path that is currently a (possibly non-empty) destination directory, `--force` removes that directory tree first — confined to the receive root and symlink-safe (O_NOFOLLOW fd walk, symlinks removed by name, never followed) — so the atomic install can place the file. Without `--force` such a write fails and the run aborts. Divergence: `--force` acts on the immediate-install path only; under `--delay-updates` a blocking directory is not cleared (publication renames over regular files) |
| `--prune-empty-dirs` | Prune empty dir chains | ✅ Implemented | Long-only: FastSync's `-m` is already multithreading (recorded divergence — rsync's `-m` short form is not reassigned). FastSync's recursive transfer never emits directory entries, so empty directories are inherently never transferred (which is rsync's `-m` behavior) and truly-empty destination directory chains are removed by `--delete` regardless of this flag. The flag's additional real effect is on the `--dirs` explicit directory-entry generator: a plain `-d <empty-dir>` run omits the empty source directory's entry, so nothing is created at the destination (no `STATUS_MKDIR`, no `-i`/`--out-format` change line, and an existing empty mirror becomes an extra that `--delete` prunes). Explicitly `--files-from`-listed directories always pass through (documented `--files-from` behavior). A directory that still holds an excluded-but-protected file survives, matching the `--delete-excluded` default | | `-m`, `--prune-empty-dirs` | Prune empty dir chains | ✅ Implemented | `-m`/`--prune-empty-dirs` (Phase 7 Wave A freed the rsync short `-m`; FastSync multithreading is now `-j`/`--threads`). FastSync's recursive transfer never emits directory entries, so empty directories are inherently never transferred (which is rsync's `-m` behavior) and truly-empty destination directory chains are removed by `--delete` regardless of this flag. The flag's additional real effect is on the `--dirs` explicit directory-entry generator: a plain `-d <empty-dir>` run omits the empty source directory's entry, so nothing is created at the destination (no `STATUS_MKDIR`, no `-i`/`--out-format` change line, and an existing empty mirror becomes an extra that `--delete` prunes). Explicitly `--files-from`-listed directories always pass through (documented `--files-from` behavior). A directory that still holds an excluded-but-protected file survives, matching the `--delete-excluded` default |
**Deletion-timing implementation notes (Phase 3):** the delete flags above are **Deletion-timing implementation notes (Phase 3):** the delete flags above are
real. Two new config booleans (`delete_during`, `delete_delay`) join the already real. Two new config booleans (`delete_during`, `delete_delay`) join the already
@@ -238,7 +238,7 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
| Flag | Rsync Description | FastSync Status | Notes | | Flag | Rsync Description | FastSync Status | Notes |
|------|-------------------|-----------------|-------| |------|-------------------|-----------------|-------|
| `-M`, `--preserve` | Preserve file metadata | ✅ Implemented | Mode, uid, gid, mtime | | `-M`, `--preserve` | Preserve file metadata | ✅ Implemented | Mode, uid, gid, mtime |
| `-p`, `--perms` | Preserve permissions | 🔀 Alt Arg | `-p` means SSH port; permissions preserved via `-M`/`--preserve` | | `-p`, `--perms` | Preserve permissions | ✅ Implemented | Phase 7 Wave A: `-p`/`--perms` now preserve permission bits, folded into FastSync's broad metadata bundle (`--preserve`); the SSH port moved to `--ssh-port`. rsync-parity short form |
| `-o`, `--owner` | Preserve owner | ✅ Implemented | Part of -M | | `-o`, `--owner` | Preserve owner | ✅ Implemented | Part of -M |
| `-g`, `--group` | Preserve group | ✅ Implemented | Part of -M | | `-g`, `--group` | Preserve group | ✅ Implemented | Part of -M |
| `-t`, `--times` | Preserve modification times | ✅ Implemented | Part of -M | | `-t`, `--times` | Preserve modification times | ✅ Implemented | Part of -M |
@@ -246,7 +246,7 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
| `--chmod=CHMOD` | Affect file permissions | ✅ Implemented | Supports numeric and symbolic `ugo` `rwx` changes; retains receiver safety masking | | `--chmod=CHMOD` | Affect file permissions | ✅ Implemented | Supports numeric and symbolic `ugo` `rwx` changes; retains receiver safety masking |
| `-A`, `--acls` | Preserve ACLs | ✅ Implemented | Implemented on Linux via the POSIX-ACL xattr representation: the sender captures the `system.posix_acl_access` / `system.posix_acl_default` xattrs into the same bounded whitelisted set as `-X`, transmits them per-file, and the receiver re-applies them fd-relative. Setting an ACL the receiver is not permitted to set (non-root on a file it does not own, unsupported filesystem) is logged and skipped, never fatal. libacl is **not** required. Only the `system.posix_acl_*` namespaces plus `user.*` are ever applied; privileged namespaces are never applied (see the Phase-4 xattr/ACL notes below). Implies metadata transmission | | `-A`, `--acls` | Preserve ACLs | ✅ Implemented | Implemented on Linux via the POSIX-ACL xattr representation: the sender captures the `system.posix_acl_access` / `system.posix_acl_default` xattrs into the same bounded whitelisted set as `-X`, transmits them per-file, and the receiver re-applies them fd-relative. Setting an ACL the receiver is not permitted to set (non-root on a file it does not own, unsupported filesystem) is logged and skipped, never fatal. libacl is **not** required. Only the `system.posix_acl_*` namespaces plus `user.*` are ever applied; privileged namespaces are never applied (see the Phase-4 xattr/ACL notes below). Implies metadata transmission |
| `-X`, `--xattrs` | Preserve extended attributes | ✅ Implemented | Preserves unprivileged `user.*` extended attributes (Linux `listxattr`/`getxattr` on capture, `fsetxattr` on the written destination fd). Both capture (sender) and application (receiver) are restricted to the `user.*` namespace and the two POSIX ACL xattrs, so a client can **never** force a `security.*`/`trusted.*`/privileged attribute onto the destination; the receiver independently re-validates every incoming name against this whitelist and rejects anything else. Payloads are bounded (per-name ≤255B, per-value ≤1MiB, per-file count ≤256 total bytes ≤4MiB) on both ends, and an oversized/malformed frame is a clean protocol rejection (no OOM). Applied fd-relative to the exact written file. Implies metadata transmission. Incompatible with `-s` (chunk serialization), rejected up front (see the notes); a `--link-dest`/`-H` hard-link copy fallback re-applies the attributes so they are not dropped when a link is refused | | `-X`, `--xattrs` | Preserve extended attributes | ✅ Implemented | Preserves unprivileged `user.*` extended attributes (Linux `listxattr`/`getxattr` on capture, `fsetxattr` on the written destination fd). Both capture (sender) and application (receiver) are restricted to the `user.*` namespace and the two POSIX ACL xattrs, so a client can **never** force a `security.*`/`trusted.*`/privileged attribute onto the destination; the receiver independently re-validates every incoming name against this whitelist and rejects anything else. Payloads are bounded (per-name ≤255B, per-value ≤1MiB, per-file count ≤256 total bytes ≤4MiB) on both ends, and an oversized/malformed frame is a clean protocol rejection (no OOM). Applied fd-relative to the exact written file. Implies metadata transmission. Incompatible with `-s` (chunk serialization), rejected up front (see the notes); a `--link-dest`/`-H` hard-link copy fallback re-applies the attributes so they are not dropped when a link is refused |
| `-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 `-j`/`--threads`, `--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 | ✅ 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 | | `-D` | Same as --devices --specials | ✅ Implemented | Implies `--devices --specials`. `-D` was unassigned in FastSync (verified: no collision), so it is free to imply both device-node and special-file preservation. See the `--devices`/`--specials` rows and the Phase-4 devices notes below |
| `--devices` | Preserve device files | ⚠️ Partial | Recreates char/block device nodes on the destination via `mknod` instead of transferring content. Type + rdev are validated strictly (S_IFMT from the transmitted mode; major/minor range-checked, non-negative), and creation is **privilege-gated**: `mknod` needs `CAP_MKNOD`, so a non-root receiver (CI runs via setpriv as non-root) logs a warning and **skips the device entry safely** — the whole transfer never aborts just because the node could not be made. The node is created fd-relative below the receive root (`mknodat` on the confined secure parent), so it can never be placed outside the authorized root, never follows a symlink, and never replaces an existing directory. Only a char/block mode is honored. Crosses the wire (a new `STATUS_SPECIAL` frame carries the path + metadata mode + rdev; `PROTOCOL_VERSION` bumped **2.12.0 → 2.13.0**). Divergence: per-entry skip (not a hard error) when the receiver lacks `CAP_MKNOD`, documented in the Phase-4 devices notes | | `--devices` | Preserve device files | ⚠️ Partial | Recreates char/block device nodes on the destination via `mknod` instead of transferring content. Type + rdev are validated strictly (S_IFMT from the transmitted mode; major/minor range-checked, non-negative), and creation is **privilege-gated**: `mknod` needs `CAP_MKNOD`, so a non-root receiver (CI runs via setpriv as non-root) logs a warning and **skips the device entry safely** — the whole transfer never aborts just because the node could not be made. The node is created fd-relative below the receive root (`mknodat` on the confined secure parent), so it can never be placed outside the authorized root, never follows a symlink, and never replaces an existing directory. Only a char/block mode is honored. Crosses the wire (a new `STATUS_SPECIAL` frame carries the path + metadata mode + rdev; `PROTOCOL_VERSION` bumped **2.12.0 → 2.13.0**). Divergence: per-entry skip (not a hard error) when the receiver lacks `CAP_MKNOD`, documented in the Phase-4 devices notes |
| `--specials` | Preserve special files | ⚠️ Partial | Recreates **FIFOs** on the destination via `mkfifo` (unprivileged, so this is a real, assertable behavior under CI). Sockets cannot be recreated by any standard filesystem call and are skipped with an explicit note (best-effort / unsupported, matching the plan). FIFO creation is privileged-gated only in the sense of graceful skip on any permission failure. Node creation is confined below the receive root (`mkfifoat` on the secure fd-relative parent; no `..`, no symlink follow). Crosses the wire like `--devices` (the `STATUS_SPECIAL` frame; `PROTOCOL_VERSION` bumped **2.12.0 → 2.13.0**). See the Phase-4 devices notes | | `--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 |
@@ -414,7 +414,7 @@ transferred normally and carries the data; each later (sibling) member is
transmitted as a payload-less `STATUS_HARDLINK` frame carrying its destination transmitted as a payload-less `STATUS_HARDLINK` frame carrying its destination
path, the group id, and the first member's destination-relative wire path. path, the group id, and the first member's destination-relative wire path.
Ordering is guaranteed by forcing the sequential scanner whenever `-H` is on Ordering is guaranteed by forcing the sequential scanner whenever `-H` is on
(even under `-m`), so the first member is always emitted — and, on the receiver's (even under `-j`/`--threads`), so the first member is always emitted — and, on the receiver's
single write thread, installed — before any of its siblings; the receiver is single write thread, installed — before any of its siblings; the receiver is
therefore always able to link to an already-present first member, including the therefore always able to link to an already-present first member, including the
"first member already up-to-date/skipped" case (the sibling links to or copies "first member already up-to-date/skipped" case (the sibling links to or copies
@@ -563,7 +563,7 @@ was bumped **2.12.0 → 2.13.0** (peers must match, exactly as prior phases did)
symlink is being transmitted (`-l`/`-k`/`-a` off) `--munge-links` has nothing symlink is being transmitted (`-l`/`-k`/`-a` off) `--munge-links` has nothing
to rewrite and is inert. -*K/`--keep-dirlinks` policy is installed per to rewrite and is inert. -*K/`--keep-dirlinks` policy is installed per
connection at config-accept (stable for the whole transfer, never racy under connection at config-accept (stable for the whole transfer, never racy under
`-m`), and only ever follows an in-root symlink-to-directory.* `-j`/`--threads`), and only ever follows an in-root symlink-to-directory.*
**Compatibility (byte-identical when all three are absent):** `-k`, `-K` and **Compatibility (byte-identical when all three are absent):** `-k`, `-K` and
`--munge-links` are opt-in. Without them the scanner's link handling, the wire `--munge-links` are opt-in. Without them the scanner's link handling, the wire
@@ -597,10 +597,10 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| Flag | Rsync Description | FastSync Status | Notes | | Flag | Rsync Description | FastSync Status | Notes |
|------|-------------------|-----------------|-------| |------|-------------------|-----------------|-------|
| `-z`, `--compress` | Compress file data | 🔀 Alt Arg | Always uses zstd (rsync supports multiple algorithms) | | `-z`, `--compress` | Compress file data | ✅ Implemented | Always uses zstd (rsync supports multiple algorithms — a documented divergence, selectable via `--compress-choice`). Phase 7 Wave A: `-z` is now the compression short form; `-c` is rsync's `--checksum` |
| `--compress-choice=STR`, `--zc=STR` | Choose compression algorithm | ✅ Implemented | FastSync supports `zstd` and `none` | | `--compress-choice=STR`, `--zc=STR` | Choose compression algorithm | ✅ Implemented | FastSync supports `zstd` and `none` |
| `--compress-level=NUM`, `--zl=NUM` | Set compression level | ✅ Implemented | 1-22, default 5 | | `--compress-level=NUM`, `--zl=NUM` | Set compression level | ✅ Implemented | 1-22, default 5 |
| `--compress-threads=NUM` | Set compression threads | ✅ Implemented | `compression_threads` config field (client-only; does not cross the wire). Sets the number of worker threads used by the zstd compression pool to NUM (1..64; 0/garbage/oversized rejected up front). Accepted in both `--compress-threads=NUM` and two-argument `--compress-threads NUM` forms. Composes with `-c`/compression; under the `-m` multithreaded pipeline it parallelizes compressed chunk encoding. See test_tcp.py `-c --compress-threads=2` and test_client_cli.c | | `--compress-threads=NUM` | Set compression threads | ✅ Implemented | `compression_threads` config field (client-only; does not cross the wire). Sets the number of worker threads used by the zstd compression pool to NUM (1..64; 0/garbage/oversized rejected up front). Accepted in both `--compress-threads=NUM` and two-argument `--compress-threads NUM` forms. Composes with `-z`/compression; under the `-j`/`--threads` multithreaded pipeline it parallelizes compressed chunk encoding. See test_tcp.py `-z --compress-threads=2` and test_client_cli.c |
| `--skip-compress=LIST` | Skip compress for suffixes | ✅ Implemented | Comma-separated, case-insensitive suffix list; empty list skips none; incompatible with FastSync chunk serialization (`-s`) | | `--skip-compress=LIST` | Skip compress for suffixes | ✅ Implemented | Comma-separated, case-insensitive suffix list; empty list skips none; incompatible with FastSync chunk serialization (`-s`) |
## 13. Connectivity ## 13. Connectivity
@@ -616,7 +616,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| `--address=ADDRESS` | Bind address for outgoing socket | ✅ Implemented | Binds the outgoing client socket to a local source address before `connect()` (resolved with the same `-4`/`-6` family hints as the destination). Local socket concern: never crosses the wire | | `--address=ADDRESS` | Bind address for outgoing socket | ✅ Implemented | Binds the outgoing client socket to a local source address before `connect()` (resolved with the same `-4`/`-6` family hints as the destination). Local socket concern: never crosses the wire |
| `-4`, `--ipv4` | Prefer IPv4 | ✅ Implemented | Forces `AF_INET` in the `getaddrinfo` hints for client destination/source resolution and the server bind (see the Phase 5, Wave B note). Mutually exclusive with `-6` | | `-4`, `--ipv4` | Prefer IPv4 | ✅ Implemented | Forces `AF_INET` in the `getaddrinfo` hints for client destination/source resolution and the server bind (see the Phase 5, Wave B note). Mutually exclusive with `-6` |
| `-6`, `--ipv6` | Prefer IPv6 | ✅ Implemented | Forces `AF_INET6` in the `getaddrinfo` hints for client destination/source resolution and the server bind. Mutually exclusive with `-4` | | `-6`, `--ipv6` | Prefer IPv6 | ✅ Implemented | Forces `AF_INET6` in the `getaddrinfo` hints for client destination/source resolution and the server bind. Mutually exclusive with `-4` |
| `--remote-option=OPT`, `-M` | Send an option only to the remote side | ✅ Implemented | Long form only; each value is appended to the remote server invocation over SSH as an individually single-quote-escaped shell word in `ssh_build_remote_command()`. Values are validated (non-empty, no control characters) and shell metacharacters cannot break out of the quoting (`;`, `&`, `|`, <code>`</code>, `$`, `(`, `)`, quotes are neutralized), so a value cannot inject an arbitrary remote command and a subsequent `--` on the client line cannot be turned into one. The options never cross the binary config frame. Divergence: the short `-M` form is intentionally unavailable because `-M` is already FastSync's metadata-preservation flag/multiplier (see Phase 5 notes below) | | `--remote-option=OPT`, `-M` | Send an option only to the remote side | ✅ Implemented | Each value is appended to the remote server invocation over SSH as an individually single-quote-escaped shell word in `ssh_build_remote_command()`. Values are validated (non-empty, no control characters) and shell metacharacters cannot break out of the quoting (`;`, `&`, `|`, <code>`</code>, `$`, `(`, `)`, quotes are neutralized), so a value cannot inject an arbitrary remote command and a subsequent `--` on the client line cannot be turned into one. The options never cross the binary config frame. Phase 7 Wave A: the short `-M` form is now available (as `-M OPT` and `-M=OPT`), matching rsync; metadata mode moved to long-only `--preserve` |
## 14. Daemon Mode ## 14. Daemon Mode
@@ -661,7 +661,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| Flag | Rsync Description | FastSync Status | Notes | | Flag | Rsync Description | FastSync Status | Notes |
|------|-------------------|-----------------|-------| |------|-------------------|-----------------|-------|
| `--write-batch=FILE` | Write batched update to file | ✅ Implemented | Phase-6 residual-batch (client-only): runs the normal live transfer AND additionally emits a self-contained single-file batch of the whole source tree. The batch is a magic/format-version header followed by length-prefixed `chunk_serialize` blobs (full file images), replayable byte-identically by `--read-batch` on another machine with no source/server. `--write-batch` drives the single-threaded transfer path (the `-m` path consumes the config before the separate batch scan pass). See the Phase-6 batch note below | | `--write-batch=FILE` | Write batched update to file | ✅ Implemented | Phase-6 residual-batch (client-only): runs the normal live transfer AND additionally emits a self-contained single-file batch of the whole source tree. The batch is a magic/format-version header followed by length-prefixed `chunk_serialize` blobs (full file images), replayable byte-identically by `--read-batch` on another machine with no source/server. `--write-batch` drives the single-threaded transfer path (the multithreaded path consumes the config before the separate batch scan pass). See the Phase-6 batch note below |
| `--only-write-batch=FILE` | Write batch without updating dest | ✅ Implemented | Phase-6 residual-batch: emits the self-contained batch FILE only — NO destination update, NO server connection. Requires a source (scans it and serializes the full tree to FILE). Same single-file format as `--write-batch`, so the file is re-appliable via `--read-batch=FILE DEST`. See the Phase-6 batch note below | | `--only-write-batch=FILE` | Write batch without updating dest | ✅ Implemented | Phase-6 residual-batch: emits the self-contained batch FILE only — NO destination update, NO server connection. Requires a source (scans it and serializes the full tree to FILE). Same single-file format as `--write-batch`, so the file is re-appliable via `--read-batch=FILE DEST`. See the Phase-6 batch note below |
| `--read-batch=FILE` | Read batched update from file | ✅ Implemented | Phase-6 residual-batch: applies a previously written batch FILE locally to the destination. NO source and NO server — positional args are the destination only. Reads the magic/version header, then length-prefixed records, `chunk_deserialize`, and applies each via the confined `file_save_to_disk_full` path (same O_NOFOLLOW / `..`-rejection / root-confinement as the network receiver, so an attacker-controlled batch cannot escape the destination root). Malformed/truncated/oversized/traversal records are rejected cleanly. See the Phase-6 batch note below | | `--read-batch=FILE` | Read batched update from file | ✅ Implemented | Phase-6 residual-batch: applies a previously written batch FILE locally to the destination. NO source and NO server — positional args are the destination only. Reads the magic/version header, then length-prefixed records, `chunk_deserialize`, and applies each via the confined `file_save_to_disk_full` path (same O_NOFOLLOW / `..`-rejection / root-confinement as the network receiver, so an attacker-controlled batch cannot escape the destination root). Malformed/truncated/oversized/traversal records are rejected cleanly. See the Phase-6 batch note below |
@@ -669,13 +669,13 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| Flag | Rsync Description | FastSync Status | Notes | | Flag | Rsync Description | FastSync Status | Notes |
|------|-------------------|-----------------|-------| |------|-------------------|-----------------|-------|
| `--stop-after=MINS` | Stop after N minutes | ✅ Implemented | Client-only sender stop deadline (Phase 6): computing `--stop-after=MINS` (a positive minute count; 0/negative/garbage rejected) and `--stop-at=TIME` (`HH:MM`, `HH:MM:SS`, or `now+N[smhd]`; a past time stops immediately). The transfer stops ELEGANTLY at the next chunk boundary: everything already fully sent is kept and applied, the run returns 0, and --delete (late/delete-after timing) does NOT wipe the destination — when the scan is cut short the partial keep-set manifest is suppressed with a warning (the delete walk is skipped rather than acting on an incomplete keep-set, so unscanned source mirrors survive). `--delete-before`/`--delete-during` still run their complete pre-scan (which ignores the deadline). Local client-only fields: never serialized into the wire config frame, so no PROTOCOL_VERSION bump. `--stop-after` uses CLOCK_MONOTONIC; `--stop-at` uses the wall clock. Works single-threaded and under `-m` (multithreaded). Divergence: rsync computes `--stop-after` from the run start; FastSync likewise. When both are given, the earlier of the two deadlines wins (checked per iteration). See the Phase-6 stop notes below | | `--stop-after=MINS` | Stop after N minutes | ✅ Implemented | Client-only sender stop deadline (Phase 6): computing `--stop-after=MINS` (a positive minute count; 0/negative/garbage rejected) and `--stop-at=TIME` (`HH:MM`, `HH:MM:SS`, or `now+N[smhd]`; a past time stops immediately). The transfer stops ELEGANTLY at the next chunk boundary: everything already fully sent is kept and applied, the run returns 0, and --delete (late/delete-after timing) does NOT wipe the destination — when the scan is cut short the partial keep-set manifest is suppressed with a warning (the delete walk is skipped rather than acting on an incomplete keep-set, so unscanned source mirrors survive). `--delete-before`/`--delete-during` still run their complete pre-scan (which ignores the deadline). Local client-only fields: never serialized into the wire config frame, so no PROTOCOL_VERSION bump. `--stop-after` uses CLOCK_MONOTONIC; `--stop-at` uses the wall clock. Works single-threaded and under `-j`/`--threads` (multithreaded). Divergence: rsync computes `--stop-after` from the run start; FastSync likewise. When both are given, the earlier of the two deadlines wins (checked per iteration). See the Phase-6 stop notes below |
| `--stop-at=TIME` | Stop at specified time | ✅ Implemented | Same feature as `--stop-after` (deadline transfer stop), absolute wall-clock form (`HH:MM[:SS]` or `now+N[smhd]`). See the row above and the Phase-6 stop notes | | `--stop-at=TIME` | Stop at specified time | ✅ Implemented | Same feature as `--stop-after` (deadline transfer stop), absolute wall-clock form (`HH:MM[:SS]` or `now+N[smhd]`). See the row above and the Phase-6 stop notes |
| `--fsync` | Fsync every written file before publication | ✅ Implemented | | | `--fsync` | Fsync every written file before publication | ✅ Implemented | |
| `--protocol=NUM` | Force older protocol version | ✅ Implemented | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.16.0) with no downgrade/backward-compat code paths, so `--protocol=2.16.0` is accepted (it sets the version claim the client sends, which the server already requires to match exactly) and **every other value is rejected up front** with a clear error before any connection — it does not and cannot speak an older or virtual wire format. Divergence from rsync (which negotiates a range and downgrades to an integer 0..31): FastSync's honest contract is force-to-the-one-supported-value; a genuine downgrade would require a per-version compatibility layer that does not exist. Client-only; the server-side exact-match check is unchanged. `--protocol=2.16`/`2.15.0`/`2.17.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below | | `--protocol=NUM` | Force older protocol version | ✅ Implemented | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.16.0) with no downgrade/backward-compat code paths, so `--protocol=2.16.0` is accepted (it sets the version claim the client sends, which the server already requires to match exactly) and **every other value is rejected up front** with a clear error before any connection — it does not and cannot speak an older or virtual wire format. Divergence from rsync (which negotiates a range and downgrades to an integer 0..31): FastSync's honest contract is force-to-the-one-supported-value; a genuine downgrade would require a per-version compatibility layer that does not exist. Client-only; the server-side exact-match check is unchanged. `--protocol=2.16`/`2.15.0`/`2.17.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below |
| `--iconv=CONVERT_SPEC` | Charset conversion | ✅ Implemented | Charset conversion of FILE NAMES (not content) at the protocol boundary via iconv(3): `--iconv=LOCAL[,REMOTE]` — the sender converts each local filename LOCAL→REMOTE before transmitting, and the receiver converts each wire filename REMOTE→LOCAL before creating/writing. The full CONVERT_SPEC is serialized into the config frame as a new trailing string field so the peer knows the wire charset; **PROTOCOL_VERSION bumped 2.15.0 → 2.16.0**. `LOCAL[,REMOTE]` parse: single charset ⇒ LOCAL==REMOTE (identity both ways); garbage rejected up front. Validation probes BOTH directions (a spec that only opens one way is refused, as is a NUL-emitting target charset like utf-16/utf-32/ucs-2, since filenames cannot contain NUL). An unrepresentable name (EILSEQ/EINVAL) fails that path cleanly with a logged `--iconv: cannot convert file name ...` and is never written mangled/truncated. Conversion is applied at EVERY wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest, the incremental-check path, and the `-s`/`chunk_serialize` embedded blob path), on both client and server (`--iconv` is also a server/daemon option). Zero overhead when unset. See the Phase-6 iconv notes below | | `--iconv=CONVERT_SPEC` | Charset conversion | ✅ Implemented | Charset conversion of FILE NAMES (not content) at the protocol boundary via iconv(3): `--iconv=LOCAL[,REMOTE]` — the sender converts each local filename LOCAL→REMOTE before transmitting, and the receiver converts each wire filename REMOTE→LOCAL before creating/writing. The full CONVERT_SPEC is serialized into the config frame as a new trailing string field so the peer knows the wire charset; **PROTOCOL_VERSION bumped 2.15.0 → 2.16.0**. `LOCAL[,REMOTE]` parse: single charset ⇒ LOCAL==REMOTE (identity both ways); garbage rejected up front. Validation probes BOTH directions (a spec that only opens one way is refused, as is a NUL-emitting target charset like utf-16/utf-32/ucs-2, since filenames cannot contain NUL). An unrepresentable name (EILSEQ/EINVAL) fails that path cleanly with a logged `--iconv: cannot convert file name ...` and is never written mangled/truncated. Conversion is applied at EVERY wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest, the incremental-check path, and the `-s`/`chunk_serialize` embedded blob path), on both client and server (`--iconv` is also a server/daemon option). Zero overhead when unset. See the Phase-6 iconv notes below |
| `--checksum-seed=NUM` | Set checksum seed | ✅ Implemented | Sets the seed for FastSync's whole-file xxHash64 digest (full 64-bit seed) and for the delta path's per-block xxHash32 strong checksum (low 32 bits of the seed). An explicit seed deterministically changes every computed digest on BOTH endpoints (sender and receiver share the seed via the config frame, protocol 2.10.0), so identical runs with the same seed skip the same files and a changed seed changes the digests — the explicit-seed path that makes xxHash comparisons deterministic. `--checksum-choice=md5` has no seed and ignores it (documented). The value is a strict decimal 0..2⁶⁴-1 (blank, signed, or non-numeric values are rejected). Like rsync, a seed only matters where a digest is actually computed (`--checksum` or a basis-dir run, or a delta transfer); it does not by itself enable `--checksum`/`--delta`. Divergence from rsync: the default is seed 0, and FastSync never randomizes the seed (rsync uses a random per-transfer seed when `--checksum-seed` is unset); FastSync's unset default therefore reproduces its historical byte-for-byte behavior | | `--checksum-seed=NUM` | Set checksum seed | ✅ Implemented | Sets the seed for FastSync's whole-file xxHash64 digest (full 64-bit seed) and for the delta path's per-block xxHash32 strong checksum (low 32 bits of the seed). An explicit seed deterministically changes every computed digest on BOTH endpoints (sender and receiver share the seed via the config frame, protocol 2.10.0), so identical runs with the same seed skip the same files and a changed seed changes the digests — the explicit-seed path that makes xxHash comparisons deterministic. `--checksum-choice=md5` has no seed and ignores it (documented). The value is a strict decimal 0..2⁶⁴-1 (blank, signed, or non-numeric values are rejected). Like rsync, a seed only matters where a digest is actually computed (`--checksum` or a basis-dir run, or a delta transfer); it does not by itself enable `--checksum`/`--delta`. Divergence from rsync: the default is seed 0, and FastSync never randomizes the seed (rsync uses a random per-transfer seed when `--checksum-seed` is unset); FastSync's unset default therefore reproduces its historical byte-for-byte behavior |
| `--secluded-args` | Use protocol to send args | 🔄 Compatibility No-op | Accepted for CLI compatibility; it does not change FastSync transport or protocol behavior. `-s` remains chunk serialization. | | `--secluded-args`, `-s` | Use protocol to send args | 🔄 Compatibility No-op | Accepted for CLI compatibility, including the rsync short `-s` (Phase 7 Wave A); it does not change FastSync transport or protocol behavior, because remote SSH argv is already built injection-safe (single-quote-escaped). Chunk serialization is the long-only `--chunk-serialization`. |
| `--no-OPTION` | Turn off implied option | ✅ Supported | Supported boolean FastSync options and archive-implied options; unsafe or value-taking options are rejected. | | `--no-OPTION` | Turn off implied option | ✅ Supported | Supported boolean FastSync options and archive-implied options; unsafe or value-taking options are rejected. |
--- ---
@@ -683,7 +683,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
## Implementation Difficulty Plan ## Implementation Difficulty Plan
**Phase 5 notes (remote-option wave):** `--remote-option=OPT` (long form only) and `--trust-sender` landed here. **Phase 5 notes (remote-option wave):** `--remote-option=OPT` (long form only) and `--trust-sender` landed here.
- `--remote-option` is CLIENT-only and never serialized into the binary config frame. On the SSH transport the client forwards each value to the remote server by appending it to the remote command line in `ssh_build_remote_command()`, after ` --stdio`, as an individually single-quoted shell word (`'...'` with `'\''` for embedded quotes). Values are validated at CLI parse time (non-empty; no ASCII control characters) and rejected otherwise, and a non-conforming value is refused again in the command builder, so shell metacharacters (`;`, `&`, `|`, backticks, `$()`, quotes) can never break out of the quoting to inject an unrelated remote command — including after a client-side `--` separator, whose arguments are never forwarded anyway. Because the remote options affect the *remote server invocation*, not the transmitted config, the wire frame layout is unchanged, but `PROTOCOL_VERSION` was bumped **2.13.0 → 2.14.0** as the Phase-5 lockstep release marker (a 2.14 client against a 2.13 server fails the version check cleanly rather than the old server rejecting an unfamiliar forwarded argv later). Divergence: rsync's short `-M` form of `--remote-option` is intentionally NOT implemented, because `-M` is already FastSync's metadata-preservation mode/multiplier. - `--remote-option` is CLIENT-only and never serialized into the binary config frame. On the SSH transport the client forwards each value to the remote server by appending it to the remote command line in `ssh_build_remote_command()`, after ` --stdio`, as an individually single-quoted shell word (`'...'` with `'\''` for embedded quotes). Values are validated at CLI parse time (non-empty; no ASCII control characters) and rejected otherwise, and a non-conforming value is refused again in the command builder, so shell metacharacters (`;`, `&`, `|`, backticks, `$()`, quotes) can never break out of the quoting to inject an unrelated remote command — including after a client-side `--` separator, whose arguments are never forwarded anyway. Because the remote options affect the *remote server invocation*, not the transmitted config, the wire frame layout is unchanged, but `PROTOCOL_VERSION` was bumped **2.13.0 → 2.14.0** as the Phase-5 lockstep release marker (a 2.14 client against a 2.13 server fails the version check cleanly rather than the old server rejecting an unfamiliar forwarded argv later). Divergence: rsync's short `-M` form of `--remote-option` was intentionally NOT implemented at that time because `-M` was FastSync metadata mode; **Phase 7 Wave A later freed `-M` for `--remote-option` and moved metadata to long-only `--preserve`** (see the Sending Options table).
- `--trust-sender` is a receiver-local policy: it never crosses the wire (the sender's value is never serialized, so a wire peer can never enable it). On the receiving process it skips the up-front re-validation of the incoming file list (empty/`..` path rejection and the escaping-symlink-target containment), trusting the sender's list instead of double-checking — fewer checks, faster, and potentially unsafe, matching rsync. It is OFF by default (`config.trust_sender`). As a deliberate safety floor, the low-level fd-relative confinement primitives are NOT disabled: `file_open_secure_parent()` (O_NOFOLLOW walk, `..` rejection, root containment) and leaf/destination confinement still hold, so even under `--trust-sender` a hostile sender cannot write or create a symlink outside the authorized root — the relaxation only removes the redundant list-layer double-checks, never the root-confinement guarantees. - `--trust-sender` is a receiver-local policy: it never crosses the wire (the sender's value is never serialized, so a wire peer can never enable it). On the receiving process it skips the up-front re-validation of the incoming file list (empty/`..` path rejection and the escaping-symlink-target containment), trusting the sender's list instead of double-checking — fewer checks, faster, and potentially unsafe, matching rsync. It is OFF by default (`config.trust_sender`). As a deliberate safety floor, the low-level fd-relative confinement primitives are NOT disabled: `file_open_secure_parent()` (O_NOFOLLOW walk, `..` rejection, root containment) and leaf/destination confinement still hold, so even under `--trust-sender` a hostile sender cannot write or create a symlink outside the authorized root — the relaxation only removes the redundant list-layer double-checks, never the root-confinement guarantees.
The estimates below cover the currently unimplemented features in this document. They assume one engineer familiar with the codebase, include implementation and focused tests, and exclude production rollout time. A feature should not be marked implemented until its behavior is tested in both local and SSH/TCP paths where applicable. The estimates below cover the currently unimplemented features in this document. They assume one engineer familiar with the codebase, include implementation and focused tests, and exclude production rollout time. A feature should not be marked implemented until its behavior is tested in both local and SSH/TCP paths where applicable.
@@ -784,7 +784,7 @@ These are the hardest compatibility items because they require durable formats o
| `--stop-after=MINS`; `--stop-at=TIME` | M | ✅ Implemented (see the Advanced table and Phase-6 stop notes below): deadline propagation and safe early stop with --delete safety | | `--stop-after=MINS`; `--stop-at=TIME` | M | ✅ Implemented (see the Advanced table and Phase-6 stop notes below): deadline propagation and safe early stop with --delete safety |
| `--early-input=FILE`; `--password-file=FILE` | M | Securely read startup credentials/input with permission checks and no secret disclosure in logs. | | `--early-input=FILE`; `--password-file=FILE` | M | Securely read startup credentials/input with permission checks and no secret disclosure in logs. |
**Phase 6, Wave A (stop deadline) shipping note:** `--stop-after=MINS` and `--stop-at=TIME` are client-only sender stop deadlines. `--stop-after` takes a positive minute count (0/negative/garbage rejected); `--stop-at` takes `HH:MM`, `HH:MM:SS`, or `now+N[smhd]` (a past time stops immediately, a garbage spec is rejected at parse time). The deadline is computed once at the start of the transfer (CLOCK_MONOTONIC for `--stop-after`, wall clock via `time()` for `--stop-at`) and checked at every chunk boundary in both the single-threaded `send_files` loop and the multithreaded `send_chunks_multithreaded` path, and inside the scanner loops so a busy scan itself stops. When it fires, the transfer stops ELEGANTLY: the in-flight chunk completes, the existing completion tail runs (summary, `disconnect`), and the run returns 0 — exactly like rsync's clean early stop. Because the deadline is client-only and never crosses the wire config frame, no PROTOCOL_VERSION bump is required. The safety-critical interaction is with `--delete`: FastSync streams while scanning, so a deadline can cut the source scan short and yield a PARTIAL keep-set manifest; committing that would make the receiver delete destination mirrors of source files not yet scanned. So the sender tracks `scan_stopped_early` and, when it is true on the late/delete-after (`--delete`/`--delete-after`/`--delete-delay`) path, SUPPRESSES the keep-set manifest (logs a warning) so no deletion happens from an incomplete set — this is the safe direction (preserves data; the delete simply does not run). `--delete-before`/`--delete-during` are unaffected: their complete pre-scan runs before any data and ignores the deadline (a stop can be exceeded by that pre-scan). Under `-m` the stop is symmetric and the scanner thread's still-in-progress manifest appends can never race the tail because the tail does not read the manifest on the early-stop path. **Phase 6, Wave A (stop deadline) shipping note:** `--stop-after=MINS` and `--stop-at=TIME` are client-only sender stop deadlines. `--stop-after` takes a positive minute count (0/negative/garbage rejected); `--stop-at` takes `HH:MM`, `HH:MM:SS`, or `now+N[smhd]` (a past time stops immediately, a garbage spec is rejected at parse time). The deadline is computed once at the start of the transfer (CLOCK_MONOTONIC for `--stop-after`, wall clock via `time()` for `--stop-at`) and checked at every chunk boundary in both the single-threaded `send_files` loop and the multithreaded `send_chunks_multithreaded` path, and inside the scanner loops so a busy scan itself stops. When it fires, the transfer stops ELEGANTLY: the in-flight chunk completes, the existing completion tail runs (summary, `disconnect`), and the run returns 0 — exactly like rsync's clean early stop. Because the deadline is client-only and never crosses the wire config frame, no PROTOCOL_VERSION bump is required. The safety-critical interaction is with `--delete`: FastSync streams while scanning, so a deadline can cut the source scan short and yield a PARTIAL keep-set manifest; committing that would make the receiver delete destination mirrors of source files not yet scanned. So the sender tracks `scan_stopped_early` and, when it is true on the late/delete-after (`--delete`/`--delete-after`/`--delete-delay`) path, SUPPRESSES the keep-set manifest (logs a warning) so no deletion happens from an incomplete set — this is the safe direction (preserves data; the delete simply does not run). `--delete-before`/`--delete-during` are unaffected: their complete pre-scan runs before any data and ignores the deadline (a stop can be exceeded by that pre-scan). Under `-j`/`--threads` the stop is symmetric and the scanner thread's still-in-progress manifest appends can never race the tail because the tail does not read the manifest on the early-stop path.
**Phase 6, Wave B (iconv) shipping note (PROTOCOL 2.15.0 → 2.16.0):** `--iconv=LOCAL[,REMOTE]` converts file NAMES at the wire boundary (never content). The full CONVERT_SPEC is serialized into the config frame as a new trailing string field (empty→NULL canonicalized), so both ends share the same wire charset interpretation; this required the PROTOCOL bump because the frame is a strict ordered sequence and a peer that does not parse the new trailing field would desynchronize. Each end derives LOCAL (its own charset) and REMOTE (the wire charset): the sender opens LOCAL→REMOTE and converts every transmitted filename; the receiver opens REMOTE→LOCAL and converts every received filename before creating/writing. Conversion is applied at every wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest keep/protected/missing entries, the incremental-check path, and the embedded `-s`/chunk-blob path). A name it cannot convert (EILSEQ/EINVAL) is failed cleanly with a logged `--iconv: cannot convert file name ...` and is never written truncated/mangled. Validation probes both directions up front (both the sender local→remote and the receiver remote→local, and, for a server/daemon with its own `--iconv`, the client-REMOTE→server-LOCAL pair) so an unusable spec is rejected before the connection rather than mid-transfer, and NUL-emitting target charsets (utf-16/utf-32/ucs-2) are refused because filenames cannot contain NUL. Divergence documented upstream: the receiver does NOT half-swap; the wire charset always comes from the sender's REMOTE half, so a server whose local charset differs from the client's LOCAL must declare it with its own `--iconv`. Conversion is process-global and runs on a single thread per process (sender thread / receiver-loop thread), initialized before worker threads start and freed after they join. **Phase 6, Wave B (iconv) shipping note (PROTOCOL 2.15.0 → 2.16.0):** `--iconv=LOCAL[,REMOTE]` converts file NAMES at the wire boundary (never content). The full CONVERT_SPEC is serialized into the config frame as a new trailing string field (empty→NULL canonicalized), so both ends share the same wire charset interpretation; this required the PROTOCOL bump because the frame is a strict ordered sequence and a peer that does not parse the new trailing field would desynchronize. Each end derives LOCAL (its own charset) and REMOTE (the wire charset): the sender opens LOCAL→REMOTE and converts every transmitted filename; the receiver opens REMOTE→LOCAL and converts every received filename before creating/writing. Conversion is applied at every wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest keep/protected/missing entries, the incremental-check path, and the embedded `-s`/chunk-blob path). A name it cannot convert (EILSEQ/EINVAL) is failed cleanly with a logged `--iconv: cannot convert file name ...` and is never written truncated/mangled. Validation probes both directions up front (both the sender local→remote and the receiver remote→local, and, for a server/daemon with its own `--iconv`, the client-REMOTE→server-LOCAL pair) so an unusable spec is rejected before the connection rather than mid-transfer, and NUL-emitting target charsets (utf-16/utf-32/ucs-2) are refused because filenames cannot contain NUL. Divergence documented upstream: the receiver does NOT half-swap; the wire charset always comes from the sender's REMOTE half, so a server whose local charset differs from the client's LOCAL must declare it with its own `--iconv`. Conversion is process-global and runs on a single thread per process (sender thread / receiver-loop thread), initialized before worker threads start and freed after they join.
@@ -798,7 +798,7 @@ These are the hardest compatibility items because they require durable formats o
These are the last compatibility items and the closing phase toward rsync flag parity. Per the project decision: every rsync flag (short **and** long) that is *possible* gets real rsync-parity behavior; anything physically impossible becomes an explicit **Impossible/Divergence** status (accepted for CLI compatibility, safely inert, with coverage tests proving that); and the two privilege flags are deferred to the final wave pending an explicit privilege-model decision. The remaining `⚠️ Partial`, `🔄 Compatibility No-op`, `🔀 Alt Arg`, and `❌ Not Implemented` rows in the Summary are this phase's scope. All Wave A renames are **client-side only** (the wire config fields `use_compression`/`use_metadata`/`use_sendfile`/`use_chunk_serialization` are unchanged), so they require **no `PROTOCOL_VERSION` bump**. These are the last compatibility items and the closing phase toward rsync flag parity. Per the project decision: every rsync flag (short **and** long) that is *possible* gets real rsync-parity behavior; anything physically impossible becomes an explicit **Impossible/Divergence** status (accepted for CLI compatibility, safely inert, with coverage tests proving that); and the two privilege flags are deferred to the final wave pending an explicit privilege-model decision. The remaining `⚠️ Partial`, `🔄 Compatibility No-op`, `🔀 Alt Arg`, and `❌ Not Implemented` rows in the Summary are this phase's scope. All Wave A renames are **client-side only** (the wire config fields `use_compression`/`use_metadata`/`use_sendfile`/`use_chunk_serialization` are unchanged), so they require **no `PROTOCOL_VERSION` bump**.
**Wave A — CLI namespace parity (rename colliding FastSync short flags).** This is the prerequisite for all short-flag parity: it frees the short letters rsync needs and makes the three `🔀 Alt Arg` rows real. Renames touch `src/client/client_cli.c` `OPTION_TABLE` + the legacy bool table, `src/client/usage.c`, the README, and integration fixtures that reference the old short flags; the server's independent little CLI stays as-is (`-p` there remains the server port). Includes an opportunistic mechanical CLI refactor (align the client tables; dedupe where the pattern is identical). A fresh rsync-collision audit fixes the final short letters for the freed FastSync flags during this wave. Flags already matching rsync (`-e`/`--rsh`, `-S`/`--sparse`, `-H`, `-K`, `-k`, `-A`, `-X`, `-x`, `-C`, `-R`, `-d`, `-u`, `-W`, `-I`, `-l`, `-o`) are untouched. **Wave A — CLI namespace parity (rename colliding FastSync short flags) — ✅ implemented.** This freed the short letters rsync needs and made the three `🔀 Alt Arg` rows real. `-c`→`--checksum`, `-m`→`--prune-empty-dirs`, `-M`→`--remote-option`, `-f`→`--filter`, `-s`→`--secluded-args`, `-p`→`--perms`, `-T`→`--temp-dir`, `-a`/`--archive`→real `-rlptgoD`. FastSync's own flags moved to long-form-only or new shorts: `-j`/`--threads` (multithreading), `--preserve` (metadata), `--sendfile`, `--chunk-serialization`, `--timeout`, `--ssh-port`. The server's independent little CLI keeps `-p` as its port. All client-side, no wire change, no `PROTOCOL_VERSION` bump. Unit tests 37/37, full integration 400 passed, cppcheck and clang-format clean. Known Wave-A limitation: `--no-perms`/`--no-compress`-style negation of the newly-aliased shorts is not wired into the negatable set (only the long-form `--preserve`/`--compress`/`--no-links` negations exist); `--archive --no-perms` is consequently not supported yet — a minor deviation from rsync, acceptable for Wave A.
| FastSync flag today | rsync wants that name | Proposed rename | | FastSync flag today | rsync wants that name | Proposed rename |
|---------------------|----------------------|-----------------| |---------------------|----------------------|-----------------|
@@ -857,10 +857,10 @@ Ranked by user demand, implementation complexity, and interoperability impact (_
| Feature | Description | | Feature | Description |
|---------|-------------| |---------|-------------|
| `-m` | Multithreaded pipeline (scanner/loader/sender) | | `-j` / `--threads` | Multithreaded pipeline (scanner/loader/sender) (renamed from `-m` in Phase 7 Wave A; `-m` is now rsync `--prune-empty-dirs`) |
| `-s` | Chunk serialization mode | | `--chunk-serialization` | Chunk serialization mode (long form only; `-s` is now rsync `--secluded-args`) |
| `-f` / `--sendfile` | Zero-copy sendfile() syscall (TCP only) | | `--sendfile` | Zero-copy sendfile() syscall (TCP only) (long form only; `-f` is now rsync `--filter`) |
| `-c [level]` | zstd compression level (1-22) | | `-z [level]` / `--compress` | zstd compression level (1-22) (`-c` is now rsync `--checksum`) |
| `--chunk-size` | Configurable chunk size | | `--chunk-size` | Configurable chunk size |
| `--tls` | TLS encryption (mutual auth) | | `--tls` | TLS encryption (mutual auth) |
| `--fastsync-server-path` | Path to fastsync-server binary | | `--fastsync-server-path` | Path to fastsync-server binary |
+51 -34
View File
@@ -554,7 +554,7 @@ static const OptionEntry OPTION_TABLE[] = {
{"--stats", NULL, OPT_FLAG, offsetof(Config, stats)}, {"--stats", NULL, OPT_FLAG, offsetof(Config, stats)},
{"--human-readable", "-h", OPT_FLAG, offsetof(Config, human_readable)}, {"--human-readable", "-h", OPT_FLAG, offsetof(Config, human_readable)},
{"--partial", NULL, OPT_FLAG, offsetof(Config, partial)}, {"--partial", NULL, OPT_FLAG, offsetof(Config, partial)},
{"--secluded-args", NULL, OPT_NOOP, 0}, {"--secluded-args", "-s", OPT_NOOP, 0},
{"--update", "-u", OPT_FLAG, offsetof(Config, update)}, {"--update", "-u", OPT_FLAG, offsetof(Config, update)},
{"--old-args", NULL, OPT_FLAG, offsetof(Config, old_args)}, {"--old-args", NULL, OPT_FLAG, offsetof(Config, old_args)},
{"--rsh", "-e", OPT_STRING, offsetof(Config, rsh_command)}, {"--rsh", "-e", OPT_STRING, offsetof(Config, rsh_command)},
@@ -573,7 +573,7 @@ static const OptionEntry OPTION_TABLE[] = {
{"--append", NULL, OPT_FLAG, offsetof(Config, append)}, {"--append", NULL, OPT_FLAG, offsetof(Config, append)},
{"--append-verify", NULL, OPT_FLAG, offsetof(Config, append_verify)}, {"--append-verify", NULL, OPT_FLAG, offsetof(Config, append_verify)},
{"--fsync", NULL, OPT_FLAG, offsetof(Config, use_fsync)}, {"--fsync", NULL, OPT_FLAG, offsetof(Config, use_fsync)},
{"--checksum", NULL, OPT_FLAG, offsetof(Config, checksum)}, {"--checksum", "-c", OPT_FLAG, offsetof(Config, checksum)},
{"--8-bit-output", "-8", OPT_FLAG, offsetof(Config, eight_bit_output)}, {"--8-bit-output", "-8", OPT_FLAG, offsetof(Config, eight_bit_output)},
{"--itemize-changes", "-i", OPT_FLAG, offsetof(Config, itemize_changes)}, {"--itemize-changes", "-i", OPT_FLAG, offsetof(Config, itemize_changes)},
{"--list-only", NULL, OPT_FLAG, offsetof(Config, list_only)}, {"--list-only", NULL, OPT_FLAG, offsetof(Config, list_only)},
@@ -620,7 +620,7 @@ static const OptionEntry OPTION_TABLE[] = {
{"--max-delete", NULL, OPT_NONNEG_INT, offsetof(Config, max_delete)}, {"--max-delete", NULL, OPT_NONNEG_INT, offsetof(Config, max_delete)},
{"--ignore-errors", NULL, OPT_FLAG, offsetof(Config, ignore_errors)}, {"--ignore-errors", NULL, OPT_FLAG, offsetof(Config, ignore_errors)},
{"--force", NULL, OPT_FLAG, offsetof(Config, force_delete)}, {"--force", NULL, OPT_FLAG, offsetof(Config, force_delete)},
{"--prune-empty-dirs", NULL, OPT_FLAG, offsetof(Config, prune_empty_dirs)}, {"--prune-empty-dirs", "-m", OPT_FLAG, offsetof(Config, prune_empty_dirs)},
{"--ignore-missing-args", NULL, OPT_FLAG, offsetof(Config, ignore_missing_args)}, {"--ignore-missing-args", NULL, OPT_FLAG, offsetof(Config, ignore_missing_args)},
{"--delete-missing-args", NULL, OPT_FLAG, offsetof(Config, delete_missing_args)}, {"--delete-missing-args", NULL, OPT_FLAG, offsetof(Config, delete_missing_args)},
@@ -635,7 +635,7 @@ static const OptionEntry OPTION_TABLE[] = {
/* --rsync-path is rsync's spelling for the same "server program path"; it /* --rsync-path is rsync's spelling for the same "server program path"; it
* is a pure alias for fastsync_server_path (never a distinct field). */ * is a pure alias for fastsync_server_path (never a distinct field). */
{"--rsync-path", NULL, OPT_STRING, offsetof(Config, fastsync_server_path)}, {"--rsync-path", NULL, OPT_STRING, offsetof(Config, fastsync_server_path)},
{"--temp-dir", NULL, OPT_STRING, offsetof(Config, temp_dir)}, {"--temp-dir", "-T", OPT_STRING, offsetof(Config, temp_dir)},
{"--partial-dir", NULL, OPT_STRING, offsetof(Config, partial_dir)}, {"--partial-dir", NULL, OPT_STRING, offsetof(Config, partial_dir)},
{"--suffix", NULL, OPT_STRING, offsetof(Config, suffix)}, {"--suffix", NULL, OPT_STRING, offsetof(Config, suffix)},
{"--compress-choice", "--zc", OPT_STRING, offsetof(Config, compress_choice)}, {"--compress-choice", "--zc", OPT_STRING, offsetof(Config, compress_choice)},
@@ -668,10 +668,10 @@ static const OptionEntry OPTION_TABLE[] = {
{"--xattrs", "-X", OPT_FLAG, offsetof(Config, preserve_xattrs)}, {"--xattrs", "-X", OPT_FLAG, offsetof(Config, preserve_xattrs)},
{"--acls", "-A", OPT_FLAG, offsetof(Config, preserve_acls)}, {"--acls", "-A", OPT_FLAG, offsetof(Config, preserve_acls)},
{"--fake-super", NULL, OPT_FLAG, offsetof(Config, fake_super)}, {"--fake-super", NULL, OPT_FLAG, offsetof(Config, fake_super)},
/* Long-form-only: rsync's -M short form of --remote-option is INTENTIONALLY /* rsync's -M/--remote-option: -M is now the short alias for --remote-option
* unavailable because -M already means metadata mode in FastSync (a * (metadata mode is long-only --preserve), handled in the parse loop where
* documented divergence; see RSYNC_COMPAT.md). --trust-sender is a local * --remote-option is parsed. --trust-sender is a local receiver policy and
* receiver policy and never travels to the remote peer. */ * never travels to the remote peer. */
{"--trust-sender", NULL, OPT_FLAG, offsetof(Config, trust_sender)}, {"--trust-sender", NULL, OPT_FLAG, offsetof(Config, trust_sender)},
}; };
@@ -696,17 +696,17 @@ static const NegatableOption NEGATABLE_OPTIONS[] = {
{"sparse", "S", offsetof(Config, preserve_sparse)}, {"sparse", "S", offsetof(Config, preserve_sparse)},
{"inplace", NULL, offsetof(Config, inplace)}, {"inplace", NULL, offsetof(Config, inplace)},
{"preallocate", NULL, offsetof(Config, preallocate)}, {"preallocate", NULL, offsetof(Config, preallocate)},
{"checksum", NULL, offsetof(Config, checksum)}, {"checksum", "c", offsetof(Config, checksum)},
{"from0", NULL, offsetof(Config, from0)}, {"from0", NULL, offsetof(Config, from0)},
{"cvs-exclude", NULL, offsetof(Config, cvs_exclude)}, {"cvs-exclude", NULL, offsetof(Config, cvs_exclude)},
/* These options are also implied by --archive or handled outside the table. */ /* These options are also implied by --archive or handled outside the table. */
{"compress", "c", offsetof(Config, use_compression)}, {"compress", NULL, offsetof(Config, use_compression)},
{"compress", "z", offsetof(Config, use_compression)}, {"compress", "z", offsetof(Config, use_compression)},
{"multithreading", "m", offsetof(Config, use_multithreading)}, {"multithreading", "j", offsetof(Config, use_multithreading)},
{"preserve", "M", offsetof(Config, use_metadata)}, {"preserve", NULL, offsetof(Config, use_metadata)},
{"sendfile", "f", offsetof(Config, use_sendfile)}, {"sendfile", NULL, offsetof(Config, use_sendfile)},
{"chunk-serialization", "s", offsetof(Config, use_chunk_serialization)}, {"chunk-serialization", NULL, offsetof(Config, use_chunk_serialization)},
{"xattrs", "X", offsetof(Config, preserve_xattrs)}, {"xattrs", "X", offsetof(Config, preserve_xattrs)},
{"acls", "A", offsetof(Config, preserve_acls)}, {"acls", "A", offsetof(Config, preserve_acls)},
{"fake-super", NULL, offsetof(Config, fake_super)}, {"fake-super", NULL, offsetof(Config, fake_super)},
@@ -1029,17 +1029,35 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
config->preserve_specials = true; config->preserve_specials = true;
log_info_message(LOG_INFO_MISC, "Enabled preservation of device and special files (-D)"); 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 = /* Real rsync archive (-rlptgoD). FastSync is always recursive and always
!config->compress_choice || strcmp(config->compress_choice, "zstd") == 0; * preserves hard-link/other transfer semantics per its own flags, so -a
config->use_multithreading = true; * implies links, full metadata (perms/times/group/owner as FastSync's
* broad bundle), devices and specials. Compression and multithreading
* are NOT implied (they are no longer part of archive mode). */
config->follow_symlinks = true;
config->use_metadata = true; config->use_metadata = true;
log_info_message(LOG_INFO_MISC, "Enabled archive mode (-c -m -M)"); config->preserve_devices = true;
} else if (opt_is(argv[i], "-p", NULL)) { config->preserve_specials = true;
log_info_message(LOG_INFO_MISC,
"Enabled archive mode (-rlptgoD: links, metadata, devices, specials)");
} else if (opt_is(argv[i], "-p", "--perms")) {
/* rsync -p/--perms: preserve permission bits. Folded into FastSync's
* broad metadata bundle (mode/mtime travel together). */
config->use_metadata = true;
log_info_message(LOG_INFO_MISC, "Enabled permission preservation");
} else if (opt_is(argv[i], "--ssh-port", NULL)) {
if (i + 1 >= argc) { if (i + 1 >= argc) {
log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]);
return -1; return -1;
} }
if (set_positive_int_option(&config->ssh_port, argv[++i], "-p") != 0) if (set_positive_int_option(&config->ssh_port, argv[++i], "--ssh-port") != 0)
return -1;
if (config->ssh_port > 65535) {
log_message(LOG_LEVEL_ERROR, "SSH port must be 1-65535");
return -1;
}
} else if (strncmp(argv[i], "--ssh-port=", 11) == 0) {
if (set_positive_int_option(&config->ssh_port, argv[i] + 11, "--ssh-port") != 0)
return -1; return -1;
if (config->ssh_port > 65535) { if (config->ssh_port > 65535) {
log_message(LOG_LEVEL_ERROR, "SSH port must be 1-65535"); log_message(LOG_LEVEL_ERROR, "SSH port must be 1-65535");
@@ -1085,7 +1103,7 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
config->delta_max_file_size = val; config->delta_max_file_size = val;
else else
log_message(LOG_LEVEL_WARNING, "--delta-max value %llu too small, using default", val); log_message(LOG_LEVEL_WARNING, "--delta-max value %llu too small, using default", val);
} else if (opt_is(argv[i], "-c", "-z")) { } else if (opt_is(argv[i], "-z", "--compress")) {
config->use_compression = config->use_compression =
!config->compress_choice || strcmp(config->compress_choice, "zstd") == 0; !config->compress_choice || strcmp(config->compress_choice, "zstd") == 0;
log_info_message(LOG_INFO_MISC, "Enabled Compression"); log_info_message(LOG_INFO_MISC, "Enabled Compression");
@@ -1102,20 +1120,20 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
i++; i++;
} }
} }
} else if (opt_is(argv[i], "-M", "--preserve")) { } else if (opt_is(argv[i], "--preserve", NULL)) {
config->use_metadata = true; config->use_metadata = true;
log_info_message(LOG_INFO_MISC, "Enabled metadata preservation"); log_info_message(LOG_INFO_MISC, "Enabled metadata preservation");
} else if (opt_is(argv[i], "-E", "--executability")) { } else if (opt_is(argv[i], "-E", "--executability")) {
config->use_metadata = true; config->use_metadata = true;
config->use_executability = true; config->use_executability = true;
log_info_message(LOG_INFO_MISC, "Enabled executable permission preservation"); log_info_message(LOG_INFO_MISC, "Enabled executable permission preservation");
} else if (opt_is(argv[i], "-f", "--sendfile")) { } else if (opt_is(argv[i], "--sendfile", NULL)) {
config->use_sendfile = true; config->use_sendfile = true;
log_info_message(LOG_INFO_MISC, "Enabled sendfile"); log_info_message(LOG_INFO_MISC, "Enabled sendfile");
} else if (opt_is(argv[i], "-m", NULL)) { } else if (opt_is(argv[i], "-j", "--threads")) {
config->use_multithreading = true; config->use_multithreading = true;
log_info_message(LOG_INFO_MISC, "Enabled Multithreading"); log_info_message(LOG_INFO_MISC, "Enabled Multithreading");
} else if (opt_is(argv[i], "-s", NULL)) { } else if (opt_is(argv[i], "--chunk-serialization", NULL)) {
config->use_chunk_serialization = true; config->use_chunk_serialization = true;
log_info_message(LOG_INFO_MISC, "Enabled Chunk Serialization"); log_info_message(LOG_INFO_MISC, "Enabled Chunk Serialization");
} else if (opt_is(argv[i], "--server-port", NULL)) { } else if (opt_is(argv[i], "--server-port", NULL)) {
@@ -1210,7 +1228,10 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
} else if (strncmp(argv[i], "--filter=", 9) == 0) { } else if (strncmp(argv[i], "--filter=", 9) == 0) {
if (config_add_filter(config, argv[i] + 9) != 0) if (config_add_filter(config, argv[i] + 9) != 0)
return -1; return -1;
} else if (opt_is(argv[i], "--filter", NULL)) { } else if (strncmp(argv[i], "-f=", 3) == 0) {
if (config_add_filter(config, argv[i] + 3) != 0)
return -1;
} else if (opt_is(argv[i], "--filter", "-f")) {
if (i + 1 >= argc) { if (i + 1 >= argc) {
log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]);
return -1; return -1;
@@ -1248,13 +1269,6 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
} else if (opt_is(argv[i], "--info", NULL)) { } else if (opt_is(argv[i], "--info", NULL)) {
if (i + 1 >= argc || parse_info_flags(argv[++i], config) != 0) if (i + 1 >= argc || parse_info_flags(argv[++i], config) != 0)
return -1; return -1;
} else if (opt_is(argv[i], "-T", NULL)) {
if (i + 1 >= argc) {
log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]);
return -1;
}
if (set_positive_int_option(&config->timeout, argv[++i], "-T") != 0)
return -1;
} else if (strncmp(argv[i], "--skip-compress=", 16) == 0) { } else if (strncmp(argv[i], "--skip-compress=", 16) == 0) {
if (parse_skip_compress(config, argv[i] + 16) != 0) if (parse_skip_compress(config, argv[i] + 16) != 0)
return -1; return -1;
@@ -1308,7 +1322,10 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
} else if (strncmp(argv[i], "--remote-option=", 16) == 0) { } else if (strncmp(argv[i], "--remote-option=", 16) == 0) {
if (config_add_remote_option(config, argv[i] + 16, "--remote-option") != 0) if (config_add_remote_option(config, argv[i] + 16, "--remote-option") != 0)
return -1; return -1;
} else if (opt_is(argv[i], "--remote-option", NULL)) { } else if (strncmp(argv[i], "-M=", 3) == 0) {
if (config_add_remote_option(config, argv[i] + 3, "-M") != 0)
return -1;
} else if (opt_is(argv[i], "--remote-option", "-M")) {
if (i + 1 >= argc) { if (i + 1 >= argc) {
log_message(LOG_LEVEL_ERROR, "missing argument for --remote-option"); log_message(LOG_LEVEL_ERROR, "missing argument for --remote-option");
return -1; return -1;
+22 -23
View File
@@ -17,12 +17,14 @@ void print_usage(void) {
printf(" /local/path TCP transport (requires server on localhost:8080)\n"); printf(" /local/path TCP transport (requires server on localhost:8080)\n");
printf("\n"); printf("\n");
printf("Options:\n"); printf("Options:\n");
printf(" -c [level] Enable compression (level 1-22, default 5)\n"); printf(" -c, --checksum Verify content by checksum instead of size+mtime\n");
printf(" -z [level] Alias for -c\n"); printf(" -z, --compress [level] Enable compression (level 1-22, default 5)\n");
printf(" -a, --archive Archive mode (-c -m -M)\n"); printf(" -a, --archive rsync archive mode (-rlptgoD): links, metadata,\n");
printf(" devices and specials (not compression/multithreading)\n");
printf(" -n, --dry-run Show what would be transferred\n"); printf(" -n, --dry-run Show what would be transferred\n");
printf(" --remove-source-files Remove regular source files after successful transfer\n"); printf(" --remove-source-files Remove regular source files after successful transfer\n");
printf(" -p <port> SSH port (default: 22)\n"); printf(" -p, --perms Preserve permission bits (part of the metadata bundle)\n");
printf(" --ssh-port <port> SSH port (default: 22)\n");
printf(" -e, --rsh <command> Remote shell to launch on the client for the SSH\n"); printf(" -e, --rsh <command> Remote shell to launch on the client for the SSH\n");
printf(" transport (default: ssh). The command may include\n"); printf(" transport (default: ssh). The command may include\n");
printf(" arguments, e.g. -e \"ssh -p 2222\"\n"); printf(" arguments, e.g. -e \"ssh -p 2222\"\n");
@@ -80,9 +82,8 @@ void print_usage(void) {
printf(" entry's destination mirror receiver-side. Independent of\n"); printf(" entry's destination mirror receiver-side. Independent of\n");
printf(" --delete (it does not imply --delete; a non-empty directory\n"); printf(" --delete (it does not imply --delete; a non-empty directory\n");
printf(" mirror is removed only with --force or --delete)\n"); printf(" mirror is removed only with --force or --delete)\n");
printf(" --prune-empty-dirs Do not transfer empty directory entries (--dirs mode);\n"); printf(" -m, --prune-empty-dirs Do not transfer empty directory entries (--dirs mode);\n");
printf(" recursive transfers never send empty dirs. rsync's -m\n"); printf(" recursive transfers never send empty dirs\n");
printf(" short form stays FastSync multithreading\n");
printf(" Note: each timing flag implies --delete. Combining a timing flag with\n"); printf(" Note: each timing flag implies --delete. Combining a timing flag with\n");
printf(" --no-delete (in either order) is rejected as a config error.\n"); printf(" --no-delete (in either order) is rejected as a config error.\n");
printf(" --ignore-existing Skip files that already exist on receiver\n"); printf(" --ignore-existing Skip files that already exist on receiver\n");
@@ -105,8 +106,8 @@ void print_usage(void) {
printf(" --files-from <file> Read the source file list from FILE (paths relative to the " printf(" --files-from <file> Read the source file list from FILE (paths relative to the "
"source root)\n"); "source root)\n");
printf(" -0, --from0 Entries in --files-from are NUL-delimited\n"); printf(" -0, --from0 Entries in --files-from are NUL-delimited\n");
printf(" --filter=RULE rsync-style filter rule (+/- include/exclude; repeatable; the\n"); printf(" -f, --filter=RULE rsync-style filter rule (+/- include/exclude; repeatable;\n");
printf(" rsync -f short form conflicts with FastSync sendfile -f)\n"); printf(" both --filter=RULE and the -f RULE / -f=RULE short forms work)\n");
printf(" -C, --cvs-exclude Auto-ignore common CVS/SCM files (.git/, .svn/, *.o, *~, ...)\n"); printf(" -C, --cvs-exclude Auto-ignore common CVS/SCM files (.git/, .svn/, *.o, *~, ...)\n");
printf(" -F Apply per-directory .rsync-filter files during the scan\n"); printf(" -F Apply per-directory .rsync-filter files during the scan\n");
printf(" --max-size <n> Skip files larger than n bytes\n"); printf(" --max-size <n> Skip files larger than n bytes\n");
@@ -143,10 +144,11 @@ void print_usage(void) {
DELTA_BLOCK_SIZE_DEFAULT); DELTA_BLOCK_SIZE_DEFAULT);
printf(" --delta-max <n> Max file size for delta transfer (default: %llu)\n", printf(" --delta-max <n> Max file size for delta transfer (default: %llu)\n",
DELTA_MAX_FILE_SIZE); DELTA_MAX_FILE_SIZE);
printf(" -m Enable multithreading\n"); printf(" -j, --threads Enable multithreading\n");
printf(" -s Enable chunk serialization\n"); printf(" --chunk-serialization Enable chunk serialization (long form only)\n");
printf(" --secluded-args Accept rsync compatibility option (no effect)\n"); printf(" -s, --secluded-args Protect-args compatibility option (no effect; remote\n");
printf(" -f Enable sendfile (TCP only, not with -c or -s)\n"); printf(" SSH argv is already built injection-safe)\n");
printf(" --sendfile Enable sendfile zero-copy (TCP only; long form only)\n");
printf(" --compress-choice <alg> Compression algorithm (default: zstd)\n"); printf(" --compress-choice <alg> Compression algorithm (default: zstd)\n");
printf(" --zc <alg> Alias for --compress-choice\n"); printf(" --zc <alg> Alias for --compress-choice\n");
printf(" -v, --verbose Enable debug logging\n"); printf(" -v, --verbose Enable debug logging\n");
@@ -154,7 +156,7 @@ void print_usage(void) {
printf(" --debug=FLAGS Fine-grained debug logging (use --debug=help for flags)\n"); printf(" --debug=FLAGS Fine-grained debug logging (use --debug=help for flags)\n");
printf(" --info=FLAGS Fine-grained info: copy,misc,skip,stats,all,none\n"); printf(" --info=FLAGS Fine-grained info: copy,misc,skip,stats,all,none\n");
printf(" none suppresses info even with --verbose\n"); printf(" none suppresses info even with --verbose\n");
printf(" -M, --preserve Preserve file metadata\n"); printf(" --preserve Preserve file metadata (long form only)\n");
printf(" -E, --executability Preserve executable permission bits\n"); printf(" -E, --executability Preserve executable permission bits\n");
printf(" -X, --xattrs Preserve user extended attributes (user.* only;\n"); printf(" -X, --xattrs Preserve user extended attributes (user.* only;\n");
printf(" privileged security.*/trusted.* namespaces are\n"); printf(" privileged security.*/trusted.* namespaces are\n");
@@ -178,8 +180,8 @@ void print_usage(void) {
printf(" USER:GROUP, USER (owner only), :GROUP (group only); a\n"); printf(" USER:GROUP, USER (owner only), :GROUP (group only); a\n");
printf(" value of * means the current/root user as appropriate.\n"); printf(" value of * means the current/root user as appropriate.\n");
printf(" Names resolve on the source machine; @N for numerics.\n"); printf(" Names resolve on the source machine; @N for numerics.\n");
printf(" Note: -M is already FastSync's preserve flag; these use\n"); printf(" (Metadata is enabled with --preserve; -M now means\n");
printf(" long forms only.\n"); printf(" rsync's --remote-option.)\n");
printf(" --chunk-size <n> Chunk size in bytes (default: %d)\n", DEFAULT_CHUNK_SIZE); printf(" --chunk-size <n> Chunk size in bytes (default: %d)\n", DEFAULT_CHUNK_SIZE);
printf(" --source-dir <path> Source directory\n"); printf(" --source-dir <path> Source directory\n");
printf(" --dest-dir <path> Destination directory\n"); printf(" --dest-dir <path> Destination directory\n");
@@ -197,8 +199,7 @@ void print_usage(void) {
printf(" --cert <path> TLS certificate file (PEM)\n"); printf(" --cert <path> TLS certificate file (PEM)\n");
printf(" --key <path> TLS private key file (PEM)\n"); printf(" --key <path> TLS private key file (PEM)\n");
printf(" --ca <path> TLS CA certificate file (PEM)\n"); printf(" --ca <path> TLS CA certificate file (PEM)\n");
printf(" --timeout <sec> I/O timeout in seconds (default: 30)\n"); printf(" --timeout <sec> I/O timeout in seconds (default: 30; long form only)\n");
printf(" -T <sec> Alias for --timeout\n");
printf(" --contimeout <sec> Connection timeout in seconds (default: 10)\n"); printf(" --contimeout <sec> Connection timeout in seconds (default: 10)\n");
printf(" --stop-after=MINS Stop the transfer after MINS minutes (a positive\n"); printf(" --stop-after=MINS Stop the transfer after MINS minutes (a positive\n");
printf(" integer); whatever was already transferred is kept\n"); printf(" integer); whatever was already transferred is kept\n");
@@ -227,17 +228,15 @@ void print_usage(void) {
printf(" --stderr=MODE Route logging to stderr: errors or all\n"); printf(" --stderr=MODE Route logging to stderr: errors or all\n");
printf(" --partial Keep partial files on interrupted transfer\n"); printf(" --partial Keep partial files on interrupted transfer\n");
printf(" --partial-dir <dir> Directory for partial files\n"); printf(" --partial-dir <dir> Directory for partial files\n");
printf(" --temp-dir <dir> Scratch dir for temp files before atomic install\n"); printf(" -T, --temp-dir <dir> Scratch dir for temp files before atomic install\n");
printf(" --fastsync-server-path <path>\n"); printf(" --fastsync-server-path <path>\n");
printf(" Path to fastsync-server on remote (default: fastsync-server)\n"); printf(" Path to fastsync-server on remote (default: fastsync-server)\n");
printf( printf(
" --old-args Disable safe SSH command argument quoting (legacy compatibility)\n"); " --old-args Disable safe SSH command argument quoting (legacy compatibility)\n");
printf(" --remote-option=OPT Append OPT to the REMOTE server invocation over SSH\n"); printf(" -M, --remote-option=OPT Append OPT to the REMOTE server invocation over SSH\n");
printf(" (repeatable; each value is single-quote-escaped on the remote\n"); printf(" (repeatable; each value is single-quote-escaped on the remote\n");
printf(" command line; empty values and values with control characters\n"); printf(" command line; empty values and values with control characters\n");
printf(" are rejected). Long form only: rsync's -M short form is NOT\n"); printf(" are rejected; -M OPT, -M=OPT and --remote-option=OPT work)\n");
printf(" available because -M already means metadata preservation in\n");
printf(" FastSync (documented divergence)\n");
printf(" --trust-sender Trust the remote sender's file list: the receiver skips its\n"); printf(" --trust-sender Trust the remote sender's file list: the receiver skips its\n");
printf(" own up-front path-traversal/containment re-validation of the\n"); printf(" own up-front path-traversal/containment re-validation of the\n");
printf(" incoming file list (fewer checks, faster, potentially unsafe).\n"); printf(" incoming file list (fewer checks, faster, potentially unsafe).\n");
+1 -1
View File
@@ -138,7 +138,7 @@ class TestAppend:
self._place(source, REL, prefix + added) self._place(source, REL, prefix + added)
self._place(self._dest_file(source, dest, ""), REL, prefix) self._place(self._dest_file(source, dest, ""), REL, prefix)
result, _ = run_client(source, dest, flags=["--append", "-m"], port=shared_server.port) result, _ = run_client(source, dest, flags=["--append", "--threads"], port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"--append -m failed: {(result.stderr or result.stdout)[:400]}" f"--append -m failed: {(result.stderr or result.stdout)[:400]}"
assert self._read(self._dest_file(source, dest, ""), REL) == prefix + added assert self._read(self._dest_file(source, dest, ""), REL) == prefix + added
+126 -122
View File
@@ -101,14 +101,14 @@ class TestDeviceSpecial:
assert os.major(st.st_rdev) == 1 and os.minor(st.st_rdev) == 3 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): def test_m_remove_source_files_keeps_recreated_fifo(self, shared_server):
"""-m --remove-source-files --specials: a recreated FIFO must NOT be """--threads --remove-source-files --specials: a recreated FIFO must NOT be
acknowledged as a removable source (its outcome must not shift the acknowledged as a removable source (its outcome must not shift the
per-file status stream, which would break the run and mis-remove 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.""" adjacent regular file). The regular file is removed; the FIFO stays."""
self._setup() self._setup()
os.mkfifo(os.path.join(DEVICE_SOURCE, "pipe.fifo")) os.mkfifo(os.path.join(DEVICE_SOURCE, "pipe.fifo"))
result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST, result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST,
flags=["-m", "--remove-source-files", "--specials"], flags=["--threads", "--remove-source-files", "--specials"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, ( assert result.returncode == 0, (
f"Exit {result.returncode}: {result.stderr[:300]}" f"Exit {result.returncode}: {result.stderr[:300]}"
@@ -122,14 +122,14 @@ class TestDeviceSpecial:
@pytest.mark.skipif(os.geteuid() != 0, reason="requires root to create device nodes") @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): def test_m_remove_source_files_keeps_recreated_device(self, shared_server):
"""Root-only: -m --remove-source-files --devices must not remove a """Root-only: --threads --remove-source-files --devices must not remove a
source device node the receiver recreated (mirrors the single-threaded source device node the receiver recreated (mirrors the single-threaded
behavior; the special is never acknowledged as a removable source).""" behavior; the special is never acknowledged as a removable source)."""
self._setup() self._setup()
src_dev = os.path.join(DEVICE_SOURCE, "realdev") src_dev = os.path.join(DEVICE_SOURCE, "realdev")
os.mknod(src_dev, stat.S_IFCHR | 0o666, os.makedev(1, 3)) os.mknod(src_dev, stat.S_IFCHR | 0o666, os.makedev(1, 3))
result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST, result, _ = run_client(DEVICE_SOURCE, DEVICE_DEST,
flags=["-m", "--remove-source-files", "--devices"], flags=["--threads", "--remove-source-files", "--devices"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, ( assert result.returncode == 0, (
f"Exit {result.returncode}: {result.stderr[:300]}" f"Exit {result.returncode}: {result.stderr[:300]}"
@@ -263,7 +263,7 @@ class TestRemoveSourceFiles:
os.makedirs(os.path.join(source, "directory")) os.makedirs(os.path.join(source, "directory"))
os.symlink("one.txt", os.path.join(source, "link.txt")) os.symlink("one.txt", os.path.join(source, "link.txt"))
result, _ = run_client(source, dest, flags=["--remove-source-files", "-m"], result, _ = run_client(source, dest, flags=["--remove-source-files", "--threads"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, f"Remove-source sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"Remove-source sync failed: {result.stderr[:200]}"
assert not os.path.exists(os.path.join(source, "one.txt")) assert not os.path.exists(os.path.join(source, "one.txt"))
@@ -320,10 +320,10 @@ class TestRemoveSourceFiles:
with open(source_file, "wb") as f: with open(source_file, "wb") as f:
f.write(b"keep after skip") f.write(b"keep after skip")
# The seed run preserves timestamps (-M) so the destination copy has the # The seed run preserves timestamps (--preserve) so the destination copy has the
# source's exact mtime; otherwise the incremental skip would depend on # source's exact mtime; otherwise the incremental skip would depend on
# both writes landing in the same whole second (a race). # both writes landing in the same whole second (a race).
result, _ = run_client(source, dest, flags=["-M"], port=shared_server.port) result, _ = run_client(source, dest, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0 assert result.returncode == 0
result, _ = run_client(source, dest, result, _ = run_client(source, dest,
flags=["--remove-source-files", "--incremental"], flags=["--remove-source-files", "--incremental"],
@@ -348,11 +348,15 @@ class TestArchiveMode:
assert not missing, f"Missing: {missing}" assert not missing, f"Missing: {missing}"
assert not mismatches, f"Mismatch: {mismatches}" assert not mismatches, f"Mismatch: {mismatches}"
def test_archive_implied_options_can_be_negated(self, shared_server): def test_archive_with_negated_links(self, shared_server):
# --archive implies links + metadata + devices + specials. Devices/specials
# force metadata transmission (recreating a node needs the metadata mode), so
# the post-parse layer keeps use_metadata on even under --no-preserve; only the
# independently-negatable --no-links actually takes effect here.
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, dur = run_client( result, dur = run_client(
SOURCE_DIR, DEST_DIR, SOURCE_DIR, DEST_DIR,
flags=["--archive", "--no-compress", "--no-m", "--no-preserve"], flags=["--archive", "--no-links"],
port=shared_server.port, port=shared_server.port,
) )
if result.returncode != 0: if result.returncode != 0:
@@ -477,7 +481,7 @@ class TestSkipCompress:
f.write((b"skip compression case test\n" * 100)) f.write((b"skip compression case test\n" * 100))
result, _ = run_client( result, _ = run_client(
SOURCE_DIR, DEST_DIR, SOURCE_DIR, DEST_DIR,
flags=["-c", "--skip-compress=.txt"], flags=["-z", "--skip-compress=.txt"],
port=shared_server.port, port=shared_server.port,
) )
assert result.returncode == 0, f"Skip-compress sync failed: {(result.stderr or result.stdout)[:200]}" assert result.returncode == 0, f"Skip-compress sync failed: {(result.stderr or result.stdout)[:200]}"
@@ -489,7 +493,7 @@ class TestSkipCompress:
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client( result, _ = run_client(
SOURCE_DIR, DEST_DIR, SOURCE_DIR, DEST_DIR,
flags=["-c", "--skip-compress="], flags=["-z", "--skip-compress="],
port=shared_server.port, port=shared_server.port,
) )
assert result.returncode == 0, f"Empty skip-compress sync failed: {(result.stderr or result.stdout)[:200]}" assert result.returncode == 0, f"Empty skip-compress sync failed: {(result.stderr or result.stdout)[:200]}"
@@ -503,7 +507,7 @@ class TestSkipCompress:
path = os.path.join(SOURCE_DIR, "incremental-skip.TXT") path = os.path.join(SOURCE_DIR, "incremental-skip.TXT")
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(b"original skipped content\n") f.write(b"original skipped content\n")
flags = ["-c", "-M", "--skip-compress=.txt"] flags = ["-z", "--preserve", "--skip-compress=.txt"]
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"Initial sync failed: {(result.stderr or result.stdout)[:200]}" assert result.returncode == 0, f"Initial sync failed: {(result.stderr or result.stdout)[:200]}"
with open(path, "wb") as f: with open(path, "wb") as f:
@@ -521,7 +525,7 @@ class TestSkipCompress:
def test_skip_compress_rejects_chunk_serialization(self, shared_server): def test_skip_compress_rejects_chunk_serialization(self, shared_server):
result, _ = run_client( result, _ = run_client(
SOURCE_DIR, DEST_DIR, SOURCE_DIR, DEST_DIR,
flags=["-c", "-s", "--skip-compress=.txt"], flags=["-z", "--chunk-serialization", "--skip-compress=.txt"],
port=shared_server.port, port=shared_server.port,
) )
assert result.returncode != 0 assert result.returncode != 0
@@ -620,7 +624,7 @@ class TestIncremental:
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, dur = run_client( result, dur = run_client(
SOURCE_DIR, DEST_DIR, SOURCE_DIR, DEST_DIR,
flags=["-M"], flags=["--preserve"],
port=shared_server.port, port=shared_server.port,
) )
assert result.returncode == 0, f"First sync failed: {result.stderr[:100]}" assert result.returncode == 0, f"First sync failed: {result.stderr[:100]}"
@@ -628,7 +632,7 @@ class TestIncremental:
start = time.monotonic() start = time.monotonic()
result, dur = run_client( result, dur = run_client(
SOURCE_DIR, DEST_DIR, SOURCE_DIR, DEST_DIR,
flags=["-M", "--incremental"], flags=["--preserve", "--incremental"],
port=shared_server.port, port=shared_server.port,
) )
incremental_time = time.monotonic() - start incremental_time = time.monotonic() - start
@@ -645,7 +649,7 @@ class TestIncremental:
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client( result, _ = run_client(
SOURCE_DIR, DEST_DIR, SOURCE_DIR, DEST_DIR,
flags=["-M"], flags=["--preserve"],
port=shared_server.port, port=shared_server.port,
) )
assert result.returncode == 0 assert result.returncode == 0
@@ -656,7 +660,7 @@ class TestIncremental:
result, dur = run_client( result, dur = run_client(
SOURCE_DIR, DEST_DIR, SOURCE_DIR, DEST_DIR,
flags=["-M", "--incremental"], flags=["--preserve", "--incremental"],
port=shared_server.port, port=shared_server.port,
) )
assert result.returncode == 0 assert result.returncode == 0
@@ -673,7 +677,7 @@ class TestIncremental:
def test_checksum_detects_same_size_and_mtime_change(self, shared_server): def test_checksum_detects_same_size_and_mtime_change(self, shared_server):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0 assert result.returncode == 0
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
@@ -685,7 +689,7 @@ class TestIncremental:
os.utime(received_file, (source_stat.st_atime, source_stat.st_mtime)) os.utime(received_file, (source_stat.st_atime, source_stat.st_mtime))
result, _ = run_client(SOURCE_DIR, DEST_DIR, result, _ = run_client(SOURCE_DIR, DEST_DIR,
flags=["-M", "--incremental", "--checksum"], flags=["--preserve", "--incremental", "--checksum"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, f"Checksum sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"Checksum sync failed: {result.stderr[:200]}"
with open(received_file, "rb") as f: with open(received_file, "rb") as f:
@@ -693,7 +697,7 @@ class TestIncremental:
def test_size_only_skips_same_size_with_different_mtime(self, shared_server): def test_size_only_skips_same_size_with_different_mtime(self, shared_server):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0 assert result.returncode == 0
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
@@ -704,7 +708,7 @@ class TestIncremental:
result, _ = run_client( result, _ = run_client(
SOURCE_DIR, DEST_DIR, SOURCE_DIR, DEST_DIR,
flags=["-M", "--incremental", "--size-only"], flags=["--preserve", "--incremental", "--size-only"],
port=shared_server.port, port=shared_server.port,
) )
assert result.returncode == 0, f"Size-only sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"Size-only sync failed: {result.stderr[:200]}"
@@ -713,7 +717,7 @@ class TestIncremental:
def test_ignore_times_transfers_same_size_and_mtime(self, shared_server): def test_ignore_times_transfers_same_size_and_mtime(self, shared_server):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0 assert result.returncode == 0
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
@@ -725,7 +729,7 @@ class TestIncremental:
os.utime(received_file, (source_stat.st_atime, source_stat.st_mtime)) os.utime(received_file, (source_stat.st_atime, source_stat.st_mtime))
result, _ = run_client(SOURCE_DIR, DEST_DIR, result, _ = run_client(SOURCE_DIR, DEST_DIR,
flags=["-M", "--incremental", "--ignore-times"], flags=["--preserve", "--incremental", "--ignore-times"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, f"Ignore-times sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"Ignore-times sync failed: {result.stderr[:200]}"
with open(received_file, "rb") as f: with open(received_file, "rb") as f:
@@ -733,7 +737,7 @@ class TestIncremental:
def test_modify_window_allows_subsecond_mtime_difference(self, shared_server): def test_modify_window_allows_subsecond_mtime_difference(self, shared_server):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0 assert result.returncode == 0
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
@@ -746,7 +750,7 @@ class TestIncremental:
source_stat.st_mtime_ns - 1500000000)) source_stat.st_mtime_ns - 1500000000))
result, _ = run_client(SOURCE_DIR, DEST_DIR, result, _ = run_client(SOURCE_DIR, DEST_DIR,
flags=["-M", "--incremental", "--modify-window=2"], flags=["--preserve", "--incremental", "--modify-window=2"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, f"Modify-window sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"Modify-window sync failed: {result.stderr[:200]}"
with open(received_file, "rb") as f: with open(received_file, "rb") as f:
@@ -754,7 +758,7 @@ class TestIncremental:
def test_whole_file_disables_delta_and_keeps_compression(self, shared_server): def test_whole_file_disables_delta_and_keeps_compression(self, shared_server):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0 assert result.returncode == 0
source_file = os.path.join(SOURCE_DIR, "medium.txt") source_file = os.path.join(SOURCE_DIR, "medium.txt")
@@ -764,7 +768,7 @@ class TestIncremental:
result, _ = run_client( result, _ = run_client(
SOURCE_DIR, SOURCE_DIR,
DEST_DIR, DEST_DIR,
flags=["-M", "--incremental", "--delta", "-W", "-c"], flags=["--preserve", "--incremental", "--delta", "-W", "-z"],
port=shared_server.port, port=shared_server.port,
) )
assert result.returncode == 0, f"Whole-file sync failed: {(result.stderr or result.stdout)[:200]}" assert result.returncode == 0, f"Whole-file sync failed: {(result.stderr or result.stdout)[:200]}"
@@ -795,11 +799,11 @@ class TestChecksumChoice:
@pytest.mark.parametrize("mt", [False, True]) @pytest.mark.parametrize("mt", [False, True])
def test_unchanged_skipped_and_bytes_preserved(self, shared_server, algo, mt): def test_unchanged_skipped_and_bytes_preserved(self, shared_server, algo, mt):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}"
flags = (["-M", "--incremental", "--checksum", f"--checksum-choice={algo}"] + flags = (["--preserve", "--incremental", "--checksum", f"--checksum-choice={algo}"] +
(["-m"] if mt else [])) (["--threads"] if mt else []))
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"checksum {algo} run failed: {result.stderr[:200]}" assert result.returncode == 0, f"checksum {algo} run failed: {result.stderr[:200]}"
@@ -816,7 +820,7 @@ class TestChecksumChoice:
@pytest.mark.parametrize("mt", [False, True]) @pytest.mark.parametrize("mt", [False, True])
def test_changed_same_size_mtime_redetected(self, shared_server, algo, mt): def test_changed_same_size_mtime_redetected(self, shared_server, algo, mt):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0 assert result.returncode == 0
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
@@ -827,8 +831,8 @@ class TestChecksumChoice:
f.write(b"DDDDDDDDDDDD") # same size, different content f.write(b"DDDDDDDDDDDD") # same size, different content
os.utime(received_file, (source_stat.st_atime, source_stat.st_mtime)) os.utime(received_file, (source_stat.st_atime, source_stat.st_mtime))
flags = (["-M", "--incremental", "--checksum", f"--checksum-choice={algo}"] + flags = (["--preserve", "--incremental", "--checksum", f"--checksum-choice={algo}"] +
(["-m"] if mt else [])) (["--threads"] if mt else []))
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"checksum {algo} redetect failed: {result.stderr[:200]}" assert result.returncode == 0, f"checksum {algo} redetect failed: {result.stderr[:200]}"
with open(received_file, "rb") as f: with open(received_file, "rb") as f:
@@ -840,10 +844,10 @@ class TestChecksumChoice:
# small handshake travels, not the payloads. Proxy byte counts are not # small handshake travels, not the payloads. Proxy byte counts are not
# available for -m (multithreaded connections), so single-thread only. # available for -m (multithreaded connections), so single-thread only.
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0 assert result.returncode == 0
flags = ["-M", "--incremental", "--checksum", f"--checksum-choice={algo}"] flags = ["--preserve", "--incremental", "--checksum", f"--checksum-choice={algo}"]
proxy = CountingProxy(shared_server.port) proxy = CountingProxy(shared_server.port)
cmd = (CLIENT_CMD + ["--source-dir", SOURCE_DIR, "--dest-dir", DEST_DIR, cmd = (CLIENT_CMD + ["--source-dir", SOURCE_DIR, "--dest-dir", DEST_DIR,
"--save-to-disk", "--server-port", str(proxy.port)] + flags) "--save-to-disk", "--server-port", str(proxy.port)] + flags)
@@ -855,8 +859,8 @@ class TestChecksumChoice:
@pytest.mark.parametrize("mt", [False, True]) @pytest.mark.parametrize("mt", [False, True])
def test_seed_is_deterministic_and_preserves_content(self, shared_server, mt): def test_seed_is_deterministic_and_preserves_content(self, shared_server, mt):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
flags = ["-M", "--incremental", "--checksum", flags = ["--preserve", "--incremental", "--checksum",
"--checksum-choice=xxh64", "--checksum-seed=987654"] + (["-m"] if mt else []) "--checksum-choice=xxh64", "--checksum-seed=987654"] + (["--threads"] if mt else [])
first, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port) first, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port)
assert first.returncode == 0, f"seeded run failed: {first.stderr[:200]}" assert first.returncode == 0, f"seeded run failed: {first.stderr[:200]}"
@@ -894,7 +898,7 @@ class TestChecksumChoice:
big = os.path.join(source, "big.bin") big = os.path.join(source, "big.bin")
with open(big, "wb") as f: with open(big, "wb") as f:
f.write(bytes(range(256)) * 200) # 51200 bytes > delta 16K floor f.write(bytes(range(256)) * 200) # 51200 bytes > delta 16K floor
result, _ = run_client(source, dest, flags=["-M"], port=shared_server.port) result, _ = run_client(source, dest, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0, f"seed delta seed failed: {result.stderr[:200]}" assert result.returncode == 0, f"seed delta seed failed: {result.stderr[:200]}"
# Edit a region so the receiver must match a changed block with the seed. # Edit a region so the receiver must match a changed block with the seed.
@@ -906,8 +910,8 @@ class TestChecksumChoice:
# edit and the prior sync share a second. Setting an old dest mtime # edit and the prior sync share a second. Setting an old dest mtime
# guarantees the delta path is exercised deterministically. # guarantees the delta path is exercised deterministically.
os.utime(os.path.join(get_dest_received_dir(dest, source), "big.bin"), (0, 0)) os.utime(os.path.join(get_dest_received_dir(dest, source), "big.bin"), (0, 0))
flags = (["-M", "--incremental", "--delta", "--checksum-seed=314159"] + flags = (["--preserve", "--incremental", "--delta", "--checksum-seed=314159"] +
(["-m"] if mt else [])) (["--threads"] if mt else []))
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"seed delta run failed: {result.stderr[:200]}" assert result.returncode == 0, f"seed delta run failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
@@ -975,7 +979,7 @@ class TestExisting:
@pytest.mark.ci @pytest.mark.ci
def test_existing_updates_existing_and_skips_new(self, shared_server): def test_existing_updates_existing_and_skips_new(self, shared_server):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0, f"Initial sync failed: {(result.stderr or result.stdout)[:200]}" assert result.returncode == 0, f"Initial sync failed: {(result.stderr or result.stdout)[:200]}"
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
@@ -988,7 +992,7 @@ class TestExisting:
try: try:
result, _ = run_client(SOURCE_DIR, DEST_DIR, result, _ = run_client(SOURCE_DIR, DEST_DIR,
flags=["-M", "--existing"], port=shared_server.port) flags=["--preserve", "--existing"], port=shared_server.port)
assert result.returncode == 0, f"--existing sync failed: {(result.stderr or result.stdout)[:200]}" assert result.returncode == 0, f"--existing sync failed: {(result.stderr or result.stdout)[:200]}"
with open(os.path.join(received, "small.txt"), "rb") as f: with open(os.path.join(received, "small.txt"), "rb") as f:
@@ -1060,7 +1064,7 @@ class TestDelete:
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client( result, _ = run_client(
SOURCE_DIR, DEST_DIR, SOURCE_DIR, DEST_DIR,
flags=["-M"], flags=["--preserve"],
port=shared_server.port, port=shared_server.port,
) )
assert result.returncode == 0 assert result.returncode == 0
@@ -1076,7 +1080,7 @@ class TestDelete:
result, dur = run_client( result, dur = run_client(
SOURCE_DIR, DEST_DIR, SOURCE_DIR, DEST_DIR,
flags=["-M", "--delete"], flags=["--preserve", "--delete"],
port=shared_server.port, port=shared_server.port,
) )
@@ -1119,7 +1123,7 @@ class TestProgress:
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, dur = run_client( result, dur = run_client(
SOURCE_DIR, DEST_DIR, SOURCE_DIR, DEST_DIR,
flags=["-m", "-h", "--progress"], flags=["--threads", "-h", "--progress"],
port=shared_server.port, port=shared_server.port,
) )
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}" assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}"
@@ -1145,7 +1149,7 @@ class TestInfo:
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client( result, _ = run_client(
SOURCE_DIR, DEST_DIR, SOURCE_DIR, DEST_DIR,
flags=["-m", "--info=stats"], flags=["--threads", "--info=stats"],
port=shared_server.port, port=shared_server.port,
) )
assert result.returncode == 0, f"Info stats sync failed: {(result.stderr or result.stdout)[:200]}" assert result.returncode == 0, f"Info stats sync failed: {(result.stderr or result.stdout)[:200]}"
@@ -1277,7 +1281,7 @@ class TestRemoveSourceFilesSkips:
assert result.returncode == 0 assert result.returncode == 0
result, _ = run_client(source, dest, result, _ = run_client(source, dest,
flags=["--remove-source-files", "--ignore-existing", "-m"], flags=["--remove-source-files", "--ignore-existing", "--threads"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, f"Sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"Sync failed: {result.stderr[:200]}"
# Destination already has the file, so the receiver (writer thread) # Destination already has the file, so the receiver (writer thread)
@@ -1425,7 +1429,7 @@ class TestOneFileSystem:
source = os.path.join(TEST_DATA_DIR, "ofs_m_src") source = os.path.join(TEST_DATA_DIR, "ofs_m_src")
self._make_tree(source) self._make_tree(source)
self._assert_full_tree_transferred(source, os.path.join(TEST_DATA_DIR, "ofs_m_dst"), self._assert_full_tree_transferred(source, os.path.join(TEST_DATA_DIR, "ofs_m_dst"),
shared_server.port, ["-m", "--one-file-system"]) shared_server.port, ["--threads", "--one-file-system"])
def test_x_skips_other_device_mountpoint(self, shared_server): def test_x_skips_other_device_mountpoint(self, shared_server):
if os.geteuid() != 0 or shutil.which("mount") is None or shutil.which("umount") is None: if os.geteuid() != 0 or shutil.which("mount") is None or shutil.which("umount") is None:
@@ -1518,7 +1522,7 @@ class TestTempDir:
source = self._make_source("tempdir_src") source = self._make_source("tempdir_src")
dest = os.path.join(TEST_DATA_DIR, "tempdir_dst") dest = os.path.join(TEST_DATA_DIR, "tempdir_dst")
clean_dir(dest) clean_dir(dest)
flags = ["--temp-dir=scratch"] + (["-m"] if mt else []) flags = ["--temp-dir=scratch"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"temp-dir sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"temp-dir sync failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
@@ -1629,7 +1633,7 @@ class TestListOnly:
def test_list_only_multithreaded(self): def test_list_only_multithreaded(self):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--list-only", "-m"]) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--list-only", "--threads"])
assert result.returncode == 0, f"list-only -m failed: {result.stderr[:200]}" assert result.returncode == 0, f"list-only -m failed: {result.stderr[:200]}"
for full_path in _source_files(): for full_path in _source_files():
assert full_path in result.stdout, f"list-only -m omitted {full_path}" assert full_path in result.stdout, f"list-only -m omitted {full_path}"
@@ -1643,7 +1647,7 @@ class TestItemizeChanges:
def test_first_run_prints_sent_lines(self, shared_server): def test_first_run_prints_sent_lines(self, shared_server):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR, result, _ = run_client(SOURCE_DIR, DEST_DIR,
flags=["-M", "-i"], port=shared_server.port) flags=["--preserve", "-i"], port=shared_server.port)
assert result.returncode == 0, f"itemize sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"itemize sync failed: {result.stderr[:200]}"
sent_lines = {">f+++++++++ " + p for p in _source_files()} sent_lines = {">f+++++++++ " + p for p in _source_files()}
assert sent_lines <= set(result.stdout.splitlines()), ( assert sent_lines <= set(result.stdout.splitlines()), (
@@ -1652,10 +1656,10 @@ class TestItemizeChanges:
def test_incremental_second_run_prints_no_line_for_unchanged(self, shared_server): def test_incremental_second_run_prints_no_line_for_unchanged(self, shared_server):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-M"], port=shared_server.port) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}"
result, _ = run_client(SOURCE_DIR, DEST_DIR, result, _ = run_client(SOURCE_DIR, DEST_DIR,
flags=["-M", "-i", "--incremental"], flags=["--preserve", "-i", "--incremental"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, f"incremental itemize failed: {result.stderr[:200]}" assert result.returncode == 0, f"incremental itemize failed: {result.stderr[:200]}"
itemized = [line for line in result.stdout.splitlines() if line and line[0] in ">.<c"] itemized = [line for line in result.stdout.splitlines() if line and line[0] in ">.<c"]
@@ -1664,7 +1668,7 @@ class TestItemizeChanges:
def test_multithreaded_emits_same_itemize_lines(self, shared_server): def test_multithreaded_emits_same_itemize_lines(self, shared_server):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR, result, _ = run_client(SOURCE_DIR, DEST_DIR,
flags=["-M", "-i", "-m"], port=shared_server.port) flags=["--preserve", "-i", "--threads"], port=shared_server.port)
assert result.returncode == 0, f"itemize -m sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"itemize -m sync failed: {result.stderr[:200]}"
sent_lines = {">f+++++++++ " + p for p in _source_files()} sent_lines = {">f+++++++++ " + p for p in _source_files()}
assert sent_lines <= set(result.stdout.splitlines()), ( assert sent_lines <= set(result.stdout.splitlines()), (
@@ -1690,14 +1694,14 @@ class TestItemizeChanges:
with open(untouched, "wb") as fh: with open(untouched, "wb") as fh:
fh.write(b"stable\n") fh.write(b"stable\n")
result, _ = run_client(source, dest, flags=["-M"], port=shared_server.port) result, _ = run_client(source, dest, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}"
with open(changed, "wb") as fh: with open(changed, "wb") as fh:
fh.write(b"edited payload\n") fh.write(b"edited payload\n")
result, _ = run_client(source, dest, result, _ = run_client(source, dest,
flags=["-M", "-i", "--incremental"], flags=["--preserve", "-i", "--incremental"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, f"incremental itemize failed: {result.stderr[:200]}" assert result.returncode == 0, f"incremental itemize failed: {result.stderr[:200]}"
itemized = [line for line in result.stdout.splitlines() if line.startswith(">f")] itemized = [line for line in result.stdout.splitlines() if line.startswith(">f")]
@@ -1724,7 +1728,7 @@ class TestOutFormat:
def test_out_format_multithreaded_matches_single(self, shared_server): def test_out_format_multithreaded_matches_single(self, shared_server):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR, result, _ = run_client(SOURCE_DIR, DEST_DIR,
flags=["--out-format=%f %l", "-m"], port=shared_server.port) flags=["--out-format=%f %l", "--threads"], port=shared_server.port)
assert result.returncode == 0, f"out-format -m sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"out-format -m sync failed: {result.stderr[:200]}"
expected = {f"{p} {os.path.getsize(p)}" for p in _source_files()} expected = {f"{p} {os.path.getsize(p)}" for p in _source_files()}
got = set(result.stdout.splitlines()) got = set(result.stdout.splitlines())
@@ -1767,16 +1771,16 @@ class TestLogFileFormat:
result, _ = run_client( result, _ = run_client(
source, source,
dest, dest,
flags=["--log-file", log_path, "--log-file-format=%f %l", "-m"], flags=["--log-file", log_path, "--log-file-format=%f %l", "--threads"],
port=shared_server.port, port=shared_server.port,
) )
assert result.returncode == 0, f"log-file -m sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"log-file --threads sync failed: {result.stderr[:200]}"
assert os.path.exists(log_path), "--log-file created no log" assert os.path.exists(log_path), "--log-file created no log"
with open(log_path, encoding="utf-8", errors="replace") as fh: with open(log_path, encoding="utf-8", errors="replace") as fh:
content = fh.read() content = fh.read()
expected = {f"{os.path.join(source, rel)} {len(data)}" for rel, data in files.items()} expected = {f"{os.path.join(source, rel)} {len(data)}" for rel, data in files.items()}
for line in expected: for line in expected:
assert line in content, f"log file (-m) missing {line!r}" assert line in content, f"log file (--threads) missing {line!r}"
class TestDelayUpdates: class TestDelayUpdates:
@@ -1812,7 +1816,7 @@ class TestDelayUpdates:
result, _ = run_client(source, plain_dest, port=shared_server.port) result, _ = run_client(source, plain_dest, port=shared_server.port)
assert result.returncode == 0, f"plain sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"plain sync failed: {result.stderr[:200]}"
flags = ["--delay-updates"] + (["-m"] if mt else []) flags = ["--delay-updates"] + (["--threads"] if mt else [])
result, _ = run_client(source, delay_dest, flags=flags, port=shared_server.port) result, _ = run_client(source, delay_dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"delay-updates sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"delay-updates sync failed: {result.stderr[:200]}"
@@ -1834,7 +1838,7 @@ class TestDelayUpdates:
source = self._make_source("delay_rerun_src") source = self._make_source("delay_rerun_src")
dest = os.path.join(TEST_DATA_DIR, "delay_rerun_dst") dest = os.path.join(TEST_DATA_DIR, "delay_rerun_dst")
clean_dir(dest) clean_dir(dest)
flags = ["--delay-updates", "-M", "--incremental"] + (["-m"] if mt else []) flags = ["--delay-updates", "--preserve", "--incremental"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"first delayed sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"first delayed sync failed: {result.stderr[:200]}"
@@ -1853,7 +1857,7 @@ class TestDelayUpdates:
source = self._make_source("delay_rsf_src") source = self._make_source("delay_rsf_src")
dest = os.path.join(TEST_DATA_DIR, "delay_rsf_dst") dest = os.path.join(TEST_DATA_DIR, "delay_rsf_dst")
clean_dir(dest) clean_dir(dest)
flags = ["--remove-source-files", "--delay-updates"] + (["-m"] if mt else []) flags = ["--remove-source-files", "--delay-updates"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"delayed remove-source sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"delayed remove-source sync failed: {result.stderr[:200]}"
@@ -1891,7 +1895,7 @@ class TestDelayUpdates:
fh.write(b"BBBB") fh.write(b"BBBB")
os.remove(os.path.join(source, "extra.txt")) os.remove(os.path.join(source, "extra.txt"))
flags = ["--delete", "--delay-updates"] + (["-m"] if mt else []) flags = ["--delete", "--delay-updates"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"delete+delay-updates sync failed: {result.stderr[:200]}" f"delete+delay-updates sync failed: {result.stderr[:200]}"
@@ -1945,7 +1949,7 @@ class TestDelayUpdates:
with open(os.path.join(received, "sub"), "wb") as fh: with open(os.path.join(received, "sub"), "wb") as fh:
fh.write(b"blocks the nested destination directory") fh.write(b"blocks the nested destination directory")
flags = ["--delay-updates"] + (["-m"] if mt else []) flags = ["--delay-updates"] + (["--threads"] if mt else [])
if remove_source_files: if remove_source_files:
flags += ["--remove-source-files"] flags += ["--remove-source-files"]
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
@@ -1982,7 +1986,7 @@ class TestDelayUpdates:
fh.write(b"changed on source") fh.write(b"changed on source")
with open(os.path.join(source, "deliver.txt"), "wb") as fh: with open(os.path.join(source, "deliver.txt"), "wb") as fh:
fh.write(b"new file") fh.write(b"new file")
flags = ["--remove-source-files", "--ignore-existing", "--delay-updates"] + (["-m"] if mt else []) flags = ["--remove-source-files", "--ignore-existing", "--delay-updates"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"delayed skip sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"delayed skip sync failed: {result.stderr[:200]}"
# keep.txt already existed at the destination: receiver skip -> source stays. # keep.txt already existed at the destination: receiver skip -> source stays.
@@ -2045,7 +2049,7 @@ class TestRelativeFilesFrom:
dest = os.path.join(TEST_DATA_DIR, "rel_dst") dest = os.path.join(TEST_DATA_DIR, "rel_dst")
clean_dir(dest) clean_dir(dest)
lst = _write_rel_list(b"top.txt\nsub/x.txt\n") lst = _write_rel_list(b"top.txt\nsub/x.txt\n")
flags = ["--files-from", lst, "-R"] + (["-m"] if mt else []) flags = ["--files-from", lst, "-R"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"-R files-from sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"-R files-from sync failed: {result.stderr[:200]}"
assert _read_file(os.path.join(dest, "sub", "x.txt")) == b"x\n", \ assert _read_file(os.path.join(dest, "sub", "x.txt")) == b"x\n", \
@@ -2064,7 +2068,7 @@ class TestRelativeFilesFrom:
source = _make_relative_source("rel_only_src") source = _make_relative_source("rel_only_src")
dest = os.path.join(TEST_DATA_DIR, "rel_only_dst") dest = os.path.join(TEST_DATA_DIR, "rel_only_dst")
clean_dir(dest) clean_dir(dest)
flags = ["-R"] + (["-m"] if mt else []) flags = ["-R"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"-R alone sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"-R alone sync failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
@@ -2077,7 +2081,7 @@ class TestRelativeFilesFrom:
dest = os.path.join(TEST_DATA_DIR, "rel_noR_dst") dest = os.path.join(TEST_DATA_DIR, "rel_noR_dst")
clean_dir(dest) clean_dir(dest)
lst = _write_rel_list(b"sub/x.txt\n") lst = _write_rel_list(b"sub/x.txt\n")
flags = ["--files-from", lst] + (["-m"] if mt else []) flags = ["--files-from", lst] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"files-from sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"files-from sync failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
@@ -2140,7 +2144,7 @@ class TestMissingArgs:
dest = os.path.join(TEST_DATA_DIR, "mg_default_dst") dest = os.path.join(TEST_DATA_DIR, "mg_default_dst")
clean_dir(dest) clean_dir(dest)
lst = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n") lst = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n")
flags = ["--files-from", lst] + (["-m"] if mt else []) flags = ["--files-from", lst] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode != 0, "a listed-but-missing entry did not fail the run" assert result.returncode != 0, "a listed-but-missing entry did not fail the run"
assert "gone.txt" in (result.stderr or result.stdout) assert "gone.txt" in (result.stderr or result.stdout)
@@ -2154,7 +2158,7 @@ class TestMissingArgs:
dest = os.path.join(TEST_DATA_DIR, "mg_ignore_dst") dest = os.path.join(TEST_DATA_DIR, "mg_ignore_dst")
clean_dir(dest) clean_dir(dest)
lst = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n") lst = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n")
flags = ["--files-from", lst, "--ignore-missing-args"] + (["-m"] if mt else []) flags = ["--files-from", lst, "--ignore-missing-args"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"ignore-missing-args sync failed: {result.stderr[:300]}" assert result.returncode == 0, f"ignore-missing-args sync failed: {result.stderr[:300]}"
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
@@ -2171,7 +2175,7 @@ class TestMissingArgs:
dest = os.path.join(TEST_DATA_DIR, "mg_all_missing_dst") dest = os.path.join(TEST_DATA_DIR, "mg_all_missing_dst")
clean_dir(dest) clean_dir(dest)
lst = _write_rel_list(b"gone1.txt\ngone2.txt\n") lst = _write_rel_list(b"gone1.txt\ngone2.txt\n")
flags = ["--files-from", lst, "--ignore-missing-args"] + (["-m"] if mt else []) flags = ["--files-from", lst, "--ignore-missing-args"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"all-missing run should succeed (rsync parity): {result.stderr[:300]}" f"all-missing run should succeed (rsync parity): {result.stderr[:300]}"
@@ -2184,7 +2188,7 @@ class TestMissingArgs:
dest = os.path.join(TEST_DATA_DIR, "mg_empty_dst") dest = os.path.join(TEST_DATA_DIR, "mg_empty_dst")
clean_dir(dest) clean_dir(dest)
lst = _write_rel_list(b"") lst = _write_rel_list(b"")
flags = ["--files-from", lst, "--ignore-missing-args"] + (["-m"] if mt else []) flags = ["--files-from", lst, "--ignore-missing-args"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode != 0, "an empty --files-from list must stay a hard error" assert result.returncode != 0, "an empty --files-from list must stay a hard error"
assert "contains no entries" in (result.stderr or result.stdout) assert "contains no entries" in (result.stderr or result.stdout)
@@ -2201,7 +2205,7 @@ class TestMissingArgs:
server.start(extra_args=["--allow-delete"]) server.start(extra_args=["--allow-delete"])
seed = _write_rel_list(b"a.txt\nsub/b.txt\n") seed = _write_rel_list(b"a.txt\nsub/b.txt\n")
result, _ = run_client(source, dest, result, _ = run_client(source, dest,
flags=["--files-from", seed, "-R"] + (["-m"] if mt else []), flags=["--files-from", seed, "-R"] + (["--threads"] if mt else []),
port=server.port) port=server.port)
assert result.returncode == 0, f"seed -R sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"seed -R sync failed: {result.stderr[:200]}"
assert os.path.isfile(os.path.join(dest, "a.txt")) assert os.path.isfile(os.path.join(dest, "a.txt"))
@@ -2214,7 +2218,7 @@ class TestMissingArgs:
fh.write("unrelated") fh.write("unrelated")
lst = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n") lst = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n")
flags = ["--files-from", lst, "-R", "--delete-missing-args"] + (["-m"] if mt else []) flags = ["--files-from", lst, "-R", "--delete-missing-args"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, f"delete-missing sync failed: {result.stderr[:300]}" assert result.returncode == 0, f"delete-missing sync failed: {result.stderr[:300]}"
assert not os.path.exists(os.path.join(dest, "gone.txt")), \ assert not os.path.exists(os.path.join(dest, "gone.txt")), \
@@ -2227,7 +2231,7 @@ class TestMissingArgs:
# Now with --delete the unrelated extra is an ordinary extra and must go. # Now with --delete the unrelated extra is an ordinary extra and must go.
lst2 = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n") lst2 = _write_rel_list(b"a.txt\ngone.txt\nsub/b.txt\n")
flags2 = ["--files-from", lst2, "-R", "--delete-missing-args", "--delete"] + \ flags2 = ["--files-from", lst2, "-R", "--delete-missing-args", "--delete"] + \
(["-m"] if mt else []) (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags2, port=server.port) result, _ = run_client(source, dest, flags=flags2, port=server.port)
assert result.returncode == 0, f"delete-missing + delete sync failed: {result.stderr[:300]}" assert result.returncode == 0, f"delete-missing + delete sync failed: {result.stderr[:300]}"
assert not os.path.exists(os.path.join(dest, "unrelated.txt")), \ assert not os.path.exists(os.path.join(dest, "unrelated.txt")), \
@@ -2287,7 +2291,7 @@ class TestMissingArgs:
lst = _write_rel_list(b"a.txt\nprot/gone.txt\n") lst = _write_rel_list(b"a.txt\nprot/gone.txt\n")
flags = ["--files-from", lst, "--filter=- prot/", "--delete-missing-args", flags = ["--files-from", lst, "--filter=- prot/", "--delete-missing-args",
"--delete"] + (["-m"] if mt else []) "--delete"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, f"delete-missing exclude sync failed: {result.stderr[:300]}" assert result.returncode == 0, f"delete-missing exclude sync failed: {result.stderr[:300]}"
assert not os.path.exists(os.path.join(received, "prot", "gone.txt")), \ assert not os.path.exists(os.path.join(received, "prot", "gone.txt")), \
@@ -2314,7 +2318,7 @@ class TestMissingArgs:
server.start(extra_args=["--allow-delete"]) server.start(extra_args=["--allow-delete"])
lst = _write_rel_list(b"a.txt\ngone.txt\ngone2.txt\n") lst = _write_rel_list(b"a.txt\ngone.txt\ngone2.txt\n")
flags = ["--files-from", lst, "-R", "--delete-missing-args", "--delete-before"] + \ flags = ["--files-from", lst, "-R", "--delete-missing-args", "--delete-before"] + \
(["-m"] if mt else []) (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, f"early delete-missing sync failed: {result.stderr[:300]}" assert result.returncode == 0, f"early delete-missing sync failed: {result.stderr[:300]}"
assert not os.path.exists(os.path.join(dest, "gone.txt")), \ assert not os.path.exists(os.path.join(dest, "gone.txt")), \
@@ -2353,7 +2357,7 @@ class TestMissingArgs:
lst = _write_rel_list(b"a.txt\nsub/gone.txt\n") lst = _write_rel_list(b"a.txt\nsub/gone.txt\n")
flags = ["--files-from", lst, "--delete-missing-args", "--delete"] + rel_flags + \ flags = ["--files-from", lst, "--delete-missing-args", "--delete"] + rel_flags + \
(["-m"] if mt else []) (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"deep missing-entry sync failed: {result.stderr[:300]}" f"deep missing-entry sync failed: {result.stderr[:300]}"
@@ -2373,7 +2377,7 @@ class TestMissingArgs:
clean_dir(dest) clean_dir(dest)
lst = _write_rel_list(b"a.txt\ngone.txt\n") lst = _write_rel_list(b"a.txt\ngone.txt\n")
flags = ["--files-from", lst, "--dirs", "-R", "--ignore-missing-args"] + \ flags = ["--files-from", lst, "--dirs", "-R", "--ignore-missing-args"] + \
(["-m"] if mt else []) (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"--dirs ignore-missing sync failed: {result.stderr[:300]}" assert result.returncode == 0, f"--dirs ignore-missing sync failed: {result.stderr[:300]}"
assert _read_file(os.path.join(dest, "a.txt")) == b"a\n", \ assert _read_file(os.path.join(dest, "a.txt")) == b"a\n", \
@@ -2395,7 +2399,7 @@ class TestNoImpliedDirs:
dest = os.path.join(TEST_DATA_DIR, "noimplied_dst") dest = os.path.join(TEST_DATA_DIR, "noimplied_dst")
clean_dir(dest) clean_dir(dest)
lst = _write_rel_list(b"a/b.txt\n") # "a" itself is not listed lst = _write_rel_list(b"a/b.txt\n") # "a" itself is not listed
flags = ["--files-from", lst, "-R", "--no-implied-dirs"] + (["-m"] if mt else []) flags = ["--files-from", lst, "-R", "--no-implied-dirs"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode != 0, "implied parent directory was not rejected" assert result.returncode != 0, "implied parent directory was not rejected"
assert "--no-implied-dirs" in (result.stderr or result.stdout) assert "--no-implied-dirs" in (result.stderr or result.stdout)
@@ -2407,7 +2411,7 @@ class TestNoImpliedDirs:
dest = os.path.join(TEST_DATA_DIR, "noimplied_ok_dst") dest = os.path.join(TEST_DATA_DIR, "noimplied_ok_dst")
clean_dir(dest) clean_dir(dest)
lst = _write_rel_list(b"a\na/b.txt\n") lst = _write_rel_list(b"a\na/b.txt\n")
flags = ["--files-from", lst, "-R", "--no-implied-dirs"] + (["-m"] if mt else []) flags = ["--files-from", lst, "-R", "--no-implied-dirs"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"listed dir + file sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"listed dir + file sync failed: {result.stderr[:200]}"
assert _read_file(os.path.join(dest, "a", "b.txt")) == b"nested\n" assert _read_file(os.path.join(dest, "a", "b.txt")) == b"nested\n"
@@ -2418,7 +2422,7 @@ class TestNoImpliedDirs:
dest = os.path.join(TEST_DATA_DIR, "noimplied_noR_dst") dest = os.path.join(TEST_DATA_DIR, "noimplied_noR_dst")
clean_dir(dest) clean_dir(dest)
lst = _write_rel_list(b"a/b.txt\n") lst = _write_rel_list(b"a/b.txt\n")
flags = ["--files-from", lst, "--no-implied-dirs"] + (["-m"] if mt else []) flags = ["--files-from", lst, "--no-implied-dirs"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, "--no-implied-dirs without -R changed behavior" assert result.returncode == 0, "--no-implied-dirs without -R changed behavior"
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
@@ -2446,7 +2450,7 @@ class TestDirs:
source = self._make() source = self._make()
dest = os.path.join(TEST_DATA_DIR, "dirs_dst") dest = os.path.join(TEST_DATA_DIR, "dirs_dst")
clean_dir(dest) clean_dir(dest)
flags = [flag] + (["-m"] if mt else []) flags = [flag] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"{flag} sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"{flag} sync failed: {result.stderr[:200]}"
self._assert_only_empty_mirror(dest, source) self._assert_only_empty_mirror(dest, source)
@@ -2458,7 +2462,7 @@ class TestDirs:
clean_dir(dest) clean_dir(dest)
# A listed directory is created empty; a listed file is transferred. # A listed directory is created empty; a listed file is transferred.
lst = _write_rel_list(b"dir1\nsub/x.txt\n") lst = _write_rel_list(b"dir1\nsub/x.txt\n")
flags = ["--files-from", lst, "--dirs", "-R"] + (["-m"] if mt else []) flags = ["--files-from", lst, "--dirs", "-R"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"dirs files-from sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"dirs files-from sync failed: {result.stderr[:200]}"
assert os.path.isdir(os.path.join(dest, "dir1")), "listed dir was not created" assert os.path.isdir(os.path.join(dest, "dir1")), "listed dir was not created"
@@ -2476,7 +2480,7 @@ class TestDirs:
dest = os.path.join(TEST_DATA_DIR, "dirs_ff_noR_dst") dest = os.path.join(TEST_DATA_DIR, "dirs_ff_noR_dst")
clean_dir(dest) clean_dir(dest)
lst = _write_rel_list(b"dir1\n") lst = _write_rel_list(b"dir1\n")
flags = ["--files-from", lst, "--dirs"] + (["-m"] if mt else []) flags = ["--files-from", lst, "--dirs"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"dirs files-from no-R sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"dirs files-from no-R sync failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
@@ -2495,7 +2499,7 @@ class TestDirs:
dest = os.path.join(TEST_DATA_DIR, "dirs_s_dst") dest = os.path.join(TEST_DATA_DIR, "dirs_s_dst")
clean_dir(dest) clean_dir(dest)
lst = _write_rel_list(b"dir1\nsub/x.txt\n") lst = _write_rel_list(b"dir1\nsub/x.txt\n")
flags = ["--files-from", lst, "--dirs", "-R", "-s"] + (["-m"] if mt else []) flags = ["--files-from", lst, "--dirs", "-R", "--chunk-serialization"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"dirs -s sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"dirs -s sync failed: {result.stderr[:200]}"
assert os.path.isdir(os.path.join(dest, "dir1")), "listed dir was not created" assert os.path.isdir(os.path.join(dest, "dir1")), "listed dir was not created"
@@ -2554,7 +2558,7 @@ class TestMkpath:
shutil.rmtree(dest, ignore_errors=True) shutil.rmtree(dest, ignore_errors=True)
with ServerManager() as server: with ServerManager() as server:
server.start() server.start()
flags = ["-m"] if mt else [] flags = ["--threads"] if mt else []
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode != 0, "missing destination root did not fail without --mkpath" assert result.returncode != 0, "missing destination root did not fail without --mkpath"
assert not os.path.exists(dest), "missing root was created without --mkpath" assert not os.path.exists(dest), "missing root was created without --mkpath"
@@ -2566,7 +2570,7 @@ class TestMkpath:
shutil.rmtree(os.path.join(TEST_DATA_DIR, "deep"), ignore_errors=True) shutil.rmtree(os.path.join(TEST_DATA_DIR, "deep"), ignore_errors=True)
with ServerManager() as server: with ServerManager() as server:
server.start() server.start()
flags = ["--mkpath"] + (["-m"] if mt else []) flags = ["--mkpath"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, f"--mkpath sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"--mkpath sync failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
@@ -2656,7 +2660,7 @@ class TestDeleteTiming:
with open(extra, "wb") as fh: with open(extra, "wb") as fh:
fh.write(b"should be deleted") fh.write(b"should be deleted")
flags = [flag] + (["-m"] if mt else []) flags = [flag] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"{flag} sync failed: {(result.stderr or result.stdout)[:300]}" f"{flag} sync failed: {(result.stderr or result.stdout)[:300]}"
@@ -2687,7 +2691,7 @@ class TestDeleteTiming:
with open(blocker, "wb") as fh: with open(blocker, "wb") as fh:
fh.write(b"blocks the nested destination directory") fh.write(b"blocks the nested destination directory")
flags = [flag] + (["-m"] if mt else []) flags = [flag] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"{flag} (early delete) did not remove the blocker in time: " \ f"{flag} (early delete) did not remove the blocker in time: " \
@@ -2721,7 +2725,7 @@ class TestDeleteTiming:
with open(blocker, "wb") as fh: with open(blocker, "wb") as fh:
fh.write(b"blocks the nested destination directory") fh.write(b"blocks the nested destination directory")
flags = [flag] + (["-m"] if mt else []) flags = [flag] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode != 0, \ assert result.returncode != 0, \
f"{flag} (mt={mt}) unexpectedly succeeded (deletion must be deferred)" f"{flag} (mt={mt}) unexpectedly succeeded (deletion must be deferred)"
@@ -2804,7 +2808,7 @@ class TestDeletePolicy:
self._write(os.path.join(received, "extra.txt"), b"extra\n") self._write(os.path.join(received, "extra.txt"), b"extra\n")
# Default: the excluded mirrors survive --delete, genuine extras die. # Default: the excluded mirrors survive --delete, genuine extras die.
flags = ["--exclude", "*.log", timing] + (["-m"] if mt else []) flags = ["--exclude", "*.log", timing] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"default delete sync failed: {(result.stderr or result.stdout)[:300]}" f"default delete sync failed: {(result.stderr or result.stdout)[:300]}"
@@ -2817,7 +2821,7 @@ class TestDeletePolicy:
# --delete-excluded: excluded mirrors are extras again and die. # --delete-excluded: excluded mirrors are extras again and die.
self._write(os.path.join(received, "extra.txt"), b"extra\n") self._write(os.path.join(received, "extra.txt"), b"extra\n")
flags = ["--exclude", "*.log", timing, "--delete-excluded"] + (["-m"] if mt else []) flags = ["--exclude", "*.log", timing, "--delete-excluded"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"--delete-excluded sync failed: {(result.stderr or result.stdout)[:300]}" f"--delete-excluded sync failed: {(result.stderr or result.stdout)[:300]}"
@@ -2846,7 +2850,7 @@ class TestDeletePolicy:
assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}" assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
flags = ["--filter=- skipdir/", "--delete"] + (["-m"] if mt else []) flags = ["--filter=- skipdir/", "--delete"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"default delete sync failed: {(result.stderr or result.stdout)[:300]}" f"default delete sync failed: {(result.stderr or result.stdout)[:300]}"
@@ -2855,7 +2859,7 @@ class TestDeletePolicy:
assert os.path.exists(os.path.join(received, "skipdir", "deep", "b.log")), \ assert os.path.exists(os.path.join(received, "skipdir", "deep", "b.log")), \
"nested excluded dir content was deleted under plain --delete" "nested excluded dir content was deleted under plain --delete"
flags = ["--filter=- skipdir/", "--delete", "--delete-excluded"] + (["-m"] if mt else []) flags = ["--filter=- skipdir/", "--delete", "--delete-excluded"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"--delete-excluded sync failed: {(result.stderr or result.stdout)[:300]}" f"--delete-excluded sync failed: {(result.stderr or result.stdout)[:300]}"
@@ -2882,7 +2886,7 @@ class TestDeletePolicy:
self._write(os.path.join(received, name), b"extra\n") self._write(os.path.join(received, name), b"extra\n")
extras.append(os.path.join(received, name)) extras.append(os.path.join(received, name))
flags = ["--max-delete=2", timing] + (["-m"] if mt else []) flags = ["--max-delete=2", timing] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode != 0, \ assert result.returncode != 0, \
f"--max-delete=2 with 4 extras unexpectedly succeeded: {result.stderr[:300]}" f"--max-delete=2 with 4 extras unexpectedly succeeded: {result.stderr[:300]}"
@@ -2906,7 +2910,7 @@ class TestDeletePolicy:
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
for i in range(3): for i in range(3):
self._write(os.path.join(received, f"e{i}.txt"), b"extra\n") self._write(os.path.join(received, f"e{i}.txt"), b"extra\n")
flags = ["--max-delete=3", "--delete"] + (["-m"] if mt else []) flags = ["--max-delete=3", "--delete"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"--max-delete=3 with 3 extras failed: {(result.stderr or result.stdout)[:300]}" f"--max-delete=3 with 3 extras failed: {(result.stderr or result.stdout)[:300]}"
@@ -2943,7 +2947,7 @@ class TestDeletePolicy:
assert os.path.exists(os.path.join(received, "sub", "old.txt")), \ assert os.path.exists(os.path.join(received, "sub", "old.txt")), \
"non-empty dir content was lost although the run failed without --force" "non-empty dir content was lost although the run failed without --force"
flags = ["--force"] + (["-m"] if mt else []) flags = ["--force"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"--force run failed: {(result.stderr or result.stdout)[:300]}" f"--force run failed: {(result.stderr or result.stdout)[:300]}"
@@ -3000,7 +3004,7 @@ class TestDeletePolicy:
assert os.listdir(received) == [] assert os.listdir(received) == []
# prune-empty-dirs: the empty mirror is pruned by --delete. # prune-empty-dirs: the empty mirror is pruned by --delete.
flags = ["--dirs", "--prune-empty-dirs", "--delete"] + (["-m"] if mt else []) flags = ["--dirs", "--prune-empty-dirs", "--delete"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"--dirs --prune-empty-dirs --delete failed: {(result.stderr or result.stdout)[:300]}" f"--dirs --prune-empty-dirs --delete failed: {(result.stderr or result.stdout)[:300]}"
@@ -3012,7 +3016,7 @@ class TestDeletePolicy:
clean_dir(dest2) clean_dir(dest2)
with ServerManager() as server: with ServerManager() as server:
server.start(extra_args=["--allow-delete"]) server.start(extra_args=["--allow-delete"])
flags = ["--dirs", "--prune-empty-dirs", "-i"] + (["-m"] if mt else []) flags = ["--dirs", "--prune-empty-dirs", "-i"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest2, flags=flags, port=server.port) result, _ = run_client(source, dest2, flags=flags, port=server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"--dirs --prune-empty-dirs failed: {(result.stderr or result.stdout)[:300]}" f"--dirs --prune-empty-dirs failed: {(result.stderr or result.stdout)[:300]}"
@@ -3047,7 +3051,7 @@ class TestDeletePolicy:
os.makedirs(os.path.join(received, "empty", "chain")) os.makedirs(os.path.join(received, "empty", "chain"))
for prune in ([], ["--prune-empty-dirs"]): for prune in ([], ["--prune-empty-dirs"]):
flags = prune + ["--delete"] + (["-m"] if mt else []) flags = prune + ["--delete"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"prune recursive sync failed: {(result.stderr or result.stdout)[:300]}" f"prune recursive sync failed: {(result.stderr or result.stdout)[:300]}"
@@ -3058,7 +3062,7 @@ class TestDeletePolicy:
assert _read_file(os.path.join(received, "keep.txt")) == b"kept\n" assert _read_file(os.path.join(received, "keep.txt")) == b"kept\n"
# An excluded file's mirror is protected: the dir that holds it stays. # An excluded file's mirror is protected: the dir that holds it stays.
flags = ["--exclude", "*.log", "--delete", "--prune-empty-dirs"] + (["-m"] if mt else []) flags = ["--exclude", "*.log", "--delete", "--prune-empty-dirs"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port) result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"prune recursive sync failed: {(result.stderr or result.stdout)[:300]}" f"prune recursive sync failed: {(result.stderr or result.stdout)[:300]}"
@@ -3100,7 +3104,7 @@ class TestDeletePolicy:
# Default: scan error aborts the run; nothing is deleted. # Default: scan error aborts the run; nothing is deleted.
self._write(os.path.join(received, "extra.txt"), b"extra\n") self._write(os.path.join(received, "extra.txt"), b"extra\n")
flags = ["--delete"] + (["-m"] if mt else []) flags = ["--delete"] + (["--threads"] if mt else [])
result = self._run_client_as_nobody(source, dest, server.port, flags) result = self._run_client_as_nobody(source, dest, server.port, flags)
assert result.returncode != 0, "unreadable source dir did not fail the run" assert result.returncode != 0, "unreadable source dir did not fail the run"
assert os.path.exists(os.path.join(received, "extra.txt")), \ assert os.path.exists(os.path.join(received, "extra.txt")), \
@@ -3108,7 +3112,7 @@ class TestDeletePolicy:
# --ignore-errors: the readable tree transfers, deletion still runs. # --ignore-errors: the readable tree transfers, deletion still runs.
self._write(os.path.join(received, "extra.txt"), b"extra\n") self._write(os.path.join(received, "extra.txt"), b"extra\n")
flags = ["--delete", "--ignore-errors"] + (["-m"] if mt else []) flags = ["--delete", "--ignore-errors"] + (["--threads"] if mt else [])
result = self._run_client_as_nobody(source, dest, server.port, flags) result = self._run_client_as_nobody(source, dest, server.port, flags)
assert not os.path.exists(os.path.join(received, "extra.txt")), \ assert not os.path.exists(os.path.join(received, "extra.txt")), \
f"--ignore-errors did not keep deletion active: {result.stderr[:300]}" f"--ignore-errors did not keep deletion active: {result.stderr[:300]}"
@@ -3143,7 +3147,7 @@ class TestDeletePolicy:
try: try:
os.chmod(source, 0) os.chmod(source, 0)
self._write(os.path.join(received, "extra.txt"), b"extra\n") self._write(os.path.join(received, "extra.txt"), b"extra\n")
flags = [timing, "--ignore-errors"] + (["-m"] if mt else []) flags = [timing, "--ignore-errors"] + (["--threads"] if mt else [])
result = self._run_client_as_nobody(source, dest, server.port, flags) result = self._run_client_as_nobody(source, dest, server.port, flags)
assert result.returncode != 0, \ assert result.returncode != 0, \
f"unreadable source root with {timing} (mt={mt}) unexpectedly succeeded" f"unreadable source root with {timing} (mt={mt}) unexpectedly succeeded"
@@ -3371,7 +3375,7 @@ class TestBasisDestDirs:
dest = os.path.join(TEST_DATA_DIR, "basis_link_mt_dst") dest = os.path.join(TEST_DATA_DIR, "basis_link_mt_dst")
clean_dir(dest) clean_dir(dest)
basis = self._seed_basis(dest, source, "mtbasis", self._basis_tree("mt")) basis = self._seed_basis(dest, source, "mtbasis", self._basis_tree("mt"))
result, _ = run_client(source, dest, flags=["--link-dest=mtbasis", "-m"], result, _ = run_client(source, dest, flags=["--link-dest=mtbasis", "--threads"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, f"-m link-dest failed: {result.stderr[:300]}" assert result.returncode == 0, f"-m link-dest failed: {result.stderr[:300]}"
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
@@ -3665,7 +3669,7 @@ class TestFuzzy:
with open(os.path.join(source, self.NEW_NAME), "wb") as fh: with open(os.path.join(source, self.NEW_NAME), "wb") as fh:
fh.write(new_bytes) fh.write(new_bytes)
flags = ["--fuzzy"] + (["-m"] if mt else []) flags = ["--fuzzy"] + (["--threads"] if mt else [])
result, proxy = self._run_measured(source, dest, flags, shared_server.port) result, proxy = self._run_measured(source, dest, flags, shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"--fuzzy {'-m ' if mt else ''}rename failed: {(result.stderr or result.stdout)[:300]}" f"--fuzzy {'-m ' if mt else ''}rename failed: {(result.stderr or result.stdout)[:300]}"
@@ -3926,7 +3930,7 @@ class TestIdentityMapping:
with open(os.path.join(source, "f.txt"), "wb") as f: with open(os.path.join(source, "f.txt"), "wb") as f:
f.write(b"hello identity") f.write(b"hello identity")
result, _ = run_client(source, dest, result, _ = run_client(source, dest,
flags=["-M", "--numeric-ids"], flags=["--preserve", "--numeric-ids"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"exit {result.returncode}: {(result.stderr or '')[:200]}" f"exit {result.returncode}: {(result.stderr or '')[:200]}"
@@ -3943,7 +3947,7 @@ class TestIdentityMapping:
f.write(b"mapped") f.write(b"mapped")
result, _ = run_client( result, _ = run_client(
source, dest, source, dest,
flags=["-M", "--usermap=@1000:@1001", "--groupmap=@100:@101", "--chown=@2000:@2001"], flags=["--preserve", "--usermap=@1000:@1001", "--groupmap=@100:@101", "--chown=@2000:@2001"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"exit {result.returncode}: {(result.stderr or '')[:200]}" f"exit {result.returncode}: {(result.stderr or '')[:200]}"
@@ -3962,7 +3966,7 @@ class TestIdentityMapping:
f.write(b"owner") f.write(b"owner")
os.chown(src_file, 12345, 12346) os.chown(src_file, 12345, 12346)
result, _ = run_client(source, dest, result, _ = run_client(source, dest,
flags=["-M", "--numeric-ids"], flags=["--preserve", "--numeric-ids"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"exit {result.returncode}: {(result.stderr or '')[:200]}" f"exit {result.returncode}: {(result.stderr or '')[:200]}"
@@ -3984,7 +3988,7 @@ class TestIdentityMapping:
f.write(b"root chown") f.write(b"root chown")
os.chown(src_file, 1, 1) os.chown(src_file, 1, 1)
result, _ = run_client(source, dest, result, _ = run_client(source, dest,
flags=["-M", "--chown=@12345:@54321"], flags=["--preserve", "--chown=@12345:@54321"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"exit {result.returncode}: {(result.stderr or '')[:200]}" f"exit {result.returncode}: {(result.stderr or '')[:200]}"
@@ -4013,7 +4017,7 @@ class TestHardLinks:
fh.write(b"independent content\n" * 2000) fh.write(b"independent content\n" * 2000)
return src return src
@pytest.mark.parametrize("flags", [[], ["-m"], ["--delay-updates"]]) @pytest.mark.parametrize("flags", [[], ["--threads"], ["--delay-updates"]])
def test_hard_links_preserved(self, shared_server, flags): def test_hard_links_preserved(self, shared_server, flags):
src = self._make_source("hl_src") src = self._make_source("hl_src")
dest = os.path.join(TEST_DATA_DIR, "hl_dst") dest = os.path.join(TEST_DATA_DIR, "hl_dst")
@@ -4041,7 +4045,7 @@ class TestHardLinks:
src = self._make_source("hl_reject_src") src = self._make_source("hl_reject_src")
dest = os.path.join(TEST_DATA_DIR, "hl_reject_dst") dest = os.path.join(TEST_DATA_DIR, "hl_reject_dst")
clean_dir(dest) clean_dir(dest)
result, _ = run_client(src, dest, flags=["-H", "-s"], port=shared_server.port) result, _ = run_client(src, dest, flags=["-H", "--chunk-serialization"], port=shared_server.port)
assert result.returncode != 0, "-H with -s was accepted" assert result.returncode != 0, "-H with -s was accepted"
def test_hard_links_rejects_append(self, shared_server): def test_hard_links_rejects_append(self, shared_server):
@@ -4136,7 +4140,7 @@ class TestAtimes:
source = os.path.join(TEST_DATA_DIR, f"atime_{'m' if mt else 's'}_src") source = os.path.join(TEST_DATA_DIR, f"atime_{'m' if mt else 's'}_src")
dest = os.path.join(TEST_DATA_DIR, f"atime_{'m' if mt else 's'}_dst") dest = os.path.join(TEST_DATA_DIR, f"atime_{'m' if mt else 's'}_dst")
src_file, atime = self._make_source(source, dest) src_file, atime = self._make_source(source, dest)
flags = ["-U"] + (["-m"] if mt else []) flags = ["-U"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"-U failed: {(result.stderr or result.stdout)[:300]}" f"-U failed: {(result.stderr or result.stdout)[:300]}"
@@ -4158,7 +4162,7 @@ class TestAtimes:
dest = os.path.join(TEST_DATA_DIR, f"atime_ctrl_{'m' if mt else 's'}_dst") dest = os.path.join(TEST_DATA_DIR, f"atime_ctrl_{'m' if mt else 's'}_dst")
src_file, atime = self._make_source(source, dest) src_file, atime = self._make_source(source, dest)
now = time.time() now = time.time()
flags = (["-m"] if mt else []) flags = (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"control run failed: {(result.stderr or '')[:200]}" assert result.returncode == 0, f"control run failed: {(result.stderr or '')[:200]}"
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
@@ -4191,7 +4195,7 @@ class TestOpenNoatime:
atime = 730486800 # 1993-02-11, distinct and far from now atime = 730486800 # 1993-02-11, distinct and far from now
os.utime(path, ns=(atime * 10**9, atime * 10**9)) os.utime(path, ns=(atime * 10**9, atime * 10**9))
flags = ["--open-noatime"] + (["-m"] if mt else []) flags = ["--open-noatime"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"--open-noatime failed: {(result.stderr or result.stdout)[:300]}" f"--open-noatime failed: {(result.stderr or result.stdout)[:300]}"
@@ -4220,7 +4224,7 @@ class TestCrtimes:
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(payload) f.write(payload)
flags = ["-N"] + (["-m"] if mt else []) flags = ["-N"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"-N failed: {(result.stderr or result.stdout)[:300]}" f"-N failed: {(result.stderr or result.stdout)[:300]}"
@@ -4267,7 +4271,7 @@ class TestOmitTimes:
clean_dir(dest) clean_dir(dest)
with open(os.path.join(source, "a.txt"), "wb") as f: with open(os.path.join(source, "a.txt"), "wb") as f:
f.write(b"omit times content\n") f.write(b"omit times content\n")
flags = [flag] + (["-m"] if mt else []) flags = [flag] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port) result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"{flag} failed: {(result.stderr or result.stdout)[:300]}" f"{flag} failed: {(result.stderr or result.stdout)[:300]}"
@@ -4289,7 +4293,7 @@ class TestOmitTimes:
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"-d -O failed: {(result.stderr or result.stdout)[:300]}" f"-d -O failed: {(result.stderr or result.stdout)[:300]}"
result, _ = run_client(source, dest, flags=["-M", "-O", "-J"], result, _ = run_client(source, dest, flags=["--preserve", "-O", "-J"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"-M -O -J failed: {(result.stderr or result.stdout)[:300]}" f"-M -O -J failed: {(result.stderr or result.stdout)[:300]}"
@@ -4536,7 +4540,7 @@ class TestExtendedAttributes:
if not _xattr_supported(f): if not _xattr_supported(f):
pytest.skip("filesystem does not support user xattrs") pytest.skip("filesystem does not support user xattrs")
os.setxattr(f, "user.k", b"v") os.setxattr(f, "user.k", b"v")
result, _ = run_client(source, dest, flags=["-X", "-m"], port=shared_server.port) result, _ = run_client(source, dest, flags=["-X", "--threads"], port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"-X -m sync failed: {(result.stderr or result.stdout)[:300]}" f"-X -m sync failed: {(result.stderr or result.stdout)[:300]}"
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
@@ -4662,7 +4666,7 @@ class TestConnectivityClientOptions:
source, dest = self._source_and_dest("connopt_zlib") source, dest = self._source_and_dest("connopt_zlib")
with open(os.path.join(source, "text.txt"), "wb") as f: with open(os.path.join(source, "text.txt"), "wb") as f:
f.write(b"compress me\n" * 4096) f.write(b"compress me\n" * 4096)
result, _ = run_client(source, dest, flags=["--blocking-io", "-c"], result, _ = run_client(source, dest, flags=["--blocking-io", "-z"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"--blocking-io -c failed: {(result.stderr or result.stdout)[:300]}" f"--blocking-io -c failed: {(result.stderr or result.stdout)[:300]}"
+1 -1
View File
@@ -243,7 +243,7 @@ def test_iconv_chunk_serialization_blob(shared_server):
_place_bytes(source, name, data=b"blob\n") _place_bytes(source, name, data=b"blob\n")
result, _ = run_client( result, _ = run_client(
source, dest, flags=["--iconv=iso-8859-1,utf-8", "-s"], port=shared_server.port source, dest, flags=["--iconv=iso-8859-1,utf-8", "--chunk-serialization"], port=shared_server.port
) )
assert result.returncode == 0, (result.stderr or result.stdout)[:400] assert result.returncode == 0, (result.stderr or result.stdout)[:400]
+8 -8
View File
@@ -97,31 +97,31 @@ class TestSSHStandard:
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_multithreading(self): def test_multithreading(self):
r = _run_ssh_test("SSH Multithreading (-m)", ["-m"]) r = _run_ssh_test("SSH Multithreading (--threads)", ["--threads"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_compression(self): def test_compression(self):
r = _run_ssh_test("SSH Compression (-c)", ["-c"]) r = _run_ssh_test("SSH Compression (-z)", ["-z"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_chunk_serialization(self): def test_chunk_serialization(self):
r = _run_ssh_test("SSH Chunk Serialization (-s)", ["-s"]) r = _run_ssh_test("SSH Chunk Serialization (--chunk-serialization)", ["--chunk-serialization"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_compression_chunk(self): def test_compression_chunk(self):
r = _run_ssh_test("SSH Compression + Chunk (-c -s)", ["-c", "-s"]) r = _run_ssh_test("SSH Compression + Chunk (-z --chunk-serialization)", ["-z", "--chunk-serialization"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_multithread_compression(self): def test_multithread_compression(self):
r = _run_ssh_test("SSH Multithread + Compression (-m -c)", ["-m", "-c"]) r = _run_ssh_test("SSH Multithread + Compression (--threads -z)", ["--threads", "-z"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_multithread_chunk(self): def test_multithread_chunk(self):
r = _run_ssh_test("SSH Multithread + Chunk (-m -s)", ["-m", "-s"]) r = _run_ssh_test("SSH Multithread + Chunk (--threads --chunk-serialization)", ["--threads", "--chunk-serialization"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_all_flags(self): def test_all_flags(self):
r = _run_ssh_test("SSH All Flags (-m -c -s)", ["-m", "-c", "-s"]) r = _run_ssh_test("SSH All Flags (--threads -z --chunk-serialization)", ["--threads", "-z", "--chunk-serialization"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
@@ -178,7 +178,7 @@ class TestSSHConnectivity:
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_blocking_io_with_compression(self): def test_blocking_io_with_compression(self):
r = _run_ssh_test("SSH --blocking-io -c", ["--blocking-io", "-c"]) r = _run_ssh_test("SSH --blocking-io -c", ["--blocking-io", "-z"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_trust_sender(self): def test_trust_sender(self):
+3 -3
View File
@@ -231,11 +231,11 @@ class TestStopDelete:
"""-m immediate stop + --delete: the completion tail must not read the """-m immediate stop + --delete: the completion tail must not read the
still-appendable manifest (no race) and must not delete the mirrors.""" still-appendable manifest (no race) and must not delete the mirrors."""
source, dest = self._seed("del_mt", shared_server.port, many=True) source, dest = self._seed("del_mt", shared_server.port, many=True)
result, _ = run_client(source, dest, flags=["-m", "--delete", "--stop-at=now+0s"], result, _ = run_client(source, dest, flags=["--threads", "--delete", "--stop-at=now+0s"],
port=shared_server.port) port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"-m --delete immediate stop failed: {(result.stderr or result.stdout)[:400]}" f"--threads --delete immediate stop failed: {(result.stderr or result.stdout)[:400]}"
received = get_dest_received_dir(dest, source) received = get_dest_received_dir(dest, source)
mismatches, missing = verify_transfer(source, received) mismatches, missing = verify_transfer(source, received)
assert not mismatches and not missing, \ assert not mismatches and not missing, \
f"-m --delete wiped source mirrors: missing={missing} mismatches={mismatches}" f"--threads --delete wiped source mirrors: missing={missing} mismatches={mismatches}"
+13 -13
View File
@@ -30,11 +30,11 @@ def _run_tcp_test(name, port, flags, use_metadata=True, posix=False):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
if posix: if posix:
result, dur = run_client_posix(SOURCE_DIR, DEST_DIR, result, dur = run_client_posix(SOURCE_DIR, DEST_DIR,
flags=(["-M"] if use_metadata else []) + flags, flags=(["--preserve"] if use_metadata else []) + flags,
port=port) port=port)
else: else:
result, dur = run_client(SOURCE_DIR, DEST_DIR, result, dur = run_client(SOURCE_DIR, DEST_DIR,
flags=(["-M"] if use_metadata else []) + flags, flags=(["--preserve"] if use_metadata else []) + flags,
port=port) port=port)
if result.returncode != 0: if result.returncode != 0:
@@ -68,45 +68,45 @@ class TestTCPStandard:
class TestTCPFlags: class TestTCPFlags:
@pytest.mark.ci @pytest.mark.ci
def test_multithreading(self, shared_server): def test_multithreading(self, shared_server):
r = _run_tcp_test("Multithreading (-m)", shared_server.port, ["-m"]) r = _run_tcp_test("Multithreading (--threads)", shared_server.port, ["--threads"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
@pytest.mark.ci @pytest.mark.ci
def test_compression(self, shared_server): def test_compression(self, shared_server):
r = _run_tcp_test("Compression (-c)", shared_server.port, ["-c"]) r = _run_tcp_test("Compression (-z)", shared_server.port, ["-z"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_compression_threads(self, shared_server): def test_compression_threads(self, shared_server):
r = _run_tcp_test("Compression threads (-c --compress-threads=2)", shared_server.port, r = _run_tcp_test("Compression threads (-z --compress-threads=2)", shared_server.port,
["-c", "--compress-threads=2"]) ["-z", "--compress-threads=2"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_chunk_serialization(self, shared_server): def test_chunk_serialization(self, shared_server):
r = _run_tcp_test("Chunk Serialization (-s)", shared_server.port, ["-s"]) r = _run_tcp_test("Chunk Serialization (--chunk-serialization)", shared_server.port, ["--chunk-serialization"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_compression_chunk(self, shared_server): def test_compression_chunk(self, shared_server):
r = _run_tcp_test("Compression + Chunk (-c -s)", shared_server.port, ["-c", "-s"]) r = _run_tcp_test("Compression + Chunk (-z --chunk-serialization)", shared_server.port, ["-z", "--chunk-serialization"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_multithread_compression(self, shared_server): def test_multithread_compression(self, shared_server):
r = _run_tcp_test("Multithreading + Compression (-m -c)", shared_server.port, ["-m", "-c"]) r = _run_tcp_test("Multithreading + Compression (--threads -z)", shared_server.port, ["--threads", "-z"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_multithread_chunk(self, shared_server): def test_multithread_chunk(self, shared_server):
r = _run_tcp_test("Multithreading + Chunk (-m -s)", shared_server.port, ["-m", "-s"]) r = _run_tcp_test("Multithreading + Chunk (--threads --chunk-serialization)", shared_server.port, ["--threads", "--chunk-serialization"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_all_flags(self, shared_server): def test_all_flags(self, shared_server):
r = _run_tcp_test("Multithread + Compression + Chunk (-m -c -s)", shared_server.port, ["-m", "-c", "-s"]) r = _run_tcp_test("Multithread + Compression + Chunk (--threads -z --chunk-serialization)", shared_server.port, ["--threads", "-z", "--chunk-serialization"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_sendfile(self, shared_server): def test_sendfile(self, shared_server):
r = _run_tcp_test("Sendfile (-f)", shared_server.port, ["-f"]) r = _run_tcp_test("Sendfile (--sendfile)", shared_server.port, ["--sendfile"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
def test_sendfile_multithread(self, shared_server): def test_sendfile_multithread(self, shared_server):
r = _run_tcp_test("Sendfile + Multithreading (-f -m)", shared_server.port, ["-f", "-m"]) r = _run_tcp_test("Sendfile + Multithreading (--sendfile --threads)", shared_server.port, ["--sendfile", "--threads"])
assert r["status"] == "Success", r["error"] assert r["status"] == "Success", r["error"]
+2 -2
View File
@@ -132,7 +132,7 @@ class TestTLSBasic:
]) ])
result, dur = run_client( result, dur = run_client(
SOURCE_DIR, DEST_DIR, SOURCE_DIR, DEST_DIR,
flags=["-c", "--tls", flags=["-z", "--tls",
"--cert", certs["client_cert"], "--key", certs["client_key"], "--cert", certs["client_cert"], "--key", certs["client_key"],
"--ca", certs["ca"]], "--ca", certs["ca"]],
port=server.port, port=server.port,
@@ -157,7 +157,7 @@ class TestTLSBasic:
]) ])
result, dur = run_client( result, dur = run_client(
SOURCE_DIR, DEST_DIR, SOURCE_DIR, DEST_DIR,
flags=["-m", "--tls", flags=["--threads", "--tls",
"--cert", certs["client_cert"], "--key", certs["client_key"], "--cert", certs["client_cert"], "--key", certs["client_key"],
"--ca", certs["ca"]], "--ca", certs["ca"]],
port=server.port, port=server.port,
+74 -42
View File
@@ -103,19 +103,24 @@ static void test_cli_help() {
config_delete(cfg); config_delete(cfg);
} }
/* Test that --archive sets compression, multithreading, and metadata */ /* Test that --archive's config bundle matches rsync -rlptgoD semantics:
* links + metadata + devices + specials, and NOT compression/multithreading. */
static void test_cli_archive_flags() { static void test_cli_archive_flags() {
Config* cfg = config_create(); Config* cfg = config_create();
EXPECT_NOT_NULL(cfg); EXPECT_NOT_NULL(cfg);
/* Simulate --archive flag */ /* Simulate the --archive flag's implied bundle. */
cfg->use_compression = true; cfg->follow_symlinks = true;
cfg->use_multithreading = true;
cfg->use_metadata = true; cfg->use_metadata = true;
cfg->preserve_devices = true;
cfg->preserve_specials = true;
EXPECT_TRUE(cfg->use_compression); EXPECT_TRUE(cfg->follow_symlinks);
EXPECT_TRUE(cfg->use_multithreading);
EXPECT_TRUE(cfg->use_metadata); EXPECT_TRUE(cfg->use_metadata);
EXPECT_TRUE(cfg->preserve_devices);
EXPECT_TRUE(cfg->preserve_specials);
EXPECT_FALSE(cfg->use_compression);
EXPECT_FALSE(cfg->use_multithreading);
config_delete(cfg); config_delete(cfg);
} }
@@ -325,10 +330,10 @@ static void test_parse_args_fake_super() {
config_delete(cfg); config_delete(cfg);
} }
/* Test parse_args with valid port */ /* Test parse_args with valid SSH port (long form; -p is now rsync --perms) */
static void test_parse_args_valid_port() { static void test_parse_args_valid_port() {
Config* cfg = config_create(); Config* cfg = config_create();
char* argv[] = {"fastsync", "-p", "2222", "/src", "/dst"}; char* argv[] = {"fastsync", "--ssh-port", "2222", "/src", "/dst"};
int positional_args[2]; int positional_args[2];
int positional_count = 0; int positional_count = 0;
@@ -417,7 +422,7 @@ static void test_parse_args_rejects_invalid_chmod() {
/* Test parse_args rejects port > 65535 */ /* Test parse_args rejects port > 65535 */
static void test_parse_args_invalid_port() { static void test_parse_args_invalid_port() {
Config* cfg = config_create(); Config* cfg = config_create();
char* argv[] = {"fastsync", "-p", "99999", "/src", "/dst"}; char* argv[] = {"fastsync", "--ssh-port", "99999", "/src", "/dst"};
int positional_args[2]; int positional_args[2];
int positional_count = 0; int positional_count = 0;
@@ -430,7 +435,7 @@ static void test_parse_args_invalid_port() {
/* Test parse_args rejects non-numeric port */ /* Test parse_args rejects non-numeric port */
static void test_parse_args_non_numeric_port() { static void test_parse_args_non_numeric_port() {
Config* cfg = config_create(); Config* cfg = config_create();
char* argv[] = {"fastsync", "-p", "abc", "/src", "/dst"}; char* argv[] = {"fastsync", "--ssh-port", "abc", "/src", "/dst"};
int positional_args[2]; int positional_args[2];
int positional_count = 0; int positional_count = 0;
@@ -453,10 +458,10 @@ static void test_parse_args_invalid_server_port() {
config_delete(cfg); config_delete(cfg);
} }
/* Test parse_args rejects invalid compression level */ /* Test parse_args rejects invalid compression level (-z/--compress) */
static void test_parse_args_invalid_compression_level() { static void test_parse_args_invalid_compression_level() {
Config* cfg = config_create(); Config* cfg = config_create();
char* argv[] = {"fastsync", "-c", "25", "/src", "/dst"}; char* argv[] = {"fastsync", "-z", "25", "/src", "/dst"};
int positional_args[2]; int positional_args[2];
int positional_count = 0; int positional_count = 0;
@@ -466,10 +471,10 @@ static void test_parse_args_invalid_compression_level() {
config_delete(cfg); config_delete(cfg);
} }
/* Test parse_args accepts valid compression level */ /* Test parse_args accepts valid compression level (-z/--compress) */
static void test_parse_args_valid_compression_level() { static void test_parse_args_valid_compression_level() {
Config* cfg = config_create(); Config* cfg = config_create();
char* argv[] = {"fastsync", "-c", "10", "/src", "/dst"}; char* argv[] = {"fastsync", "-z", "10", "/src", "/dst"};
int positional_args[2]; int positional_args[2];
int positional_count = 0; int positional_count = 0;
@@ -977,11 +982,12 @@ static void test_parse_args_hard_links() {
config_delete(cfg); config_delete(cfg);
} }
/* -H/--hard-links violates the per-file streaming requirement of -s and the /* -H/--hard-links violates the per-file streaming requirement of
* payload-bearing tail-resume of --append: both combos are rejected up front. */ * --chunk-serialization and the payload-bearing tail-resume of --append: both
* combos are rejected up front. */
static void test_validate_config_hard_links_incompatible_modes() { static void test_validate_config_hard_links_incompatible_modes() {
Config* cfg = config_create(); Config* cfg = config_create();
char* argv_s[] = {"fastsync", "-H", "-s", "/src", "/dst"}; char* argv_s[] = {"fastsync", "-H", "--chunk-serialization", "/src", "/dst"};
int positional_args[2]; int positional_args[2];
int positional_count = 0; int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv_s, positional_args, &positional_count), 0); EXPECT_EQ_INT(parse_args(cfg, 5, argv_s, positional_args, &positional_count), 0);
@@ -1049,33 +1055,52 @@ static void test_parse_args_archive() {
int ret = parse_args(cfg, 4, argv, positional_args, &positional_count); int ret = parse_args(cfg, 4, argv, positional_args, &positional_count);
EXPECT_EQ_INT(ret, 0); EXPECT_EQ_INT(ret, 0);
EXPECT_TRUE(cfg->use_compression); EXPECT_TRUE(cfg->follow_symlinks);
EXPECT_TRUE(cfg->use_multithreading);
EXPECT_TRUE(cfg->use_metadata); EXPECT_TRUE(cfg->use_metadata);
EXPECT_TRUE(cfg->preserve_devices);
EXPECT_TRUE(cfg->preserve_specials);
EXPECT_FALSE(cfg->use_compression);
EXPECT_FALSE(cfg->use_multithreading);
config_delete(cfg); config_delete(cfg);
} }
/* Negations must override archive's implied options in argument order. */ /* Negations must override archive's implied options in argument order. Note:
* archive implies devices+specials, and device/special preservation itself
* forces metadata transmission (re-creating a node needs the metadata mode), so
* --no-preserve cannot turn metadata back off while archive keeps devices/specials
* on -- that is the correct interaction, not a bug. A link negation does work. */
static void test_parse_args_negations() { static void test_parse_args_negations() {
Config* cfg = config_create(); Config* cfg = config_create();
char* argv[] = {"fastsync", "--archive", "--no-compress", "--no-m", char* argv[] = {"fastsync", "--archive", "--no-links", "--no-preserve",
"--no-preserve", "--no-dry-run", "/src", "/dst"}; "--no-dry-run", "/src", "/dst"};
int positional_args[2]; int positional_args[2];
int positional_count = 0; int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 8, argv, positional_args, &positional_count), 0); EXPECT_EQ_INT(parse_args(cfg, 7, argv, positional_args, &positional_count), 0);
EXPECT_FALSE(cfg->use_compression); EXPECT_FALSE(cfg->follow_symlinks);
EXPECT_FALSE(cfg->use_multithreading); EXPECT_TRUE(cfg->use_metadata);
EXPECT_FALSE(cfg->use_metadata);
EXPECT_FALSE(cfg->dry_run); EXPECT_FALSE(cfg->dry_run);
EXPECT_EQ_INT(positional_count, 2); EXPECT_EQ_INT(positional_count, 2);
config_delete(cfg); config_delete(cfg);
} }
/* --no-preserve negates an explicit --preserve when nothing forces metadata back
* on (no devices/specials). */
static void test_parse_args_negate_preserve_without_devices() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--preserve", "--no-preserve", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
EXPECT_FALSE(cfg->use_metadata);
config_delete(cfg);
}
static void test_parse_args_negation_order() { static void test_parse_args_negation_order() {
Config* cfg = config_create(); Config* cfg = config_create();
char* argv[] = {"fastsync", "--no-z", "-c", "/src", "/dst"}; char* argv[] = {"fastsync", "--no-z", "-z", "/src", "/dst"};
int positional_args[2]; int positional_args[2];
int positional_count = 0; int positional_count = 0;
@@ -1428,9 +1453,11 @@ static void test_parse_args_secluded_args() {
config_delete(cfg); config_delete(cfg);
} }
static void test_parse_args_short_s_remains_chunk_serialization() { static void test_parse_args_chunk_serialization_long_form() {
Config* cfg = config_create(); Config* cfg = config_create();
char* argv[] = {"fastsync", "-s", "/src", "/dst"}; /* Chunk serialization is now long-form-only (the short -s is rsync's
* --secluded-args no-op). */
char* argv[] = {"fastsync", "--chunk-serialization", "/src", "/dst"};
int positional_args[2]; int positional_args[2];
int positional_count = 0; int positional_count = 0;
@@ -1651,7 +1678,7 @@ static void test_parse_args_fuzzy_respects_no_incremental() {
* sendfile (-f) modes reject -- mirroring the --delta constraint checks. */ * sendfile (-f) modes reject -- mirroring the --delta constraint checks. */
static void test_validate_config_fuzzy_incompatible_modes() { static void test_validate_config_fuzzy_incompatible_modes() {
Config* cfg = config_create(); Config* cfg = config_create();
char* argv[] = {"fastsync", "--fuzzy", "-s", "/src", "/dst"}; char* argv[] = {"fastsync", "--fuzzy", "--chunk-serialization", "/src", "/dst"};
int positional_args[2]; int positional_args[2];
int positional_count = 0; int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0); EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
@@ -1662,7 +1689,7 @@ static void test_validate_config_fuzzy_incompatible_modes() {
config_delete(cfg); config_delete(cfg);
cfg = config_create(); cfg = config_create();
char* sendfile_argv[] = {"fastsync", "--fuzzy", "-f", "/src", "/dst"}; char* sendfile_argv[] = {"fastsync", "--fuzzy", "--sendfile", "/src", "/dst"};
positional_count = 0; positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, sendfile_argv, positional_args, &positional_count), 0); EXPECT_EQ_INT(parse_args(cfg, 5, sendfile_argv, positional_args, &positional_count), 0);
cfg->send_directory = str_dup("/src"); cfg->send_directory = str_dup("/src");
@@ -2347,20 +2374,24 @@ static void test_parse_args_append_both() {
static void test_validate_config_append_rejects_chunk_serialization() { static void test_validate_config_append_rejects_chunk_serialization() {
Config* cfg = config_create(); Config* cfg = config_create();
char* argv[] = {"fastsync", "--append", "-s", "/src", "/dst"}; char* argv[] = {"fastsync", "--append", "--chunk-serialization", "/src", "/dst"};
int positional_args[2]; int positional_args[2];
int positional_count = 0; int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0); EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
cfg->send_directory = str_dup("/src");
cfg->receive_root_directory = str_dup("/dst");
EXPECT_FALSE(validate_config(cfg)); EXPECT_FALSE(validate_config(cfg));
config_delete(cfg); config_delete(cfg);
} }
static void test_validate_config_append_verify_rejects_chunk_serialization() { static void test_validate_config_append_verify_rejects_chunk_serialization() {
Config* cfg = config_create(); Config* cfg = config_create();
char* argv[] = {"fastsync", "--append-verify", "-s", "/src", "/dst"}; char* argv[] = {"fastsync", "--append-verify", "--chunk-serialization", "/src", "/dst"};
int positional_args[2]; int positional_args[2];
int positional_count = 0; int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0); EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
cfg->send_directory = str_dup("/src");
cfg->receive_root_directory = str_dup("/dst");
EXPECT_FALSE(validate_config(cfg)); EXPECT_FALSE(validate_config(cfg));
config_delete(cfg); config_delete(cfg);
} }
@@ -2819,18 +2850,18 @@ static void test_parse_args_remote_option_rejects_bad_values() {
config_delete(cfg); config_delete(cfg);
} }
/* A short -M form must NOT be accepted as --remote-option: -M stays FastSync /* Since the Phase-7 CLI-namespace pass, -M is rsync's --remote-option short
* metadata mode (documented divergence). */ * form (FastSync metadata mode is long-only --preserve): it consumes the next
static void test_parse_args_remote_option_no_short_M() { * argv as a remote-option value and must NOT set FastSync metadata mode. */
static void test_parse_args_remote_option_short_M() {
Config* cfg = valid_client_config(); Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg); EXPECT_NOT_NULL(cfg);
/* -M followed by a remote-option-looking word still means metadata mode. */ char* argv[] = {"fastsync", "-M", "--trust-sender", "--source-dir", "/src", "--dest-dir", "/dst"};
char* argv[] = {"fastsync", "-M", "-v", "--source-dir", "/src", "--dest-dir", "/dst"};
int positional_args[2]; int positional_args[2];
int positional_count = 0; int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv, positional_args, &positional_count), 0); EXPECT_EQ_INT(parse_args(cfg, 7, argv, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->use_metadata); EXPECT_FALSE(cfg->use_metadata);
EXPECT_EQ_INT(cfg->remote_option_count, 0); EXPECT_EQ_INT(cfg->remote_option_count, 1);
config_delete(cfg); config_delete(cfg);
} }
@@ -2955,6 +2986,7 @@ void test_client_cli() {
test_parse_args_rejects_invalid_info_flag(); test_parse_args_rejects_invalid_info_flag();
test_parse_args_archive(); test_parse_args_archive();
test_parse_args_negations(); test_parse_args_negations();
test_parse_args_negate_preserve_without_devices();
test_parse_args_negation_order(); test_parse_args_negation_order();
test_parse_args_no_preserve_blocks_implicit_metadata(); test_parse_args_no_preserve_blocks_implicit_metadata();
test_parse_args_rejects_unsafe_negation(); test_parse_args_rejects_unsafe_negation();
@@ -2970,7 +3002,7 @@ void test_client_cli() {
test_parse_args_stderr_modes(); test_parse_args_stderr_modes();
test_parse_args_rejects_unsupported_stderr_modes(); test_parse_args_rejects_unsupported_stderr_modes();
test_parse_args_secluded_args(); test_parse_args_secluded_args();
test_parse_args_short_s_remains_chunk_serialization(); test_parse_args_chunk_serialization_long_form();
test_parse_args_symlink_trust(); test_parse_args_symlink_trust();
test_parse_args_whole_file(); test_parse_args_whole_file();
test_parse_args_fuzzy_implies_delta(); test_parse_args_fuzzy_implies_delta();
@@ -3021,7 +3053,7 @@ void test_client_cli() {
test_parse_args_remote_option_space_form(); test_parse_args_remote_option_space_form();
test_parse_args_remote_option_missing_value(); test_parse_args_remote_option_missing_value();
test_parse_args_remote_option_rejects_bad_values(); test_parse_args_remote_option_rejects_bad_values();
test_parse_args_remote_option_no_short_M(); test_parse_args_remote_option_short_M();
test_parse_args_no_motd(); test_parse_args_no_motd();
test_parse_args_password_file(); test_parse_args_password_file();
} }