Merge branch 'feat/w9-dryrun' into fix/w9-integration
This commit is contained in:
@@ -6,6 +6,21 @@ run the same version because the handshake is strict.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Server-contacting `--dry-run` (protocol 2.21.0).** `--dry-run` now performs
|
||||||
|
a real handshake with a remote/daemon receiver and reports exactly what WOULD
|
||||||
|
change based on receiver state (existing destination files, mtimes, checksums,
|
||||||
|
basis dirs). The wire config carries the dry-run intent (`Config.dry_run`) and
|
||||||
|
the receiver answers each per-file check with `STATUS_DRY_RUN_TRANSFER` (would
|
||||||
|
transfer) or `STATUS_OK` (already up to date); the sender prints the
|
||||||
|
would-transfer set and its trailer without sending any file data. The receiver
|
||||||
|
performs the normal read-only incremental decision but mutates nothing: no temp
|
||||||
|
files, writes, renames, deletes, metadata/xattr/chown, or directory creation.
|
||||||
|
A plain local destination (no explicit `--server-port`/remote) keeps the
|
||||||
|
original client-side dry-run. Would-delete reporting for `--delete*` is
|
||||||
|
deferred to a follow-up; dry-run never deletes.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- Enforce the daemon's per-module `max connections` cap and add a global
|
- Enforce the daemon's per-module `max connections` cap and add a global
|
||||||
|
|||||||
+2
-2
@@ -93,7 +93,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 |
|
||||||
|------|-------------------|-----------------|-------|
|
|------|-------------------|-----------------|-------|
|
||||||
| `-n`, `--dry-run` | Trial run with no changes | ✅ Implemented | `dry_run` config field |
|
| `-n`, `--dry-run` | Trial run with no changes | ✅ Implemented | Server-contacting since protocol 2.21.0. The final routing predicate is `dry_run_targets_server()` in `src/client/client_send.c`: any target a real run would reach over the wire selects the server-contacting path — an SSH transport, a daemon `host::module` destination, an explicit `--server-host` or `--server-port`/`--port`, TLS, or a source-bind `--address` — and the client handshakes with the receiver, which runs the normal read-only per-file check and answers `STATUS_DRY_RUN_TRANSFER`/`STATUS_OK` without mutating anything. A plain local destination (none of those) keeps the original client-side manifest that never dials the default `127.0.0.1:8080`. Would-delete reporting for `--delete*` is deferred (dry-run never deletes). |
|
||||||
| `-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 |
|
||||||
@@ -795,7 +795,7 @@ These are the hardest compatibility items because they require durable formats o
|
|||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
**Phase 6, Wave C (protocol-version) shipping note (no PROTOCOL_VERSION change):** `--protocol=NUM` lets the client force the wire protocol version for a transfer. FastSync's protocol is a single lockstep format: the config frame is a strict ordered sequence and the server requires the client's version string to equal `PROTOCOL_VERSION` exactly (`config_receive_with_validate`, src/shared/config.c) — there are no older-format code paths and no downgrade/negotiation machinery, so a lower/higher/virtual version can never be spoken. The honest contract is therefore: `--protocol=2.21.0` (the current `PROTOCOL_VERSION`, as of the error-detail wave) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `2.20.0`, `2.19.0`, `2.18.0`, `2.18`, `2.17.0`, `2.16.0`, `2.15.0`, `3.0.0`, rsync-integer spellings like `216`/`31`, garbage, empty — is rejected up front in `validate_config()` before any connection, with a clear error that FastSync supports only its current wire protocol and cannot speak an older or virtual one. Implementation is client-only: a server-side `--protocol` is intentionally not added because the server has no negotiation (it only enforces exact match), and it could only ever be the current version. This preserves (and slightly tightens) existing validation: the client now also refuses to launch with a version it cannot actually speak, rather than only the server rejecting it later. A genuine downgrade would require a per-version compatibility layer for every frame/feature added since (append 2.10, preallocate 2.11, hardlinks 2.12, devices/specials/symlink-trust/xattr 2.13, remote-option 2.14, daemon module/auth 2.15, iconv 2.16, dir/symlink times 2.17, privilege flags --super/--copy-as 2.18, SCRAM daemon auth 2.19, packed metadata 2.20) and is intentionally out of scope — documented divergences from rsync's integer-negotiated downgrade remain.
|
**Phase 6, Wave C (protocol-version) shipping note (no PROTOCOL_VERSION change):** `--protocol=NUM` lets the client force the wire protocol version for a transfer. FastSync's protocol is a single lockstep format: the config frame is a strict ordered sequence and the server requires the client's version string to equal `PROTOCOL_VERSION` exactly (`config_receive_with_validate`, src/shared/config.c) — there are no older-format code paths and no downgrade/negotiation machinery, so a lower/higher/virtual version can never be spoken. The honest contract is therefore: `--protocol=2.21.0` (the current `PROTOCOL_VERSION`, as of the combined error-detail + server-contacting dry-run wave) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `2.20.0`, `2.19.0`, `2.18.0`, `2.18`, `2.17.0`, `2.16.0`, `2.15.0`, `3.0.0`, rsync-integer spellings like `216`/`31`, garbage, empty — is rejected up front in `validate_config()` before any connection, with a clear error that FastSync supports only its current wire protocol and cannot speak an older or virtual one. Implementation is client-only: a server-side `--protocol` is intentionally not added because the server has no negotiation (it only enforces exact match), and it could only ever be the current version. This preserves (and slightly tightens) existing validation: the client now also refuses to launch with a version it cannot actually speak, rather than only the server rejecting it later. A genuine downgrade would require a per-version compatibility layer for every frame/feature added since (append 2.10, preallocate 2.11, hardlinks 2.12, devices/specials/symlink-trust/xattr 2.13, remote-option 2.14, daemon module/auth 2.15, iconv 2.16, dir/symlink times 2.17, privilege flags --super/--copy-as 2.18, SCRAM daemon auth 2.19, packed metadata 2.20) and is intentionally out of scope — documented divergences from rsync's integer-negotiated downgrade remain.
|
||||||
|
|
||||||
**Phase-1/2 selection-and-update status correction (docs):** `-I/--ignore-times`, `--size-only`, `-@/--modify-window`, `--existing`, `--ignore-existing`, `-u/--update`, `-W/--whole-file`, and `--compress-threads` were previously listed as not-implemented in this document but are in fact fully implemented and tested on `dev`. This pass corrects the matrix to match the code. The realistic model of these is that FastSync is a *sender-driven* whole-tree copy, so the size+mtime quick-check and all three receiver-policy skips (`--existing`, `--ignore-existing`, `-u`) are evaluated against the **destination** on the receiver side, and their booleans cross the wire in the config frame. `-I`/`--size-only`/`--modify-window` modify the `--incremental` per-file `STATUS_CHECK` handshake's match predicate (`-I` disables the mtime leg and forces transfer; `--size-only` drops only the mtime leg; `--modify-window` adds tolerance to `metadata_mtime_matches`); they require `--incremental` (or a basis dir) to have a handshake to affect, mirroring how they only matter where a quick-check exists in rsync. `--existing`/`--ignore-existing`/`-u` are receiver write-time policies (skipping the write / newer-destination guard) applied across the regular-file, `--delay-updates`-staged, hardlink-sibling, and special/device paths; `-u` implies `-M` metadata and uses a second-then-nanosecond strict `>` newer check; both correctly influence `--remove-source-files` (a skipped source is not removed). `-W/--whole-file` disables block-level delta (opt-in via `--delta`), folded into the wire `use_delta` so no protocol bump was needed, and makes `--fuzzy` inert; `--append`/`--append-verify` are rejected with `-W`. `--compress-threads=NUM` (1..64, client-only, never crosses the wire) sizes the zstd compression worker pool. No code was changed by this correction; the implementation had landed in earlier merge waves (feat/ignore-times, feat/ignore-existing via the newer `file_to_disk_secure_no_replace`/`linkat EEXIST` path, feat/size-only, feat/modify-window, feat/whole-file, feat/update, compression-threads).
|
**Phase-1/2 selection-and-update status correction (docs):** `-I/--ignore-times`, `--size-only`, `-@/--modify-window`, `--existing`, `--ignore-existing`, `-u/--update`, `-W/--whole-file`, and `--compress-threads` were previously listed as not-implemented in this document but are in fact fully implemented and tested on `dev`. This pass corrects the matrix to match the code. The realistic model of these is that FastSync is a *sender-driven* whole-tree copy, so the size+mtime quick-check and all three receiver-policy skips (`--existing`, `--ignore-existing`, `-u`) are evaluated against the **destination** on the receiver side, and their booleans cross the wire in the config frame. `-I`/`--size-only`/`--modify-window` modify the `--incremental` per-file `STATUS_CHECK` handshake's match predicate (`-I` disables the mtime leg and forces transfer; `--size-only` drops only the mtime leg; `--modify-window` adds tolerance to `metadata_mtime_matches`); they require `--incremental` (or a basis dir) to have a handshake to affect, mirroring how they only matter where a quick-check exists in rsync. `--existing`/`--ignore-existing`/`-u` are receiver write-time policies (skipping the write / newer-destination guard) applied across the regular-file, `--delay-updates`-staged, hardlink-sibling, and special/device paths; `-u` implies `-M` metadata and uses a second-then-nanosecond strict `>` newer check; both correctly influence `--remove-source-files` (a skipped source is not removed). `-W/--whole-file` disables block-level delta (opt-in via `--delta`), folded into the wire `use_delta` so no protocol bump was needed, and makes `--fuzzy` inert; `--append`/`--append-verify` are rejected with `-W`. `--compress-threads=NUM` (1..64, client-only, never crosses the wire) sizes the zstd compression worker pool. No code was changed by this correction; the implementation had landed in earlier merge waves (feat/ignore-times, feat/ignore-existing via the newer `file_to_disk_secure_no_replace`/`linkat EEXIST` path, feat/size-only, feat/modify-window, feat/whole-file, feat/update, compression-threads).
|
||||||
|
|
||||||
|
|||||||
@@ -1106,6 +1106,11 @@ static bool cli_handle_table_option(CliParseCtx* ctx) {
|
|||||||
}
|
}
|
||||||
config->use_metadata = true;
|
config->use_metadata = true;
|
||||||
}
|
}
|
||||||
|
/* Remember that --server-host was explicitly given (the field itself
|
||||||
|
defaults to 127.0.0.1, so a value check cannot distinguish it). Used
|
||||||
|
by --dry-run to route an explicit remote target to the server. */
|
||||||
|
if (entry->offset == offsetof(Config, server_host))
|
||||||
|
config->server_host_set = true;
|
||||||
}
|
}
|
||||||
} else if (apply_table_option(config, entry, NULL) != 0) {
|
} else if (apply_table_option(config, entry, NULL) != 0) {
|
||||||
ctx->exit_code = -1;
|
ctx->exit_code = -1;
|
||||||
@@ -1389,6 +1394,7 @@ static int set_server_port_option(Config* config, const char* value, const char*
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
config->server_port = port;
|
config->server_port = port;
|
||||||
|
config->server_port_set = true;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+225
-2
@@ -497,6 +497,28 @@ static void disconnect_transfer_client(Client* client) {
|
|||||||
client_delete(client);
|
client_delete(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* True when --dry-run should contact a receiver rather than running the
|
||||||
|
* client-side local manifest. Any target a real run would reach over the wire
|
||||||
|
* selects the server-contacting path: a remote (SSH host:path), a daemon
|
||||||
|
* (host::module/path), an explicit --server-host, --server-port/--port, TLS, or
|
||||||
|
* a source-bind --address. A plain local destination (none of these) keeps the
|
||||||
|
* original client-side behavior, which never dials the default 127.0.0.1:8080. */
|
||||||
|
static bool dry_run_targets_server(const Config* config) {
|
||||||
|
if (!config)
|
||||||
|
return false;
|
||||||
|
if (config->transport == TRANSPORT_SSH)
|
||||||
|
return true;
|
||||||
|
if (config->module && config->module[0] != '\0')
|
||||||
|
return true;
|
||||||
|
if (config->server_host_set || config->server_port_set)
|
||||||
|
return true;
|
||||||
|
if (config->use_tls)
|
||||||
|
return true;
|
||||||
|
if (config->address != NULL)
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
static bool add_chunk_to_manifest(ArrayList* manifest, const Chunk* chunk) {
|
static bool add_chunk_to_manifest(ArrayList* manifest, const Chunk* chunk) {
|
||||||
if (!manifest)
|
if (!manifest)
|
||||||
return true;
|
return true;
|
||||||
@@ -1048,6 +1070,19 @@ static int incremental_check(Client* client, File* file, const Config* config,
|
|||||||
*resume_offset = offset;
|
*resume_offset = offset;
|
||||||
return 3;
|
return 3;
|
||||||
}
|
}
|
||||||
|
/* Server-contacting --dry-run: the receiver decided the file is not up to
|
||||||
|
date and answered "would transfer" WITHOUT expecting any data. Treat it as
|
||||||
|
the dry-run code ONLY when this session actually requested dry-run. A
|
||||||
|
hostile/buggy peer that emits it outside dry-run is a protocol error: fail
|
||||||
|
closed (and send STATUS_ERROR) rather than fall through to the normal path,
|
||||||
|
which would transmit file data the receiver is not reading and desync. */
|
||||||
|
if (s == STATUS_DRY_RUN_TRANSFER) {
|
||||||
|
if (config->dry_run)
|
||||||
|
return 4;
|
||||||
|
log_message(LOG_LEVEL_ERROR, "Unexpected DRY_RUN_TRANSFER status outside a --dry-run session");
|
||||||
|
send_status(client->file_descriptor, STATUS_ERROR);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
if (s != STATUS_NEXT) {
|
if (s != STATUS_NEXT) {
|
||||||
log_server_rejection("Unexpected server status");
|
log_server_rejection("Unexpected server status");
|
||||||
send_status(client->file_descriptor, STATUS_ERROR);
|
send_status(client->file_descriptor, STATUS_ERROR);
|
||||||
@@ -1188,6 +1223,174 @@ static int send_append(const Client* client, File* file, Config* config,
|
|||||||
return ok ? 0 : -1;
|
return ok ? 0 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Server-contacting --dry-run. Connects to the configured remote/daemon and
|
||||||
|
* runs the normal per-file incremental decision WITHOUT transmitting any file
|
||||||
|
* data: the receiver (which also sees dry_run=true on the wire) answers
|
||||||
|
* STATUS_OK for an up-to-date file and STATUS_DRY_RUN_TRANSFER for a file it
|
||||||
|
* would otherwise write, mutating nothing on either side. The would-transfer
|
||||||
|
* set and the same trailer as the local dry-run are printed. A
|
||||||
|
* --compare-dest exact basis hit with no destination copy is reported as a
|
||||||
|
* skip by the receiver.
|
||||||
|
*
|
||||||
|
* Only regular files take the receiver-consulted check; directory / symlink /
|
||||||
|
* special / hard-link-sibling entries have no per-file content check, so they
|
||||||
|
* are reported conservatively as would-transfer and their frames are never
|
||||||
|
* sent (which is what keeps the receiver mutation-free). --delete* is
|
||||||
|
* deliberately NOT transmitted in dry-run, so no deletion can occur; the
|
||||||
|
* would-delete manifest report is a documented follow-up.
|
||||||
|
*
|
||||||
|
* Returns 0 on success, 1 on error. */
|
||||||
|
static int send_dry_run_remote(Config* config) {
|
||||||
|
int from_skipped = 0;
|
||||||
|
ArrayList* missing_args = NULL;
|
||||||
|
if (config->delete_missing_args) {
|
||||||
|
missing_args = array_list_create(free);
|
||||||
|
if (!missing_args)
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (!files_from_list_check(config, missing_args, &from_skipped)) {
|
||||||
|
if (missing_args)
|
||||||
|
array_list_delete(missing_args);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (missing_args)
|
||||||
|
array_list_delete(missing_args);
|
||||||
|
/* Alternate basis dirs force the whole-file per-file check on the real
|
||||||
|
receiver; refuse an oversize source up front exactly as send_files does so
|
||||||
|
dry-run reports the same clear diagnostic instead of aborting mid-stream. */
|
||||||
|
if (config_has_basis(config) && !basis_oversize_preflight(config))
|
||||||
|
return 1;
|
||||||
|
/* Would-delete reporting requires a receiver-side read-only extras walk that
|
||||||
|
is not implemented yet; be explicit that --delete is a no-op in dry-run
|
||||||
|
rather than silently ignoring it. */
|
||||||
|
if ((config->use_delete || config->delete_missing_args) && !config->quiet)
|
||||||
|
log_message(LOG_LEVEL_WARNING,
|
||||||
|
"--dry-run: would-delete reporting is not available in this release; nothing is "
|
||||||
|
"deleted");
|
||||||
|
|
||||||
|
/* A live session may follow, so arm graceful abort handling. */
|
||||||
|
client_set_abort_armed(true);
|
||||||
|
Client* client = connect_transfer_client(config);
|
||||||
|
if (!client) {
|
||||||
|
if (config->transport == TRANSPORT_TCP)
|
||||||
|
log_message(LOG_LEVEL_ERROR, "could not connect to server%s",
|
||||||
|
config->use_tls ? " via TLS" : "");
|
||||||
|
client_set_abort_armed(false);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
ProtocolSession session;
|
||||||
|
protocol_session_init(&session, client->file_descriptor, client->file_descriptor);
|
||||||
|
protocol_session_set_io_timeout(&session, config->timeout);
|
||||||
|
protocol_session_set_ssl(&session, (SSL*)client->ssl);
|
||||||
|
protocol_session_bind(&session);
|
||||||
|
|
||||||
|
int ret = 1;
|
||||||
|
PreparedScanner prepared;
|
||||||
|
memset(&prepared, 0, sizeof(prepared));
|
||||||
|
DirectoryScanner* scanner = NULL;
|
||||||
|
if (!config_send(client->file_descriptor, config))
|
||||||
|
goto dry_fail;
|
||||||
|
receive_daemon_motd(client, config);
|
||||||
|
if (!prepare_scanner(config, 0, &prepared))
|
||||||
|
goto dry_fail;
|
||||||
|
scanner = directory_scanner_create_with_options(config->send_directory, &prepared.options);
|
||||||
|
if (!scanner)
|
||||||
|
goto dry_fail;
|
||||||
|
|
||||||
|
int file_count = 0;
|
||||||
|
unsigned long long total_bytes = 0;
|
||||||
|
char size_buffer[32];
|
||||||
|
if (!config->quiet)
|
||||||
|
printf("Dry run: files to be transferred\n");
|
||||||
|
Chunk* chunk;
|
||||||
|
while ((chunk = directory_scanner_next(scanner)) != NULL) {
|
||||||
|
for (int i = 0; i < chunk->element_count; i++) {
|
||||||
|
File* f = chunk->items[i];
|
||||||
|
if (!f)
|
||||||
|
continue;
|
||||||
|
unsigned long long fsize = f->data ? f->data->size : 0;
|
||||||
|
bool would;
|
||||||
|
if (f->is_dir || f->is_symlink || f->is_special ||
|
||||||
|
(f->link_group != 0 && !f->link_first && f->hardlink_target != NULL)) {
|
||||||
|
/* No receiver-side content check exists for these frame types; a real
|
||||||
|
run would (re)create them, so report would-transfer and send no
|
||||||
|
frame (the receiver must stay mutation-free). */
|
||||||
|
would = true;
|
||||||
|
} else if (fsize > MAX_RECEIVE_WHOLE_FILE_SIZE && !config->use_incremental &&
|
||||||
|
!config_has_basis(config)) {
|
||||||
|
/* A non-incremental run streams a >whole-file-limit source without the
|
||||||
|
STATUS_CHECK handshake, so no read-only receiver decision is possible
|
||||||
|
(and none is needed: a real run would transfer it). */
|
||||||
|
would = true;
|
||||||
|
} else {
|
||||||
|
DeltaSignature* sig = NULL;
|
||||||
|
unsigned long long resume_offset = 0;
|
||||||
|
int rc = incremental_check(client, f, config, &sig, &resume_offset);
|
||||||
|
delta_signature_destroy(sig);
|
||||||
|
if (rc < 0) {
|
||||||
|
chunk_destroy(chunk);
|
||||||
|
goto dry_fail;
|
||||||
|
}
|
||||||
|
if (rc == 1)
|
||||||
|
continue; /* up to date; nothing to report */
|
||||||
|
if (rc != 4) {
|
||||||
|
log_message(LOG_LEVEL_ERROR, "Unexpected receiver reply during dry-run");
|
||||||
|
chunk_destroy(chunk);
|
||||||
|
goto dry_fail;
|
||||||
|
}
|
||||||
|
would = true;
|
||||||
|
}
|
||||||
|
if (would) {
|
||||||
|
if (!config->quiet) {
|
||||||
|
char* escaped_path = output_escape(file_wire_path(f), config->eight_bit_output);
|
||||||
|
if (!escaped_path) {
|
||||||
|
chunk_destroy(chunk);
|
||||||
|
goto dry_fail;
|
||||||
|
}
|
||||||
|
if (config->human_readable)
|
||||||
|
printf(" %s (%s)\n", escaped_path,
|
||||||
|
display_bytes(fsize, true, size_buffer, sizeof(size_buffer)));
|
||||||
|
else
|
||||||
|
printf(" %s (%llu bytes)\n", escaped_path, fsize);
|
||||||
|
free(escaped_path);
|
||||||
|
}
|
||||||
|
total_bytes += fsize;
|
||||||
|
file_count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chunk_destroy(chunk);
|
||||||
|
}
|
||||||
|
bool io_error = directory_scanner_had_io_error(scanner);
|
||||||
|
if (directory_scanner_failed(scanner))
|
||||||
|
goto dry_fail;
|
||||||
|
if (io_error)
|
||||||
|
log_message(LOG_LEVEL_WARNING, "source scan hit an unreadable directory");
|
||||||
|
/* Terminate the stream so the receiver emits its success frame; no data
|
||||||
|
frame and no delete manifest are ever sent in dry-run. */
|
||||||
|
if (!send_status(client->file_descriptor, STATUS_FINISHED))
|
||||||
|
goto dry_fail;
|
||||||
|
Status status;
|
||||||
|
if (!receive_status(client->file_descriptor, &status) || status != STATUS_OK)
|
||||||
|
goto dry_fail;
|
||||||
|
if (!config->quiet) {
|
||||||
|
if (config->human_readable)
|
||||||
|
printf("Total: %d files, %s\n", file_count,
|
||||||
|
display_bytes(total_bytes, true, size_buffer, sizeof(size_buffer)));
|
||||||
|
else
|
||||||
|
printf("Total: %d files, %.1f MB\n", file_count, (double)total_bytes / (double)BYTES_PER_MIB);
|
||||||
|
}
|
||||||
|
ret = io_error ? 1 : 0;
|
||||||
|
|
||||||
|
dry_fail:
|
||||||
|
if (scanner)
|
||||||
|
directory_scanner_destroy(scanner);
|
||||||
|
prepared_scanner_destroy(&prepared);
|
||||||
|
disconnect_transfer_client(client);
|
||||||
|
protocol_session_unbind();
|
||||||
|
client_set_abort_armed(false);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
// Send a single file directly (non-incremental path).
|
// Send a single file directly (non-incremental path).
|
||||||
static bool send_file_direct(File* file, int fd, bool use_metadata, int compression_level,
|
static bool send_file_direct(File* file, int fd, bool use_metadata, int compression_level,
|
||||||
const Config* config) {
|
const Config* config) {
|
||||||
@@ -1314,6 +1517,16 @@ static int send_single_file(Client* client, File* file, Config* config, bool use
|
|||||||
}
|
}
|
||||||
return arc == 0 ? 0 : -1;
|
return arc == 0 ? 0 : -1;
|
||||||
}
|
}
|
||||||
|
// rc == 4: the receiver answered DRY_RUN_TRANSFER, which is only valid in
|
||||||
|
// incremental_check's dedicated dry-run consumer. send_single_file never
|
||||||
|
// runs a dry-run session, so this is a protocol error: abort instead of
|
||||||
|
// falling through and sending data the receiver is not reading.
|
||||||
|
if (rc == 4) {
|
||||||
|
log_message(LOG_LEVEL_ERROR, "Receiver answered DRY_RUN_TRANSFER in a non-dry-run transfer");
|
||||||
|
delta_signature_destroy(sig);
|
||||||
|
send_status(client->file_descriptor, STATUS_ERROR);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
// rc == 0: unchanged file, skip
|
// rc == 0: unchanged file, skip
|
||||||
// rc == 2: server sent delta signature but sendfile doesn't support delta
|
// rc == 2: server sent delta signature but sendfile doesn't support delta
|
||||||
delta_signature_destroy(sig);
|
delta_signature_destroy(sig);
|
||||||
@@ -1355,6 +1568,14 @@ static int send_single_file(Client* client, File* file, Config* config, bool use
|
|||||||
}
|
}
|
||||||
return arc == 0 ? 0 : -1;
|
return arc == 0 ? 0 : -1;
|
||||||
}
|
}
|
||||||
|
if (rc == 4) {
|
||||||
|
/* See the sendfile branch above: DRY_RUN_TRANSFER is only valid in the
|
||||||
|
dedicated dry-run consumer, never in the normal per-file send path. */
|
||||||
|
log_message(LOG_LEVEL_ERROR, "Receiver answered DRY_RUN_TRANSFER in a non-dry-run transfer");
|
||||||
|
delta_signature_destroy(sig);
|
||||||
|
send_status(client->file_descriptor, STATUS_ERROR);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
if (rc == 2 && config->use_delta && !config->whole_file) {
|
if (rc == 2 && config->use_delta && !config->whole_file) {
|
||||||
int drc = send_delta(client, file, sig, config);
|
int drc = send_delta(client, file, sig, config);
|
||||||
delta_signature_destroy(sig);
|
delta_signature_destroy(sig);
|
||||||
@@ -1956,7 +2177,8 @@ int send_files(Config* config) {
|
|||||||
if (config->list_only)
|
if (config->list_only)
|
||||||
return send_list_only(config);
|
return send_list_only(config);
|
||||||
if (config->dry_run)
|
if (config->dry_run)
|
||||||
return send_dry_run_manifest(config);
|
return dry_run_targets_server(config) ? send_dry_run_remote(config)
|
||||||
|
: send_dry_run_manifest(config);
|
||||||
ArrayList* missing_args = NULL;
|
ArrayList* missing_args = NULL;
|
||||||
int skipped = 0;
|
int skipped = 0;
|
||||||
if (config->delete_missing_args) {
|
if (config->delete_missing_args) {
|
||||||
@@ -2268,7 +2490,8 @@ int send_files_multithreaded(Config** config_ptr) {
|
|||||||
if (config->list_only)
|
if (config->list_only)
|
||||||
return send_list_only(config);
|
return send_list_only(config);
|
||||||
if (config->dry_run)
|
if (config->dry_run)
|
||||||
return send_dry_run_manifest(config);
|
return dry_run_targets_server(config) ? send_dry_run_remote(config)
|
||||||
|
: send_dry_run_manifest(config);
|
||||||
ArrayList* missing_args = NULL;
|
ArrayList* missing_args = NULL;
|
||||||
int skipped = 0;
|
int skipped = 0;
|
||||||
if (config->delete_missing_args) {
|
if (config->delete_missing_args) {
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ bool validate_config(const Config* config) {
|
|||||||
"--write-batch, --only-write-batch, and --read-batch are mutually exclusive");
|
"--write-batch, --only-write-batch, and --read-batch are mutually exclusive");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
/* A dry-run of a local batch apply is not meaningful: --read-batch bypasses
|
||||||
|
the client-side scan/server decision entirely, so dry-run would have no
|
||||||
|
wire state to report (and must not be used as a mutation escape hatch).
|
||||||
|
--only-write-batch likewise never contacts a receiver. Reject both up
|
||||||
|
front instead of silently ignoring --dry-run. */
|
||||||
|
if (config->dry_run && (read_batch || only_write_batch)) {
|
||||||
|
log_message(LOG_LEVEL_ERROR,
|
||||||
|
"--dry-run cannot be combined with --read-batch or --only-write-batch; "
|
||||||
|
"a dry-run of a local batch apply is not meaningful");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (read_batch) {
|
if (read_batch) {
|
||||||
if (!config->receive_root_directory) {
|
if (!config->receive_root_directory) {
|
||||||
log_message(LOG_LEVEL_ERROR, "--read-batch requires a destination directory");
|
log_message(LOG_LEVEL_ERROR, "--read-batch requires a destination directory");
|
||||||
|
|||||||
+36
-6
@@ -288,10 +288,19 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
|||||||
goto fail;
|
goto fail;
|
||||||
}
|
}
|
||||||
if (status == STATUS_CHECK) {
|
if (status == STATUS_CHECK) {
|
||||||
bool skipped;
|
bool skipped = false;
|
||||||
File* file = receive_incremental_check(file_descriptor, config, &skipped);
|
bool would_transfer = false;
|
||||||
if (!skipped && (!file || !sink->store_file(file, sink->context)))
|
File* file = receive_incremental_check_ex(file_descriptor, config, &skipped, &would_transfer);
|
||||||
|
if (config->dry_run) {
|
||||||
|
/* Server-contacting --dry-run: the reply has already been sent
|
||||||
|
(STATUS_OK = up to date, STATUS_DRY_RUN_TRANSFER = would transfer) and
|
||||||
|
nothing may be stored. Both flags false means a genuine protocol
|
||||||
|
error (STATUS_ERROR already sent or sent by receive_error below). */
|
||||||
|
if (!skipped && !would_transfer)
|
||||||
|
goto receive_error;
|
||||||
|
} else if (!skipped && (!file || !sink->store_file(file, sink->context))) {
|
||||||
goto receive_error;
|
goto receive_error;
|
||||||
|
}
|
||||||
} else if (status == STATUS_CHUNK) {
|
} else if (status == STATUS_CHUNK) {
|
||||||
Chunk* chunk = receive_chunk_data(file_descriptor, config);
|
Chunk* chunk = receive_chunk_data(file_descriptor, config);
|
||||||
if (!chunk || !receiver_process_chunk(chunk, sink))
|
if (!chunk || !receiver_process_chunk(chunk, sink))
|
||||||
@@ -323,6 +332,15 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver
|
|||||||
DeleteManifest* manifest = receive_manifest_entries(file_descriptor);
|
DeleteManifest* manifest = receive_manifest_entries(file_descriptor);
|
||||||
if (!manifest)
|
if (!manifest)
|
||||||
goto fail; /* receive_manifest_entries already sent STATUS_ERROR */
|
goto fail; /* receive_manifest_entries already sent STATUS_ERROR */
|
||||||
|
if (config->dry_run) {
|
||||||
|
/* Server-contacting --dry-run mutates nothing, so a keep-set manifest
|
||||||
|
is consumed and discarded. The early-delete mode still needs its ACK
|
||||||
|
so a sender blocked on the delete handshake is not left hanging. */
|
||||||
|
delete_manifest_free(manifest);
|
||||||
|
if (early_delete && !send_status(file_descriptor, STATUS_OK))
|
||||||
|
goto fail;
|
||||||
|
goto next_status;
|
||||||
|
}
|
||||||
if (early_delete) {
|
if (early_delete) {
|
||||||
/* --delete-before / --delete-during: the manifest is authoritative the
|
/* --delete-before / --delete-during: the manifest is authoritative the
|
||||||
moment it arrives, before any file data. Delete now and acknowledge
|
moment it arrives, before any file data. Delete now and acknowledge
|
||||||
@@ -441,7 +459,11 @@ typedef struct {
|
|||||||
static bool receiver_save_file(File* file, void* context_pointer) {
|
static bool receiver_save_file(File* file, void* context_pointer) {
|
||||||
ReceiverSaveContext* context = context_pointer;
|
ReceiverSaveContext* context = context_pointer;
|
||||||
FileSaveResult result = FILE_SAVE_ERROR;
|
FileSaveResult result = FILE_SAVE_ERROR;
|
||||||
if (!context->config->save_to_disk) {
|
if (context->config->dry_run) {
|
||||||
|
/* Defense in depth: a dry-run receiver mutates nothing even if a data
|
||||||
|
frame reaches the sink (the sender is not supposed to send one). */
|
||||||
|
result = FILE_SAVE_SKIPPED;
|
||||||
|
} else if (!context->config->save_to_disk) {
|
||||||
/* Nothing is stored; report the file as not-written so a
|
/* Nothing is stored; report the file as not-written so a
|
||||||
--remove-source-files sender keeps its source. */
|
--remove-source-files sender keeps its source. */
|
||||||
result = FILE_SAVE_SKIPPED;
|
result = FILE_SAVE_SKIPPED;
|
||||||
@@ -457,8 +479,12 @@ static bool receiver_save_file(File* file, void* context_pointer) {
|
|||||||
file_destroy(file);
|
file_destroy(file);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (result != FILE_SAVE_ERROR && context->config->remove_source_files && !file->is_dir &&
|
/* A dry-run receiver mutates nothing AND records no per-file outcomes: a
|
||||||
!file->is_special && !file->skip &&
|
hostile dry-run client that streamed data frames anyway must not be able to
|
||||||
|
grow `outcomes` without bound (receiver_outcomes_append reallocs uncharged)
|
||||||
|
or force a per-frame ack. */
|
||||||
|
if (!context->config->dry_run && result != FILE_SAVE_ERROR &&
|
||||||
|
context->config->remove_source_files && !file->is_dir && !file->is_special && !file->skip &&
|
||||||
!receiver_outcomes_append(&context->outcomes, (unsigned char)result)) {
|
!receiver_outcomes_append(&context->outcomes, (unsigned char)result)) {
|
||||||
file_destroy(file);
|
file_destroy(file);
|
||||||
return false;
|
return false;
|
||||||
@@ -469,6 +495,10 @@ static bool receiver_save_file(File* file, void* context_pointer) {
|
|||||||
|
|
||||||
static bool receiver_send_success_frame(int fd, void* context_pointer) {
|
static bool receiver_send_success_frame(int fd, void* context_pointer) {
|
||||||
ReceiverSaveContext* context = context_pointer;
|
ReceiverSaveContext* context = context_pointer;
|
||||||
|
/* Server-contacting --dry-run: nothing was staged or written, so there is
|
||||||
|
nothing to publish and no directory times to stamp. */
|
||||||
|
if (context->config->dry_run)
|
||||||
|
return receiver_send_final_success(fd, context->config, &context->outcomes);
|
||||||
/* --delay-updates: the whole protocol stream (including manifest/delete
|
/* --delay-updates: the whole protocol stream (including manifest/delete
|
||||||
handling, which ran inside receiver_process) has succeeded and every
|
handling, which ran inside receiver_process) has succeeded and every
|
||||||
staged file was fully written. Publish them atomically now, before the
|
staged file was fully written. Publish them atomically now, before the
|
||||||
|
|||||||
@@ -196,7 +196,11 @@ int write_thread(void* pipeline_context) {
|
|||||||
}
|
}
|
||||||
size_t file_bytes = file->data ? file->data->size : 0;
|
size_t file_bytes = file->data ? file->data->size : 0;
|
||||||
FileSaveResult result = FILE_SAVE_SKIPPED;
|
FileSaveResult result = FILE_SAVE_SKIPPED;
|
||||||
if (save_to_disk) {
|
/* Server-contacting --dry-run: never write. The receiver thread does not
|
||||||
|
enqueue anything on the dry-run path, but this keeps the writer thread
|
||||||
|
provably mutation-free if a data frame ever reached it. */
|
||||||
|
bool dry_run = context->config->dry_run;
|
||||||
|
if (save_to_disk && !dry_run) {
|
||||||
result = file_save_to_disk_full(root_directory, file, context->config);
|
result = file_save_to_disk_full(root_directory, file, context->config);
|
||||||
if (result == FILE_SAVE_ERROR) {
|
if (result == FILE_SAVE_ERROR) {
|
||||||
file_destroy(file);
|
file_destroy(file);
|
||||||
@@ -215,7 +219,7 @@ int write_thread(void* pipeline_context) {
|
|||||||
/* P7 Wave D: a directory's times are never applied inline (a later child
|
/* P7 Wave D: a directory's times are never applied inline (a later child
|
||||||
write would clobber them); accumulate the metadata here and let the
|
write would clobber them); accumulate the metadata here and let the
|
||||||
caller apply it once every writer has drained. */
|
caller apply it once every writer has drained. */
|
||||||
if (result != FILE_SAVE_ERROR && file->is_dir && file->metadata &&
|
if (!dry_run && result != FILE_SAVE_ERROR && file->is_dir && file->metadata &&
|
||||||
dir_times_should_capture(context->config) &&
|
dir_times_should_capture(context->config) &&
|
||||||
!dir_time_list_add(&context->dir_times, file->path, file->metadata)) {
|
!dir_time_list_add(&context->dir_times, file->path, file->metadata)) {
|
||||||
file_destroy(file);
|
file_destroy(file);
|
||||||
@@ -234,8 +238,8 @@ int write_thread(void* pipeline_context) {
|
|||||||
which sources were actually written versus skipped on the receiver.
|
which sources were actually written versus skipped on the receiver.
|
||||||
Explicit directory entries and recreated device/special nodes have no
|
Explicit directory entries and recreated device/special nodes have no
|
||||||
source and are never acknowledged (mirrors receiver.c). */
|
source and are never acknowledged (mirrors receiver.c). */
|
||||||
if (context->config->remove_source_files && !file->is_dir && !file->is_special && !file->skip &&
|
if (!dry_run && context->config->remove_source_files && !file->is_dir && !file->is_special &&
|
||||||
!receiver_outcomes_append(&context->outcomes, (unsigned char)result)) {
|
!file->skip && !receiver_outcomes_append(&context->outcomes, (unsigned char)result)) {
|
||||||
file_destroy(file);
|
file_destroy(file);
|
||||||
pipeline_context_receiver_note_bytes_released(context, file_bytes);
|
pipeline_context_receiver_note_bytes_released(context, file_bytes);
|
||||||
mtx_lock(&context->mutex);
|
mtx_lock(&context->mutex);
|
||||||
|
|||||||
+38
-16
@@ -217,12 +217,24 @@ static bool path_is_within(const char* root, const char* path) {
|
|||||||
root is rejected up front instead of being silently invented by a later
|
root is rejected up front instead of being silently invented by a later
|
||||||
write. Both paths are confined to the authorized root by the secure file
|
write. Both paths are confined to the authorized root by the secure file
|
||||||
helpers. */
|
helpers. */
|
||||||
|
/* Existence-only half of the precondition: the destination root must already
|
||||||
|
resolve to a directory below the authorized root. Never creates anything, so
|
||||||
|
a server-contacting --dry-run can apply the exact same fail-closed check a
|
||||||
|
real run would without mutating the tree. */
|
||||||
|
static bool receive_root_exists(const Config* config) {
|
||||||
|
if (!config || !config->receive_root_directory)
|
||||||
|
return false;
|
||||||
|
return file_directory_exists_secure(config->receive_root_directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full precondition for a real run: --mkpath creates the root (and missing
|
||||||
|
leading components), otherwise it must already exist as a directory. */
|
||||||
static bool ensure_receive_root(const Config* config) {
|
static bool ensure_receive_root(const Config* config) {
|
||||||
if (!config || !config->receive_root_directory)
|
if (!config || !config->receive_root_directory)
|
||||||
return false;
|
return false;
|
||||||
if (config->mkpath)
|
if (config->mkpath)
|
||||||
return file_ensure_directory_secure(config->receive_root_directory);
|
return file_ensure_directory_secure(config->receive_root_directory);
|
||||||
return file_directory_exists_secure(config->receive_root_directory);
|
return receive_root_exists(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool configure_authorization(const char* root) {
|
static bool configure_authorization(const char* root) {
|
||||||
@@ -263,9 +275,11 @@ typedef enum {
|
|||||||
} ModuleAuthResult;
|
} ModuleAuthResult;
|
||||||
|
|
||||||
/* Looks up the daemon module selected by the client's config frame and rejects
|
/* Looks up the daemon module selected by the client's config frame and rejects
|
||||||
* a `read only` one (every FastSync network transfer writes; there is no
|
* a `read only` one for a real write transfer. A server-contacting --dry-run
|
||||||
* read-only wire operation yet). Returns the module, or NULL with *error set
|
* IS a read-only wire operation (it reports what would transfer/skip and
|
||||||
* to the caller-facing rejection message. */
|
* mutates nothing), so a `read only` module is the safest possible dry-run
|
||||||
|
* target and is accepted. Returns the module, or NULL with *error set to the
|
||||||
|
* caller-facing rejection message. */
|
||||||
static const DaemonModule* module_gate_lookup_module(const Config* config, const char** error) {
|
static const DaemonModule* module_gate_lookup_module(const Config* config, const char** error) {
|
||||||
const DaemonModule* module = daemon_conf_find_module(g_daemon_conf, config->module);
|
const DaemonModule* module = daemon_conf_find_module(g_daemon_conf, config->module);
|
||||||
if (module == NULL) {
|
if (module == NULL) {
|
||||||
@@ -276,7 +290,7 @@ static const DaemonModule* module_gate_lookup_module(const Config* config, const
|
|||||||
*error = "requested daemon module does not exist";
|
*error = "requested daemon module does not exist";
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
if (module->read_only) {
|
if (module->read_only && !config->dry_run) {
|
||||||
log_message(LOG_LEVEL_ERROR, "daemon module '%s' is read only; refusing write transfer",
|
log_message(LOG_LEVEL_ERROR, "daemon module '%s' is read only; refusing write transfer",
|
||||||
config->module);
|
config->module);
|
||||||
*error = "requested daemon module is read only";
|
*error = "requested daemon module is read only";
|
||||||
@@ -556,9 +570,10 @@ static const char* module_gate_install_root(const Config* config, const DaemonMo
|
|||||||
* becomes the authorized root via configure_authorization -- exactly the same
|
* becomes the authorized root via configure_authorization -- exactly the same
|
||||||
* root confinement the standalone server applies to its single
|
* root confinement the standalone server applies to its single
|
||||||
* --destination-root, but per-module and NEVER client-chosen. The module is
|
* --destination-root, but per-module and NEVER client-chosen. The module is
|
||||||
* refused (with a clear log) when it is unknown, when it is `read only` (every
|
* refused (with a clear log) when it is unknown, when it is `read only` for a
|
||||||
* FastSync network transfer writes; there is no read-only wire operation yet),
|
* real write transfer (a server-contacting --dry-run is a read-only wire
|
||||||
* when it requests client-chosen ownership without the module's
|
* operation and may target a `read only` module), when it requests
|
||||||
|
* client-chosen ownership without the module's
|
||||||
* `client owner = yes` opt-in (P7 Wave E hardening), or when the presented
|
* `client owner = yes` opt-in (P7 Wave E hardening), or when the presented
|
||||||
* daemon credentials fail for a module that declares `auth users`. Wave A
|
* daemon credentials fail for a module that declares `auth users`. Wave A
|
||||||
* refused every auth-required module (auth was not yet implemented); Wave B
|
* refused every auth-required module (auth was not yet implemented); Wave B
|
||||||
@@ -756,9 +771,14 @@ void handler(int file_descriptor) {
|
|||||||
skipped via its implied --ignore-missing-args, but nothing is deleted). */
|
skipped via its implied --ignore-missing-args, but nothing is deleted). */
|
||||||
config->delete_missing_args = config->delete_missing_args && allow_delete;
|
config->delete_missing_args = config->delete_missing_args && allow_delete;
|
||||||
/* --mkpath: create the destination root (and its missing leading components)
|
/* --mkpath: create the destination root (and its missing leading components)
|
||||||
before anything else; without it the root must pre-exist. A failure here
|
* before anything else; without it the root must pre-exist. The precondition
|
||||||
aborts the connection cleanly before any file data is exchanged. */
|
* is UNCONDITIONAL: a server-contacting --dry-run must reject exactly the
|
||||||
if (!ensure_receive_root(config)) {
|
* root a real session would reject, so a client cannot set the wire dry_run
|
||||||
|
* bit to relax it. Dry-run only runs the existence/directory check (never
|
||||||
|
* --mkpath) so it creates nothing while still failing closed. A failure here
|
||||||
|
* aborts the connection cleanly before any file data is exchanged. */
|
||||||
|
bool root_ok = config->dry_run ? receive_root_exists(config) : ensure_receive_root(config);
|
||||||
|
if (!root_ok) {
|
||||||
char* escaped_root = output_escape(config->receive_root_directory, log_get_8_bit_output());
|
char* escaped_root = output_escape(config->receive_root_directory, log_get_8_bit_output());
|
||||||
log_message(LOG_LEVEL_ERROR, "destination root is not available: %s",
|
log_message(LOG_LEVEL_ERROR, "destination root is not available: %s",
|
||||||
escaped_root ? escaped_root : "<allocation failed>");
|
escaped_root ? escaped_root : "<allocation failed>");
|
||||||
@@ -767,8 +787,9 @@ void handler(int file_descriptor) {
|
|||||||
}
|
}
|
||||||
/* A --delay-updates transfer stages under a private 0700 directory inside
|
/* A --delay-updates transfer stages under a private 0700 directory inside
|
||||||
the receive root. Create it up front (wiping leftovers of any previously
|
the receive root. Create it up front (wiping leftovers of any previously
|
||||||
interrupted delayed transfer) so a fully-skipped run also starts clean. */
|
interrupted delayed transfer) so a fully-skipped run also starts clean.
|
||||||
if (config->delay_updates) {
|
A dry-run stages nothing, so the staging tree is never created. */
|
||||||
|
if (config->delay_updates && !config->dry_run) {
|
||||||
config->delay_context = delay_updates_context_create(config->receive_root_directory);
|
config->delay_context = delay_updates_context_create(config->receive_root_directory);
|
||||||
if (!config->delay_context || !delay_updates_prepare(config->delay_context)) {
|
if (!config->delay_context || !delay_updates_prepare(config->delay_context)) {
|
||||||
log_message(LOG_LEVEL_ERROR, "Failed to initialize --delay-updates staging area");
|
log_message(LOG_LEVEL_ERROR, "Failed to initialize --delay-updates staging area");
|
||||||
@@ -864,12 +885,13 @@ void handler(int file_descriptor) {
|
|||||||
thrd_join(receiver, &receiver_result);
|
thrd_join(receiver, &receiver_result);
|
||||||
thrd_join(writer, &writer_result);
|
thrd_join(writer, &writer_result);
|
||||||
bool transfer_ok = receiver_result == thrd_success && writer_result == thrd_success;
|
bool transfer_ok = receiver_result == thrd_success && writer_result == thrd_success;
|
||||||
if (transfer_ok) {
|
if (transfer_ok && !config->dry_run) {
|
||||||
/* Commit-style (late) deletion: receive_thread handed the keep-set
|
/* Commit-style (late) deletion: receive_thread handed the keep-set
|
||||||
manifest here instead of deleting while write_thread might still be
|
manifest here instead of deleting while write_thread might still be
|
||||||
draining, so by now every file is on disk and the whole transfer is
|
draining, so by now every file is on disk and the whole transfer is
|
||||||
known to have succeeded. Remove the extras before publishing a
|
known to have succeeded. Remove the extras before publishing a
|
||||||
--delay-updates run; the walker skips the staging directory. */
|
--delay-updates run; the walker skips the staging directory. A
|
||||||
|
server-contacting --dry-run deletes nothing (no manifest is sent). */
|
||||||
if (context->deferred_manifest) {
|
if (context->deferred_manifest) {
|
||||||
if (!manifest_delete_all(config, context->deferred_manifest)) {
|
if (!manifest_delete_all(config, context->deferred_manifest)) {
|
||||||
transfer_ok = false;
|
transfer_ok = false;
|
||||||
@@ -878,7 +900,7 @@ void handler(int file_descriptor) {
|
|||||||
context->deferred_manifest = NULL;
|
context->deferred_manifest = NULL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (transfer_ok) {
|
if (transfer_ok && !config->dry_run) {
|
||||||
/* --delay-updates: receive_thread has finished the whole protocol stream
|
/* --delay-updates: receive_thread has finished the whole protocol stream
|
||||||
(including manifest/delete handling) and write_thread has drained its
|
(including manifest/delete handling) and write_thread has drained its
|
||||||
queue, so every staged file is complete. Publish atomically before the
|
queue, so every staged file is complete. Publish atomically before the
|
||||||
|
|||||||
+7
-6
@@ -21,7 +21,6 @@ static void config_set_defaults(Config* config) {
|
|||||||
config->scanner_threads = 0;
|
config->scanner_threads = 0;
|
||||||
config->metadata_explicitly_disabled = false;
|
config->metadata_explicitly_disabled = false;
|
||||||
config->show_progress = false;
|
config->show_progress = false;
|
||||||
config->dry_run = false;
|
|
||||||
config->compression_threads = 0;
|
config->compression_threads = 0;
|
||||||
config->ssh_port = 22;
|
config->ssh_port = 22;
|
||||||
config->transport = TRANSPORT_TCP;
|
config->transport = TRANSPORT_TCP;
|
||||||
@@ -42,6 +41,8 @@ static void config_set_defaults(Config* config) {
|
|||||||
config->tls_ca = NULL;
|
config->tls_ca = NULL;
|
||||||
config->server_host = str_dup("127.0.0.1");
|
config->server_host = str_dup("127.0.0.1");
|
||||||
config->server_port = 8080;
|
config->server_port = 8080;
|
||||||
|
config->server_port_set = false;
|
||||||
|
config->server_host_set = false;
|
||||||
/* 0 means "--timeout not given": the transport keeps its own built-in 30 s
|
/* 0 means "--timeout not given": the transport keeps its own built-in 30 s
|
||||||
* socket timeout (tcp_set_timeouts ignores non-positive values) and the
|
* socket timeout (tcp_set_timeouts ignores non-positive values) and the
|
||||||
* protocol layer keeps its built-in 60 s per-message deadline. A positive
|
* protocol layer keeps its built-in 60 s per-message deadline. A positive
|
||||||
@@ -190,11 +191,11 @@ static bool validate_received_config(const Config* config) {
|
|||||||
valid_wire_bool(config->delay_updates) && valid_wire_bool(config->mkpath) &&
|
valid_wire_bool(config->delay_updates) && valid_wire_bool(config->mkpath) &&
|
||||||
valid_wire_bool(config->partial) && valid_wire_bool(config->delete_before) &&
|
valid_wire_bool(config->partial) && valid_wire_bool(config->delete_before) &&
|
||||||
valid_wire_bool(config->checksum) && valid_wire_bool(config->eight_bit_output) &&
|
valid_wire_bool(config->checksum) && valid_wire_bool(config->eight_bit_output) &&
|
||||||
checksum_algo_valid(config->checksum_algo) && identity_wire_valid(config) &&
|
valid_wire_bool(config->dry_run) && checksum_algo_valid(config->checksum_algo) &&
|
||||||
valid_wire_bool(config->preserve_atimes) && valid_wire_bool(config->preserve_crtimes) &&
|
identity_wire_valid(config) && valid_wire_bool(config->preserve_atimes) &&
|
||||||
valid_wire_bool(config->omit_dir_times) && valid_wire_bool(config->omit_link_times) &&
|
valid_wire_bool(config->preserve_crtimes) && valid_wire_bool(config->omit_dir_times) &&
|
||||||
valid_wire_bool(config->munge_links) && valid_wire_bool(config->keep_dirlinks) &&
|
valid_wire_bool(config->omit_link_times) && valid_wire_bool(config->munge_links) &&
|
||||||
valid_wire_bool(config->fake_super) &&
|
valid_wire_bool(config->keep_dirlinks) && valid_wire_bool(config->fake_super) &&
|
||||||
(!config->copy_as_set || (config->copy_as_uid >= 0 && config->copy_as_gid >= 0)) &&
|
(!config->copy_as_set || (config->copy_as_uid >= 0 && config->copy_as_gid >= 0)) &&
|
||||||
(!config->use_compression ||
|
(!config->use_compression ||
|
||||||
(config->compression_level >= 1 && config->compression_level <= 22)) &&
|
(config->compression_level >= 1 && config->compression_level <= 22)) &&
|
||||||
|
|||||||
+47
-14
@@ -109,6 +109,11 @@ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF
|
|||||||
* =========================================================================== */
|
* =========================================================================== */
|
||||||
#define CONFIG_WIRE_HEADER_FIELDS(X) X(version, char*, str_dup(PROTOCOL_VERSION), STR)
|
#define CONFIG_WIRE_HEADER_FIELDS(X) X(version, char*, str_dup(PROTOCOL_VERSION), STR)
|
||||||
|
|
||||||
|
/* dry_run (--dry-run) is CLIENT-INTENT that now CROSSES the wire (protocol
|
||||||
|
* 2.21.0): the receiver needs it to answer what WOULD transfer/skip without
|
||||||
|
* touching disk. The client-only launch behavior (no server contact for a
|
||||||
|
* local destination) is decided separately in client_send.c before the frame
|
||||||
|
* is ever sent. */
|
||||||
#define CONFIG_WIRE_CORE_FIELDS(X) \
|
#define CONFIG_WIRE_CORE_FIELDS(X) \
|
||||||
X(eight_bit_output, bool, false, BOOL_8BIT) \
|
X(eight_bit_output, bool, false, BOOL_8BIT) \
|
||||||
X(max_alloc, unsigned long long, DEFAULT_MAX_ALLOC, RAW_MAXALLOC) \
|
X(max_alloc, unsigned long long, DEFAULT_MAX_ALLOC, RAW_MAXALLOC) \
|
||||||
@@ -122,7 +127,8 @@ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF
|
|||||||
X(use_executability, bool, false, BOOL) \
|
X(use_executability, bool, false, BOOL) \
|
||||||
X(compression_level, int, 5, INT) \
|
X(compression_level, int, 5, INT) \
|
||||||
X(chunk_size, unsigned long long, DEFAULT_CHUNK_SIZE, RAW) \
|
X(chunk_size, unsigned long long, DEFAULT_CHUNK_SIZE, RAW) \
|
||||||
X(use_sendfile, bool, false, BOOL)
|
X(use_sendfile, bool, false, BOOL) \
|
||||||
|
X(dry_run, bool, false, BOOL)
|
||||||
|
|
||||||
#define CONFIG_WIRE_DELTA_FIELDS(X) \
|
#define CONFIG_WIRE_DELTA_FIELDS(X) \
|
||||||
X(use_delete, bool, false, BOOL) \
|
X(use_delete, bool, false, BOOL) \
|
||||||
@@ -261,7 +267,6 @@ typedef struct Config {
|
|||||||
int scanner_threads;
|
int scanner_threads;
|
||||||
bool metadata_explicitly_disabled;
|
bool metadata_explicitly_disabled;
|
||||||
bool show_progress;
|
bool show_progress;
|
||||||
bool dry_run;
|
|
||||||
int compression_threads;
|
int compression_threads;
|
||||||
int ssh_port;
|
int ssh_port;
|
||||||
TransportType transport;
|
TransportType transport;
|
||||||
@@ -281,6 +286,18 @@ typedef struct Config {
|
|||||||
bool use_tls;
|
bool use_tls;
|
||||||
char* server_host;
|
char* server_host;
|
||||||
int server_port;
|
int server_port;
|
||||||
|
/* True when --server-port/--port was explicitly given. CLIENT-ONLY (never
|
||||||
|
* serialized): --dry-run uses it to decide whether a real server handshake
|
||||||
|
* was requested, so a plain local destination (no explicit port) keeps the
|
||||||
|
* existing client-side dry-run behavior instead of dialing the default
|
||||||
|
* 127.0.0.1:8080. */
|
||||||
|
bool server_port_set;
|
||||||
|
/* True when --server-host was explicitly given. CLIENT-ONLY (never
|
||||||
|
* serialized), and distinct from the "127.0.0.1" default: --dry-run uses it
|
||||||
|
* to route an explicit remote target to the server so it reports receiver
|
||||||
|
* state exactly like a real run, instead of silently running the client-side
|
||||||
|
* manifest. */
|
||||||
|
bool server_host_set;
|
||||||
char* tls_cert;
|
char* tls_cert;
|
||||||
char* tls_key;
|
char* tls_key;
|
||||||
char* tls_ca;
|
char* tls_ca;
|
||||||
@@ -758,19 +775,35 @@ typedef struct Config {
|
|||||||
* mixed 2.19/2.20 deployment. The chunk codec, which already used the packed
|
* mixed 2.19/2.20 deployment. The chunk codec, which already used the packed
|
||||||
* metadata_to_buf()/metadata_from_buf() form, is unchanged.
|
* metadata_to_buf()/metadata_from_buf() form, is unchanged.
|
||||||
*
|
*
|
||||||
* Error-Detail Wave: 2.20.0 -> 2.21.0.
|
* Error-Detail + Server-contacting Dry-run Wave: 2.20.0 -> 2.21.0.
|
||||||
*
|
*
|
||||||
* WHY the bump, grounded in the wire: a server may now answer a rejected
|
* WHY the bump, grounded in the wire: this release combines two changes on the
|
||||||
* operation with STATUS_ERROR_DETAIL followed by a bounded (<=
|
* same lockstep version.
|
||||||
* MAX_ERROR_DETAIL_BYTES) length-prefixed string instead of a bare
|
*
|
||||||
* STATUS_ERROR (see protocol.h). The config-frame LAYOUT is unchanged, but the
|
* (1) Error detail: a server may now answer a rejected operation with
|
||||||
* FRAME STREAM gains a new framed body after a status, so a 2.20 peer that does
|
* STATUS_ERROR_DETAIL followed by a bounded (<= MAX_ERROR_DETAIL_BYTES)
|
||||||
* not consume it would desynchronize on the following exchange. The strict
|
* length-prefixed string instead of a bare STATUS_ERROR (see protocol.h). The
|
||||||
* same-version handshake (config_receive rejects a mismatched version before
|
* config-frame LAYOUT is unchanged, but the FRAME STREAM gains a new framed
|
||||||
* parsing anything else) is what keeps a 2.21 client and a 2.20 server from ever
|
* body after a status, so a 2.20 peer that does not consume it would
|
||||||
* reaching that state. receive_status() transparently maps STATUS_ERROR_DETAIL
|
* desynchronize on the following exchange. receive_status() transparently maps
|
||||||
* back to STATUS_ERROR for every existing call site and captures the reason into
|
* STATUS_ERROR_DETAIL back to STATUS_ERROR for every existing call site and
|
||||||
* a thread-local buffer consulted via protocol_last_error(). */
|
* captures the reason into a thread-local buffer consulted via
|
||||||
|
* protocol_last_error().
|
||||||
|
*
|
||||||
|
* (2) --dry-run: --dry-run now contacts the receiver and reports exactly what
|
||||||
|
* WOULD change. The binary config frame gains one serialized bool
|
||||||
|
* (Config->dry_run) appended to CONFIG_WIRE_CORE_FIELDS after use_sendfile, and
|
||||||
|
* the frame stream gains one terminal status (STATUS_DRY_RUN_TRANSFER) sent in
|
||||||
|
* reply to a per-file STATUS_CHECK when the file is not already up to date.
|
||||||
|
* The receiver performs the normal read-only incremental decision but no
|
||||||
|
* mutation; the sender then skips the data.
|
||||||
|
*
|
||||||
|
* Any config-frame layout or frame-sequence change must bump the protocol
|
||||||
|
* version: a 2.20 peer would desynchronize on the extra trailing byte, the
|
||||||
|
* unknown status, or the unconsumed detail body, and the strict same-version
|
||||||
|
* handshake (config_receive rejects a mismatched version before parsing
|
||||||
|
* anything else) is what keeps a 2.21 client and a 2.20 server from ever
|
||||||
|
* reaching that state. */
|
||||||
#define PROTOCOL_VERSION "2.21.0"
|
#define PROTOCOL_VERSION "2.21.0"
|
||||||
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
|
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
|
||||||
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
|
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
|
||||||
|
|||||||
@@ -569,6 +569,13 @@ static FileSaveResult file_save_write_device(const char* root_directory, const F
|
|||||||
|
|
||||||
FileSaveResult file_save_to_disk_full(const char* root_directory, const File* file,
|
FileSaveResult file_save_to_disk_full(const char* root_directory, const File* file,
|
||||||
const Config* config) {
|
const Config* config) {
|
||||||
|
/* Central no-mutation guard: a server-contacting --dry-run (or a local batch
|
||||||
|
apply that somehow carries dry_run) must never touch the destination, no
|
||||||
|
matter which caller reached this primitive. The per-caller guards remain,
|
||||||
|
but this is the last line of defense for every save path. Report SKIPPED
|
||||||
|
so a --remove-source-files sender correctly keeps its source. */
|
||||||
|
if (config && config->dry_run)
|
||||||
|
return FILE_SAVE_SKIPPED;
|
||||||
/* Backups are incompatible with ignore-existing: moving the entry first
|
/* Backups are incompatible with ignore-existing: moving the entry first
|
||||||
would make a concurrent no-replace commit overwrite its old name. */
|
would make a concurrent no-replace commit overwrite its old name. */
|
||||||
bool backup_enabled = config && config->backup && !config->ignore_existing;
|
bool backup_enabled = config && config->backup && !config->ignore_existing;
|
||||||
@@ -1651,12 +1658,15 @@ static File* receive_full_file(int fd, const Config* config, const char* path) {
|
|||||||
* receive_incremental_check() decomposition.
|
* receive_incremental_check() decomposition.
|
||||||
*
|
*
|
||||||
* The per-file STATUS_CHECK fast path is split into the small helpers below,
|
* The per-file STATUS_CHECK fast path is split into the small helpers below,
|
||||||
* called in order by a short linear orchestrator. Each helper owns one
|
* called in order by a short linear orchestrator (receive_incremental_check_ex).
|
||||||
* decision: request validation, secure destination open, metadata-only skip,
|
* Each helper owns one decision: request validation, secure destination open,
|
||||||
|
* metadata-only skip, server-contacting --dry-run no-mutation short-circuit,
|
||||||
* alternate-basis match, --append tail resume, block delta, --fuzzy basis, and
|
* alternate-basis match, --append tail resume, block delta, --fuzzy basis, and
|
||||||
* the final "send the whole file" fallback. Every protocol send/receive and
|
* the final "send the whole file" fallback. Every protocol send/receive and
|
||||||
* every resource cleanup is preserved exactly; the wire is byte-for-byte
|
* every resource cleanup is preserved exactly; the non-dry-run wire is
|
||||||
* unchanged.
|
* byte-for-byte unchanged. receive_incremental_check_ex additionally exposes a
|
||||||
|
* `would_transfer` out-param for the dry-run caller; the 3-arg
|
||||||
|
* receive_incremental_check wrapper passes NULL.
|
||||||
* ------------------------------------------------------------------------- */
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
/* Owned state threaded through the helpers below. */
|
/* Owned state threaded through the helpers below. */
|
||||||
@@ -1681,6 +1691,7 @@ typedef enum {
|
|||||||
INCREMENTAL_CONTINUE, /* proceed to the next helper */
|
INCREMENTAL_CONTINUE, /* proceed to the next helper */
|
||||||
INCREMENTAL_ERROR, /* protocol/validation failure: return NULL */
|
INCREMENTAL_ERROR, /* protocol/validation failure: return NULL */
|
||||||
INCREMENTAL_SKIP, /* up to date: *skipped = true, return NULL */
|
INCREMENTAL_SKIP, /* up to date: *skipped = true, return NULL */
|
||||||
|
INCREMENTAL_DRY_RUN, /* --dry-run resolved: flags set, return NULL */
|
||||||
INCREMENTAL_FILE, /* a File* was produced (out_file) */
|
INCREMENTAL_FILE, /* a File* was produced (out_file) */
|
||||||
} IncrementalCheckOutcome;
|
} IncrementalCheckOutcome;
|
||||||
|
|
||||||
@@ -1845,6 +1856,42 @@ static IncrementalCheckOutcome incremental_check_quick_skip(IncrementalCheckStat
|
|||||||
return INCREMENTAL_CONTINUE;
|
return INCREMENTAL_CONTINUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Server-contacting --dry-run no-mutation short-circuit. Runs after the
|
||||||
|
quick-skip decision and before any path that could touch the destination.
|
||||||
|
When dry_run is set and the file is not already up to date the receiver must
|
||||||
|
materialize nothing (no basis link/copy, no append/delta/full transfer) and
|
||||||
|
the sender must send no data, so answer STATUS_DRY_RUN_TRANSFER and stop.
|
||||||
|
The one exception is a --compare-dest exact hit with no destination copy: a
|
||||||
|
real run would suppress the data without changing the destination, so it
|
||||||
|
reports as a skip (STATUS_OK) exactly as the full basis path below would.
|
||||||
|
Everything read here (destination file, basis candidates) is read-only. */
|
||||||
|
static IncrementalCheckOutcome incremental_check_dry_run_shortcut(IncrementalCheckState* state,
|
||||||
|
bool* skipped,
|
||||||
|
bool* would_transfer) {
|
||||||
|
const Config* config = state->config;
|
||||||
|
if (!config->dry_run)
|
||||||
|
return INCREMENTAL_CONTINUE;
|
||||||
|
|
||||||
|
bool skip_via_compare = false;
|
||||||
|
if (config_has_basis(config) && !config->ignore_times) {
|
||||||
|
BasisMatch basis;
|
||||||
|
basis_match_find(config, state->check_path, state->check_size, (time_t)state->check_mtime,
|
||||||
|
(long)state->check_mtime_nsec, state->check_digest, state->check_digest_len,
|
||||||
|
false, &basis);
|
||||||
|
if (basis.hit && basis.type == BASIS_DEST_COMPARE && !state->has_old_file)
|
||||||
|
skip_via_compare = true;
|
||||||
|
basis_match_free(&basis);
|
||||||
|
}
|
||||||
|
Status reply = skip_via_compare ? STATUS_OK : STATUS_DRY_RUN_TRANSFER;
|
||||||
|
if (!send_status(state->fd, reply))
|
||||||
|
return INCREMENTAL_ERROR;
|
||||||
|
if (skip_via_compare)
|
||||||
|
*skipped = true;
|
||||||
|
else if (would_transfer)
|
||||||
|
*would_transfer = true;
|
||||||
|
return INCREMENTAL_DRY_RUN;
|
||||||
|
}
|
||||||
|
|
||||||
/* Alternate basis directories (--compare-dest/--copy-dest/--link-dest): a hit
|
/* Alternate basis directories (--compare-dest/--copy-dest/--link-dest): a hit
|
||||||
either suppresses the transfer (compare-dest) or materializes the file from
|
either suppresses the transfer (compare-dest) or materializes the file from
|
||||||
the basis without a data frame. */
|
the basis without a data frame. */
|
||||||
@@ -2133,7 +2180,14 @@ static File* incremental_check_receive_full(IncrementalCheckState* state) {
|
|||||||
return receive_full_file(state->fd, state->config, state->check_path);
|
return receive_full_file(state->fd, state->config, state->check_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
|
/* Core implementation. `would_transfer` (may be NULL) is set true only on the
|
||||||
|
* server-contacting --dry-run path, when the file is not up to date and the
|
||||||
|
* receiver answered STATUS_DRY_RUN_TRANSFER; the caller then knows no File is
|
||||||
|
* returned and nothing was stored. */
|
||||||
|
File* receive_incremental_check_ex(int fd, const Config* config, bool* skipped,
|
||||||
|
bool* would_transfer) {
|
||||||
|
if (would_transfer)
|
||||||
|
*would_transfer = false;
|
||||||
if (!config || !skipped) {
|
if (!config || !skipped) {
|
||||||
send_status(fd, STATUS_ERROR);
|
send_status(fd, STATUS_ERROR);
|
||||||
return NULL;
|
return NULL;
|
||||||
@@ -2163,6 +2217,13 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
|
|||||||
goto done;
|
goto done;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dry-run resolves here (no mutation) or falls through to the normal path. */
|
||||||
|
outcome = incremental_check_dry_run_shortcut(&state, skipped, would_transfer);
|
||||||
|
if (outcome == INCREMENTAL_ERROR)
|
||||||
|
goto done;
|
||||||
|
if (outcome != INCREMENTAL_CONTINUE)
|
||||||
|
goto done;
|
||||||
|
|
||||||
outcome = incremental_check_try_basis(&state, &result);
|
outcome = incremental_check_try_basis(&state, &result);
|
||||||
if (outcome == INCREMENTAL_ERROR)
|
if (outcome == INCREMENTAL_ERROR)
|
||||||
goto done;
|
goto done;
|
||||||
@@ -2198,6 +2259,10 @@ done:
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
|
||||||
|
return receive_incremental_check_ex(fd, config, skipped, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
File* file_receive(const Config* config, int file_descriptor) {
|
File* file_receive(const Config* config, int file_descriptor) {
|
||||||
char* path = receive_wire_str(file_descriptor);
|
char* path = receive_wire_str(file_descriptor);
|
||||||
if (path == NULL)
|
if (path == NULL)
|
||||||
@@ -2909,6 +2974,11 @@ bool manifest_delete_missing_args(const Config* config, DeleteManifest* manifest
|
|||||||
bool manifest_delete_all(const Config* config, DeleteManifest* manifest) {
|
bool manifest_delete_all(const Config* config, DeleteManifest* manifest) {
|
||||||
if (!config || !manifest)
|
if (!config || !manifest)
|
||||||
return false;
|
return false;
|
||||||
|
/* Central no-mutation guard: a dry-run never deletes. No manifest is sent on
|
||||||
|
the dry-run path, but a hostile/buggy peer could; treat it as a no-op so
|
||||||
|
the receiver can never remove anything. */
|
||||||
|
if (config->dry_run)
|
||||||
|
return true;
|
||||||
if (config->delete_missing_args && !manifest_delete_missing_args(config, manifest))
|
if (config->delete_missing_args && !manifest_delete_missing_args(config, manifest))
|
||||||
return false;
|
return false;
|
||||||
if (config->use_delete && !manifest_delete_extras(config, manifest))
|
if (config->use_delete && !manifest_delete_extras(config, manifest))
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ File* file_receive_symlink(int file_descriptor, const Config* config);
|
|||||||
File* file_receive_special(int file_descriptor);
|
File* file_receive_special(int file_descriptor);
|
||||||
bool file_special_rdev_valid(int32_t major, int32_t minor, mode_t mode);
|
bool file_special_rdev_valid(int32_t major, int32_t minor, mode_t mode);
|
||||||
File* receive_incremental_check(int fd, const Config* config, bool* skipped);
|
File* receive_incremental_check(int fd, const Config* config, bool* skipped);
|
||||||
|
/* Extended variant used by the receiver. `would_transfer` (may be NULL) is set
|
||||||
|
* true only on the server-contacting --dry-run path when the file is not up to
|
||||||
|
* date: the receiver has already sent STATUS_DRY_RUN_TRANSFER and returns NULL
|
||||||
|
* without storing anything. On that path `*skipped` is true for an up-to-date
|
||||||
|
* (STATUS_OK) file and both flags are false for a genuine error. */
|
||||||
|
File* receive_incremental_check_ex(int fd, const Config* config, bool* skipped,
|
||||||
|
bool* would_transfer);
|
||||||
|
|
||||||
/* P7 Wave D directory-time accumulator. The receiver collects the metadata of
|
/* P7 Wave D directory-time accumulator. The receiver collects the metadata of
|
||||||
* every directory it creates/receives (STATUS_MKDIR with metadata and/or the
|
* every directory it creates/receives (STATUS_MKDIR with metadata and/or the
|
||||||
|
|||||||
@@ -466,6 +466,8 @@ static const char* status_to_string(Status status) {
|
|||||||
return "AUTH_FAILED";
|
return "AUTH_FAILED";
|
||||||
case STATUS_ERROR_DETAIL:
|
case STATUS_ERROR_DETAIL:
|
||||||
return "ERROR_DETAIL";
|
return "ERROR_DETAIL";
|
||||||
|
case STATUS_DRY_RUN_TRANSFER:
|
||||||
|
return "DRY_RUN_TRANSFER";
|
||||||
default:
|
default:
|
||||||
return "UNKNOWN";
|
return "UNKNOWN";
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -145,8 +145,17 @@ enum NET_STATUS {
|
|||||||
* of a bare STATUS_ERROR. receive_status() consumes the string and maps the
|
* of a bare STATUS_ERROR. receive_status() consumes the string and maps the
|
||||||
* status back to STATUS_ERROR, so every pre-2.21 call site keeps working;
|
* status back to STATUS_ERROR, so every pre-2.21 call site keeps working;
|
||||||
* callers that want the human-readable reason consult protocol_last_error().
|
* callers that want the human-readable reason consult protocol_last_error().
|
||||||
* Appended last so the existing wire values never move. */
|
* Appended immediately after STATUS_AUTH_FAILED so the existing wire values
|
||||||
STATUS_ERROR_DETAIL
|
* never move. */
|
||||||
|
STATUS_ERROR_DETAIL,
|
||||||
|
/* Server-contacting --dry-run (protocol 2.21.0). Sent by the receiver in
|
||||||
|
* response to a per-file STATUS_CHECK when the wire config carries
|
||||||
|
* dry_run=true and the file is NOT already up to date: it tells the sender
|
||||||
|
* the file WOULD be transferred, and the sender must NOT transmit any data
|
||||||
|
* (the receiver reads none in dry-run). STATUS_OK keeps its meaning in this
|
||||||
|
* path ("already up to date / nothing to do"). Appended after
|
||||||
|
* STATUS_ERROR_DETAIL so no existing status is renumbered. */
|
||||||
|
STATUS_DRY_RUN_TRANSFER
|
||||||
};
|
};
|
||||||
|
|
||||||
void io_set_fds(int read_fd, int write_fd);
|
void io_set_fds(int read_fd, int write_fd);
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ from common import (
|
|||||||
_find_free_port,
|
_find_free_port,
|
||||||
_wait_for_port,
|
_wait_for_port,
|
||||||
)
|
)
|
||||||
|
# The dry-run no-mutation contract is asserted with the same structural snapshot
|
||||||
|
# (mode/inode/mtime/xattr/content) the feature suite uses.
|
||||||
|
from test_features import _snapshot_tree
|
||||||
|
|
||||||
SOURCE_DIR = os.path.join(TEST_DATA_DIR, "daemon_source")
|
SOURCE_DIR = os.path.join(TEST_DATA_DIR, "daemon_source")
|
||||||
MODULE_ROOT = os.path.join(TEST_DATA_DIR, "daemon_modules")
|
MODULE_ROOT = os.path.join(TEST_DATA_DIR, "daemon_modules")
|
||||||
@@ -355,6 +358,33 @@ class TestDaemonRejection:
|
|||||||
assert result.returncode != 0
|
assert result.returncode != 0
|
||||||
assert self._tree_files() == before, "read-only rejection wrote under the module root"
|
assert self._tree_files() == before, "read-only rejection wrote under the module root"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_read_only_module_allows_dry_run(self, daemon):
|
||||||
|
"""A server-contacting --dry-run IS a read-only wire operation, so a
|
||||||
|
`read only = yes` module is the safest dry-run target and must accept it
|
||||||
|
while writing nothing."""
|
||||||
|
result, _ = run_client(SOURCE_DIR, "127.0.0.1::readonly", flags=["--dry-run"],
|
||||||
|
port=daemon.port)
|
||||||
|
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||||
|
assert "Dry run:" in result.stdout, result.stdout[:200]
|
||||||
|
assert _tree_file_count(READONLY_MODULE) == 0, "read-only dry-run wrote a file"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_module_dry_run_mutates_nothing(self, daemon):
|
||||||
|
"""A daemon-module dry-run reports would-transfer entries but leaves the
|
||||||
|
module tree structurally identical (mode/inode/mtime/xattr/content)."""
|
||||||
|
result = _push("127.0.0.1::files", daemon.port)
|
||||||
|
assert result.returncode == 0, result.stderr or result.stdout
|
||||||
|
before = _snapshot_tree(FILES_MODULE)
|
||||||
|
# --ignore-times forces every regular file to be reported as
|
||||||
|
# would-transfer, so the dry-run exercises the receiver decision rather
|
||||||
|
# than an all-skip shortcut -- while still mutating nothing.
|
||||||
|
result, _ = run_client(SOURCE_DIR, "127.0.0.1::files",
|
||||||
|
flags=["--dry-run", "--ignore-times"], port=daemon.port)
|
||||||
|
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||||
|
assert "Dry run:" in result.stdout, result.stdout[:200]
|
||||||
|
assert _snapshot_tree(FILES_MODULE) == before, "daemon dry-run mutated the module root"
|
||||||
|
|
||||||
def test_unknown_module_rejected(self, daemon):
|
def test_unknown_module_rejected(self, daemon):
|
||||||
result = _push("127.0.0.1::no-such-module", daemon.port)
|
result = _push("127.0.0.1::no-such-module", daemon.port)
|
||||||
assert result.returncode != 0
|
assert result.returncode != 0
|
||||||
|
|||||||
@@ -370,6 +370,353 @@ class TestDryRun:
|
|||||||
assert not mismatches, f"Mismatch: {mismatches}"
|
assert not mismatches, f"Mismatch: {mismatches}"
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_xattrs(path):
|
||||||
|
"""Return a stable, comparable tuple of (name, value) xattr pairs.
|
||||||
|
|
||||||
|
Returns None when the platform/filesystem does not expose xattrs so both
|
||||||
|
snapshots agree on "unavailable" instead of one being treated as changed."""
|
||||||
|
try:
|
||||||
|
names = os.listxattr(path, follow_symlinks=False)
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
return None
|
||||||
|
if not names:
|
||||||
|
return ()
|
||||||
|
pairs = []
|
||||||
|
for name in sorted(names):
|
||||||
|
try:
|
||||||
|
value = os.getxattr(path, name, follow_symlinks=False)
|
||||||
|
except OSError:
|
||||||
|
value = None
|
||||||
|
pairs.append((name, value))
|
||||||
|
return tuple(pairs)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_tree(root):
|
||||||
|
"""Return a structural snapshot of a directory tree.
|
||||||
|
|
||||||
|
Every entry (including directories) is recorded as
|
||||||
|
(inode, mtime_ns, mode, xattrs, kind-specific payload) so a dry-run that
|
||||||
|
touched a mode, inode, mtime, xattr, or content is observable. Regular
|
||||||
|
files carry their size+bytes, symlinks their target, and special entries
|
||||||
|
(FIFO/socket/device) their size only -- opening a special file could block.
|
||||||
|
Returns an empty dict for a missing root so "nothing was created" is also
|
||||||
|
observable."""
|
||||||
|
snapshot = {}
|
||||||
|
if not os.path.exists(root):
|
||||||
|
return snapshot
|
||||||
|
for dirpath, dirnames, filenames in os.walk(root):
|
||||||
|
for name in list(dirnames) + filenames:
|
||||||
|
path = os.path.join(dirpath, name)
|
||||||
|
rel = os.path.relpath(path, root)
|
||||||
|
st = os.lstat(path)
|
||||||
|
entry = [st.st_ino, st.st_mtime_ns, stat.S_IMODE(st.st_mode), _snapshot_xattrs(path)]
|
||||||
|
if stat.S_ISLNK(st.st_mode):
|
||||||
|
entry.append(("symlink", os.readlink(path)))
|
||||||
|
elif stat.S_ISREG(st.st_mode):
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
data = fh.read()
|
||||||
|
entry += [st.st_size, data]
|
||||||
|
else:
|
||||||
|
entry.append(st.st_size)
|
||||||
|
snapshot[rel] = tuple(entry)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteDryRun:
|
||||||
|
"""Server-contacting --dry-run (protocol 2.21.0): contacts the receiver,
|
||||||
|
reports what WOULD transfer/skip based on receiver state, and mutates
|
||||||
|
nothing on either side."""
|
||||||
|
|
||||||
|
def _seed(self, source):
|
||||||
|
clean_dir(source)
|
||||||
|
os.makedirs(os.path.join(source, "nested"), exist_ok=True)
|
||||||
|
with open(os.path.join(source, "keep.txt"), "wb") as f:
|
||||||
|
f.write(b"unchanged content\n")
|
||||||
|
with open(os.path.join(source, "changed.txt"), "wb") as f:
|
||||||
|
f.write(b"original content\n")
|
||||||
|
with open(os.path.join(source, "nested", "deep.txt"), "wb") as f:
|
||||||
|
f.write(b"deep file\n")
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_remote_dry_run_reports_changes_and_mutates_nothing(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "remote_dry_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "remote_dry_dst")
|
||||||
|
self._seed(source)
|
||||||
|
clean_dir(dest)
|
||||||
|
|
||||||
|
# Populate the destination with a real transfer, then make exactly one
|
||||||
|
# file differ (content+size) and add a brand-new file.
|
||||||
|
result, _ = run_client(source, dest, port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"seed transfer failed: {result.stderr[:200]}"
|
||||||
|
received = get_dest_received_dir(dest, source)
|
||||||
|
|
||||||
|
with open(os.path.join(source, "changed.txt"), "wb") as f:
|
||||||
|
f.write(b"a much longer replacement payload\n")
|
||||||
|
with open(os.path.join(source, "added.txt"), "wb") as f:
|
||||||
|
f.write(b"newly added\n")
|
||||||
|
|
||||||
|
before = _snapshot_tree(received)
|
||||||
|
# --checksum makes the up-to-date decision content-based (the seed
|
||||||
|
# transfer did not preserve mtimes), so keep.txt/deep.txt report skip.
|
||||||
|
result, _ = run_client(source, dest, flags=["--dry-run", "--checksum"],
|
||||||
|
port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"remote dry-run failed: {result.stderr[:300]}"
|
||||||
|
assert "Dry run:" in result.stdout, result.stdout[:200]
|
||||||
|
assert "changed.txt" in result.stdout, result.stdout
|
||||||
|
assert "added.txt" in result.stdout, result.stdout
|
||||||
|
assert "keep.txt" not in result.stdout, (
|
||||||
|
f"up-to-date file must not be reported as would-transfer: {result.stdout}"
|
||||||
|
)
|
||||||
|
assert "deep.txt" not in result.stdout, result.stdout
|
||||||
|
assert _snapshot_tree(received) == before, "remote dry-run mutated the destination"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_remote_dry_run_into_empty_dest_creates_nothing(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "remote_dry_empty_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "remote_dry_empty_dst")
|
||||||
|
self._seed(source)
|
||||||
|
clean_dir(dest)
|
||||||
|
received = get_dest_received_dir(dest, source)
|
||||||
|
assert not os.path.exists(received)
|
||||||
|
|
||||||
|
result, _ = run_client(source, dest, flags=["--dry-run"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"exit {result.returncode}: {result.stderr[:300]}"
|
||||||
|
assert "keep.txt" in result.stdout
|
||||||
|
assert "changed.txt" in result.stdout
|
||||||
|
assert "deep.txt" in result.stdout
|
||||||
|
# Nowhere may the receiver have created the destination mirror.
|
||||||
|
assert not os.path.exists(received), "dry-run created directories on the receiver"
|
||||||
|
assert _snapshot_tree(received) == {}
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_remote_dry_run_mkpath_does_not_create_root(self, shared_server):
|
||||||
|
"""A wire dry_run cannot make --mkpath create anything, and it cannot
|
||||||
|
relax the precondition either: a nonexistent root is rejected (a real
|
||||||
|
run without the created root is impossible in dry-run) while nothing is
|
||||||
|
created."""
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "remote_dry_mk_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "remote_dry_mk_dst")
|
||||||
|
self._seed(source)
|
||||||
|
shutil.rmtree(dest, ignore_errors=True)
|
||||||
|
assert not os.path.exists(dest)
|
||||||
|
|
||||||
|
result, _ = run_client(source, dest, flags=["--dry-run", "--mkpath"],
|
||||||
|
port=shared_server.port)
|
||||||
|
assert result.returncode != 0, "dry-run --mkpath accepted a nonexistent receive root"
|
||||||
|
assert not os.path.exists(dest), "dry-run --mkpath created the destination root"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_remote_dry_run_with_delete_does_not_delete(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "remote_dry_del_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "remote_dry_del_dst")
|
||||||
|
self._seed(source)
|
||||||
|
clean_dir(dest)
|
||||||
|
result, _ = run_client(source, dest, port=shared_server.port)
|
||||||
|
assert result.returncode == 0, result.stderr[:200]
|
||||||
|
received = get_dest_received_dir(dest, source)
|
||||||
|
extra = os.path.join(received, "extra.txt")
|
||||||
|
with open(extra, "wb") as f:
|
||||||
|
f.write(b"must survive a dry-run delete\n")
|
||||||
|
before = _snapshot_tree(received)
|
||||||
|
|
||||||
|
for flags in (["--dry-run", "--delete"], ["--dry-run", "--delete-after"]):
|
||||||
|
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"{flags}: {result.stderr[:300]}"
|
||||||
|
assert os.path.exists(extra), f"{flags} deleted an extra in dry-run"
|
||||||
|
assert _snapshot_tree(received) == before, f"{flags} mutated the destination"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_remote_dry_run_quiet_is_silent(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "remote_dry_quiet_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "remote_dry_quiet_dst")
|
||||||
|
self._seed(source)
|
||||||
|
clean_dir(dest)
|
||||||
|
result, _ = run_client(source, dest, flags=["-q", "--dry-run"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, result.stderr[:300]
|
||||||
|
assert result.stdout == ""
|
||||||
|
assert result.stderr == ""
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_remote_dry_run_threaded_routes_to_server(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "remote_dry_mt_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "remote_dry_mt_dst")
|
||||||
|
self._seed(source)
|
||||||
|
clean_dir(dest)
|
||||||
|
result, _ = run_client(source, dest, flags=["--dry-run", "--threads"],
|
||||||
|
port=shared_server.port)
|
||||||
|
assert result.returncode == 0, result.stderr[:300]
|
||||||
|
assert "changed.txt" in result.stdout
|
||||||
|
assert _snapshot_tree(get_dest_received_dir(dest, source)) == {}
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_normal_transfer_unaffected_by_dry_run(self, shared_server):
|
||||||
|
"""A real transfer after dry-run still installs the changes."""
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "remote_dry_normal_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "remote_dry_normal_dst")
|
||||||
|
self._seed(source)
|
||||||
|
clean_dir(dest)
|
||||||
|
run_client(source, dest, port=shared_server.port)
|
||||||
|
received = get_dest_received_dir(dest, source)
|
||||||
|
with open(os.path.join(source, "changed.txt"), "wb") as f:
|
||||||
|
f.write(b"updated payload for the real transfer\n")
|
||||||
|
run_client(source, dest, flags=["--dry-run"], port=shared_server.port)
|
||||||
|
|
||||||
|
result, _ = run_client(source, dest, port=shared_server.port)
|
||||||
|
assert result.returncode == 0, result.stderr[:200]
|
||||||
|
with open(os.path.join(received, "changed.txt"), "rb") as f:
|
||||||
|
assert f.read() == b"updated payload for the real transfer\n"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_remote_dry_run_delay_updates_mutates_nothing(self, shared_server):
|
||||||
|
"""--delay-updates stages under the receive root; a dry-run must neither
|
||||||
|
create that staging tree nor publish anything (mode/inode/mtime intact)."""
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "remote_dry_delay_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "remote_dry_delay_dst")
|
||||||
|
self._seed(source)
|
||||||
|
clean_dir(dest)
|
||||||
|
result, _ = run_client(source, dest, flags=["--delay-updates"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, result.stderr[:200]
|
||||||
|
|
||||||
|
with open(os.path.join(source, "changed.txt"), "wb") as f:
|
||||||
|
f.write(b"changed for delay-updates dry-run\n")
|
||||||
|
before = _snapshot_tree(dest)
|
||||||
|
result, _ = run_client(source, dest, flags=["--dry-run", "--delay-updates"],
|
||||||
|
port=shared_server.port)
|
||||||
|
assert result.returncode == 0, result.stderr[:300]
|
||||||
|
assert "changed.txt" in result.stdout, result.stdout
|
||||||
|
assert _snapshot_tree(dest) == before, "delay-updates dry-run mutated the destination"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_remote_dry_run_backup_mutates_nothing(self, shared_server):
|
||||||
|
"""--backup would rename the old file aside; a dry-run must not."""
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "remote_dry_backup_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "remote_dry_backup_dst")
|
||||||
|
self._seed(source)
|
||||||
|
clean_dir(dest)
|
||||||
|
result, _ = run_client(source, dest, port=shared_server.port)
|
||||||
|
assert result.returncode == 0, result.stderr[:200]
|
||||||
|
|
||||||
|
with open(os.path.join(source, "changed.txt"), "wb") as f:
|
||||||
|
f.write(b"changed for backup dry-run\n")
|
||||||
|
before = _snapshot_tree(dest)
|
||||||
|
result, _ = run_client(source, dest, flags=["--dry-run", "--backup"],
|
||||||
|
port=shared_server.port)
|
||||||
|
assert result.returncode == 0, result.stderr[:300]
|
||||||
|
assert "changed.txt" in result.stdout, result.stdout
|
||||||
|
assert _snapshot_tree(dest) == before, "--backup dry-run mutated the destination"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_remote_dry_run_symlink_mutates_nothing(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "remote_dry_symlink_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "remote_dry_symlink_dst")
|
||||||
|
self._seed(source)
|
||||||
|
os.symlink("changed.txt", os.path.join(source, "link"))
|
||||||
|
clean_dir(dest)
|
||||||
|
result, _ = run_client(source, dest, flags=["-a"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, result.stderr[:200]
|
||||||
|
received = get_dest_received_dir(dest, source)
|
||||||
|
assert os.path.islink(os.path.join(received, "link"))
|
||||||
|
|
||||||
|
# Re-point the source link so the entry is genuinely stale, then prove a
|
||||||
|
# dry-run leaves the destination link target, inode, and mtime untouched.
|
||||||
|
os.unlink(os.path.join(source, "link"))
|
||||||
|
os.symlink("keep.txt", os.path.join(source, "link"))
|
||||||
|
before = _snapshot_tree(dest)
|
||||||
|
result, _ = run_client(source, dest, flags=["-a", "--dry-run"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, result.stderr[:300]
|
||||||
|
assert _snapshot_tree(dest) == before, "symlink dry-run mutated the destination"
|
||||||
|
assert os.readlink(os.path.join(received, "link")) == "changed.txt"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_remote_dry_run_hardlink_mutates_nothing(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "remote_dry_hardlink_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "remote_dry_hardlink_dst")
|
||||||
|
clean_dir(source)
|
||||||
|
clean_dir(dest)
|
||||||
|
with open(os.path.join(source, "h1.txt"), "wb") as f:
|
||||||
|
f.write(b"hardlinked payload\n")
|
||||||
|
os.link(os.path.join(source, "h1.txt"), os.path.join(source, "h2.txt"))
|
||||||
|
result, _ = run_client(source, dest, flags=["-H"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, result.stderr[:200]
|
||||||
|
received = get_dest_received_dir(dest, source)
|
||||||
|
assert os.stat(os.path.join(received, "h1.txt")).st_ino == \
|
||||||
|
os.stat(os.path.join(received, "h2.txt")).st_ino
|
||||||
|
|
||||||
|
# Change the shared inode; both names are now stale in the destination.
|
||||||
|
with open(os.path.join(source, "h1.txt"), "wb") as f:
|
||||||
|
f.write(b"changed hardlinked payload\n")
|
||||||
|
before = _snapshot_tree(dest)
|
||||||
|
result, _ = run_client(source, dest, flags=["-H", "--dry-run"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, result.stderr[:300]
|
||||||
|
assert _snapshot_tree(dest) == before, "hardlink dry-run mutated the destination"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_remote_dry_run_fifo_special_mutates_nothing(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "remote_dry_fifo_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "remote_dry_fifo_dst")
|
||||||
|
clean_dir(source)
|
||||||
|
clean_dir(dest)
|
||||||
|
with open(os.path.join(source, "plain.txt"), "wb") as f:
|
||||||
|
f.write(b"plain\n")
|
||||||
|
os.mkfifo(os.path.join(source, "existing.fifo"))
|
||||||
|
result, _ = run_client(source, dest, flags=["--specials"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, result.stderr[:200]
|
||||||
|
received = get_dest_received_dir(dest, source)
|
||||||
|
assert stat.S_ISFIFO(os.lstat(os.path.join(received, "existing.fifo")).st_mode)
|
||||||
|
|
||||||
|
os.mkfifo(os.path.join(source, "new.fifo"))
|
||||||
|
before = _snapshot_tree(dest)
|
||||||
|
result, _ = run_client(source, dest, flags=["--specials", "--dry-run"],
|
||||||
|
port=shared_server.port)
|
||||||
|
assert result.returncode == 0, result.stderr[:300]
|
||||||
|
assert not os.path.exists(os.path.join(received, "new.fifo")), \
|
||||||
|
"dry-run created a FIFO on the receiver"
|
||||||
|
assert _snapshot_tree(dest) == before, "special-node dry-run mutated the destination"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_read_batch_with_dry_run_is_refused(self, shared_server):
|
||||||
|
"""A dry-run of a local batch apply is meaningless (and must not become a
|
||||||
|
mutation escape hatch): the CLI rejects the combination up front."""
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "remote_dry_batch_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "remote_dry_batch_dst")
|
||||||
|
self._seed(source)
|
||||||
|
clean_dir(dest)
|
||||||
|
result, _ = run_client(source, dest, flags=["--read-batch=/nonexistent.batch", "--dry-run"],
|
||||||
|
port=shared_server.port)
|
||||||
|
assert result.returncode != 0, "read-batch + dry-run was accepted"
|
||||||
|
combined = (result.stderr or "") + (result.stdout or "")
|
||||||
|
assert "cannot be combined" in combined or "--dry-run" in combined, combined[:300]
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_remote_dry_run_bad_root_fails_like_real_run(self, shared_server):
|
||||||
|
"""A wire dry_run must not relax the destination-root precondition: a
|
||||||
|
missing or non-directory root that fails a real run fails a dry-run too,
|
||||||
|
and the dry-run must not create/replace anything."""
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "remote_dry_badroot_src")
|
||||||
|
self._seed(source)
|
||||||
|
|
||||||
|
missing = os.path.join(TEST_DATA_DIR, "remote_dry_badroot_missing")
|
||||||
|
shutil.rmtree(missing, ignore_errors=True)
|
||||||
|
real, _ = run_client(source, missing, port=shared_server.port)
|
||||||
|
assert real.returncode != 0, "real run accepted a missing receive root"
|
||||||
|
assert not os.path.exists(missing), "real run created the missing root"
|
||||||
|
dry, _ = run_client(source, missing, flags=["--dry-run"], port=shared_server.port)
|
||||||
|
assert dry.returncode != 0, "dry-run accepted a missing receive root a real run rejects"
|
||||||
|
assert not os.path.exists(missing), "dry-run created the missing receive root"
|
||||||
|
|
||||||
|
fileroot = os.path.join(TEST_DATA_DIR, "remote_dry_badroot_file")
|
||||||
|
shutil.rmtree(fileroot, ignore_errors=True)
|
||||||
|
with open(fileroot, "wb") as f:
|
||||||
|
f.write(b"i am a regular file, not a directory\n")
|
||||||
|
real, _ = run_client(source, fileroot, port=shared_server.port)
|
||||||
|
assert real.returncode != 0, "real run accepted a regular-file receive root"
|
||||||
|
dry, _ = run_client(source, fileroot, flags=["--dry-run"], port=shared_server.port)
|
||||||
|
assert dry.returncode != 0, "dry-run accepted a regular-file receive root a real run rejects"
|
||||||
|
with open(fileroot, "rb") as f:
|
||||||
|
assert f.read() == b"i am a regular file, not a directory\n", \
|
||||||
|
"dry-run clobbered a regular-file receive root"
|
||||||
|
|
||||||
|
|
||||||
class TestRemoveSourceFiles:
|
class TestRemoveSourceFiles:
|
||||||
def test_removes_only_transferred_regular_files(self, shared_server):
|
def test_removes_only_transferred_regular_files(self, shared_server):
|
||||||
source = os.path.join(TEST_DATA_DIR, "remove_source")
|
source = os.path.join(TEST_DATA_DIR, "remove_source")
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class TestProtocol:
|
|||||||
shutil.rmtree(dest, ignore_errors=True)
|
shutil.rmtree(dest, ignore_errors=True)
|
||||||
os.makedirs(dest)
|
os.makedirs(dest)
|
||||||
_seed_protocol_source(source)
|
_seed_protocol_source(source)
|
||||||
for bad in ("2.19.0", "2.18.0", "2.17.0", "2.15.0", "2.16.0", "216", "31"):
|
for bad in ("2.20.0", "2.19.0", "2.18.0", "2.17.0", "2.15.0", "2.16.0", "216", "31"):
|
||||||
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
|
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
|
||||||
port=shared_server.port)
|
port=shared_server.port)
|
||||||
assert result.returncode != 0, f"--protocol={bad} should be rejected"
|
assert result.returncode != 0, f"--protocol={bad} should be rejected"
|
||||||
|
|||||||
@@ -590,6 +590,9 @@ static void test_parse_args_port_alias() {
|
|||||||
int positional_count = 0;
|
int positional_count = 0;
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv_space, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 5, argv_space, positional_args, &positional_count), 0);
|
||||||
EXPECT_EQ_INT(cfg->server_port, 9000);
|
EXPECT_EQ_INT(cfg->server_port, 9000);
|
||||||
|
/* The default port is 8080; the explicit bit is what lets --dry-run tell an
|
||||||
|
explicit remote target from the default and route to the server. */
|
||||||
|
EXPECT_TRUE(cfg->server_port_set);
|
||||||
config_delete(cfg);
|
config_delete(cfg);
|
||||||
|
|
||||||
cfg = config_create();
|
cfg = config_create();
|
||||||
@@ -597,6 +600,7 @@ static void test_parse_args_port_alias() {
|
|||||||
positional_count = 0;
|
positional_count = 0;
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_inline, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv_inline, positional_args, &positional_count), 0);
|
||||||
EXPECT_EQ_INT(cfg->server_port, 9001);
|
EXPECT_EQ_INT(cfg->server_port, 9001);
|
||||||
|
EXPECT_TRUE(cfg->server_port_set);
|
||||||
config_delete(cfg);
|
config_delete(cfg);
|
||||||
|
|
||||||
cfg = config_create();
|
cfg = config_create();
|
||||||
@@ -604,6 +608,29 @@ static void test_parse_args_port_alias() {
|
|||||||
positional_count = 0;
|
positional_count = 0;
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_long, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv_long, positional_args, &positional_count), 0);
|
||||||
EXPECT_EQ_INT(cfg->server_port, 9002);
|
EXPECT_EQ_INT(cfg->server_port, 9002);
|
||||||
|
EXPECT_TRUE(cfg->server_port_set);
|
||||||
|
config_delete(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An explicit --server-host must set its own routing bit (the field itself
|
||||||
|
* defaults to 127.0.0.1, so a value check cannot distinguish an explicit host
|
||||||
|
* from the default); --dry-run uses it to route to the server. */
|
||||||
|
static void test_parse_args_server_host_sets_routing_bit() {
|
||||||
|
Config* cfg = config_create();
|
||||||
|
int positional_args[2];
|
||||||
|
int positional_count = 0;
|
||||||
|
EXPECT_FALSE(cfg->server_host_set);
|
||||||
|
char* argv_space[] = {"fastsync", "--server-host", "example.test", "/src", "/dst"};
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 5, argv_space, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_EQ_STR(cfg->server_host, "example.test");
|
||||||
|
EXPECT_TRUE(cfg->server_host_set);
|
||||||
|
config_delete(cfg);
|
||||||
|
|
||||||
|
cfg = config_create();
|
||||||
|
char* argv_inline[] = {"fastsync", "--server-host=example.test", "/src", "/dst"};
|
||||||
|
positional_count = 0;
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv_inline, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_TRUE(cfg->server_host_set);
|
||||||
config_delete(cfg);
|
config_delete(cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3301,6 +3328,7 @@ void test_client_cli() {
|
|||||||
test_parse_args_non_numeric_port();
|
test_parse_args_non_numeric_port();
|
||||||
test_parse_args_invalid_server_port();
|
test_parse_args_invalid_server_port();
|
||||||
test_parse_args_port_alias();
|
test_parse_args_port_alias();
|
||||||
|
test_parse_args_server_host_sets_routing_bit();
|
||||||
test_parse_args_threads();
|
test_parse_args_threads();
|
||||||
test_client_abort_flag();
|
test_client_abort_flag();
|
||||||
test_parse_args_invalid_compression_level();
|
test_parse_args_invalid_compression_level();
|
||||||
|
|||||||
+7
-4
@@ -2347,6 +2347,7 @@ static void golden_config_populate(Config* c) {
|
|||||||
c->compression_level = 7;
|
c->compression_level = 7;
|
||||||
c->chunk_size = 65536;
|
c->chunk_size = 65536;
|
||||||
c->use_sendfile = false;
|
c->use_sendfile = false;
|
||||||
|
c->dry_run = true;
|
||||||
c->use_delete = true;
|
c->use_delete = true;
|
||||||
c->use_incremental = true;
|
c->use_incremental = true;
|
||||||
c->size_only = false;
|
c->size_only = false;
|
||||||
@@ -2398,7 +2399,7 @@ static void golden_config_populate(Config* c) {
|
|||||||
c->modify_window = 3;
|
c->modify_window = 3;
|
||||||
c->compress_choice = str_dup("zstd");
|
c->compress_choice = str_dup("zstd");
|
||||||
/* "u=rwx,go=rx" is the same 11 bytes as the original "u=rwX,go=rX" (so the
|
/* "u=rwx,go=rx" is the same 11 bytes as the original "u=rwX,go=rX" (so the
|
||||||
* frame stays 633 bytes) but X is not in FastSync's chmod grammar, and the
|
* frame stays 637 bytes) but X is not in FastSync's chmod grammar, and the
|
||||||
* receive-side golden validates the frame. */
|
* receive-side golden validates the frame. */
|
||||||
c->chmod_spec = str_dup("u=rwx,go=rx");
|
c->chmod_spec = str_dup("u=rwx,go=rx");
|
||||||
c->skip_compress_set = true;
|
c->skip_compress_set = true;
|
||||||
@@ -2445,9 +2446,11 @@ static void golden_config_populate(Config* c) {
|
|||||||
|
|
||||||
/* The pinned golden frame (protocol 2.21.0). The values below are the only
|
/* The pinned golden frame (protocol 2.21.0). The values below are the only
|
||||||
* thing that ties the generated table to the historical wire format; update
|
* thing that ties the generated table to the historical wire format; update
|
||||||
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. */
|
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. The combined
|
||||||
#define GOLDEN_WIRE_LEN 633
|
* 2.21.0 wave appends the serialized dry_run bool to CONFIG_WIRE_CORE_FIELDS
|
||||||
#define GOLDEN_WIRE_HASH 7591559741712449854ULL
|
* and keeps the protocol version string at 2.21.0. */
|
||||||
|
#define GOLDEN_WIRE_LEN 637
|
||||||
|
#define GOLDEN_WIRE_HASH 13228626061067899189ULL
|
||||||
|
|
||||||
static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) {
|
static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) {
|
||||||
unsigned long long h = 1469598103934665603ULL;
|
unsigned long long h = 1469598103934665603ULL;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include "protocol.h"
|
#include "protocol.h"
|
||||||
#include "test_utils.h"
|
#include "test_utils.h"
|
||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
|
#include <dirent.h>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@@ -376,6 +377,88 @@ static void test_incremental_check_size_mismatch_full_transfer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Server-contacting --dry-run: with the wire config's dry_run set, a file that
|
||||||
|
is NOT up to date makes the receiver answer STATUS_DRY_RUN_TRANSFER and
|
||||||
|
return immediately; no data body is read and the destination file is left
|
||||||
|
byte-for-byte unchanged (no temp file, no write, no rename). */
|
||||||
|
static void test_incremental_check_dry_run_reports_transfer_without_writing() {
|
||||||
|
Config* cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
cfg->dry_run = true;
|
||||||
|
char* root = make_check_root("dryw");
|
||||||
|
EXPECT_NOT_NULL(root);
|
||||||
|
cfg->receive_root_directory = str_dup(root);
|
||||||
|
write_check_file(root, "file.txt", "0123456789abcdef");
|
||||||
|
|
||||||
|
char path[1024];
|
||||||
|
snprintf(path, sizeof(path), "%s/file.txt", root);
|
||||||
|
struct stat st;
|
||||||
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
|
|
||||||
|
int p[2];
|
||||||
|
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||||
|
io_set_fds(p[0], p[1]);
|
||||||
|
io_set_bwlimit(0);
|
||||||
|
|
||||||
|
pid_t pid = fork();
|
||||||
|
if (pid == 0) {
|
||||||
|
alarm(30);
|
||||||
|
close(p[1]);
|
||||||
|
io_set_fds(p[0], p[0]);
|
||||||
|
bool skipped = false;
|
||||||
|
bool would_transfer = false;
|
||||||
|
File* file = receive_incremental_check_ex(p[0], cfg, &skipped, &would_transfer);
|
||||||
|
bool ok = file == NULL && !skipped && would_transfer;
|
||||||
|
file_destroy(file);
|
||||||
|
config_delete(cfg);
|
||||||
|
close(p[0]);
|
||||||
|
_exit(ok ? 0 : 1);
|
||||||
|
} else {
|
||||||
|
close(p[0]);
|
||||||
|
io_set_fds(p[1], p[1]);
|
||||||
|
EXPECT_TRUE(send_str(p[1], "file.txt"));
|
||||||
|
unsigned long long size = (unsigned long long)st.st_size + 1;
|
||||||
|
long long mtime = (long long)st.st_mtime;
|
||||||
|
long long mtime_nsec = 0;
|
||||||
|
#ifdef __linux__
|
||||||
|
mtime_nsec = (long long)st.st_mtim.tv_nsec;
|
||||||
|
#endif
|
||||||
|
EXPECT_TRUE(send_n_data(p[1], &size, sizeof(size)));
|
||||||
|
EXPECT_TRUE(send_n_data(p[1], &mtime, sizeof(mtime)));
|
||||||
|
EXPECT_TRUE(send_n_data(p[1], &mtime_nsec, sizeof(mtime_nsec)));
|
||||||
|
Status s;
|
||||||
|
EXPECT_TRUE(receive_status(p[1], &s));
|
||||||
|
EXPECT_EQ_INT(s, STATUS_DRY_RUN_TRANSFER);
|
||||||
|
|
||||||
|
int status;
|
||||||
|
waitpid(pid, &status, 0);
|
||||||
|
close(p[1]);
|
||||||
|
config_delete(cfg);
|
||||||
|
/* The destination file must be untouched and no temp sibling may appear. */
|
||||||
|
char buf[32] = {0};
|
||||||
|
int fd = open(path, O_RDONLY);
|
||||||
|
EXPECT_TRUE(fd >= 0);
|
||||||
|
ssize_t got = read(fd, buf, sizeof(buf) - 1);
|
||||||
|
EXPECT_EQ_INT((int)got, 16);
|
||||||
|
EXPECT_EQ_STR(buf, "0123456789abcdef");
|
||||||
|
close(fd);
|
||||||
|
DIR* d = opendir(root);
|
||||||
|
EXPECT_NOT_NULL(d);
|
||||||
|
int entries = 0;
|
||||||
|
const struct dirent* e;
|
||||||
|
while ((e = readdir(d)) != NULL) {
|
||||||
|
if (strcmp(e->d_name, ".") != 0 && strcmp(e->d_name, "..") != 0)
|
||||||
|
entries++;
|
||||||
|
}
|
||||||
|
closedir(d);
|
||||||
|
EXPECT_EQ_INT(entries, 1);
|
||||||
|
unlink(path);
|
||||||
|
rmdir(root);
|
||||||
|
free(root);
|
||||||
|
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Issue #256: when a received delta claims a result above the whole-file cap,
|
/* Issue #256: when a received delta claims a result above the whole-file cap,
|
||||||
receive_delta_file must mark the operation failed so the caller aborts with
|
receive_delta_file must mark the operation failed so the caller aborts with
|
||||||
STATUS_ERROR instead of emitting STATUS_NEXT and waiting for a body that
|
STATUS_ERROR instead of emitting STATUS_NEXT and waiting for a body that
|
||||||
@@ -771,6 +854,7 @@ void test_server() {
|
|||||||
test_receive_incremental_check_rejects_invalid_nanoseconds();
|
test_receive_incremental_check_rejects_invalid_nanoseconds();
|
||||||
test_incremental_check_quick_skip_by_mtime();
|
test_incremental_check_quick_skip_by_mtime();
|
||||||
test_incremental_check_size_mismatch_full_transfer();
|
test_incremental_check_size_mismatch_full_transfer();
|
||||||
|
test_incremental_check_dry_run_reports_transfer_without_writing();
|
||||||
test_incremental_check_delta_oversize_reports_failure();
|
test_incremental_check_delta_oversize_reports_failure();
|
||||||
test_late_manifest_abort_frees_keepset();
|
test_late_manifest_abort_frees_keepset();
|
||||||
test_late_manifest_eof_frees_keepset();
|
test_late_manifest_eof_frees_keepset();
|
||||||
|
|||||||
Reference in New Issue
Block a user