diff --git a/src/shared/file.c b/src/shared/file.c index 46a0770..3f6b93b 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -410,6 +411,79 @@ bool file_rename_secure(const char* old_path, const char* new_path) { return ok; } +/* Recursively delete every entry inside an open directory, never following a + symlink (an O_NOFOLLOW fd walk, so a symlink planted inside the tree can + never redirect removal outside of it). The directory itself is left in + place; returns false on any failure. */ +static bool wipe_dir_fd(int dirfd) { + int scanfd = dup(dirfd); + 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; + struct stat st; + if (fstatat(dirfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) { + if (errno != ENOENT) + operation_ok = false; + 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 = wipe_dir_fd(childfd); + close(childfd); + } else if (errno != ENOENT) { + operation_ok = false; + } + if (child_removed && unlinkat(dirfd, entry->d_name, AT_REMOVEDIR) != 0 && errno != ENOENT) + operation_ok = false; + } else { + /* Files and symlinks alike are removed by name, never followed. */ + if (unlinkat(dirfd, entry->d_name, 0) != 0 && errno != ENOENT) + operation_ok = false; + } + } + closedir(dir); + return operation_ok; +} + +/* Remove the whole directory tree at `path` (confined below the authorized + root, symlink-safe). --force uses this to clear a non-empty destination + directory that blocks an incoming regular file. Returns true when the path + no longer exists as a directory (a missing path or a non-directory at the + final component is a no-op success; the normal write path replaces files). */ +bool file_remove_tree_secure(const char* path) { + if (!path) + return false; + char* leaf = NULL; + int parent_fd = file_open_secure_parent(path, &leaf, false); + if (parent_fd < 0) + return false; + int dirfd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (dirfd < 0) { + bool absent = errno == ENOENT || errno == ENOTDIR || errno == ELOOP; + close(parent_fd); + free(leaf); + return absent; + } + bool ok = wipe_dir_fd(dirfd); + close(dirfd); + if (ok && unlinkat(parent_fd, leaf, AT_REMOVEDIR) != 0 && errno != ENOENT) + ok = false; + close(parent_fd); + free(leaf); + return ok; +} + /* Open a private staging/scratch directory, creating it (and any missing path components) on demand. dir_path is expected to already be confined below the authorized root by the caller; file_open_secure_parent re-checks that diff --git a/src/shared/file.h b/src/shared/file.h index 25ea5d4..54f9157 100644 --- a/src/shared/file.h +++ b/src/shared/file.h @@ -32,6 +32,10 @@ int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs) bool file_ensure_directory_secure(const char* path); bool file_directory_exists_secure(const char* path); bool file_rename_secure(const char* old_path, const char* new_path); +/* Remove the whole directory tree at `path` (confined, symlink-safe). Used by + --force to clear a non-empty destination directory that blocks an incoming + regular file. See the .c for the exact success semantics. */ +bool file_remove_tree_secure(const char* path); /* Open a private 0700 directory (creating it on demand) that must live below the authorized root. Used for the --temp-dir scratch directory and the --delay-updates staging directory. */