#include "test_delete_plan.h" #include "charset.h" #include "config.h" #include "delete_plan.h" #include "protocol.h" #include "test_utils.h" #include "utils.h" #include #include #include #include #include #include #include #include /* Discards everything written to `fd` until EOF, so a sender that regresses to * emitting an over-budget frame does not block forever on a full socket. */ typedef struct { int fd; } DrainArg; static int drain_fd_thread(void* arg) { DrainArg* drain = arg; char buffer[8192]; while (read(drain->fd, buffer, sizeof(buffer)) > 0) ; return 0; } /* Send one STATUS_DELETE_PLAN body (the leading status is consumed by the * caller/receiver entry point) describing `dir` with no kept children. */ static void send_plan_frame(int fd, const char* dir) { EXPECT_TRUE(send_int(fd, 0)); /* has_config */ EXPECT_TRUE(send_int(fd, 1)); /* apply: a real plan */ EXPECT_TRUE(send_wire_str(fd, dir)); EXPECT_TRUE(send_int(fd, 0)); /* kept child dirs */ EXPECT_TRUE(send_int(fd, 0)); /* kept child files */ } /* --delete-delay: a directory snapshotted into the plan that is refilled before * the commit is re-scanned and removed recursively (rsync parity). Regression * for the old single-unlink ENOTEMPTY path that left the directory behind. */ static void test_delete_delay_refilled_dir_removed_recursively(void) { char root[] = "/tmp/fastsync_dp_refill_XXXXXX"; EXPECT_TRUE(mkdtemp(root) != NULL); char extra[1024]; snprintf(extra, sizeof(extra), "%s/extra", root); EXPECT_EQ_INT(mkdir(extra, 0700), 0); Config* config = config_create(); EXPECT_NOT_NULL(config); config->receive_root_directory = str_dup(root); config->use_delete = true; config->delete_delay = true; int p[2]; EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); DeletePlanSession* session = delete_plan_session_create(config); EXPECT_NOT_NULL(session); send_plan_frame(p[1], "."); EXPECT_EQ_INT(delete_plan_session_receive(session, config, p[0]), 0); /* The empty extra directory was snapshotted, not removed yet. */ EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 0); /* Refill the directory while the deferred commit is pending. */ char refill[1200]; snprintf(refill, sizeof(refill), "%s/new.txt", extra); int fd = open(refill, O_WRONLY | O_CREAT | O_TRUNC, 0600); EXPECT_TRUE(fd >= 0); close(fd); EXPECT_EQ_INT(delete_plan_session_commit(session, config), DELETE_COMMIT_OK); /* The late content and the directory itself are both removed. */ EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 2); struct stat st; EXPECT_TRUE(lstat(extra, &st) != 0); delete_plan_session_destroy(session); close(p[0]); close(p[1]); rmdir(root); config_delete(config); } /* The complement: a deferred regular extra that DOES get removed is counted. */ static void test_delete_delay_removed_file_counted(void) { char root[] = "/tmp/fastsync_dp_file_XXXXXX"; EXPECT_TRUE(mkdtemp(root) != NULL); char extra[1024]; snprintf(extra, sizeof(extra), "%s/extra.txt", root); int fd = open(extra, O_WRONLY | O_CREAT | O_TRUNC, 0600); EXPECT_TRUE(fd >= 0); close(fd); Config* config = config_create(); EXPECT_NOT_NULL(config); config->receive_root_directory = str_dup(root); config->use_delete = true; config->delete_delay = true; int p[2]; EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); DeletePlanSession* session = delete_plan_session_create(config); EXPECT_NOT_NULL(session); send_plan_frame(p[1], "."); EXPECT_EQ_INT(delete_plan_session_receive(session, config, p[0]), 0); EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 0); EXPECT_EQ_INT(delete_plan_session_commit(session, config), DELETE_COMMIT_OK); EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 1); EXPECT_TRUE(lstat(extra, &(struct stat){0}) != 0); delete_plan_session_destroy(session); close(p[0]); close(p[1]); rmdir(root); config_delete(config); } /* --max-delete is charged on actual removals, not at plan/snapshot time: after * receiving the plans the budget is untouched, and only the commit removes up to * the limit. */ static void test_delete_delay_max_delete_bounds_actual(void) { char root[] = "/tmp/fastsync_dp_max_XXXXXX"; EXPECT_TRUE(mkdtemp(root) != NULL); for (int i = 0; i < 3; i++) { char path[1024]; snprintf(path, sizeof(path), "%s/e%d.txt", root, i); int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600); EXPECT_TRUE(fd >= 0); close(fd); } Config* config = config_create(); EXPECT_NOT_NULL(config); config->receive_root_directory = str_dup(root); config->use_delete = true; config->delete_delay = true; config->max_delete = 1; int p[2]; EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); DeletePlanSession* session = delete_plan_session_create(config); EXPECT_NOT_NULL(session); send_plan_frame(p[1], "."); EXPECT_EQ_INT(delete_plan_session_receive(session, config, p[0]), 0); /* Nothing removed yet, so the budget is not consumed at snapshot time. */ EXPECT_FALSE(delete_plan_session_limit_reached(session)); EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 0); EXPECT_EQ_INT(delete_plan_session_commit(session, config), DELETE_COMMIT_LIMIT_REACHED); EXPECT_TRUE(delete_plan_session_limit_reached(session)); EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 1); delete_plan_session_destroy(session); close(p[0]); close(p[1]); for (int i = 0; i < 3; i++) { char path[1024]; snprintf(path, sizeof(path), "%s/e%d.txt", root, i); unlink(path); } rmdir(root); config_delete(config); } /* --max-delete is charged on ACTUAL removals: the refilled directory's late * content is removed first (consuming the single budget slot), so the directory * itself and the later extra are skipped, matching rsync. The two plans are * sent as separate frames for "a" then "b", so the ordering that decides which * entry gets the budget is deterministic (unlike readdir order). */ static void test_delete_delay_actual_removal_charges_budget(void) { char root[] = "/tmp/fastsync_dp_planbudget_XXXXXX"; EXPECT_TRUE(mkdtemp(root) != NULL); char adir[1024], bdir[1024], xdir[1024], ydir[1024]; snprintf(adir, sizeof(adir), "%s/a", root); snprintf(bdir, sizeof(bdir), "%s/b", root); snprintf(xdir, sizeof(xdir), "%s/a/x", root); snprintf(ydir, sizeof(ydir), "%s/b/y", root); EXPECT_EQ_INT(mkdir(adir, 0700), 0); EXPECT_EQ_INT(mkdir(bdir, 0700), 0); EXPECT_EQ_INT(mkdir(xdir, 0700), 0); EXPECT_EQ_INT(mkdir(ydir, 0700), 0); Config* config = config_create(); EXPECT_NOT_NULL(config); config->receive_root_directory = str_dup(root); config->use_delete = true; config->delete_delay = true; config->max_delete = 1; int p[2]; EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); DeletePlanSession* session = delete_plan_session_create(config); EXPECT_NOT_NULL(session); /* Both plan snapshots are taken; neither consumes budget yet. */ send_plan_frame(p[1], "a"); EXPECT_EQ_INT(delete_plan_session_receive(session, config, p[0]), 0); EXPECT_FALSE(delete_plan_session_limit_reached(session)); send_plan_frame(p[1], "b"); EXPECT_EQ_INT(delete_plan_session_receive(session, config, p[0]), 0); EXPECT_FALSE(delete_plan_session_limit_reached(session)); EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 0); /* Refill a/x after its plan: the recursive commit must remove this content. */ char refill[1200]; snprintf(refill, sizeof(refill), "%s/new.txt", xdir); int fd = open(refill, O_WRONLY | O_CREAT | O_TRUNC, 0600); EXPECT_TRUE(fd >= 0); close(fd); EXPECT_EQ_INT(delete_plan_session_commit(session, config), DELETE_COMMIT_LIMIT_REACHED); /* The one budget slot removed the late content; the two directories survive. */ EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 1); EXPECT_TRUE(delete_plan_session_limit_reached(session)); struct stat st; EXPECT_TRUE(lstat(refill, &st) != 0); EXPECT_EQ_INT(lstat(xdir, &st), 0); EXPECT_EQ_INT(lstat(ydir, &st), 0); delete_plan_session_destroy(session); close(p[0]); close(p[1]); rmdir(xdir); rmdir(ydir); rmdir(adir); rmdir(bdir); rmdir(root); config_delete(config); } /* Send a config-only carrier frame (apply=false): the per-run config block with * one --delete-missing-args exact path, and no directory walk. */ static void send_config_only_frame(int fd, const char* missing_path) { EXPECT_TRUE(send_int(fd, 1)); /* has_config */ EXPECT_TRUE(send_int(fd, 0)); /* protected prefixes */ EXPECT_TRUE(send_int(fd, 0)); /* size-skipped */ EXPECT_TRUE(send_int(fd, 1)); /* missing args */ EXPECT_TRUE(send_wire_str(fd, missing_path)); EXPECT_TRUE(send_int(fd, 0)); /* per-directory filter-rule block is empty */ EXPECT_TRUE(send_int(fd, 0)); /* apply = false */ EXPECT_TRUE(send_wire_str(fd, ".")); EXPECT_TRUE(send_int(fd, 0)); EXPECT_TRUE(send_int(fd, 0)); } /* The config-only carrier frame (apply=false) still applies the * --delete-missing-args exact deletions even though it walks no directory. This * is the fix for a --files-from list that synchronizes no directory. */ static void test_config_only_frame_applies_missing_args(void) { char root[] = "/tmp/fastsync_dp_cfgonly_XXXXXX"; EXPECT_TRUE(mkdtemp(root) != NULL); char gone[1024]; snprintf(gone, sizeof(gone), "%s/gone.txt", root); int fd = open(gone, O_WRONLY | O_CREAT | O_TRUNC, 0600); EXPECT_TRUE(fd >= 0); close(fd); Config* config = config_create(); EXPECT_NOT_NULL(config); config->receive_root_directory = str_dup(root); config->delete_missing_args = true; int p[2]; EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); DeletePlanSession* session = delete_plan_session_create(config); EXPECT_NOT_NULL(session); send_config_only_frame(p[1], "gone.txt"); EXPECT_EQ_INT(delete_plan_session_receive(session, config, p[0]), 0); EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 1); EXPECT_TRUE(lstat(gone, &(struct stat){0}) != 0); delete_plan_session_destroy(session); close(p[0]); close(p[1]); rmdir(root); config_delete(config); } /* The per-directory filter-rule block (protocol 2.30.0) must be bounded on * receive: every count, the action/sides domain, the owner-directory syntax and * the pattern length are validated so a hostile peer can neither overread nor * allocate unboundedly. It also round-trips a valid group faithfully. */ static void test_filter_dir_rules_receive_bounds(void) { int p[2]; FilterRuleList* out = NULL; /* Group count beyond the cap is rejected. */ EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); EXPECT_TRUE(send_int(p[1], MAX_FILTER_RULES + 1)); EXPECT_FALSE(delete_filter_dir_rules_receive(p[0], &out)); EXPECT_NULL(out); close(p[0]); close(p[1]); /* A negative group count is rejected. */ EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); EXPECT_TRUE(send_int(p[1], -1)); EXPECT_FALSE(delete_filter_dir_rules_receive(p[0], &out)); EXPECT_NULL(out); close(p[0]); close(p[1]); /* An empty block is valid and yields NULL. */ EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); EXPECT_TRUE(send_int(p[1], 0)); EXPECT_TRUE(delete_filter_dir_rules_receive(p[0], &out)); EXPECT_NULL(out); close(p[0]); close(p[1]); /* An unknown action is a protocol error. */ EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); EXPECT_TRUE(send_int(p[1], 1)); EXPECT_TRUE(send_wire_str(p[1], "")); EXPECT_TRUE(send_int(p[1], 1)); EXPECT_TRUE(send_int(p[1], 999)); EXPECT_FALSE(delete_filter_dir_rules_receive(p[0], &out)); EXPECT_NULL(out); close(p[0]); close(p[1]); /* An absolute owner directory is rejected (confinement). */ EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); EXPECT_TRUE(send_int(p[1], 1)); EXPECT_TRUE(send_wire_str(p[1], "/etc")); EXPECT_TRUE(send_int(p[1], 0)); EXPECT_FALSE(delete_filter_dir_rules_receive(p[0], &out)); EXPECT_NULL(out); close(p[0]); close(p[1]); /* An over-long pattern is rejected before allocation. */ { char* big = malloc(MAX_PROTECT_PATTERN_LEN + 2); EXPECT_NOT_NULL(big); memset(big, 'a', MAX_PROTECT_PATTERN_LEN + 1); big[MAX_PROTECT_PATTERN_LEN + 1] = '\0'; EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); EXPECT_TRUE(send_int(p[1], 1)); EXPECT_TRUE(send_wire_str(p[1], "")); EXPECT_TRUE(send_int(p[1], 1)); EXPECT_TRUE(send_int(p[1], (int)FILTER_ACTION_EXCLUDE)); EXPECT_TRUE(send_int(p[1], (int)(FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER))); EXPECT_TRUE(send_int(p[1], 0)); EXPECT_TRUE(send_int(p[1], 0)); EXPECT_TRUE(send_int(p[1], 0)); EXPECT_TRUE(send_int(p[1], 0)); EXPECT_TRUE(send_wire_str(p[1], big)); EXPECT_FALSE(delete_filter_dir_rules_receive(p[0], &out)); EXPECT_NULL(out); close(p[0]); close(p[1]); free(big); } /* A valid group round-trips its owner, no-inherit flag and pattern. */ EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); EXPECT_TRUE(send_int(p[1], 1)); EXPECT_TRUE(send_wire_str(p[1], "sub")); EXPECT_TRUE(send_int(p[1], 1)); EXPECT_TRUE(send_int(p[1], (int)FILTER_ACTION_EXCLUDE)); EXPECT_TRUE(send_int(p[1], (int)(FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER))); EXPECT_TRUE(send_int(p[1], 0)); EXPECT_TRUE(send_int(p[1], 0)); EXPECT_TRUE(send_int(p[1], 0)); EXPECT_TRUE(send_int(p[1], 1)); /* no_inherit */ EXPECT_TRUE(send_wire_str(p[1], "*.log")); EXPECT_TRUE(delete_filter_dir_rules_receive(p[0], &out)); EXPECT_NOT_NULL(out); EXPECT_EQ_INT(out->count, 1); EXPECT_EQ_STR(out->items[0]->owner, "sub"); EXPECT_EQ_STR(out->items[0]->pattern, "*.log"); EXPECT_TRUE(out->items[0]->no_inherit); filter_rule_list_free(out); close(p[0]); close(p[1]); } /* The per-directory rule sender enforces exactly the receiver's limits: an * over-long pattern and an over-budget owner+pattern total are rejected locally * with a clear error instead of emitting a frame the peer would abort the * transfer on. A valid block still round-trips. */ static void test_filter_dir_rules_send_bounds(void) { int p[2]; /* An over-long pattern is rejected before anything is written. */ { FilterRuleList* list = filter_rule_list_create(); EXPECT_NOT_NULL(list); FilterRule* rule = calloc(1, sizeof(FilterRule)); EXPECT_NOT_NULL(rule); rule->action = FILTER_ACTION_EXCLUDE; rule->sides = FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER; rule->owner = str_dup("sub"); rule->pattern = malloc(MAX_PROTECT_PATTERN_LEN + 2); EXPECT_NOT_NULL(rule->owner); EXPECT_NOT_NULL(rule->pattern); memset(rule->pattern, 'a', MAX_PROTECT_PATTERN_LEN + 1); rule->pattern[MAX_PROTECT_PATTERN_LEN + 1] = '\0'; EXPECT_TRUE(filter_rule_list_add(list, rule)); EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); EXPECT_FALSE(delete_filter_dir_rules_send(p[1], list)); close(p[0]); close(p[1]); filter_rule_list_free(list); } /* A cumulative owner+pattern total over MAX_FILTER_BYTES is rejected. */ { FilterRuleList* list = filter_rule_list_create(); EXPECT_NOT_NULL(list); int per = MAX_PROTECT_PATTERN_LEN; int need = MAX_FILTER_BYTES / per + 1; EXPECT_TRUE(need < MAX_FILTER_RULES); for (int i = 0; i < need; i++) { FilterRule* rule = calloc(1, sizeof(FilterRule)); EXPECT_NOT_NULL(rule); rule->action = FILTER_ACTION_EXCLUDE; rule->sides = FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER; rule->owner = str_dup(""); rule->pattern = malloc((size_t)per + 1); EXPECT_NOT_NULL(rule->owner); EXPECT_NOT_NULL(rule->pattern); memset(rule->pattern, 'b', (size_t)per); rule->pattern[per] = '\0'; EXPECT_TRUE(filter_rule_list_add(list, rule)); } EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); DrainArg drain = {p[0]}; thrd_t drainer; EXPECT_EQ_INT(thrd_create(&drainer, drain_fd_thread, &drain), thrd_success); bool sent = delete_filter_dir_rules_send(p[1], list); close(p[1]); thrd_join(drainer, NULL); EXPECT_FALSE(sent); close(p[0]); filter_rule_list_free(list); } /* A valid block still round-trips through send -> receive. */ { FilterRuleList* list = filter_rule_list_create(); EXPECT_NOT_NULL(list); FilterRule* rule = calloc(1, sizeof(FilterRule)); EXPECT_NOT_NULL(rule); rule->action = FILTER_ACTION_EXCLUDE; rule->sides = FILTER_SIDE_SENDER | FILTER_SIDE_RECEIVER; rule->owner = str_dup("sub"); rule->pattern = str_dup("*.log"); EXPECT_NOT_NULL(rule->owner); EXPECT_NOT_NULL(rule->pattern); EXPECT_TRUE(filter_rule_list_add(list, rule)); EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); EXPECT_TRUE(delete_filter_dir_rules_send(p[1], list)); FilterRuleList* out = NULL; EXPECT_TRUE(delete_filter_dir_rules_receive(p[0], &out)); EXPECT_NOT_NULL(out); EXPECT_EQ_INT(out->count, 1); EXPECT_EQ_STR(out->items[0]->owner, "sub"); EXPECT_EQ_STR(out->items[0]->pattern, "*.log"); filter_rule_list_free(out); close(p[0]); close(p[1]); filter_rule_list_free(list); } } void test_delete_plan(void) { test_delete_delay_refilled_dir_removed_recursively(); test_delete_delay_removed_file_counted(); test_delete_delay_max_delete_bounds_actual(); test_delete_delay_actual_removal_charges_budget(); test_config_only_frame_applies_missing_args(); test_filter_dir_rules_receive_bounds(); test_filter_dir_rules_send_bounds(); }