#include "utils.h" #include "array_list.h" #include "log.h" #include #include #include #include #include #include #include #include #include static int authorized_root_fd = -1; static char* authorized_root_path; bool utils_set_authorized_root(int fd, const char* canonical_path) { char* path_copy = canonical_path ? str_dup(canonical_path) : NULL; if (canonical_path && !path_copy) { authorized_root_fd = -1; free(authorized_root_path); authorized_root_path = NULL; return false; } authorized_root_fd = fd; free(authorized_root_path); authorized_root_path = path_copy; return true; } void utils_set_authorized_root_fd(int fd) { (void)utils_set_authorized_root(fd, NULL); } static bool path_is_within_root(const char* root, const char* path) { size_t root_len = strlen(root); return strncmp(root, path, root_len) == 0 && (path[root_len] == '\0' || path[root_len] == '/'); } static int open_authorized_destination(const char* dest_root) { if (authorized_root_fd < 0 || !authorized_root_path || !dest_root || !path_is_within_root(authorized_root_path, dest_root)) return -1; int dirfd = dup(authorized_root_fd); if (dirfd < 0) return -1; const char* relative_path = dest_root + strlen(authorized_root_path); while (*relative_path == '/') relative_path++; char* relative = str_dup(*relative_path ? relative_path : "."); if (!relative) { close(dirfd); return -1; } char* saveptr = NULL; char* component = strtok_r(relative, "/", &saveptr); while (component) { if (strcmp(component, "..") == 0) { free(relative); close(dirfd); return -1; } if (strcmp(component, ".") == 0) { component = strtok_r(NULL, "/", &saveptr); continue; } int next = openat(dirfd, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); if (next < 0) { free(relative); close(dirfd); return -1; } close(dirfd); dirfd = next; component = strtok_r(NULL, "/", &saveptr); } free(relative); return dirfd; } char* str_dup(const char* string) { if (string == NULL) return NULL; size_t str_len = strlen(string); char* new_string = (char*)malloc(str_len + 1); if (new_string == NULL) return NULL; memcpy(new_string, string, str_len + 1); return new_string; } char* output_escape(const char* string, bool eight_bit_output) { if (!string) return NULL; size_t length = strlen(string); if (length > (SIZE_MAX - 1) / 5) return NULL; char* escaped = malloc(length * 5 + 1); if (!escaped) return NULL; size_t out = 0; for (size_t i = 0; i < length; i++) { unsigned char byte = (unsigned char)string[i]; if ((byte >= 32 && byte <= 126) || (eight_bit_output && byte >= 128)) { escaped[out++] = (char)byte; } else { escaped[out++] = '\\'; escaped[out++] = '#'; escaped[out++] = (char)('0' + ((byte >> 6) & 7)); escaped[out++] = (char)('0' + ((byte >> 3) & 7)); escaped[out++] = (char)('0' + (byte & 7)); } } escaped[out] = '\0'; return escaped; } /* Match a glob pattern against a string. Supported wildcards: * ? matches any single character except '/'. * * matches any sequence of characters within one path component (no '/'). * ** matches any sequence of characters, including '/' (cross-directory). * slash-star-star-slash is treated as a cross-directory wildcard when it appears between * literals. */ bool glob_match(const char* pattern, const char* str) { while (*pattern) { if (*pattern == '*') { if (*(pattern + 1) == '*') { /* globstar: match across directories */ pattern += 2; if (*pattern == '\0') return true; if (*pattern == '/') pattern++; while (*str) { if (glob_match(pattern, str)) return true; str++; } return glob_match(pattern, str); } /* single *: match within one path component */ pattern++; while (*str && *str != '/') { if (glob_match(pattern, str)) return true; str++; } return glob_match(pattern, str); } else if (*pattern == '?') { if (!*str || *str == '/') return false; pattern++; str++; } else { if (*pattern != *str) { /* allow literal / ** / rest to match any number of directories */ if (*pattern == '/' && *(pattern + 1) == '*' && *(pattern + 2) == '*') { const char* rest = pattern + 3; if (*rest == '/') rest++; return glob_match(rest, str); } return false; } pattern++; str++; } } return *str == '\0'; } bool format_human_bytes(unsigned long long bytes, char* buffer, size_t buffer_size) { static const char* const units[] = {"B", "KB", "MB", "GB", "TB", "PB", "EB"}; double value = (double)bytes; size_t unit = 0; int written; if (!buffer || buffer_size == 0) return false; while (value >= 1024.0 && unit < sizeof(units) / sizeof(units[0]) - 1) { value /= 1024.0; unit++; } if (unit == 0) written = snprintf(buffer, buffer_size, "%llu %s", bytes, units[unit]); else written = snprintf(buffer, buffer_size, "%.1f %s", value, units[unit]); return written >= 0 && (size_t)written < buffer_size; } static bool is_dir_in_manifest(const char* rel_path, ArrayList* manifest) { size_t len = strlen(rel_path); for (int i = 0; i < manifest->size; i++) { const char* entry = (const char*)manifest->items[i]; // Check if entry starts with rel_path + '/' or matches exactly if (strncmp(entry, rel_path, len) == 0 && (entry[len] == '/' || entry[len] == '\0')) return true; } return false; } /* True when child_rel is, or lies below, a protected entry. A prefix "a" therefore protects "a" and "a/b/c" but not "ab". Entries with top_level_only set only protect DIRECT children of the receive root (at_root); nested directories that share such a name stay ordinary destination content. */ bool path_under_skip_prefix(const char* child_rel, bool at_root, const DeleteSkipEntry* skips, int skip_count) { for (int i = 0; i < skip_count; i++) { if (skips[i].top_level_only && !at_root) continue; size_t prefix_len = strlen(skips[i].prefix); if (strncmp(child_rel, skips[i].prefix, prefix_len) == 0 && (child_rel[prefix_len] == '\0' || child_rel[prefix_len] == '/')) return true; } return false; } /* All-or-nothing max-delete needs to know BEFORE any unlink whether the run would delete more than max_delete entries. This rehearsal pass walks the destination with the same decisions as the delete pass but never touches the filesystem: it counts every regular file the delete pass would unlink and every directory it would rmdir (a directory is removed only once every entry below it has been removed and nothing the walker leaves in place survives). Entries the walker never removes (symlinks, manifest-listed files, protected prefixes) mark the enclosing directory as surviving, exactly as they would make a real rmdir fail with ENOTEMPTY. Stops early once *count reaches the cap (sets *exceeds). Returns false on a traversal error. */ static bool count_extras_fd(int dirfd, const char* rel_path, ArrayList* manifest, size_t cap, size_t* count, bool* exceeds, const DeleteSkipEntry* skips, int skip_count, bool* survives) { /* openat(dirfd, ".") opens an independent file description: a dup() would share dirfd's file offset, and a prior rehearsal pass must not have drained this directory's stream before the delete pass reads it again. */ int scanfd = openat(dirfd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); if (scanfd < 0) return false; DIR* dir = fdopendir(scanfd); if (!dir) { close(scanfd); return false; } bool operation_ok = true; bool local_survives = false; bool at_root = rel_path[0] == '\0'; const struct dirent* entry; while ((entry = readdir(dir)) != NULL) { if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) continue; if (*exceeds) break; char* child_rel = path_cat((char*)rel_path, entry->d_name); if (!child_rel) { operation_ok = false; continue; } if (path_under_skip_prefix(child_rel, at_root, skips, skip_count)) { local_survives = true; free(child_rel); continue; } struct stat st; if (fstatat(dirfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) { if (errno != ENOENT) operation_ok = false; free(child_rel); continue; } if (S_ISLNK(st.st_mode)) { local_survives = true; free(child_rel); continue; } if (S_ISDIR(st.st_mode)) { int childfd = openat(dirfd, entry->d_name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); bool child_ok = true; bool child_survives = true; if (childfd >= 0) { child_ok = count_extras_fd(childfd, child_rel, manifest, cap, count, exceeds, skips, skip_count, &child_survives); close(childfd); } else if (errno != ENOENT) { operation_ok = false; } if (!child_ok) operation_ok = false; if (is_dir_in_manifest(child_rel, manifest)) { /* A directory with kept content below it is never removed. */ local_survives = true; } else if (child_survives) { /* The directory still holds entries the walker leaves in place, so an rmdir would fail with ENOTEMPTY; the delete pass leaves it behind rather than reporting an error (matching rsync). */ local_survives = true; } else { if (*count >= cap) { *exceeds = true; } else { (*count)++; } } } else { bool found = false; for (int i = 0; i < manifest->size; i++) { if (strcmp((char*)manifest->items[i], child_rel) == 0) { found = true; break; } } if (!found) { if (*count >= cap) { *exceeds = true; } else { (*count)++; } } } free(child_rel); } closedir(dir); *survives = local_survives; return operation_ok; } static bool delete_extras_fd(int dirfd, const char* rel_path, ArrayList* manifest, size_t max_delete, size_t* deleted_count, const DeleteSkipEntry* skips, int skip_count) { /* Independent file description (see count_extras_fd). */ int scanfd = openat(dirfd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); if (scanfd < 0) return false; DIR* dir = fdopendir(scanfd); if (!dir) { close(scanfd); return false; } bool operation_ok = true; const struct dirent* entry; while ((entry = readdir(dir)) != NULL) { if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) continue; char* child_rel = path_cat((char*)rel_path, entry->d_name); if (!child_rel) { operation_ok = false; continue; } /* A --delay-updates run keeps its staging directory as a direct child of the receive root, and basis-dir snapshots live below it too. Their contents are not manifest entries, so descending into them would delete every staged / basis file as an "extra". Only the staging name (a top-level-only prefix) and the basis prefixes are protected: a nested destination directory that happens to be called .fastsync-stage is ordinary content. */ if (path_under_skip_prefix(child_rel, rel_path[0] == '\0', skips, skip_count)) { free(child_rel); continue; } struct stat st; if (fstatat(dirfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) { if (errno != ENOENT) operation_ok = false; free(child_rel); continue; } // Skip symlinks to prevent following them outside the destination tree if (S_ISLNK(st.st_mode)) { free(child_rel); continue; } if (S_ISDIR(st.st_mode)) { int childfd = openat(dirfd, entry->d_name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); bool child_removed = false; if (childfd >= 0) { child_removed = delete_extras_fd(childfd, child_rel, manifest, max_delete, deleted_count, skips, skip_count); if (!child_removed) operation_ok = false; close(childfd); } else if (errno != ENOENT) { operation_ok = false; } if (child_removed && !is_dir_in_manifest(child_rel, manifest)) { if (*deleted_count >= max_delete) { operation_ok = false; } else { if (unlinkat(dirfd, entry->d_name, AT_REMOVEDIR) != 0) { /* ENOENT: already gone (fine). ENOTEMPTY/EEXIST: the directory still holds entries the walker leaves in place (a protected excluded prefix, a kept file the manifest protects, a symlink); rsync leaves such a directory behind, so this is not an error. Only genuine I/O failures abort the deletion. */ if (errno != ENOENT && errno != ENOTEMPTY && errno != EEXIST) operation_ok = false; } else { (*deleted_count)++; } } } } else { // Check if relative path is in manifest bool found = false; for (int i = 0; i < manifest->size; i++) { if (strcmp((char*)manifest->items[i], child_rel) == 0) { found = true; break; } } if (!found) { if (*deleted_count >= max_delete) { operation_ok = false; free(child_rel); continue; } if (unlinkat(dirfd, entry->d_name, 0) != 0) { if (errno != ENOENT) operation_ok = false; } else { (*deleted_count)++; } char* escaped_path = output_escape(child_rel, log_get_8_bit_output()); fprintf(stderr, " Deleted: %s\n", escaped_path ? escaped_path : ""); free(escaped_path); } } free(child_rel); } closedir(dir); return operation_ok; } DeleteWalkResult delete_extras_limited(const char* dest_root, ArrayList* manifest, size_t max_delete, const DeleteSkipEntry* skips, int skip_count, size_t* deleted_out) { if (deleted_out) *deleted_out = 0; if (!manifest) return DELETE_WALK_ERROR; int rootfd; if (authorized_root_fd >= 0) { if (authorized_root_path) rootfd = open_authorized_destination(dest_root); else if (dest_root == NULL) rootfd = dup(authorized_root_fd); else rootfd = -1; } else { rootfd = open(dest_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); } if (rootfd < 0) return DELETE_WALK_ERROR; if (max_delete != SIZE_MAX) { /* Rehearse the deletion first so a run that would exceed the cap removes nothing (rsync's all-or-nothing --max-delete contract). */ size_t count = 0; bool exceeds = false; bool survives = false; bool counted_ok = count_extras_fd(rootfd, "", manifest, max_delete, &count, &exceeds, skips, skip_count, &survives); if (!counted_ok) { close(rootfd); return DELETE_WALK_ERROR; } if (exceeds) { close(rootfd); return DELETE_WALK_LIMIT_EXCEEDED; } } size_t deleted_count = 0; bool ok = delete_extras_fd(rootfd, "", manifest, max_delete, &deleted_count, skips, skip_count); if (close(rootfd) != 0) ok = false; if (deleted_out) *deleted_out = deleted_count; return ok ? DELETE_WALK_OK : DELETE_WALK_ERROR; } bool delete_extras(const char* dest_root, ArrayList* manifest) { return delete_extras_limited(dest_root, manifest, SIZE_MAX, NULL, 0, NULL) == DELETE_WALK_OK; } bool has_path_traversal(const char* path) { if (!path) return true; char* dup = str_dup(path); if (!dup) return true; char* saveptr; const char* part = strtok_r(dup, "/", &saveptr); while (part) { if (strcmp(part, "..") == 0) { free(dup); return true; } part = strtok_r(NULL, "/", &saveptr); } free(dup); return false; } bool utils_valid_batch_path(const char* path) { return path && path[0] != '\0' && path[0] != '/' && !has_path_traversal(path); } char* path_cat(const char* path1, const char* path2) { if (path1 == NULL || *path1 == '\0') return str_dup(path2); if (path2 == NULL || *path2 == '\0') return str_dup(path1); size_t path1_len = strlen(path1); size_t path2_len = strlen(path2); size_t offset = 0; if (path1[path1_len - 1] == '/') path1_len -= 1; if (path2[0] == '/') { offset = 1; path2_len -= 1; } if (path1_len > SIZE_MAX - path2_len - 2) return NULL; char* new_path = malloc(path1_len + path2_len + 2); if (new_path == NULL) return NULL; memcpy(new_path, path1, path1_len); new_path[path1_len] = '/'; memcpy(new_path + path1_len + 1, path2 + offset, path2_len); new_path[path1_len + path2_len + 1] = '\0'; return new_path; } bool append_resume_eligible(unsigned long long old_size, unsigned long long check_size) { return old_size < check_size; } bool append_tail_length(unsigned long long old_size, unsigned long long check_size, unsigned long long* tail_out) { if (!tail_out || !append_resume_eligible(old_size, check_size)) return false; *tail_out = check_size - old_size; return true; }