fix(fs): rsync push iconv direction; gate empty-dir emission; reclassify --temp-dir

- --iconv now matches rsync's push direction: the destination charset is the
  client spec's REMOTE half, so a default receiver writes wire names verbatim;
  a server's own --iconv LOCAL overrides it (daemon charset analog). Updated
  unit + integration tests and added a default-server differential gate case.
- Empty-directory emission is gated behind a new ScannerOptions.emit_empty_dirs
  set only by the real sender, so low-level scanner helpers keep the historical
  file-only list.
- --temp-dir reclassified to Divergent: relative dirs match rsync exactly
  (resolved under the destination), but an absolute path is deliberately
  rejected by the confined receiver; differential test added.
- Docs/tally: 109 Parity / 22 Caveat / 25 Divergent.
This commit is contained in:
2026-09-18 20:39:20 +02:00
parent 13708352ec
commit 91197fd7cf
11 changed files with 157 additions and 55 deletions
+3
View File
@@ -412,6 +412,9 @@ static bool prepare_scanner(const Config* config, int num_threads, PreparedScann
options->exclude_per_dir_filter_files = config->per_dir_filter_count >= 2;
options->dirs = config->dirs;
options->relative = config->relative;
/* A real recursive transfer recreates empty source directories (rsync
parity); low-level scanner users leave this off. */
options->emit_empty_dirs = true;
/* --no-implied-dirs only has meaning with -R (rsync): without it the option
is a documented no-op, so the scanner must not suppress directory
metadata. */
+3 -2
View File
@@ -1399,8 +1399,9 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
if (entry == NULL) {
/* The directory is exhausted: if nothing was transferred or descended
from it, recreate it at the destination as an explicit entry. */
if (!scanner->current_dir_produced && !scanner->options.prune_empty_dirs &&
!scanner->options.list_dirs && scanner->options.file_list == NULL) {
if (scanner->options.emit_empty_dirs && !scanner->current_dir_produced &&
!scanner->options.prune_empty_dirs && !scanner->options.list_dirs &&
scanner->options.file_list == NULL) {
if (!scanner_emit_empty_dir(scanner, chunk_data))
scanner->failed = true;
}
+6
View File
@@ -151,6 +151,12 @@ typedef struct {
bool capture_dir_times;
ArrayList* dir_entries;
mtx_t* dir_entries_mutex;
/* Recreate empty source directories on a recursive transfer: emit a
* payload-less directory entry for every traversed directory that produced
* no transferred/descended child. Off by default so low-level scanner users
* (unit helpers, --list-only) see only the historical file list; the real
* sender sets it in prepare_scanner. */
bool emit_empty_dirs;
/* --no-implied-dirs with -R + --files-from: a directory that is only an
* implied parent of a listed entry (not itself listed, nor below a listed
* directory) must not carry source metadata; it is created with default
+2 -2
View File
@@ -90,8 +90,8 @@ void print_usage(void) {
printf(" entry's destination mirror receiver-side. Independent of\n");
printf(" --delete (it does not imply --delete; a non-empty directory\n");
printf(" mirror is removed only with --force or --delete)\n");
printf(" -m, --prune-empty-dirs Do not transfer empty directory entries (--dirs mode);\n");
printf(" recursive transfers never send empty dirs\n");
printf(" -m, --prune-empty-dirs Do not create empty directories (a recursive transfer\n");
printf(" otherwise recreates them, like rsync)\n");
printf(" Note: each timing flag implies --delete. Combining a timing flag with\n");
printf(" --no-delete (in either order) is rejected as a config error.\n");
printf(" --ignore-existing Skip files that already exist on receiver\n");