fix(parity): dry-run delete protections, delete-delay actual-removal budget, --info name2
- -n/--delete sends the same protected/size-skipped/scope as a real run, so the read-only would-delete walk no longer over-reports (row -> caveat) - --delete-delay charges --max-delete on actual removals and recursively re-scans a refilled deferred directory at commit; independent deferred cap - --info=name emits the leading ./ root line and name2 'is uptodate' lines - differential tests promoted from residual pins to rsync parity assertions
This commit is contained in:
@@ -75,6 +75,14 @@ bool change_list_enabled(const Config* config) {
|
||||
(config->info_level & LOG_INFO_NAME) != 0);
|
||||
}
|
||||
|
||||
/* Emitted once, lazily, ahead of the first --info=name entry: rsync prints the
|
||||
* transfer-root `./` name line when the root directory is (re)created. */
|
||||
static bool name_root_printed = false;
|
||||
|
||||
void change_reset_name_root(void) {
|
||||
name_root_printed = false;
|
||||
}
|
||||
|
||||
/* ---- Itemize code ---- */
|
||||
|
||||
/* Format the permission bits as an `ls -l` string, e.g. `-rw-r--r--`. */
|
||||
@@ -212,6 +220,23 @@ static char* change_render_name(const ChangeEvent* event) {
|
||||
return line.data;
|
||||
}
|
||||
|
||||
/* rsync's `--info=name2` line for an unchanged entry: `NAME is uptodate`. */
|
||||
static char* change_render_name_uptodate(const ChangeEvent* event) {
|
||||
char* name = change_render_name(event);
|
||||
if (name == NULL)
|
||||
return NULL;
|
||||
size_t length = strlen(name);
|
||||
char* line = malloc(length + sizeof(" is uptodate"));
|
||||
if (line == NULL) {
|
||||
free(name);
|
||||
return NULL;
|
||||
}
|
||||
memcpy(line, name, length);
|
||||
memcpy(line + length, " is uptodate", sizeof(" is uptodate"));
|
||||
free(name);
|
||||
return line;
|
||||
}
|
||||
|
||||
/* ---- --out-format / --log-file-format ---- */
|
||||
|
||||
/* rsync 3.4.1's `%C` uses the negotiated TRANSFER checksum (the first name of a
|
||||
@@ -461,10 +486,24 @@ static void print_escaped_line(FILE* stream, const char* line, bool eight_bit_ou
|
||||
void change_emit(const Config* config, const ChangeEvent* event) {
|
||||
if (event == NULL || !change_list_enabled(config))
|
||||
return;
|
||||
if (event->decision == CHANGE_UP_TO_DATE)
|
||||
return;
|
||||
bool to_stdout = config->itemize_changes || config->out_format != NULL;
|
||||
bool to_log = config->log_file != NULL && config->log_file_format != NULL;
|
||||
bool progress_active = config->show_progress || (config->info_level & LOG_INFO_PROGRESS);
|
||||
if (event->decision == CHANGE_UP_TO_DATE) {
|
||||
/* --info=name2 prints `NAME is uptodate` for entries the receiver already
|
||||
had. An itemize/out-format run reports them through its own format (or
|
||||
not at all), the progress stream has no frame for them, and neither the
|
||||
itemize nor the log-file stream previously reported an up-to-date entry,
|
||||
so nothing else here changes. */
|
||||
if (!to_stdout && (config->info_level & LOG_INFO_NAME_UPTODATE) != 0 && !progress_active) {
|
||||
char* line = change_render_name_uptodate(event);
|
||||
if (line != NULL) {
|
||||
print_escaped_line(stdout, line, config->eight_bit_output);
|
||||
free(line);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (to_stdout) {
|
||||
char* line = config->out_format != NULL
|
||||
? change_render_format(config->out_format, config, event)
|
||||
@@ -473,11 +512,15 @@ void change_emit(const Config* config, const ChangeEvent* event) {
|
||||
print_escaped_line(stdout, line, config->eight_bit_output);
|
||||
free(line);
|
||||
}
|
||||
} else if ((config->info_level & LOG_INFO_NAME) != 0 &&
|
||||
!(config->show_progress || (config->info_level & LOG_INFO_PROGRESS))) {
|
||||
} else if ((config->info_level & LOG_INFO_NAME) != 0 && !progress_active) {
|
||||
/* --info=name without -i/--out-format: print the updated entry's name. The
|
||||
--progress path owns the name line when progress output is active (it
|
||||
emits the same names before the progress frames), so do not duplicate. */
|
||||
emits the same names before the progress frames), so do not duplicate.
|
||||
The transfer-root `./` line precedes the first such name. */
|
||||
if (!name_root_printed) {
|
||||
name_root_printed = true;
|
||||
fputs("./\n", stdout);
|
||||
}
|
||||
char* line = change_render_name(event);
|
||||
if (line != NULL) {
|
||||
print_escaped_line(stdout, line, config->eight_bit_output);
|
||||
@@ -643,6 +686,29 @@ void change_emit_file_sent(const Config* config, const File* file) {
|
||||
change_emit_file_sent_bytes(config, file, payload, 0);
|
||||
}
|
||||
|
||||
void change_emit_file_uptodate(const Config* config, const File* file) {
|
||||
if (file == NULL || !change_list_enabled(config))
|
||||
return;
|
||||
ChangeEvent event;
|
||||
memset(&event, 0, sizeof(event));
|
||||
event.decision = CHANGE_UP_TO_DATE;
|
||||
event.is_directory = false;
|
||||
event.is_symlink = file->is_symlink;
|
||||
event.is_special = file->is_special;
|
||||
event.is_hardlink = file->link_group != 0 && !file->link_first;
|
||||
event.symlink_target = file->symlink_target;
|
||||
event.hardlink_target = file->hardlink_target;
|
||||
event.size = file->data != NULL ? file->data->size : 0;
|
||||
event.dest = file->dest_state;
|
||||
char* name = NULL;
|
||||
char* path = NULL;
|
||||
fill_event_from_file(config, file, &event, &name, &path);
|
||||
if (name != NULL && path != NULL)
|
||||
change_emit(config, &event);
|
||||
free(name);
|
||||
free(path);
|
||||
}
|
||||
|
||||
void change_emit_dir_sent(const Config* config, const File* file) {
|
||||
if (file == NULL || !change_list_enabled(config))
|
||||
return;
|
||||
|
||||
@@ -102,4 +102,13 @@ void change_emit_file_sent(const Config* config, const File* file);
|
||||
/* Build and emit a CHANGE_SENT event for an explicit directory entry (-d). */
|
||||
void change_emit_dir_sent(const Config* config, const File* file);
|
||||
|
||||
/* Build and emit a CHANGE_UP_TO_DATE event for a file the receiver already had.
|
||||
* With --info=name2 it renders rsync's "NAME is uptodate" line (no output
|
||||
* otherwise). */
|
||||
void change_emit_file_uptodate(const Config* config, const File* file);
|
||||
|
||||
/* Reset the lazy transfer-root `./` line emitted ahead of the first
|
||||
* --info=name entry. Call once at the start of a transfer. */
|
||||
void change_reset_name_root(void);
|
||||
|
||||
#endif
|
||||
|
||||
+13
-3
@@ -614,9 +614,19 @@ static int parse_info_flags(const char* value, Config* config) {
|
||||
}
|
||||
if (strcmp(name, "copy") == 0)
|
||||
flag = LOG_INFO_COPY;
|
||||
else if (strcmp(name, "name") == 0)
|
||||
flag = LOG_INFO_NAME;
|
||||
else if (strcmp(name, "misc") == 0)
|
||||
else if (strcmp(name, "name") == 0) {
|
||||
/* name level 2 adds rsync's "is uptodate" lines. */
|
||||
if (level == 0)
|
||||
parsed &= ~(uint32_t)(LOG_INFO_NAME | LOG_INFO_NAME_UPTODATE);
|
||||
else {
|
||||
parsed |= LOG_INFO_NAME;
|
||||
if (level >= 2)
|
||||
parsed |= LOG_INFO_NAME_UPTODATE;
|
||||
else
|
||||
parsed &= ~(uint32_t)LOG_INFO_NAME_UPTODATE;
|
||||
}
|
||||
continue;
|
||||
} else if (strcmp(name, "misc") == 0)
|
||||
flag = LOG_INFO_MISC;
|
||||
else if (strcmp(name, "skip") == 0)
|
||||
flag = LOG_INFO_SKIP;
|
||||
|
||||
+38
-22
@@ -350,6 +350,7 @@ static void print_delete_reports(const Config* config, const ArrayList* paths) {
|
||||
}
|
||||
|
||||
static void client_progress_begin(const Config* config) {
|
||||
change_reset_name_root();
|
||||
g_progress_active =
|
||||
(config->show_progress || info_flag_enabled(config, LOG_INFO_PROGRESS)) && !config->quiet;
|
||||
g_progress_xferred = 0;
|
||||
@@ -1928,11 +1929,41 @@ static int send_dry_run_remote(Config* config) {
|
||||
DirectoryScanner* scanner = NULL;
|
||||
ArrayList* dry_manifest = NULL;
|
||||
ArrayList* dry_dirs = NULL;
|
||||
ArrayList* dry_excluded = NULL;
|
||||
ArrayList* dry_size_skipped = 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;
|
||||
/* -n --delete: build the same keep-set manifest, protected prefixes, and
|
||||
synchronized-directory scope a real run would send, so the receiver's
|
||||
read-only extras walk enumerates exactly the deletions a real run makes. */
|
||||
if (config->use_delete) {
|
||||
dry_manifest = array_list_create(free);
|
||||
dry_dirs = array_list_create(free);
|
||||
dry_size_skipped = array_list_create(free);
|
||||
if (!dry_manifest || !dry_dirs || !dry_size_skipped)
|
||||
goto dry_fail;
|
||||
if (!config->delete_excluded) {
|
||||
dry_excluded = array_list_create(free);
|
||||
if (!dry_excluded)
|
||||
goto dry_fail;
|
||||
prepared.options.excluded_paths = dry_excluded;
|
||||
}
|
||||
prepared.options.size_skipped_paths = dry_size_skipped;
|
||||
/* A --files-from subset confines the extras walk to the directories the
|
||||
scan synchronized; a full recursive transfer marks the root itself. */
|
||||
if (config->files_from_set == NULL) {
|
||||
char* root_marker = delete_scope_root_marker(config);
|
||||
if (!root_marker || !array_list_add(dry_dirs, root_marker)) {
|
||||
free(root_marker);
|
||||
goto dry_fail;
|
||||
}
|
||||
} else {
|
||||
prepared.options.synced_dirs = dry_dirs;
|
||||
}
|
||||
}
|
||||
scanner = directory_scanner_create_with_options(config->send_directory, &prepared.options);
|
||||
if (!scanner)
|
||||
goto dry_fail;
|
||||
@@ -1940,26 +1971,6 @@ static int send_dry_run_remote(Config* config) {
|
||||
int file_count = 0;
|
||||
unsigned long long total_bytes = 0;
|
||||
char size_buffer[32];
|
||||
/* -n --delete: build the same keep-set manifest a real run would send so the
|
||||
receiver can enumerate (read-only) the destination extras. Filter-excluded
|
||||
and size-pruned protections are not propagated here, so a filtered dry-run
|
||||
may over-report; the no-filter case is exact. */
|
||||
dry_manifest = config->use_delete ? array_list_create(free) : NULL;
|
||||
if (config->use_delete && !dry_manifest)
|
||||
goto dry_fail;
|
||||
/* Scope the receiver-side extras walk to the receive root (the "." sentinel),
|
||||
exactly as the recursive transfer path does. */
|
||||
if (config->use_delete) {
|
||||
dry_dirs = array_list_create(free);
|
||||
char* root_marker = dry_dirs ? str_dup(".") : NULL;
|
||||
if (!dry_dirs || !root_marker || !array_list_add(dry_dirs, root_marker)) {
|
||||
free(root_marker);
|
||||
if (dry_dirs)
|
||||
array_list_delete(dry_dirs);
|
||||
dry_dirs = NULL;
|
||||
goto dry_fail;
|
||||
}
|
||||
}
|
||||
if (!config->quiet)
|
||||
printf("Dry run: files to be transferred\n");
|
||||
Chunk* chunk;
|
||||
@@ -2034,8 +2045,8 @@ static int send_dry_run_remote(Config* config) {
|
||||
the terminal FINISHED. */
|
||||
bool early_delete = config->use_delete && config_delete_timing_early(config);
|
||||
if (dry_manifest) {
|
||||
if (send_delete_manifest(client->file_descriptor, dry_manifest, NULL, NULL, NULL, dry_dirs) !=
|
||||
0)
|
||||
if (send_delete_manifest(client->file_descriptor, dry_manifest, dry_excluded, dry_size_skipped,
|
||||
NULL, dry_dirs) != 0)
|
||||
goto dry_fail;
|
||||
if (early_delete) {
|
||||
Status ack;
|
||||
@@ -2091,6 +2102,10 @@ dry_fail:
|
||||
array_list_delete(dry_manifest);
|
||||
if (dry_dirs)
|
||||
array_list_delete(dry_dirs);
|
||||
if (dry_excluded)
|
||||
array_list_delete(dry_excluded);
|
||||
if (dry_size_skipped)
|
||||
array_list_delete(dry_size_skipped);
|
||||
if (scanner)
|
||||
directory_scanner_destroy(scanner);
|
||||
prepared_scanner_destroy(&prepared);
|
||||
@@ -2423,6 +2438,7 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config,
|
||||
int rc = send_single_file(client, f, config, config->use_incremental, use_sendfile);
|
||||
if (rc == 1) {
|
||||
source_file_destroy(source);
|
||||
change_emit_file_uptodate(config, f);
|
||||
continue;
|
||||
}
|
||||
if (rc < 0) {
|
||||
|
||||
+91
-25
@@ -412,11 +412,17 @@ struct DeletePlanSession {
|
||||
bool dry_run;
|
||||
size_t max_delete;
|
||||
size_t deleted;
|
||||
/* Removals charged against --max-delete. In --delete-delay mode a path is
|
||||
planned (and the budget consumed) while scanning, but `deleted` advances
|
||||
only when the commit actually unlinks it, so an entry that survives the
|
||||
commit (a directory refilled mid-transfer -> ENOTEMPTY) is not reported. */
|
||||
/* Removals charged against --max-delete. The budget is charged on ACTUAL
|
||||
removals (an unlink/rmdir that succeeded), matching rsync: a snapshotted
|
||||
entry that fails removal consumes nothing, so a later extra is still
|
||||
deleted. `planned` and `deleted` advance together for the inline paths and
|
||||
`apply_missing`; `deleted` is the reported count. */
|
||||
size_t planned;
|
||||
/* Hard bound on the deferred snapshot list. Because the budget is no longer
|
||||
charged at snapshot time, this independent cap keeps a huge destination
|
||||
from growing the list without limit (it matches the receiver's overall
|
||||
deletion bound). */
|
||||
size_t defer_cap;
|
||||
size_t skipped;
|
||||
bool limit_hit;
|
||||
bool limit_logged;
|
||||
@@ -448,6 +454,7 @@ DeletePlanSession* delete_plan_session_create(const Config* config) {
|
||||
config->max_delete >= 0 && (size_t)config->max_delete < DELETE_PLAN_SERVER_LIMIT;
|
||||
session->max_delete =
|
||||
user_limited ? (size_t)config->max_delete : (size_t)DELETE_PLAN_SERVER_LIMIT;
|
||||
session->defer_cap = DELETE_PLAN_SERVER_LIMIT;
|
||||
session->protected_prefixes = array_list_create(free);
|
||||
session->size_skipped = array_list_create(free);
|
||||
session->missing = array_list_create(free);
|
||||
@@ -592,10 +599,15 @@ static void log_deleted(const char* rel) {
|
||||
free(escaped);
|
||||
}
|
||||
|
||||
/* Append a snapshot path for --delete-delay. The budget is charged here, but
|
||||
* `deleted` is not: the path counts only once apply_deferred_path truly
|
||||
* unlinks it. */
|
||||
/* Append a snapshot path for --delete-delay. The budget is NOT charged here:
|
||||
* the remover charges --max-delete only when a path is actually unlinked (see
|
||||
* apply_deferred_path), so a snapshotted entry that survives ENOTEMPTY cannot
|
||||
* deny budget to a later extra. The independent `defer_cap` bounds the list. */
|
||||
static bool defer_add(DeletePlanSession* session, const char* rel) {
|
||||
if ((size_t)session->deferred->size >= session->defer_cap) {
|
||||
note_skipped(session);
|
||||
return true;
|
||||
}
|
||||
char* copy = str_dup(rel);
|
||||
if (!copy)
|
||||
return false;
|
||||
@@ -603,7 +615,6 @@ static bool defer_add(DeletePlanSession* session, const char* rel) {
|
||||
free(copy);
|
||||
return false;
|
||||
}
|
||||
session->planned++;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -636,16 +647,16 @@ static bool process_extra_dir(int dirfd, const char* name, const char* child_rel
|
||||
return false;
|
||||
if (survives)
|
||||
return true;
|
||||
if (!budget_available(session)) {
|
||||
note_skipped(session);
|
||||
return true;
|
||||
}
|
||||
if (session->defer && !force_now) {
|
||||
if (!defer_add(session, child_rel))
|
||||
return false;
|
||||
*removed = true;
|
||||
return true;
|
||||
}
|
||||
if (!budget_available(session)) {
|
||||
note_skipped(session);
|
||||
return true;
|
||||
}
|
||||
if (unlinkat(dirfd, name, AT_REMOVEDIR) == 0) {
|
||||
session->deleted++;
|
||||
session->planned++;
|
||||
@@ -665,13 +676,13 @@ static bool process_extra_dir(int dirfd, const char* name, const char* child_rel
|
||||
|
||||
static bool process_extra_file(int dirfd, const char* name, const char* child_rel, bool force_now,
|
||||
DeletePlanSession* session) {
|
||||
if (session->defer && !force_now) {
|
||||
return defer_add(session, child_rel);
|
||||
}
|
||||
if (!budget_available(session)) {
|
||||
note_skipped(session);
|
||||
return true;
|
||||
}
|
||||
if (session->defer && !force_now) {
|
||||
return defer_add(session, child_rel);
|
||||
}
|
||||
if (unlinkat(dirfd, name, 0) == 0) {
|
||||
session->deleted++;
|
||||
session->planned++;
|
||||
@@ -858,7 +869,9 @@ int delete_plan_session_receive(DeletePlanSession* session, const Config* config
|
||||
}
|
||||
|
||||
/* Apply one snapshotted --delete-delay path (post-order: children precede their
|
||||
* parent directory). */
|
||||
* parent directory). A directory that is still present is re-scanned so content
|
||||
* created after the plan is removed too; every actual removal charges
|
||||
* --max-delete. */
|
||||
static bool apply_deferred_path(DeletePlanSession* session, const Config* config, const char* rel) {
|
||||
char* full = path_cat(config->receive_root_directory, rel);
|
||||
if (!full)
|
||||
@@ -872,25 +885,78 @@ static bool apply_deferred_path(DeletePlanSession* session, const Config* config
|
||||
}
|
||||
struct stat st;
|
||||
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0) {
|
||||
bool absent = errno == ENOENT;
|
||||
bool absent = errno == ENOENT || errno == ENOTDIR;
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return absent;
|
||||
}
|
||||
int rc;
|
||||
if (S_ISDIR(st.st_mode))
|
||||
rc = unlinkat(parent_fd, leaf, AT_REMOVEDIR);
|
||||
else
|
||||
rc = unlinkat(parent_fd, leaf, 0);
|
||||
bool ok = rc == 0 || errno == ENOENT || errno == ENOTEMPTY || errno == EEXIST;
|
||||
if (rc == 0) {
|
||||
if (S_ISDIR(st.st_mode)) {
|
||||
if (!budget_available(session)) {
|
||||
note_skipped(session);
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return true;
|
||||
}
|
||||
int dirfd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (dirfd < 0) {
|
||||
bool absent = errno == ENOENT || errno == ENOTDIR;
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return absent;
|
||||
}
|
||||
PlanSkips skips;
|
||||
if (!build_plan_skips(config, session, &skips)) {
|
||||
close(dirfd);
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return false;
|
||||
}
|
||||
bool survives = false;
|
||||
bool ok = process_children(dirfd, rel, NULL, NULL, false, true, &skips, session, &survives);
|
||||
free(skips.entries);
|
||||
close(dirfd);
|
||||
if (!ok) {
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return false;
|
||||
}
|
||||
if (!survives) {
|
||||
if (!budget_available(session)) {
|
||||
note_skipped(session);
|
||||
} else if (unlinkat(parent_fd, leaf, AT_REMOVEDIR) == 0) {
|
||||
session->deleted++;
|
||||
session->planned++;
|
||||
log_deleted(rel);
|
||||
notify_deleted(session, rel);
|
||||
} else if (errno != ENOENT && errno != ENOTEMPTY && errno != EEXIST) {
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return true;
|
||||
}
|
||||
if (!budget_available(session)) {
|
||||
note_skipped(session);
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return true;
|
||||
}
|
||||
if (unlinkat(parent_fd, leaf, 0) == 0) {
|
||||
session->deleted++;
|
||||
session->planned++;
|
||||
log_deleted(rel);
|
||||
notify_deleted(session, rel);
|
||||
} else if (errno != ENOENT) {
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return false;
|
||||
}
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return ok;
|
||||
return true;
|
||||
}
|
||||
|
||||
void delete_plan_session_set_delete_observer(DeletePlanSession* session,
|
||||
|
||||
@@ -33,6 +33,11 @@ typedef enum {
|
||||
LOG_INFO_FLIST = 1u << 7,
|
||||
LOG_INFO_NONREG = 1u << 8,
|
||||
LOG_INFO_PROGRESS = 1u << 9,
|
||||
/* Marker for `--info=name2` and higher: also print rsync's
|
||||
"NAME is uptodate" line for entries the receiver already has. It rides in
|
||||
the info_level bitset (there is no separate Config field) and is never set
|
||||
by --info=all (which selects level 1). */
|
||||
LOG_INFO_NAME_UPTODATE = 1u << 10,
|
||||
LOG_INFO_ALL = LOG_INFO_COPY | LOG_INFO_MISC | LOG_INFO_SKIP | LOG_INFO_STATS | LOG_INFO_DEL |
|
||||
LOG_INFO_REMOVE | LOG_INFO_NAME | LOG_INFO_FLIST | LOG_INFO_NONREG |
|
||||
LOG_INFO_PROGRESS,
|
||||
|
||||
Reference in New Issue
Block a user