// // WQAccess.m // WQ Files // // Sandbox extension technique: worse_query by Taj C (forcequit), // https://github.com/forcequitOS/worse_query, GPL-3.0-or-later. // // Notes from testing on 27.0.1: // - An extension covers the folder it was issued for. A container root // extension was not enough to open a file two levels down for writing, so // writes always ask for the file's own folder first. // - /var/containers/Shared/SystemGroup itself gets no token; its children do. // #import "WQAccess.h" #import "WQCore.h" #import "WQAccessSafety.h" #import "WQLocalization.h" #import #import #import #import #import #import #import #import #import #import #import #import #import @implementation WQScanCancellation { _Atomic BOOL _cancelled; } - (BOOL)isCancelled { return atomic_load_explicit(&_cancelled, memory_order_relaxed); } - (void)cancel { atomic_store_explicit(&_cancelled, YES, memory_order_relaxed); } @end #pragma mark - Log static NSMutableString *gLog; static NSLock *gLogLock; void wq_log(NSString *format, ...) { static dispatch_once_t once; dispatch_once(&once, ^{ gLog = [NSMutableString new]; gLogLock = [NSLock new]; }); va_list args; va_start(args, format); NSString *line = [[NSString alloc] initWithFormat:format arguments:args]; va_end(args); NSLog(@"[wqfiles] %@", line); [gLogLock lock]; [gLog appendFormat:@"%@\n", line]; [gLogLock unlock]; } NSString *wq_log_text(void) { [gLogLock lock]; NSString *text = [gLog copy] ?: @""; [gLogLock unlock]; return text; } static NSString *wq_errno(int err) { return [NSString stringWithFormat:@"%s (errno %d)", strerror(err), err]; } #pragma mark - App group typedef struct __SecTask *SecTaskRef; extern SecTaskRef SecTaskCreateFromSelf(CFAllocatorRef allocator); extern CFTypeRef SecTaskCopyValueForEntitlement(SecTaskRef task, CFStringRef entitlement, CFErrorRef *error); NSString *wq_app_group(void) { static NSString *group; static dispatch_once_t once; dispatch_once(&once, ^{ NSArray *groups = nil; SecTaskRef task = SecTaskCreateFromSelf(kCFAllocatorDefault); if (task) { CFTypeRef value = SecTaskCopyValueForEntitlement(task, CFSTR("com.apple.security.application-groups"), NULL); if (value) groups = (__bridge_transfer NSArray *)value; CFRelease(task); } for (id candidate in groups) { if (![candidate isKindOfClass:NSString.class]) continue; NSURL *root = [NSFileManager.defaultManager containerURLForSecurityApplicationGroupIdentifier:candidate]; if (root) { group = candidate; if (!WQRecoverCacheSwapAtPath([root.path stringByAppendingPathComponent:@"Library"])) wq_log(@"App-group cache recovery requires attention; preserved contents retained: %@ (%@)", root.path, wq_errno(errno)); break; } } wq_log(@"App group: %@ (entitled: %@)", group ?: @"NONE", groups.count ? [groups componentsJoinedByString:@", "] : @"none"); }); return group; } #pragma mark - worse_query typedef void *(*cq_create_fn)(void); typedef void (*cq_set_u64_fn)(void *, uint64_t); typedef void (*cq_set_ids_fn)(void *, xpc_object_t); typedef void *(*cq_result_fn)(void *); typedef void (*cq_free_fn)(void *); typedef char *(*cq_token_fn)(void *); typedef int64_t (*ext_consume_fn)(const char *); // The callback returns true to continue; a void block leaves that value undefined. typedef void (*cq_iterate_fn)(void *, bool (^)(void *)); typedef const char *(*co_get_path_fn)(void *); typedef const char *(*co_get_id_fn)(void *); static void wq_release_handle(int64_t handle); static int64_t wq_query_token(NSString *group, BOOL altFlags) { static void *mgr; static cq_create_fn create; static cq_set_u64_fn setClass, setFlags, setPart; static cq_set_ids_fn setGroups; static cq_result_fn single; static cq_free_fn freeQuery; static cq_token_fn copyToken; static ext_consume_fn consume; if (!mgr) { mgr = dlopen("/usr/lib/system/libsystem_containermanager.dylib", RTLD_NOW | RTLD_LOCAL); if (!mgr) return -2; create = (cq_create_fn)dlsym(mgr, "container_query_create"); setClass = (cq_set_u64_fn)dlsym(mgr, "container_query_set_class"); setGroups = (cq_set_ids_fn)dlsym(mgr, "container_query_set_group_identifiers"); setFlags = (cq_set_u64_fn)dlsym(mgr, "container_query_operation_set_flags"); setPart = (cq_set_u64_fn)dlsym(mgr, "container_query_operation_set_part"); single = (cq_result_fn)dlsym(mgr, "container_query_get_single_result"); freeQuery = (cq_free_fn)dlsym(mgr, "container_query_free"); copyToken = (cq_token_fn)dlsym(mgr, "container_copy_sandbox_token"); consume = (ext_consume_fn)dlsym(RTLD_DEFAULT, "sandbox_extension_consume"); } if (!create || !setClass || !setGroups || !setFlags || !setPart || !single || !freeQuery || !copyToken || !consume) return -2; void *query = create(); if (!query) return -3; char *token = NULL; @try { setClass(query, 7); // app group container xpc_object_t ident = xpc_string_create(group.UTF8String); setGroups(query, ident); setPart(query, 3); // Library/Caches setFlags(query, altFlags ? 0x0000000800000000ULL : 0x0000008000000000ULL); void *result = single(query); token = result ? copyToken(result) : NULL; int64_t handle = token ? consume(token) : (result ? -6 : -5); return token && handle < 0 ? -7 : handle; } @finally { if (token) free(token); freeQuery(query); } } BOOL wq_system_areas_reachable(void) { // worse_query reaches System Data, the system groups and the App Groups // parent only on iOS 27; on iOS 26 it reaches the individual app, // extension, daemon and app group containers. return NSProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 27; } static int64_t wq_query(NSString *target, BOOL altFlags) { NSString *group = wq_app_group(); if (!group) return -1; NSFileManager *files = NSFileManager.defaultManager; NSURL *root = [files containerURLForSecurityApplicationGroupIdentifier:group]; if (!root) return -1; NSString *libraryPath = [root.path stringByAppendingPathComponent:@"Library"]; if (![files createDirectoryAtPath:libraryPath withIntermediateDirectories:YES attributes:nil error:nil]) return -4; int library = WQOpenDirectoryNoLinks(libraryPath); if (library < 0) return -4; int lock = WQOpenRegularAt(library, ".wq-caches-lock", O_RDWR | O_CREAT, 0600); if (lock < 0 || flock(lock, LOCK_EX | LOCK_NB) != 0) { if (lock >= 0) close(lock); close(library); return -4; } int64_t handle = -4; @try { // Keep the exact Caches swap point required by worse_query, but preserve // its original inode and nested contents. Next acquisition recovers an // interrupted swap from the durable marker, before doing another query. if (WQCacheSwapBegin(library, target)) handle = wq_query_token(group, altFlags); } @catch (NSException *exception) { wq_log(@"token query exception: %@", exception.reason); handle = -9; } @finally { if (!WQCacheSwapRecover(library)) { wq_log(@"token cache recovery failed: %@; preserved contents retained", libraryPath); if (handle >= 0) wq_release_handle(handle); handle = -8; } close(lock); close(library); } return handle; } static NSMutableDictionary *gGrants; static NSMutableArray *gGrantOrder; // oldest first // The kernel limits how many sandbox extensions a process may hold. Keep a // bounded working set and release the oldest; released paths are simply // granted again when they are next used. static const NSUInteger kMaxGrants = 192; static void wq_release_handle(int64_t handle) { typedef int (*ext_release_fn)(int64_t); static ext_release_fn release; if (!release) release = (ext_release_fn)dlsym(RTLD_DEFAULT, "sandbox_extension_release"); if (release && handle >= 0) release(handle); } static NSRecursiveLock *gGrantLock; static NSString *wq_norm(NSString *path) { NSString *p = path.stringByStandardizingPath; if ([p hasPrefix:@"/var/"]) p = [@"/private" stringByAppendingString:p]; return p; } // Whether the target can now be opened. Only a sandbox denial (EPERM) counts // as "the token didn't help": EACCES is ordinary file permissions (root-owned // system containers), which a token never changes. static BOOL wq_grant_opens(NSString *path) { int fd = open(path.fileSystemRepresentation, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); if (fd >= 0) { close(fd); return YES; } return errno != EPERM; } BOOL wq_grant(NSString *path) { static dispatch_once_t once; dispatch_once(&once, ^{ gGrants = [NSMutableDictionary new]; gGrantOrder = [NSMutableArray new]; gGrantLock = [NSRecursiveLock new]; }); NSString *key = wq_norm(path); [gGrantLock lock]; NSNumber *known = gGrants[key]; if (known) { [gGrantOrder removeObject:key]; // most recently used goes last [gGrantOrder addObject:key]; [gGrantLock unlock]; return YES; } while (gGrantOrder.count >= kMaxGrants) { NSString *oldest = gGrantOrder.firstObject; wq_release_handle(gGrants[oldest].longLongValue); [gGrants removeObjectForKey:oldest]; [gGrantOrder removeObjectAtIndex:0]; } struct stat st; if (lstat(key.fileSystemRepresentation, &st) != 0) { [gGrantLock unlock]; return NO; // worse_query needs an existing target } // iOS 26 needs the alternative query flags; try the likely ones first so // a grant costs one query, and the other set only when that fails. A // token can also be issued and consumed yet not open the target (seen on // 27.0.1 with an AltStore app group): check, and then try the other flags. // The set that works is remembered for the rest of the run. static int preferAlt = -1; BOOL altFirst = preferAlt >= 0 ? (BOOL)preferAlt : !wq_system_areas_reachable(); int64_t handle = wq_query(key, altFirst); BOOL usedAlt = altFirst; if (handle >= 0 && !wq_grant_opens(key)) { static BOOL logged; if (!logged) { logged = YES; wq_log(@"grant: the token for %@ doesn't open it (%@ query flags); trying the other flags", key, altFirst ? @"alternative" : @"standard"); } wq_release_handle(handle); handle = -8; } if (handle < 0 && handle != -1 && handle != -2) { int64_t alt = wq_query(key, !altFirst); if (alt >= 0 && !wq_grant_opens(key)) { wq_release_handle(alt); alt = -8; } if (alt >= 0) { handle = alt; usedAlt = !altFirst; } } // Decided once, by the first grant that works: some targets refuse to // open for their own reasons, and a per-target fallback already covers // the rest, so flipping the preference back and forth only costs queries. if (handle >= 0 && preferAlt < 0) { preferAlt = usedAlt; if (usedAlt != !wq_system_areas_reachable()) wq_log(@"grant: the %@ query flags work on this device", usedAlt ? @"alternative" : @"standard"); } if (handle >= 0) { // failures are retried next time gGrants[key] = @(handle); [gGrantOrder addObject:key]; } [gGrantLock unlock]; return handle >= 0; } static BOOL wq_is_denied(int err) { return err == EPERM || err == EACCES; } #pragma mark - Backups #ifdef WQ_ACCESS_TESTING static NSString *wq_test_backup_root; static NSString *wq_test_app_backup_root; static int64_t wq_test_volume_limit = -1; static void (^wq_test_mutation_hook)(NSString *stage, NSString *path); static void (^wq_test_tree_hook)(NSString *stage, NSString *path); static void (^wq_test_stream_hook)(NSString *stage, NSString *path); #define WQ_TREE_HOOK(stage, path) do { if (wq_test_tree_hook) wq_test_tree_hook(stage, path); } while (0) #define WQ_MUTATION_HOOK(stage, path) do { if (wq_test_mutation_hook) wq_test_mutation_hook(stage, path); } while (0) #define WQ_STREAM_HOOK(stage, path) do { if (wq_test_stream_hook) wq_test_stream_hook(stage, path); } while (0) #else #define WQ_MUTATION_HOOK(stage, path) do {} while (0) #define WQ_TREE_HOOK(stage, path) do {} while (0) #define WQ_STREAM_HOOK(stage, path) do {} while (0) #endif NSString *wq_backup_root(void) { #ifdef WQ_ACCESS_TESTING if (wq_test_backup_root) return wq_test_backup_root; #endif NSString *docs = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject; return [docs stringByAppendingPathComponent:@"Backups"]; } NSString *wq_app_backup_root(void) { #ifdef WQ_ACCESS_TESTING if (wq_test_app_backup_root) return wq_test_app_backup_root; #endif NSString *docs = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject; return [docs stringByAppendingPathComponent:@"AppBackups"]; } BOOL wq_is_backup_storage_path(NSString *path) { NSString *original = WQCanonicalVarPath(path); NSMutableArray *roots = [NSMutableArray new]; NSString *fileRoot = WQCanonicalVarPath(wq_backup_root()); NSString *appRoot = WQCanonicalVarPath(wq_app_backup_root()); if (fileRoot.length) [roots addObject:fileRoot]; if (appRoot.length) [roots addObject:appRoot]; for (NSString *root in roots) { if ([original isEqualToString:root] || [original hasPrefix:[root stringByAppendingString:@"/"]]) return YES; } return NO; } static int wq_open(NSString *path, int flags, mode_t mode); static int wq_open_directory_flags(NSString *path, int flags); // Copies everything from fd `in` to fd `out` in chunks; fsyncs `out`. static BOOL wq_stream(int in, int out, NSString **errorText) { static const size_t kChunk = 1 << 20; uint8_t *buf = malloc(kChunk); if (!buf) { if (errorText) *errorText = WQLocalized(@"Out of memory"); return NO; } BOOL ok = YES; for (;;) { ssize_t n = read(in, buf, kChunk); if (n < 0 && errno == EINTR) continue; if (n < 0) { ok = NO; if (errorText) *errorText = wq_errno(errno); break; } if (n == 0) break; for (ssize_t done = 0; done < n; ) { ssize_t w = write(out, buf + done, (size_t)(n - done)); if (w < 0 && errno == EINTR) continue; if (w <= 0) { ok = NO; if (errorText) *errorText = wq_errno(w == 0 ? EIO : errno); break; } done += w; } if (!ok) break; } free(buf); if (ok && fsync(out) != 0) { ok = NO; if (errorText) *errorText = wq_errno(errno); } return ok; } // Always durable, including own files. Safety artifacts themselves are protected // from generic overwrite/delete: recursively backing up Backups is unbounded. static BOOL wq_backup_fd(NSString *path, int fd, NSString **backupPath, NSString **errorText) { if (errorText) *errorText = nil; NSString *original = WQCanonicalVarPath(path); NSString *root = WQCanonicalVarPath(wq_backup_root()); if (!WQAbsolutePathGrammar(original) || wq_is_backup_storage_path(original)) { wq_log(@"backup: refusing invalid path or mutation of backup storage: %@", path); if (errorText) *errorText = WQLocalized(@"The path is invalid or belongs to backup storage."); return NO; } static NSDateFormatter *fmt; static uint64_t counter; static NSLock *lock; static dispatch_once_t once; dispatch_once(&once, ^{ fmt = [NSDateFormatter new]; fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; fmt.calendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSCalendarIdentifierGregorian]; fmt.dateFormat = @"yyyyMMdd-HHmmss-SSS"; lock = [NSLock new]; }); [lock lock]; NSString *stamp = counter == UINT64_MAX ? nil : [NSString stringWithFormat:@"%@-%llu", [fmt stringFromDate:NSDate.date], (unsigned long long)++counter]; [lock unlock]; if (!stamp) { if (errorText) *errorText = WQLocalized(@"Cannot allocate a backup name."); return NO; } NSString *dest = [[root stringByAppendingPathComponent:stamp] stringByAppendingString:original]; int parent = WQCreateDirectoriesNoLinks(dest.stringByDeletingLastPathComponent); if (parent < 0) { if (errorText) *errorText = wq_errno(errno); return NO; } const char *name = dest.lastPathComponent.fileSystemRepresentation; NSString *error = nil; BOOL ok = NO; // APFS clone first: a copy-on-write snapshot is independent, durable, and // does not require a full-size duplicate for every edited file. if (fclonefileat(fd, parent, name, 0) == 0) { int cloned = openat(parent, name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); struct stat clonedStat = {0}, sourceStat = {0}; if (cloned >= 0 && fstat(cloned, &clonedStat) == 0 && fstat(fd, &sourceStat) == 0 && clonedStat.st_size == sourceStat.st_size && fsync(cloned) == 0) { ok = YES; } else { error = wq_errno(errno); } if (cloned >= 0) close(cloned); if (!ok) { unlinkat(parent, name, 0); wq_log(@"backup: clone verification failed for %@ (%@)", path, error ?: @"unknown error"); } } else { int cloneError = errno; wq_log(@"backup: clone unavailable for %@ (%@); streaming", path, wq_errno(cloneError)); struct stat sourceStat = {0}; struct statfs space = {0}; if (fstat(fd, &sourceStat) == 0 && fstatfs(parent, &space) == 0) { unsigned long long available = (unsigned long long)space.f_bavail * (unsigned long long)space.f_bsize; if ((unsigned long long)sourceStat.st_size > available) { error = WQLocalized(@"Not enough free space for a full backup copy"); wq_log(@"backup: %@ (%@)", path, error); if (errorText) *errorText = error; close(parent); return NO; } } int out = WQOpenRegularAt(parent, name, O_WRONLY | O_CREAT | O_EXCL, 0600); if (out >= 0) { ok = lseek(fd, 0, SEEK_SET) >= 0 && wq_stream(fd, out, &error); if (close(out) != 0) ok = NO; } else { error = wq_errno(errno); } if (ok && fsync(parent) != 0) ok = NO; if (!ok) unlinkat(parent, name, 0); } if (ok && fsync(parent) != 0) { ok = NO; error = wq_errno(errno); } if (!ok) unlinkat(parent, name, 0); close(parent); wq_log(@"backup: %@ -> %@", path, ok ? dest : error ?: @"FAILED"); if (ok && backupPath) *backupPath = dest; if (!ok && errorText) *errorText = error ?: WQLocalized(@"Backup copy failed."); return ok; } NSString *wq_backup_original_path(NSString *backupPath) { // Validate raw components before normalization, so traversal is never hidden. if (!WQAbsolutePathGrammar(backupPath)) return nil; NSString *rootPath = WQCanonicalVarPath(wq_backup_root()); if (!rootPath.length) return nil; NSString *root = [rootPath stringByAppendingString:@"/"]; NSString *p = WQCanonicalVarPath(backupPath); if (![p hasPrefix:root]) return nil; NSArray *parts = [[p substringFromIndex:root.length] componentsSeparatedByString:@"/"]; if (parts.count < 2 || !WQBackupStampValid(parts[0])) return nil; NSString *original = [@"/" stringByAppendingString:[[parts subarrayWithRange:NSMakeRange(1, parts.count - 1)] componentsJoinedByString:@"/"]]; return WQAbsolutePathGrammar(original) ? original : nil; } #pragma mark - File operations static NSDictionary *wq_version_for_stat(const struct stat *st) { return @{ @"device": @(st->st_dev), @"inode": @(st->st_ino), @"size": @(st->st_size), @"modifiedSeconds": @(st->st_mtimespec.tv_sec), @"modifiedNanoseconds": @(st->st_mtimespec.tv_nsec), @"changedSeconds": @(st->st_ctimespec.tv_sec), @"changedNanoseconds": @(st->st_ctimespec.tv_nsec) }; } NSDictionary *wq_file_version(NSString *path) { struct stat st; if (!wq_stat(path, &st) || !S_ISREG(st.st_mode)) return nil; return wq_version_for_stat(&st); } static BOOL wq_changed(NSString **errorText) { if (errorText) *errorText = WQLocalized(@"The file changed after it was opened. Your edits were not written; reopen it and compare the changes."); return NO; } typedef struct { int fd; int parent; struct stat before; } WQMutation; static void wq_mutation_close(WQMutation *mutation) { if (mutation->fd >= 0) close(mutation->fd); if (mutation->parent >= 0) close(mutation->parent); mutation->fd = mutation->parent = -1; } // Revalidate the descriptor, directory entry, and the current parent path. A // pathname swap never redirects backup, mutation, or rollback to the new inode. static BOOL wq_mutation_current(NSString *path, WQMutation *mutation, BOOL version, NSString **errorText) { struct stat current, entry, parent, currentParent; int checkParent = WQOpenDirectoryNoLinksFlags(path.stringByDeletingLastPathComponent, O_SEARCH); BOOL ok = checkParent >= 0 && fstat(checkParent, ¤tParent) == 0 && fstat(mutation->parent, &parent) == 0 && WQSameObject(&parent, ¤tParent) && fstat(mutation->fd, ¤t) == 0 && S_ISREG(current.st_mode) && fstatat(mutation->parent, path.lastPathComponent.fileSystemRepresentation, &entry, AT_SYMLINK_NOFOLLOW) == 0 && S_ISREG(entry.st_mode) && WQSameObject(&entry, ¤t) && WQSameObject(¤t, &mutation->before); if (checkParent >= 0) close(checkParent); if (ok && version) ok = [wq_version_for_stat(¤t) isEqualToDictionary:wq_version_for_stat(&mutation->before)]; return ok ? YES : wq_changed(errorText); } static BOOL wq_mutation_open(NSString *path, NSDictionary *expected, WQMutation *mutation, int flags, NSString **errorText) { *mutation = (WQMutation){ .fd = -1, .parent = -1 }; if (!WQAbsolutePathGrammar(WQCanonicalVarPath(path))) { if (errorText) *errorText = WQLocalized(@"Invalid file path"); return NO; } if (wq_is_backup_storage_path(path)) { if (errorText) *errorText = WQLocalized(@"Backup storage cannot be overwritten or deleted through the file browser."); return NO; } NSString *parent = path.stringByDeletingLastPathComponent; mutation->parent = WQOpenDirectoryNoLinksFlags(parent, O_SEARCH); if (mutation->parent < 0 && wq_is_denied(errno) && wq_grant(parent)) mutation->parent = WQOpenDirectoryNoLinksFlags(parent, O_SEARCH); if (mutation->parent >= 0) { mutation->fd = WQOpenRegularAt(mutation->parent, path.lastPathComponent.fileSystemRepresentation, flags, 0); if (mutation->fd < 0 && wq_is_denied(errno) && wq_grant(path)) mutation->fd = WQOpenRegularAt(mutation->parent, path.lastPathComponent.fileSystemRepresentation, flags, 0); } if (mutation->fd < 0) { if (errorText) *errorText = wq_errno(errno); wq_mutation_close(mutation); return NO; } if (flock(mutation->fd, LOCK_EX | LOCK_NB) != 0) { int err = errno; // Some providers do not implement locks. Keep descriptor/version checks; // this is advisory serialization, not atomicity against other writers. if (err != ENOTSUP && err != EOPNOTSUPP && err != ENOSYS) { if (errorText) *errorText = wq_errno(err); wq_mutation_close(mutation); return NO; } wq_log(@"advisory locking unsupported: %@", path); } if (fstat(mutation->fd, &mutation->before) != 0 || (expected && ![wq_version_for_stat(&mutation->before) isEqualToDictionary:expected]) || !wq_mutation_current(path, mutation, YES, errorText)) { wq_changed(errorText); wq_mutation_close(mutation); return NO; } WQ_MUTATION_HOOK(@"verified", path); return YES; } static BOOL wq_mutation_backup(NSString *path, WQMutation *mutation, NSString **backup, NSString **errorText) { if (!wq_mutation_current(path, mutation, YES, errorText)) return NO; NSString *backupError = nil; if (!wq_backup_fd(path, mutation->fd, backup, &backupError)) { if (errorText) *errorText = WQLocalizedFormat(@"Backup of the current file failed, nothing was written: %@", backupError ?: WQLocalized(@"unknown error")); return NO; } WQ_MUTATION_HOOK(@"backedUp", path); return wq_mutation_current(path, mutation, YES, errorText); } static BOOL wq_restore_backup_fd(WQMutation *mutation, NSString *backup, NSString **errorText) { int in = WQOpenRegularAt(AT_FDCWD, backup.fileSystemRepresentation, O_RDONLY, 0); if (in < 0) { if (errorText) *errorText = wq_errno(errno); return NO; } // This is still the original, locked descriptor, never a reopened pathname. BOOL ok = lseek(mutation->fd, 0, SEEK_SET) >= 0 && ftruncate(mutation->fd, 0) == 0 && wq_stream(in, mutation->fd, errorText); close(in); return ok; } static BOOL wq_pread_exact(int fd, void *buffer, size_t length, off_t offset) { size_t done = 0; while (done < length) { ssize_t n = pread(fd, (uint8_t *)buffer + done, length - done, offset + (off_t)done); if (n < 0 && errno == EINTR) continue; if (n <= 0) { if (n == 0) errno = EIO; return NO; } done += (size_t)n; } return YES; } // Compare a complete intended document, or the complete backup with patches // overlaid in write order. Buffers stay bounded even for a very large hex file. static NSDictionary *wq_verified_saved_version(WQMutation *mutation, NSData *data, NSDictionary *runs) { struct stat before, after; if (fstat(mutation->fd, &before) != 0 || !S_ISREG(before.st_mode) || before.st_size < 0) return nil; uint8_t expected[65536], actual[65536]; BOOL ok = YES; if (data) { if ((unsigned long long)before.st_size != data.length) return nil; for (unsigned long long offset = 0; offset < data.length && ok; ) { size_t count = (size_t)MIN((unsigned long long)sizeof(expected), (unsigned long long)data.length - offset); memcpy(expected, (const uint8_t *)data.bytes + (NSUInteger)offset, count); ok = wq_pread_exact(mutation->fd, actual, count, (off_t)offset) && memcmp(expected, actual, count) == 0; offset += count; } } else { // A patch leaves every unedited byte untouched. The descriptor was // pinned and version-checked; verifying the changed runs is sufficient // and avoids reading the entire file for a one-byte edit. if (!runs || (unsigned long long)before.st_size != (unsigned long long)mutation->before.st_size) return nil; for (NSNumber *key in runs) { NSData *run = runs[key]; unsigned long long start = key.unsignedLongLongValue; if (!WQPatchRangeValid(start, run.length, (unsigned long long)before.st_size)) return nil; for (NSUInteger done = 0; done < run.length && ok; ) { size_t count = (size_t)MIN((NSUInteger)sizeof(expected), run.length - done); ok = wq_pread_exact(mutation->fd, actual, count, (off_t)(start + done)) && memcmp((const uint8_t *)run.bytes + done, actual, count) == 0; done += (NSUInteger)count; } if (!ok) break; } } if (!ok || fstat(mutation->fd, &after) != 0 || ![wq_version_for_stat(&before) isEqualToDictionary:wq_version_for_stat(&after)]) return nil; return wq_version_for_stat(&after); } static BOOL wq_mutation_finish(NSString *path, WQMutation *mutation, NSString *backup, BOOL ok, NSString *failure, NSString **errorText, NSDictionary **savedVersion, NSData *expectedData, NSDictionary *expectedRuns) { if (savedVersion) *savedVersion = nil; NSDictionary *verifiedVersion = nil; BOOL rollbackAllowed = YES; if (ok && fsync(mutation->fd) != 0) { ok = NO; failure = wq_errno(errno); } WQ_MUTATION_HOOK(@"written", path); if (ok && !wq_mutation_current(path, mutation, NO, errorText)) { ok = NO; failure = WQLocalized(@"The target path was replaced during writing"); } if (ok && (expectedData || expectedRuns)) { verifiedVersion = wq_verified_saved_version(mutation, expectedData, expectedRuns); if (!verifiedVersion || !wq_mutation_current(path, mutation, NO, errorText)) { ok = NO; rollbackAllowed = NO; // A concurrent writer may own the newer contents. failure = WQLocalized(@"Saved contents could not be verified; possible concurrent modification"); } } if (!ok) { NSString *rollbackError = nil; BOOL restored = rollbackAllowed && backup && wq_restore_backup_fd(mutation, backup, &rollbackError); if (errorText) *errorText = [NSString stringWithFormat:@"%@; %@. Backup: %@", failure ?: WQLocalized(@"Write failed"), restored ? @"the original descriptor contents were restored" : (rollbackAllowed ? [@"rollback failed: " stringByAppendingString:rollbackError ?: WQLocalized(@"unknown error")] : @"no rollback was attempted to avoid overwriting concurrent changes"), backup ?: @"none"]; } // Rollback must finish before close releases the lock. A close failure has // no safe reopen/rollback path; retain the durable backup and report it. if (close(mutation->fd) != 0 && ok) { ok = NO; if (errorText) *errorText = WQLocalizedFormat(@"Close failed: %@. Backup: %@", wq_errno(errno), backup); } mutation->fd = -1; wq_mutation_close(mutation); if (ok && savedVersion) *savedVersion = verifiedVersion; return ok; } NSArray *wq_list(NSString *dir, NSString **errorText) { NSError *error = nil; NSArray *items = [NSFileManager.defaultManager contentsOfDirectoryAtPath:dir error:&error]; if (!items && wq_grant(dir)) items = [NSFileManager.defaultManager contentsOfDirectoryAtPath:dir error:&error]; if (!items && errorText) *errorText = error.localizedDescription ?: WQLocalized(@"Cannot list this folder"); return items; } static int wq_open(NSString *path, int flags, mode_t mode) { if (flags & O_TRUNC) { errno = EINVAL; return -1; } int fd = WQOpenRegularAt(AT_FDCWD, path.fileSystemRepresentation, flags, mode); if (fd >= 0 || !wq_is_denied(errno)) return fd; // The file's own folder first, then the file itself. if (wq_grant(path.stringByDeletingLastPathComponent)) { fd = WQOpenRegularAt(AT_FDCWD, path.fileSystemRepresentation, flags, mode); if (fd >= 0 || !wq_is_denied(errno)) return fd; } if (!(flags & O_CREAT) && wq_grant(path)) fd = WQOpenRegularAt(AT_FDCWD, path.fileSystemRepresentation, flags, mode); if (fd < 0) { // Say why: a sandbox refusal (EPERM) and plain Unix permissions // (EACCES: owner/mode don't allow it) need different fixes. int err = errno; struct stat st; BOOL have = lstat(path.fileSystemRepresentation, &st) == 0; wq_log(@"open %@ for %@ -> %@; file uid %d mode %o, we are uid %d", path, (flags & O_ACCMODE) == O_RDONLY ? @"reading" : @"writing", wq_errno(err), have ? (int)st.st_uid : -1, have ? (int)(st.st_mode & 07777) : 0, (int)getuid()); errno = err; } return fd; } NSData *wq_read(NSString *path, NSUInteger maxBytes, NSString **errorText) { int fd = wq_open(path, O_RDONLY, 0); if (fd < 0) { if (errorText) *errorText = wq_errno(errno); return nil; } NSMutableData *data = [NSMutableData new]; uint8_t buf[65536]; for (;;) { size_t want = sizeof(buf); if (maxBytes && data.length + want > maxBytes) want = maxBytes - data.length; if (want == 0) break; ssize_t n = read(fd, buf, want); if (n < 0) { if (errno == EINTR) continue; if (errorText) *errorText = wq_errno(errno); close(fd); return nil; } if (n == 0) break; [data appendBytes:buf length:(NSUInteger)n]; } close(fd); return data; } static BOOL wq_write_all(int fd, NSData *data) { const uint8_t *p = data.bytes; NSUInteger left = data.length; while (left > 0) { ssize_t n = write(fd, p, left); if (n < 0) { if (errno == EINTR) continue; return NO; } if (n == 0) { errno = EIO; return NO; } p += n; left -= (NSUInteger)n; } return YES; } // Writes, then flushes and closes, reporting the first failure of the three. static BOOL wq_finish_fd(int fd, BOOL ok, int *err) { if (ok && fsync(fd) != 0) { ok = NO; *err = errno; } if (close(fd) != 0 && ok) { ok = NO; *err = errno; } return ok; } static BOOL wq_write_checked(NSString *path, NSData *data, NSDictionary *expected, NSDictionary **savedVersion, BOOL verify, NSString **errorText) { if (savedVersion) *savedVersion = nil; WQMutation mutation; if (!wq_mutation_open(path, expected, &mutation, O_RDWR, errorText)) return NO; NSString *backup = nil; if (!wq_mutation_backup(path, &mutation, &backup, errorText)) { wq_mutation_close(&mutation); return NO; } BOOL ok = lseek(mutation.fd, 0, SEEK_SET) >= 0 && ftruncate(mutation.fd, 0) == 0 && wq_write_all(mutation.fd, data); NSString *failure = ok ? nil : wq_errno(errno); return wq_mutation_finish(path, &mutation, backup, ok, failure, errorText, savedVersion, verify ? data : nil, nil); } BOOL wq_write_if_unchanged_get_version(NSString *path, NSData *data, NSDictionary *expected, NSDictionary **savedVersion, NSString **errorText) { return wq_write_checked(path, data, expected, savedVersion, YES, errorText); } BOOL wq_write_if_unchanged(NSString *path, NSData *data, NSDictionary *expected, NSString **errorText) { return wq_write_checked(path, data, expected, nil, NO, errorText); } BOOL wq_write(NSString *path, NSData *data, NSString **errorText) { return wq_write_if_unchanged(path, data, nil, errorText); } BOOL wq_write_from_file(NSString *path, NSString *source, NSString **errorText) { return wq_write_from_file_with_backup(path, source, NULL, errorText); } BOOL wq_write_from_file_with_backup(NSString *path, NSString *source, NSString **backupUnit, NSString **errorText) { if (backupUnit) *backupUnit = nil; int in = WQOpenRegularAt(AT_FDCWD, source.fileSystemRepresentation, O_RDONLY, 0); if (in < 0) { if (errorText) *errorText = wq_errno(errno); return NO; } WQMutation mutation; if (!wq_mutation_open(path, nil, &mutation, O_RDWR, errorText)) { close(in); return NO; } struct stat src; if (fstat(in, &src) != 0 || WQSameObject(&src, &mutation.before)) { if (errorText) *errorText = WQLocalized(@"Source and destination are the same file"); close(in); wq_mutation_close(&mutation); return NO; } NSDictionary *sourceVersion = wq_version_for_stat(&src); NSString *backup = nil; if (!wq_mutation_backup(path, &mutation, &backup, errorText)) { close(in); wq_mutation_close(&mutation); return NO; } WQ_STREAM_HOOK(@"ready", source); NSString *error = nil; BOOL ok = lseek(mutation.fd, 0, SEEK_SET) >= 0 && ftruncate(mutation.fd, 0) == 0 && wq_stream(in, mutation.fd, &error); struct stat srcAfter = {0}; if (ok && (fstat(in, &srcAfter) != 0 || ![sourceVersion isEqualToDictionary:wq_version_for_stat(&srcAfter)])) { ok = NO; error = WQLocalized(@"The source changed while it was being copied"); } if (!ok && !error) error = wq_errno(errno); close(in); BOOL finished = wq_mutation_finish(path, &mutation, backup, ok, error, errorText, nil, nil, nil); if (finished && backupUnit && backup.length) { // Backups//: the unit is the stamp folder. NSString *root = WQCanonicalVarPath(wq_backup_root()); NSString *canonical = WQCanonicalVarPath(backup); if (root.length && [canonical hasPrefix:[root stringByAppendingString:@"/"]]) { NSString *stamp = [[canonical substringFromIndex:root.length + 1] componentsSeparatedByString:@"/"].firstObject; if (stamp.length) *backupUnit = [root stringByAppendingPathComponent:stamp]; } } return finished; } BOOL wq_create_file_from(NSString *path, NSString *source, NSString **errorText) { if (wq_is_backup_storage_path(path)) { if (errorText) *errorText = WQLocalized(@"Backup storage cannot be modified through the file browser."); return NO; } NSString *canonical = WQCanonicalVarPath(path); if (!WQAbsolutePathGrammar(canonical)) { if (errorText) *errorText = WQLocalized(@"Invalid file path"); return NO; } int in = WQOpenRegularAt(AT_FDCWD, source.fileSystemRepresentation, O_RDONLY, 0); if (in < 0) { if (errorText) *errorText = wq_errno(errno); return NO; } NSString *parentPath = path.stringByDeletingLastPathComponent; int parent = wq_open_directory_flags(parentPath, O_SEARCH); if (parent < 0) { if (errorText) *errorText = wq_errno(errno); close(in); return NO; } const char *name = path.lastPathComponent.fileSystemRepresentation; int fd = WQOpenRegularAt(parent, name, O_WRONLY | O_CREAT | O_EXCL, 0644); if (fd < 0 && wq_is_denied(errno) && wq_grant(parentPath)) fd = WQOpenRegularAt(parent, name, O_WRONLY | O_CREAT | O_EXCL, 0644); if (fd < 0) { if (errorText) *errorText = wq_errno(errno); close(parent); close(in); return NO; } struct stat srcBefore = {0}; if (fstat(in, &srcBefore) != 0) { close(fd); close(in); close(parent); unlinkat(parent, name, 0); if (errorText) *errorText = WQLocalized(@"Cannot inspect the copy source"); return NO; } NSDictionary *sourceVersion = wq_version_for_stat(&srcBefore); WQ_STREAM_HOOK(@"ready", source); NSString *error = nil; BOOL ok = wq_stream(in, fd, &error); struct stat srcAfter = {0}; if (ok && (fstat(in, &srcAfter) != 0 || ![sourceVersion isEqualToDictionary:wq_version_for_stat(&srcAfter)])) { ok = NO; error = WQLocalized(@"The source changed while it was being copied"); } close(in); if (close(fd) != 0 && ok) { ok = NO; error = wq_errno(errno); } if (ok && fsync(parent) != 0) { ok = NO; error = wq_errno(errno); } if (!ok) unlinkat(parent, name, 0); close(parent); wq_log(@"create: %@ from %@ -> %@", path, source.lastPathComponent, ok ? @"OK" : error); if (!ok && errorText) *errorText = error; return ok; } BOOL wq_copy_to_local(NSString *path, NSString *dest, NSString **errorText) { if (!WQAbsolutePathGrammar(WQCanonicalVarPath(path)) || !WQAbsolutePathGrammar(WQCanonicalVarPath(dest))) { if (errorText) *errorText = WQLocalized(@"Invalid copy path"); return NO; } if (wq_is_backup_storage_path(dest)) { if (errorText) *errorText = WQLocalized(@"Backup storage cannot be modified through the file browser."); return NO; } int in = wq_open(path, O_RDONLY, 0); if (in < 0) { if (errorText) *errorText = wq_errno(errno); return NO; } struct stat source, destination; if (fstat(in, &source) != 0) { close(in); return NO; } // stat intentionally follows a destination alias for this identity check. if (stat(dest.fileSystemRepresentation, &destination) == 0 && WQSameObject(&source, &destination)) { close(in); if (errorText) *errorText = WQLocalized(@"Source and destination are the same file"); return NO; } int parent = WQOpenDirectoryNoLinks(dest.stringByDeletingLastPathComponent); if (parent < 0) { if (errorText) *errorText = wq_errno(errno); close(in); return NO; } NSString *stage = [@".wq-local-copy-" stringByAppendingString:NSUUID.UUID.UUIDString]; int out = WQOpenRegularAt(parent, stage.fileSystemRepresentation, O_WRONLY | O_CREAT | O_EXCL, 0644); if (out < 0) { if (errorText) *errorText = wq_errno(errno); close(parent); close(in); return NO; } BOOL ok = wq_stream(in, out, errorText); struct stat after; if (ok && (fstat(in, &after) != 0 || ![wq_version_for_stat(&source) isEqualToDictionary:wq_version_for_stat(&after)])) { ok = NO; if (errorText) *errorText = WQLocalized(@"The source changed while it was copied"); } close(in); if (close(out) != 0 && ok) { ok = NO; if (errorText) *errorText = wq_errno(errno); } // Recheck aliases after the potentially long stream, still before replacing. if (ok && fstatat(parent, dest.lastPathComponent.fileSystemRepresentation, &destination, 0) == 0 && WQSameObject(&source, &destination)) { ok = NO; if (errorText) *errorText = WQLocalized(@"Source and destination are the same file"); } if (ok) { struct stat entry; if (fstatat(parent, dest.lastPathComponent.fileSystemRepresentation, &entry, AT_SYMLINK_NOFOLLOW) == 0 && !S_ISREG(entry.st_mode) && !S_ISLNK(entry.st_mode)) { ok = NO; if (errorText) *errorText = WQLocalized(@"Destination is not a regular file"); } } if (ok && renameat(parent, stage.fileSystemRepresentation, parent, dest.lastPathComponent.fileSystemRepresentation) != 0) { ok = NO; if (errorText) *errorText = wq_errno(errno); } if (ok && fsync(parent) != 0) { ok = NO; if (errorText) *errorText = wq_errno(errno); } if (!ok) unlinkat(parent, stage.fileSystemRepresentation, 0); close(parent); return ok; } NSString *wq_permission_hint(NSString *path, BOOL forWriting) { struct stat st; if (lstat(path.fileSystemRepresentation, &st) != 0) return nil; uid_t me = getuid(); mode_t need = forWriting ? 2 : 4; // w / r BOOL allowed = st.st_uid == me ? (st.st_mode >> 6 & need) != 0 : (st.st_gid == getgid() ? (st.st_mode >> 3 & need) != 0 : (st.st_mode & need) != 0); if (allowed) return nil; NSString *owner = st.st_uid == 0 ? @"root" : [NSString stringWithFormat:@"uid %d", (int)st.st_uid]; return WQLocalizedFormat(@"This file belongs to %@ (mode %o) and only its owner may %@ it. WQ Files reaches the folder, but runs as mobile (uid %d), so no app can %@ this file.", owner, st.st_mode & 07777, forWriting ? WQLocalized(@"change") : WQLocalized(@"read"), (int)me, forWriting ? WQLocalized(@"change") : WQLocalized(@"open")); } NSData *wq_read_range(NSString *path, unsigned long long offset, NSUInteger length, NSString **errorText) { int fd = wq_open(path, O_RDONLY, 0); if (fd < 0) { if (errorText) *errorText = wq_errno(errno); return nil; } NSMutableData *data = [NSMutableData dataWithLength:length]; NSUInteger got = 0; while (got < length) { ssize_t n = pread(fd, (uint8_t *)data.mutableBytes + got, length - got, (off_t)(offset + got)); if (n < 0 && errno == EINTR) continue; if (n < 0) { if (errorText) *errorText = wq_errno(errno); close(fd); return nil; } if (n == 0) break; got += (NSUInteger)n; } close(fd); data.length = got; return data; } static BOOL wq_patch_checked(NSString *path, NSDictionary *runs, NSDictionary *expected, NSDictionary **savedVersion, BOOL verify, NSString **errorText) { if (savedVersion) *savedVersion = nil; WQMutation mutation; if (!wq_mutation_open(path, expected, &mutation, O_RDWR, errorText)) return NO; unsigned long long size = (unsigned long long)mutation.before.st_size; for (NSNumber *off in runs) { if (!WQPatchRangeValid(off.unsignedLongLongValue, runs[off].length, size)) { if (errorText) *errorText = WQLocalized(@"A change lies past the end of the file"); wq_mutation_close(&mutation); return NO; } } NSString *backup = nil; if (!wq_mutation_backup(path, &mutation, &backup, errorText)) { wq_mutation_close(&mutation); return NO; } BOOL ok = YES; int err = 0; for (NSNumber *off in [runs.allKeys sortedArrayUsingSelector:@selector(compare:)]) { NSData *run = runs[off]; for (NSUInteger done = 0; done < run.length; ) { ssize_t n = pwrite(mutation.fd, (const uint8_t *)run.bytes + done, run.length - done, (off_t)(off.unsignedLongLongValue + done)); if (n < 0 && errno == EINTR) continue; if (n <= 0) { ok = NO; err = n == 0 ? EIO : errno; break; } done += (NSUInteger)n; } if (!ok) break; } return wq_mutation_finish(path, &mutation, backup, ok, ok ? nil : wq_errno(err), errorText, savedVersion, nil, verify ? runs : nil); } BOOL wq_patch_if_unchanged_get_version(NSString *path, NSDictionary *runs, NSDictionary *expected, NSDictionary **savedVersion, NSString **errorText) { return wq_patch_checked(path, runs, expected, savedVersion, YES, errorText); } BOOL wq_patch_if_unchanged(NSString *path, NSDictionary *runs, NSDictionary *expected, NSString **errorText) { return wq_patch_checked(path, runs, expected, nil, NO, errorText); } BOOL wq_patch(NSString *path, NSDictionary *runs, NSString **errorText) { return wq_patch_if_unchanged(path, runs, nil, errorText); } BOOL wq_backup_unit_deletable(NSString *path) { NSString *canonical = WQCanonicalVarPath(path); if (!WQAbsolutePathGrammar(canonical) || [canonical.lastPathComponent hasPrefix:@"."]) return NO; NSString *parentPath = canonical.stringByDeletingLastPathComponent; NSString *grandparentPath = parentPath.stringByDeletingLastPathComponent; NSString *fileRoot = WQCanonicalVarPath(wq_backup_root()); NSString *appRoot = WQCanonicalVarPath(wq_app_backup_root()); if (fileRoot.length && [canonical hasPrefix:[fileRoot stringByAppendingString:@"/"]] && [parentPath isEqualToString:fileRoot]) return YES; if (appRoot.length && [canonical hasPrefix:[appRoot stringByAppendingString:@"/"]] && ([parentPath isEqualToString:appRoot] || [grandparentPath isEqualToString:appRoot])) return YES; return NO; } BOOL wq_delete_backup(NSString *path, NSString **errorText) { if (!wq_backup_unit_deletable(path)) { if (errorText) *errorText = WQLocalized(@"Only a complete backup unit can be deleted here."); return NO; } NSString *canonical = WQCanonicalVarPath(path); NSString *parentPath = canonical.stringByDeletingLastPathComponent; int parent = wq_open_directory_flags(parentPath, O_SEARCH); if (parent < 0) { if (errorText) *errorText = wq_errno(errno); return NO; } const char *name = canonical.lastPathComponent.fileSystemRepresentation; struct stat st; if (fstatat(parent, name, &st, AT_SYMLINK_NOFOLLOW) != 0) { if (errorText) *errorText = wq_errno(errno); close(parent); return NO; } int rc; if (S_ISDIR(st.st_mode)) { unsigned long long ignored = 0; if (!wq_purge_contents_cancellable(canonical, &ignored, nil)) { if (errorText) *errorText = WQLocalized(@"Could not remove everything inside the backup."); close(parent); return NO; } rc = unlinkat(parent, name, AT_REMOVEDIR); } else { rc = unlinkat(parent, name, 0); } int err = errno; close(parent); wq_log(@"delete backup: %@ -> %@", path, rc == 0 ? @"OK" : wq_errno(err)); if (rc != 0 && errorText) *errorText = wq_errno(err); return rc == 0; } static int wq_open_directory_flags(NSString *path, int flags) { int fd = WQOpenDirectoryNoLinksFlags(path, flags); if (fd < 0 && wq_is_denied(errno) && wq_grant(path)) fd = WQOpenDirectoryNoLinksFlags(path, flags); return fd; } static int wq_open_directory(NSString *path) { return wq_open_directory_flags(path, O_RDONLY); } BOOL wq_create_file(NSString *path, NSData *data, NSString **errorText) { if (wq_is_backup_storage_path(path)) { if (errorText) *errorText = WQLocalized(@"Backup storage cannot be modified through the file browser."); return NO; } if (!WQAbsolutePathGrammar(WQCanonicalVarPath(path))) { if (errorText) *errorText = WQLocalized(@"Invalid file path"); return NO; } NSString *parentPath = path.stringByDeletingLastPathComponent; int parent = wq_open_directory_flags(parentPath, O_SEARCH); if (parent < 0) { if (errorText) *errorText = wq_errno(errno); return NO; } const char *name = path.lastPathComponent.fileSystemRepresentation; int fd = WQOpenRegularAt(parent, name, O_WRONLY | O_CREAT | O_EXCL, 0644); if (fd < 0 && wq_is_denied(errno) && wq_grant(parentPath)) fd = WQOpenRegularAt(parent, name, O_WRONLY | O_CREAT | O_EXCL, 0644); if (fd < 0) { if (errorText) *errorText = wq_errno(errno); close(parent); return NO; } BOOL ok = wq_write_all(fd, data); int err = errno; ok = wq_finish_fd(fd, ok, &err); if (ok && fsync(parent) != 0) { ok = NO; err = errno; } if (!ok) unlinkat(parent, name, 0); close(parent); wq_log(@"create: %@ (%lu bytes) -> %@", path, (unsigned long)data.length, ok ? @"OK" : wq_errno(err)); if (!ok && errorText) *errorText = wq_errno(err); return ok; } BOOL wq_create_dir(NSString *path, NSString **errorText) { if (wq_is_backup_storage_path(path)) { if (errorText) *errorText = WQLocalized(@"Backup storage cannot be modified through the file browser."); return NO; } if (!WQAbsolutePathGrammar(WQCanonicalVarPath(path))) { if (errorText) *errorText = WQLocalized(@"Invalid folder path"); return NO; } NSString *parentPath = path.stringByDeletingLastPathComponent; int parent = wq_open_directory_flags(parentPath, O_SEARCH); if (parent < 0) { if (errorText) *errorText = wq_errno(errno); return NO; } const char *name = path.lastPathComponent.fileSystemRepresentation; int rc = mkdirat(parent, name, 0755); if (rc != 0 && wq_is_denied(errno) && wq_grant(parentPath)) rc = mkdirat(parent, name, 0755); int err = errno; if (rc == 0 && fsync(parent) != 0) { rc = -1; err = errno; } close(parent); wq_log(@"mkdir: %@ -> %@", path, rc == 0 ? @"OK" : wq_errno(err)); if (rc != 0 && errorText) *errorText = wq_errno(err); return rc == 0; } BOOL wq_delete(NSString *path, NSString **errorText) { if (wq_is_backup_storage_path(path)) { if (errorText) *errorText = WQLocalized(@"Backup storage cannot be overwritten or deleted through the file browser."); return NO; } if (!WQAbsolutePathGrammar(WQCanonicalVarPath(path))) { if (errorText) *errorText = WQLocalized(@"Invalid file path"); return NO; } NSString *parentPath = path.stringByDeletingLastPathComponent; int parent = wq_open_directory_flags(parentPath, O_SEARCH); if (parent < 0) { if (errorText) *errorText = wq_errno(errno); return NO; } const char *name = path.lastPathComponent.fileSystemRepresentation; struct stat st; if (fstatat(parent, name, &st, AT_SYMLINK_NOFOLLOW) != 0 && !(wq_is_denied(errno) && wq_grant(parentPath) && fstatat(parent, name, &st, AT_SYMLINK_NOFOLLOW) == 0)) { if (errorText) *errorText = errno == ENOENT ? WQLocalized(@"Not found") : wq_errno(errno); close(parent); return NO; } if (S_ISREG(st.st_mode)) { close(parent); WQMutation mutation; if (!wq_mutation_open(path, wq_version_for_stat(&st), &mutation, O_RDONLY, errorText)) return NO; NSString *backup = nil; if (!wq_mutation_backup(path, &mutation, &backup, errorText)) { wq_mutation_close(&mutation); return NO; } int rc = unlinkat(mutation.parent, name, 0); int err = errno; wq_mutation_close(&mutation); if (rc != 0 && errorText) *errorText = wq_errno(err); wq_log(@"delete: %@ -> %@", path, rc == 0 ? @"OK" : wq_errno(err)); return rc == 0; } int flags = S_ISDIR(st.st_mode) ? AT_REMOVEDIR : 0; int rc = unlinkat(parent, name, flags); if (rc != 0 && wq_is_denied(errno) && wq_grant(parentPath)) rc = unlinkat(parent, name, flags); int err = errno; close(parent); wq_log(@"delete: %@ -> %@", path, rc == 0 ? @"OK" : wq_errno(err)); if (rc != 0 && errorText) *errorText = err == ENOTEMPTY ? WQLocalized(@"Only empty folders can be deleted") : wq_errno(err); return rc == 0; } BOOL wq_rename(NSString *path, NSString *newName, NSString **errorText) { if (!WQAbsolutePathGrammar(WQCanonicalVarPath(path)) || newName.length == 0 || [newName containsString:@"/"] || [newName isEqualToString:@"."] || [newName isEqualToString:@".."] || [newName rangeOfString:@"\0"].location != NSNotFound) { if (errorText) *errorText = WQLocalized(@"Invalid name"); return NO; } if (wq_is_backup_storage_path(path) || wq_is_backup_storage_path([path.stringByDeletingLastPathComponent stringByAppendingPathComponent:newName])) { if (errorText) *errorText = WQLocalized(@"Backup storage cannot be renamed through the file browser."); return NO; } NSString *parentPath = path.stringByDeletingLastPathComponent; int parent = wq_open_directory_flags(parentPath, O_SEARCH); if (parent < 0) { if (errorText) *errorText = wq_errno(errno); return NO; } WQ_TREE_HOOK(@"renameReady", [parentPath stringByAppendingPathComponent:newName]); int rc = renameatx_np(parent, path.lastPathComponent.fileSystemRepresentation, parent, newName.fileSystemRepresentation, RENAME_EXCL); if (rc != 0 && wq_is_denied(errno) && wq_grant(parentPath)) rc = renameatx_np(parent, path.lastPathComponent.fileSystemRepresentation, parent, newName.fileSystemRepresentation, RENAME_EXCL); int err = errno; close(parent); wq_log(@"rename: %@ -> %@: %@", path, newName, rc == 0 ? @"OK" : wq_errno(err)); if (rc != 0 && errorText) *errorText = err == EEXIST ? WQLocalized(@"An item with that name already exists") : wq_errno(err); return rc == 0; } BOOL wq_move(NSString *path, NSString *destDir, NSString **errorText) { NSString *source = WQCanonicalVarPath(path), *target = WQCanonicalVarPath(destDir); NSString *name = source.lastPathComponent; if (!WQAbsolutePathGrammar(source) || !WQAbsolutePathGrammar(target) || !name.length) { if (errorText) *errorText = WQLocalized(@"Invalid path"); return NO; } NSString *sourceParent = source.stringByDeletingLastPathComponent; if ([sourceParent isEqualToString:target]) { if (errorText) *errorText = WQLocalized(@"It is already in this folder"); return NO; } if ([target isEqualToString:source] || [target hasPrefix:[source stringByAppendingString:@"/"]]) { if (errorText) *errorText = WQLocalized(@"A folder can't be moved into itself"); return NO; } if (wq_is_backup_storage_path(source) || wq_is_backup_storage_path([target stringByAppendingPathComponent:name])) { if (errorText) *errorText = WQLocalized(@"Backup storage cannot be changed through the file browser."); return NO; } int from = wq_open_directory_flags(sourceParent, O_SEARCH); if (from < 0) { if (errorText) *errorText = wq_errno(errno); return NO; } int to = wq_open_directory_flags(target, O_SEARCH); if (to < 0) { if (errorText) *errorText = wq_errno(errno); close(from); return NO; } const char *n = name.fileSystemRepresentation; // RENAME_EXCL: never replaces an item that is already there. int rc = renameatx_np(from, n, to, n, RENAME_EXCL); if (rc != 0 && wq_is_denied(errno)) { wq_grant(sourceParent); wq_grant(target); wq_grant(source); rc = renameatx_np(from, n, to, n, RENAME_EXCL); } int err = errno; if (rc == 0) { fsync(to); fsync(from); } close(from); close(to); wq_log(@"move: %@ -> %@: %@", path, destDir, rc == 0 ? @"OK" : wq_errno(err)); if (rc != 0 && errorText) { *errorText = err == EEXIST ? WQLocalizedFormat(@"An item named \"%@\" is already there", name) : err == EXDEV ? WQLocalized(@"Items can't be moved to another volume") : wq_errno(err); } return rc == 0; } int wq_open_for_reading(NSString *path) { return wq_open(path, O_RDONLY, 0); } // fsgetpath: resolve a filesystem object id to a path. Not in the public SDK. extern ssize_t fsgetpath(char *buf, size_t buflen, fsid_t *fsid, uint64_t objid); // Highest object id in use on the volume, roughly: APFS hands out ids in // increasing order, so a file created now gets one of the newest. Containers // of apps installed long after setup sit far above the ids of Apple's own // apps; a fixed limit misses them on a well-used device. static const int64_t kWQInodeMargin = 100000; static int64_t wq_volume_max_inode(fsid_t fsid, int64_t *probeInode) { if (probeInode) *probeInode = 0; #ifdef WQ_ACCESS_TESTING if (wq_test_volume_limit >= 0) return wq_test_volume_limit; #endif NSArray *directories = @[ NSTemporaryDirectory() ?: @"", [NSHomeDirectory() stringByAppendingPathComponent:@"Documents"], [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject] ?: @"", ]; for (NSString *directory in directories) { if (!directory.length) continue; NSString *probe = [directory stringByAppendingPathComponent: [NSString stringWithFormat:@"wq-inode-%u", arc4random()]]; int fd = open(probe.fileSystemRepresentation, O_CREAT | O_EXCL | O_WRONLY, 0600); if (fd < 0) { wq_log(@"inode probe: cannot create %@ (%@)", probe, wq_errno(errno)); continue; } struct stat st = {0}; struct statfs sfs = {0}; BOOL ok = fstat(fd, &st) == 0 && fstatfs(fd, &sfs) == 0; int err = errno; close(fd); unlink(probe.fileSystemRepresentation); if (!ok) { wq_log(@"inode probe: cannot inspect %@ (%@)", probe, wq_errno(err)); continue; } if (memcmp(&sfs.f_fsid, &fsid, sizeof(fsid)) != 0) { wq_log(@"inode probe: %@ is on a different volume", probe); continue; } if ((uint64_t)st.st_ino > (uint64_t)INT64_MAX - (uint64_t)kWQInodeMargin) return 0; if (probeInode) *probeInode = (int64_t)st.st_ino; return (int64_t)st.st_ino + kWQInodeMargin; } return 0; } // Scans object ids first...last once and collects the direct children of every // base in `bases` (each a /private/var/... path), as path -> object id. Uses at // most 4 worker threads (half the cores) at `qos`: a full scan of a well-used // device is millions of ids, and running it on every core made phones hot. // progress gets the ids done, the range size and the hits per base. Returns // one dictionary per base, or nil when cancelled. typedef void (^WQScanProgress)(int64_t done, int64_t total, const NSUInteger *counts); static NSArray *> *wq_scan_children(fsid_t fsid, int64_t firstId, int64_t lastId, NSArray *bases, WQScanCancellation *cancellation, qos_class_t qos, BOOL allCores, WQScanProgress progress) { NSUInteger baseCount = MIN(bases.count, (NSUInteger)8); // Heap tables: blocks can't capture C arrays. char **prefixes = calloc(8, sizeof(char *)); size_t *prefixLens = calloc(8, sizeof(size_t)); NSUInteger *counts = calloc(8, sizeof(NSUInteger)); if (!prefixes || !prefixLens || !counts) { free(prefixes); free(prefixLens); free(counts); return nil; } NSMutableArray *> *found = [NSMutableArray new]; for (NSUInteger i = 0; i < baseCount; i++) { prefixes[i] = strdup(bases[i].fileSystemRepresentation); prefixLens[i] = strlen(prefixes[i]); [found addObject:[NSMutableDictionary new]]; } if (firstId < 1) firstId = 1; int64_t total = lastId >= firstId ? lastId - firstId + 1 : 0; NSLock *lock = [NSLock new]; const int64_t chunk = 32768; int64_t chunks = (total + chunk - 1) / chunk; __block int64_t nextChunk = 0, done = 0; NSUInteger cores = NSProcessInfo.processInfo.activeProcessorCount; // A full scan the user agreed to wait for uses all but two cores; the // short incremental scans stay at half the cores. size_t workers = allCores ? (size_t)MAX((NSUInteger)2, MIN((NSUInteger)8, cores > 2 ? cores - 2 : 1)) : (size_t)MAX((NSUInteger)1, MIN((NSUInteger)4, cores / 2)); dispatch_apply(workers, dispatch_get_global_queue(qos, 0), ^(size_t __unused w) { fsid_t id = fsid; char buf[1200]; for (;;) { if (cancellation.isCancelled) return; [lock lock]; int64_t c = nextChunk++; [lock unlock]; if (c >= chunks) return; int64_t first = firstId + c * chunk, last = MIN(first + chunk - 1, lastId); NSMutableDictionary *local[8] = {nil}; for (int64_t ino = first; ino <= last; ino++) { if ((ino & 127) == 0 && cancellation.isCancelled) return; ssize_t n = fsgetpath(buf, sizeof(buf), &id, (uint64_t)ino); if (n <= 0) continue; // fsgetpath returns /private/var/...; bases are normalized the same way. for (NSUInteger i = 0; i < baseCount; i++) { if (strncmp(buf, prefixes[i], prefixLens[i]) == 0 && buf[prefixLens[i]] == '/' && strchr(buf + prefixLens[i] + 1, '/') == NULL) { if (!local[i]) local[i] = [NSMutableDictionary new]; local[i][[NSString stringWithUTF8String:buf]] = @(ino); break; } } } NSUInteger snapshot[8] = {0}; [lock lock]; for (NSUInteger i = 0; i < baseCount; i++) { if (local[i]) [found[i] addEntriesFromDictionary:local[i]]; snapshot[i] = counts[i] = found[i].count; } done += last - first + 1; int64_t nowDone = done; [lock unlock]; if (progress && !cancellation.isCancelled) progress(nowDone, total, snapshot); } }); for (NSUInteger i = 0; i < baseCount; i++) free(prefixes[i]); free(prefixes); free(prefixLens); free(counts); if (cancellation.isCancelled) return nil; return found; } static NSArray *wq_sorted_paths(NSDictionary *hits) { return [hits.allKeys sortedArrayUsingSelector:@selector(compare:)]; } static void wq_check_scan_completion(fsid_t fsid, int64_t probeInode, NSString *what) { if (probeInode <= 0) return; int64_t afterProbe = 0; if (wq_volume_max_inode(fsid, &afterProbe) <= 0 || afterProbe <= 0) { // A completion probe failure must not turn a usable partial list // into "no apps at all". The initial bound still found what it // scanned; callers can refresh later. wq_log(@"enumerate %@: completion probe unavailable; returning the scanned subset", what); } else if (afterProbe > probeInode + kWQInodeMargin) { wq_log(@"enumerate %@: inode IDs advanced beyond the scanned bound; the list may be incomplete until refreshed", what); } } // The three container parents that can only be listed by scanning. One scan // collects all three and is reused for a while: the Cache Cleaner alone used to // scan the whole id range three times (apps, extensions, and the daemon scan // that finds the app name database). static NSArray *wq_shared_scan_roots(void) { return @[ @"/private/var/mobile/Containers/Data/Application", @"/private/var/mobile/Containers/Data/PluginKitPlugin", @"/private/var/mobile/Containers/Data/InternalDaemon" ]; } #pragma mark - Persistent container scan // // APFS hands out object ids in increasing order and never reuses them, so a // container created after a scan has an id above that scan's probe id. The // result is kept on disk with the probe id: the next scan only checks the // ids created since, and confirms each remembered container by resolving // its id again (gone or moved containers are dropped). A full scan still // runs once a week, or when the volume or its ids don't match. static const NSTimeInterval kWQFullScanInterval = 7 * 24 * 3600; static const NSInteger kWQScanCacheVersion = 1; static NSString *wq_scan_cache_path(void) { #ifdef WQ_ACCESS_TESTING return nil; #else NSString *dir = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; return dir.length ? [dir stringByAppendingPathComponent:@"wq-container-scan.plist"] : nil; #endif } static NSDictionary *wq_scan_cache_load(fsid_t fsid, int64_t probeInode, NSTimeInterval now, NSString **whyFull) { NSString *path = wq_scan_cache_path(); if (!path) return nil; NSDictionary *cache = [NSDictionary dictionaryWithContentsOfFile:path]; if (![cache isKindOfClass:NSDictionary.class]) { *whyFull = @"no saved scan"; return nil; } NSNumber *version = cache[@"version"], *f0 = cache[@"fsid0"], *f1 = cache[@"fsid1"], *probe = cache[@"probe"], *fullAt = cache[@"fullAt"]; NSDictionary *roots = cache[@"roots"]; for (id value in @[version ?: NSNull.null, f0 ?: NSNull.null, f1 ?: NSNull.null, probe ?: NSNull.null, fullAt ?: NSNull.null]) if (![value isKindOfClass:NSNumber.class]) { *whyFull = @"saved scan unreadable"; return nil; } if (version.integerValue != kWQScanCacheVersion || ![roots isKindOfClass:NSDictionary.class]) { *whyFull = @"saved scan unreadable"; return nil; } if (f0.intValue != fsid.val[0] || f1.intValue != fsid.val[1]) { *whyFull = @"different volume"; return nil; } if (probe.longLongValue <= 0 || probe.longLongValue > probeInode) { *whyFull = @"volume ids were reset"; return nil; } NSTimeInterval age = now - fullAt.doubleValue; if (age < 0 || age >= kWQFullScanInterval) { *whyFull = @"weekly full scan"; return nil; } for (NSString *root in wq_shared_scan_roots()) if (![roots[root] isKindOfClass:NSDictionary.class]) { *whyFull = @"saved scan unreadable"; return nil; } return cache; } static void wq_scan_cache_save(fsid_t fsid, int64_t probeInode, NSTimeInterval fullAt, NSDictionary *roots) { NSString *path = wq_scan_cache_path(); if (!path) return; NSDictionary *cache = @{ @"version": @(kWQScanCacheVersion), @"fsid0": @(fsid.val[0]), @"fsid1": @(fsid.val[1]), @"probe": @(probeInode), @"fullAt": @(fullAt), @"roots": roots }; if (![cache writeToFile:path atomically:YES]) wq_log(@"scan: cannot save the container list to %@", path); } // Drops remembered containers whose id no longer resolves to the same path. static NSMutableDictionary *wq_scan_verify(fsid_t fsid, NSDictionary *remembered, NSUInteger *dropped) { NSMutableDictionary *kept = [NSMutableDictionary new]; char buf[1200]; for (NSString *path in remembered) { NSNumber *ino = remembered[path]; if (![path isKindOfClass:NSString.class] || ![ino isKindOfClass:NSNumber.class]) { (*dropped)++; continue; } fsid_t id = fsid; ssize_t n = fsgetpath(buf, sizeof(buf), &id, ino.unsignedLongLongValue); if (n > 0 && strcmp(buf, path.fileSystemRepresentation) == 0) kept[path] = ino; else (*dropped)++; } return kept; } // One shared scan runs at a time, detached from the screen that started it: // leaving the screen no longer throws the work away. Waiters stop waiting when // cancelled; the scan finishes and is reused by the next caller. static const NSTimeInterval kWQSharedScanTTL = 600; static NSObject *gSharedScanGuard; static void (^gScanProgress)(int64_t, int64_t, NSUInteger); static NSUInteger gScanProgressIndex; static id gScanProgressOwner; static void wq_shared_scan_setup(void) { static dispatch_once_t once; dispatch_once(&once, ^{ gSharedScanGuard = [NSObject new]; }); } static void wq_scan_slots_clear(void); void wq_enumerate_invalidate(void) { wq_shared_scan_setup(); @synchronized (gSharedScanGuard) { wq_scan_slots_clear(); } } // The first few path components, with UUIDs collapsed, so the Log shows which // areas are visible without listing individual files. static NSString *wq_sample_area(NSString *path) { NSArray *parts = path.pathComponents; NSMutableArray *kept = [NSMutableArray new]; for (NSString *part in parts) { if (kept.count >= 7) break; BOOL uuid = part.length == 36 && [[NSUUID alloc] initWithUUIDString:part] != nil; [kept addObject:uuid ? @"*" : part]; if (uuid && kept.count >= 6) break; } return [NSString pathWithComponents:kept]; } // LaunchServices API: each app's and plug-in's data container, where the // system lets this app ask (it refuses on iOS 27). static NSDictionary *gLSAPILabels; static NSDictionary *> *wq_lsapi_list_containers(NSArray *roots, NSString **summary) { Class workspaceClass = NSClassFromString(@"LSApplicationWorkspace"); SEL defaultSel = NSSelectorFromString(@"defaultWorkspace"); SEL appsSel = NSSelectorFromString(@"allInstalledApplications"); if (!workspaceClass || ![workspaceClass respondsToSelector:defaultSel]) { *summary = @"LaunchServices API: unavailable"; return nil; } NSMutableDictionary *labels = [NSMutableDictionary new]; NSUInteger proxies = 0, plugins = 0; @try { id (*send)(id, SEL) = (id (*)(id, SEL))objc_msgSend; id workspace = send(workspaceClass, defaultSel); if (![workspace respondsToSelector:appsSel]) { *summary = @"LaunchServices API: no application list"; return nil; } NSArray *apps = send(workspace, appsSel); SEL containerSel = NSSelectorFromString(@"dataContainerURL"), idSel = NSSelectorFromString(@"bundleIdentifier"); SEL pluginsSel = NSSelectorFromString(@"plugInKitPlugins"); void (^add)(id) = ^(id proxy) { if (![proxy respondsToSelector:containerSel] || ![proxy respondsToSelector:idSel]) return; NSURL *url = send(proxy, containerSel); NSString *bid = send(proxy, idSel); if (![url isKindOfClass:NSURL.class] || ![bid isKindOfClass:NSString.class] || !url.path.length || !bid.length) return; NSString *path = wq_norm(url.path); if (!labels[path]) labels[path] = bid; }; for (id app in [apps isKindOfClass:NSArray.class] ? apps : @[]) { proxies++; add(app); NSArray *appPlugins = [app respondsToSelector:pluginsSel] ? send(app, pluginsSel) : nil; for (id plugin in [appPlugins isKindOfClass:NSArray.class] ? appPlugins : @[]) { plugins++; add(plugin); } } } @catch (NSException *exception) { *summary = [NSString stringWithFormat:@"LaunchServices API: %@", exception.reason]; return nil; } NSMutableDictionary *found = [NSMutableDictionary new]; for (NSString *root in roots) found[root] = [NSMutableArray new]; for (NSString *path in labels) [found[path.stringByDeletingLastPathComponent] addObject:path]; NSMutableDictionary *result = [NSMutableDictionary new]; for (NSString *root in roots) result[root] = [found[root] sortedArrayUsingSelector:@selector(compare:)]; *summary = [NSString stringWithFormat:@"LaunchServices API: %lu apps and %lu plug-ins, %lu data containers (%lu apps, %lu extensions)", (unsigned long)proxies, (unsigned long)plugins, (unsigned long)labels.count, (unsigned long)[result[roots[0]] count], (unsigned long)[result[roots[1]] count]]; @synchronized (NSNull.null) { gLSAPILabels = [labels copy]; } return result; } // The container manager can list containers directly (instant, no scan). On // releases where it answers this app only with its own containers, the list // has no other apps and the fsgetpath scan is used instead. Classes are not // relied on: every class is asked and paths are sorted by their parent. static NSDictionary *> *wq_mcm_list_containers(NSArray *roots, NSString **summary) { NSMutableDictionary *> *found = [NSMutableDictionary new]; for (NSString *root in roots) found[root] = [NSMutableSet new]; NSMutableArray *perClass = [NSMutableArray new]; void *mgr = dlopen("/usr/lib/system/libsystem_containermanager.dylib", RTLD_NOW | RTLD_LOCAL); if (!mgr) { *summary = @"container manager unavailable"; return nil; } cq_create_fn create = (cq_create_fn)dlsym(mgr, "container_query_create"); cq_set_u64_fn setClass = (cq_set_u64_fn)dlsym(mgr, "container_query_set_class"); cq_iterate_fn iterate = (cq_iterate_fn)dlsym(mgr, "container_query_iterate_results_sync"); cq_free_fn freeQuery = (cq_free_fn)dlsym(mgr, "container_query_free"); co_get_path_fn getPath = (co_get_path_fn)dlsym(mgr, "container_object_get_path"); if (!create || !setClass || !iterate || !freeQuery || !getPath) { dlclose(mgr); *summary = @"container manager query unavailable"; return nil; } for (uint64_t cls = 1; cls <= 14; cls++) { void *query = create(); if (!query) continue; __block NSUInteger count = 0; setClass(query, cls); @try { iterate(query, ^bool(void *object) { const char *path = object ? getPath(object) : NULL; if (!path || !path[0]) return true; count++; NSString *norm = wq_norm([NSString stringWithUTF8String:path] ?: @""); NSString *parent = norm.stringByDeletingLastPathComponent; [found[parent] addObject:norm]; return true; }); } @catch (NSException *exception) { wq_log(@"scan: container manager class %llu: %@", cls, exception.reason); } freeQuery(query); if (count) [perClass addObject:[NSString stringWithFormat:@"%llu:%lu", cls, (unsigned long)count]]; } dlclose(mgr); NSMutableDictionary *result = [NSMutableDictionary new]; for (NSString *root in roots) result[root] = [found[root].allObjects sortedArrayUsingSelector:@selector(compare:)]; *summary = [NSString stringWithFormat:@"container manager: %lu apps, %lu extensions, %lu daemons (by class %@)", (unsigned long)[result[roots[0]] count], (unsigned long)[result[roots[1]] count], (unsigned long)[result[roots[2]] count], perClass.count ? [perClass componentsJoinedByString:@" "] : @"none"]; return result; } // Samples ids across the volume. On some releases fsgetpath resolves only the // caller's own files; then a full scan takes minutes and finds nothing. static BOOL wq_fsgetpath_sees_others(fsid_t fsid, int64_t maxInode, double *idsPerSecond) { NSMutableArray *own = [NSMutableArray arrayWithObject:wq_norm(NSHomeDirectory())]; NSString *bundle = wq_norm(NSBundle.mainBundle.bundlePath.stringByDeletingLastPathComponent); if (bundle.length) [own addObject:bundle]; NSString *group = wq_app_group(); NSURL *groupURL = group ? [NSFileManager.defaultManager containerURLForSecurityApplicationGroupIdentifier:group] : nil; if (groupURL.path.length) [own addObject:wq_norm(groupURL.path)]; int64_t stride = MAX((int64_t)1, maxInode / 20000); NSUInteger sampled = 0, resolved = 0, foreign = 0; NSCountedSet *areas = [NSCountedSet new]; char buf[1200]; CFAbsoluteTime started = CFAbsoluteTimeGetCurrent(); for (int64_t ino = 1 + (int64_t)arc4random_uniform((uint32_t)MIN(stride, (int64_t)UINT32_MAX)); ino <= maxInode; ino += stride) { sampled++; fsid_t id = fsid; if (fsgetpath(buf, sizeof(buf), &id, (uint64_t)ino) <= 0) continue; resolved++; NSString *path = [NSString stringWithUTF8String:buf]; BOOL mine = NO; for (NSString *prefix in own) if ([path hasPrefix:prefix]) { mine = YES; break; } if (!mine) { foreign++; [areas addObject:wq_sample_area(path)]; } } wq_log(@"scan: fsgetpath sample: %lu of %lu ids resolve, %lu outside WQ Files (ids up to %lld, %.1f s)", (unsigned long)resolved, (unsigned long)sampled, (unsigned long)foreign, (long long)maxInode, CFAbsoluteTimeGetCurrent() - started); if (areas.count) { NSArray *top = [areas.allObjects sortedArrayUsingComparator:^NSComparisonResult(NSString *a, NSString *b) { NSUInteger ca = [areas countForObject:a], cb = [areas countForObject:b]; return ca > cb ? NSOrderedAscending : ca < cb ? NSOrderedDescending : [a compare:b]; }]; NSMutableArray *parts = [NSMutableArray new]; for (NSString *area in [top subarrayWithRange:NSMakeRange(0, MIN(top.count, (NSUInteger)12))]) [parts addObject:[NSString stringWithFormat:@"%@ x%lu", area, (unsigned long)[areas countForObject:area]]]; wq_log(@"scan: resolvable areas: %@", [parts componentsJoinedByString:@", "]); } double elapsed = CFAbsoluteTimeGetCurrent() - started; if (idsPerSecond) *idsPerSecond = elapsed > 0 ? sampled / elapsed : 0; return foreign > 0; } // Full scans (every file id) take minutes on a well-used device. The UI // confirms them first and shows their progress; without a UI hook (or when // the app isn't in front) they don't start. static BOOL (*gFullScanConfirm)(NSString *what, int64_t ids, double seconds); static void (*gFullScanProgress)(int64_t done, int64_t total, BOOL finished); static WQScanCancellation *gFullScanStop; static _Atomic BOOL gFullScanDeclined; void wq_set_full_scan_ui(BOOL (*confirm)(NSString *what, int64_t ids, double seconds), void (*progress)(int64_t done, int64_t total, BOOL finished)) { gFullScanConfirm = confirm; gFullScanProgress = progress; } void wq_full_scan_stop(void) { @synchronized (NSNull.null) { [gFullScanStop cancel]; } } BOOL wq_full_scan_was_declined(void) { return atomic_load(&gFullScanDeclined); } // Asks before a full scan. Returns the scan's stop token, or nil when the user // (or the missing UI) declined. static WQScanCancellation *wq_full_scan_begin(NSString *what, int64_t ids, double seconds) { // Without an App Group no container can be opened or named, so a long // scan would only produce a list of unusable folders. if (!wq_app_group()) { atomic_store(&gFullScanDeclined, YES); wq_log(@"scan: no App Group; full scan of %@ skipped", what); return nil; } if (!gFullScanConfirm || !gFullScanConfirm(what, ids, seconds)) { atomic_store(&gFullScanDeclined, YES); wq_log(@"scan: full scan of %@ not started (%lld ids, about %.0f s)", what, (long long)ids, seconds); return nil; } atomic_store(&gFullScanDeclined, NO); WQScanCancellation *stop = [WQScanCancellation new]; @synchronized (NSNull.null) { gFullScanStop = stop; } return stop; } static void wq_full_scan_end(WQScanCancellation *stop) { @synchronized (NSNull.null) { if (gFullScanStop == stop) gFullScanStop = nil; } if (gFullScanProgress) gFullScanProgress(0, 0, YES); if (stop.isCancelled) wq_log(@"scan: full scan stopped"); } // Rough duration of a full scan from a single-thread sample rate. static double wq_full_scan_estimate(int64_t ids, double sampleRate) { NSUInteger cores = NSProcessInfo.processInfo.activeProcessorCount; double workers = MAX(2.0, MIN(8.0, cores > 2 ? (double)cores - 2 : 1.0)); double rate = (sampleRate > 0 ? sampleRate : 15000.0) * workers * 0.8; return ids / rate; } static NSArray *(*gContainerListProvider)(NSString *storeHint); void wq_set_container_list_provider(NSArray *(*provider)(NSString *storeHint)) { gContainerListProvider = provider; } static BOOL wq_is_ls_store_name(const char *path) { const char *name = strrchr(path, '/'); name = name ? name + 1 : path; size_t len = strlen(name); return len > 33 && strncmp(name, "com.apple.LaunchServices-", 25) == 0 && strcmp(name + len - 8, ".csstore") == 0; } // The LaunchServices database is rewritten on every app install or update, so // its id is among the newest: scan down from the top and stop at the first. static const CFTimeInterval kWQStoreSearchLimit = 20; static NSString *wq_scan_for_ls_store(fsid_t fsid, int64_t maxInode, qos_class_t qos, WQScanProgress progress) { CFAbsoluteTime started = CFAbsoluteTimeGetCurrent(); const int64_t chunk = 32768; int64_t chunks = (maxInode + chunk - 1) / chunk; __block int64_t nextChunk = 0, done = 0; __block NSString *found = nil; NSLock *lock = [NSLock new]; WQScanCancellation *stop = [WQScanCancellation new]; NSUInteger cores = NSProcessInfo.processInfo.activeProcessorCount; size_t workers = (size_t)MAX((NSUInteger)1, MIN((NSUInteger)4, cores / 2)); dispatch_apply(workers, dispatch_get_global_queue(qos, 0), ^(size_t __unused w) { fsid_t id = fsid; char buf[1200]; NSUInteger none[8] = {0}; for (;;) { if (stop.isCancelled) return; [lock lock]; int64_t c = nextChunk++; [lock unlock]; if (c >= chunks) return; // Normally found among the newest ids within seconds; past the // limit the full scan is the better way (and asks first). if (CFAbsoluteTimeGetCurrent() - started > kWQStoreSearchLimit) { [stop cancel]; return; } int64_t last = maxInode - c * chunk, first = MAX((int64_t)1, last - chunk + 1); for (int64_t ino = last; ino >= first; ino--) { if ((ino & 127) == 0 && stop.isCancelled) return; if (fsgetpath(buf, sizeof(buf), &id, (uint64_t)ino) <= 0 || !wq_is_ls_store_name(buf)) continue; [lock lock]; if (!found) found = [NSString stringWithUTF8String:buf]; [lock unlock]; [stop cancel]; return; } [lock lock]; done += last - first + 1; int64_t nowDone = done; [lock unlock]; if (progress && !stop.isCancelled) progress(nowDone, maxInode, none); } }); return found; } // The LaunchServices database keeps containers of earlier installs that no // longer exist. Drop those (lstat says ENOENT even without access); keep // anything that exists or can't be checked. static NSArray *wq_existing_containers(NSArray *paths, NSUInteger *dropped) { NSMutableArray *kept = [NSMutableArray arrayWithCapacity:paths.count]; struct stat st; for (NSString *path in paths) { if (lstat(path.fileSystemRepresentation, &st) != 0 && errno == ENOENT) { (*dropped)++; continue; } [kept addObject:path]; } return kept; } // The App Data and Extensions lists from the LaunchServices database. static NSDictionary *> *wq_containers_from_ls_store(NSArray *roots, fsid_t fsid, qos_class_t qos, WQScanProgress progress) { if (!gContainerListProvider) return nil; CFAbsoluteTime started = CFAbsoluteTimeGetCurrent(); NSArray *paths = gContainerListProvider(nil); NSString *how = @"remembered location"; if (paths.count < 2) { int64_t probeInode = 0; int64_t maxInode = wq_volume_max_inode(fsid, &probeInode); if (maxInode <= 0) return nil; NSString *store = wq_scan_for_ls_store(fsid, maxInode, qos, progress); if (!store) { wq_log(@"scan: no LaunchServices database found by id (%.1f s)", CFAbsoluteTimeGetCurrent() - started); return nil; } paths = gContainerListProvider(store); how = [NSString stringWithFormat:@"found by id in %.1f s", CFAbsoluteTimeGetCurrent() - started]; if (paths.count < 2) { wq_log(@"scan: the LaunchServices database at %@ couldn't be read or lists no containers", store); return nil; } } NSUInteger stale = 0; NSMutableArray *normalized = [NSMutableArray arrayWithCapacity:paths.count]; for (NSString *path in paths) [normalized addObject:wq_norm(path)]; paths = wq_existing_containers(normalized, &stale); if (stale) wq_log(@"scan: %lu containers in the LaunchServices database no longer exist (earlier installs); skipped", (unsigned long)stale); NSMutableDictionary *byRoot = [NSMutableDictionary new]; for (NSString *root in roots) byRoot[root] = [NSMutableArray new]; for (NSString *path in paths) [byRoot[path.stringByDeletingLastPathComponent] addObject:path]; if ([byRoot[roots[0]] count] < 2) return nil; NSMutableDictionary *result = [NSMutableDictionary new]; for (NSString *root in roots) result[root] = [byRoot[root] sortedArrayUsingSelector:@selector(compare:)]; wq_log(@"scan: %lu apps, %lu extensions from the LaunchServices database (%@); daemon containers aren't listed there", (unsigned long)[result[roots[0]] count], (unsigned long)[result[roots[1]] count], how); return result; } // inodeOnly: skip the container manager and LaunchServices shortcuts and scan // ids (iOS 26 Internal Daemons, which no database lists). static NSDictionary *> *wq_run_shared_scan(fsid_t fsid, qos_class_t qos, BOOL inodeOnly) { NSArray *roots = wq_shared_scan_roots(); atomic_store(&gFullScanDeclined, NO); if (inodeOnly) goto inodeScan; { NSString *mcmSummary = nil; NSDictionary *> *listed = wq_mcm_list_containers(roots, &mcmSummary); wq_log(@"scan: %@", mcmSummary); // Other apps' containers listed: no scan needed. A list with only this // app's own container is not enough. if ([listed[roots[0]] count] > 1) return listed; NSString *lsSummary = nil; NSDictionary *> *lsListed = wq_lsapi_list_containers(roots, &lsSummary); wq_log(@"scan: %@", lsSummary); if (!wq_system_areas_reachable() && [lsListed[roots[0]] count] > 1) { // The API doesn't list daemon containers; those still need the scan // on releases where it works, so only skip it when apps are covered. NSMutableDictionary *merged = [lsListed mutableCopy]; if (![merged[roots[2]] count]) merged[roots[2]] = @[]; return merged; } { // A full scan is tens of millions of ids (minutes, up to most of an // hour); the database lists the same app and extension containers. WQScanProgress report = ^(int64_t done, int64_t total, const NSUInteger *__unused counts) { void (^progress)(int64_t, int64_t, NSUInteger) = nil; @synchronized (gSharedScanGuard) { progress = gScanProgress; } if (progress) progress(done, total, 0); }; NSDictionary *fromStore = wq_containers_from_ls_store(roots, fsid, qos, report); if (fromStore) return fromStore; } } inodeScan:; int64_t probeInode = 0; int64_t maxInode = wq_volume_max_inode(fsid, &probeInode); if (maxInode <= 0) { wq_log(@"enumerate containers: dynamic inode limit unavailable; refusing to present a fixed-range partial scan as complete"); return nil; } if (maxInode > INT64_MAX - 32768) { wq_log(@"enumerate containers: inode bound is too large to scan safely"); return nil; } NSTimeInterval now = NSDate.date.timeIntervalSince1970; NSString *whyFull = nil; NSDictionary *cache = wq_scan_cache_load(fsid, probeInode, now, &whyFull); int64_t firstId = 1; NSTimeInterval fullAt = now; NSUInteger dropped = 0; NSMutableArray *> *known = [NSMutableArray new]; for (NSString *root in roots) { [known addObject:cache ? wq_scan_verify(fsid, cache[@"roots"][root], &dropped) : [NSMutableDictionary new]]; } if (cache) { firstId = [cache[@"probe"] longLongValue] + 1; fullAt = [cache[@"fullAt"] doubleValue]; } double sampleRate = 0; if (!cache && !wq_fsgetpath_sees_others(fsid, maxInode, &sampleRate)) { wq_log(@"scan: on this iOS version fsgetpath only resolves WQ Files' own files, so containers can't be found by scanning"); return nil; } WQScanCancellation *stop = nil; if (!cache) { stop = wq_full_scan_begin(inodeOnly ? WQLocalized(@"Internal Daemons") : WQLocalized(@"the app folders"), maxInode, wq_full_scan_estimate(maxInode, sampleRate)); if (!stop) return nil; qos = QOS_CLASS_USER_INITIATED; } CFAbsoluteTime started = CFAbsoluteTimeGetCurrent(); WQScanProgress report = ^(int64_t done, int64_t total, const NSUInteger *counts) { void (^progress)(int64_t, int64_t, NSUInteger) = nil; NSUInteger index = 0; @synchronized (gSharedScanGuard) { progress = gScanProgress; index = gScanProgressIndex; } if (progress) progress(done, total, (index < roots.count ? known[index].count : 0) + (index < 8 ? counts[index] : 0)); if (stop && gFullScanProgress) gFullScanProgress(done, total, NO); }; NSArray *lists = wq_scan_children(fsid, firstId, maxInode, roots, stop, qos, stop != nil, report); if (stop) wq_full_scan_end(stop); if (!lists) return nil; NSMutableDictionary *saved = [NSMutableDictionary new]; NSMutableDictionary *scan = [NSMutableDictionary new]; for (NSUInteger i = 0; i < roots.count; i++) { [known[i] addEntriesFromDictionary:lists[i]]; saved[roots[i]] = known[i]; scan[roots[i]] = wq_sorted_paths(known[i]); } double seconds = CFAbsoluteTimeGetCurrent() - started; int64_t ids = maxInode >= firstId ? maxInode - firstId + 1 : 0; wq_log(@"scan: %@ ids %lld-%lld (%lld ids) in %.1f s (%.0f ids/s, %@); %lu apps, %lu extensions, %lu daemons%@", cache ? @"new" : [NSString stringWithFormat:@"full (%@)", whyFull ?: @"no saved scan"], (long long)firstId, (long long)maxInode, (long long)ids, seconds, seconds > 0 ? ids / seconds : 0, qos == QOS_CLASS_USER_INITIATED ? @"foreground" : @"background", (unsigned long)known[0].count, (unsigned long)known[1].count, (unsigned long)known[2].count, dropped ? [NSString stringWithFormat:@"; %lu removed since the last scan", (unsigned long)dropped] : @""); wq_check_scan_completion(fsid, probeInode, @"containers"); wq_scan_cache_save(fsid, probeInode, fullAt, saved); return scan; } // One scan per slot runs at a time. On iOS 26 the Internal Daemons id scan // (most of an hour the first time) has its own slot, so the lists that come // from the LaunchServices database never wait for it. @interface WQScanSlot : NSObject @property (nonatomic, copy) NSDictionary *> *scan; @property (nonatomic) NSTimeInterval at; @property (nonatomic, strong) dispatch_group_t group; @property (nonatomic, strong) NSMutableDictionary *box; @end @implementation WQScanSlot @end static WQScanSlot *wq_scan_slot(BOOL inode) { static WQScanSlot *slots[2]; static dispatch_once_t once; dispatch_once(&once, ^{ slots[0] = [WQScanSlot new]; slots[1] = [WQScanSlot new]; }); return slots[inode ? 1 : 0]; } static void wq_scan_slots_clear(void) { for (int i = 0; i < 2; i++) { WQScanSlot *slot = wq_scan_slot(i == 1); slot.scan = nil; slot.at = 0; } } static NSArray *wq_shared_children(NSString *base, fsid_t fsid, WQScanCancellation *cancellation, void (^progress)(int64_t, int64_t, NSUInteger)) { wq_shared_scan_setup(); NSUInteger baseIndex = [wq_shared_scan_roots() indexOfObject:base]; // iOS 26 lists apps and extensions from the LaunchServices database; only // Internal Daemons need the (long) id scan. BOOL inodeOnly = [base isEqualToString:@"/private/var/mobile/Containers/Data/InternalDaemon"]; WQScanSlot *slot = wq_scan_slot(inodeOnly); id owner = [NSObject new]; dispatch_group_t group = nil; NSMutableDictionary *box = nil; @synchronized (gSharedScanGuard) { if (slot.scan && NSDate.date.timeIntervalSinceReferenceDate - slot.at < kWQSharedScanTTL) { NSArray *cached = slot.scan[base]; if (progress) progress(1, 1, cached.count); return cached; } if (progress) { gScanProgress = [progress copy]; gScanProgressIndex = baseIndex; gScanProgressOwner = owner; } if (!slot.group) { // A screen waiting for the result gets foreground priority; the // worker count stays at half the cores either way. qos_class_t qos = progress ? QOS_CLASS_USER_INITIATED : QOS_CLASS_UTILITY; dispatch_group_t started = dispatch_group_create(); NSMutableDictionary *startedBox = [NSMutableDictionary new]; slot.group = started; slot.box = startedBox; dispatch_group_enter(started); dispatch_async(dispatch_get_global_queue(qos, 0), ^{ NSDictionary *scan = nil; @try { scan = wq_run_shared_scan(fsid, qos, inodeOnly); } @catch (NSException *exception) { wq_log(@"scan: exception %@", exception.reason); } @synchronized (gSharedScanGuard) { if (scan) { startedBox[@"scan"] = scan; slot.scan = scan; slot.at = NSDate.date.timeIntervalSinceReferenceDate; } slot.group = nil; slot.box = nil; } dispatch_group_leave(started); }); } group = slot.group; box = slot.box; } BOOL cancelled = NO; while (dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 200 * NSEC_PER_MSEC)) != 0) { if (cancellation.isCancelled) { cancelled = YES; break; } } NSArray *result = nil; @synchronized (gSharedScanGuard) { if (gScanProgressOwner == owner) { gScanProgress = nil; gScanProgressOwner = nil; } if (!cancelled) result = box[@"scan"][base]; } return result; } NSArray *wq_enumerate(NSString *dir, int64_t maxInode, WQScanCancellation *cancellation, void (^progress)(int64_t, int64_t, NSUInteger)) { if (cancellation.isCancelled) return nil; NSString *base = wq_norm(dir); // /private/var/... form struct stat baseStat = {0}; int baseError = lstat(base.fileSystemRepresentation, &baseStat); if (baseError == 0 && !S_ISDIR(baseStat.st_mode)) { wq_log(@"enumerate %@: base is not a directory", dir); return nil; } if (baseError != 0 && errno != EACCES && errno != EPERM) { wq_log(@"enumerate %@: base is missing (%@)", dir, wq_errno(errno)); return nil; } struct statfs sfs; if (statfs(base.fileSystemRepresentation, &sfs) != 0) { // The container parent may not even be stat-able without a token. In // that case fall back to the data volume's fsid and let fsgetpath do // the work; a denied parent is not the same thing as a missing parent. if (statfs("/private/var/mobile", &sfs) != 0) { wq_log(@"enumerate %@: statfs failed (%@)", dir, wq_errno(errno)); return nil; } } fsid_t fsid = sfs.f_fsid; BOOL dynamicBound = maxInode <= 0; if (dynamicBound && [wq_shared_scan_roots() containsObject:base]) return wq_shared_children(base, fsid, cancellation, progress); // iOS 26: a full scan takes about 40 minutes; the LaunchServices database // lists the group containers of installed apps. if (dynamicBound && !wq_system_areas_reachable() && gContainerListProvider && [base isEqualToString:@"/private/var/mobile/Containers/Shared/AppGroup"]) { NSArray *all = gContainerListProvider(nil); if (all.count) { NSMutableArray *groups = [NSMutableArray new]; for (NSString *path in all) if ([wq_norm(path).stringByDeletingLastPathComponent isEqualToString:base]) [groups addObject:wq_norm(path)]; NSUInteger stale = 0; groups = [wq_existing_containers(groups, &stale) mutableCopy]; [groups sortUsingSelector:@selector(compare:)]; wq_log(@"enumerate %@: %lu group containers from the LaunchServices database%@", dir, (unsigned long)groups.count, stale ? [NSString stringWithFormat:@" (%lu no longer exist, skipped)", (unsigned long)stale] : @""); if (progress) progress(1, 1, groups.count); return groups; } } int64_t probeInode = 0; if (dynamicBound) { maxInode = wq_volume_max_inode(fsid, &probeInode); if (maxInode <= 0) { wq_log(@"enumerate %@: dynamic inode limit unavailable; refusing to present a fixed-range partial scan as complete", dir); return nil; } } if (maxInode > INT64_MAX - 32768) { wq_log(@"enumerate %@: inode bound is too large to scan safely", dir); return nil; } WQScanCancellation *stop = nil; if (dynamicBound) { stop = wq_full_scan_begin(base.lastPathComponent, maxInode, wq_full_scan_estimate(maxInode, 0)); if (!stop) return nil; } // Either the caller's cancellation or the scan window's Stop ends it. WQScanCancellation *either = stop ?: cancellation; if (stop && cancellation) { either = [WQScanCancellation new]; dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ while (!either.isCancelled) { if (stop.isCancelled || cancellation.isCancelled) { [either cancel]; break; } usleep(200000); } }); } NSArray *lists = wq_scan_children(fsid, 1, maxInode, @[base], either, QOS_CLASS_USER_INITIATED, stop != nil, ^(int64_t done, int64_t total, const NSUInteger *counts) { if (progress) progress(done, total, counts[0]); if (stop && gFullScanProgress) gFullScanProgress(done, total, NO); }); if (stop) { wq_full_scan_end(stop); [either cancel]; } if (!lists) return nil; if (dynamicBound) wq_check_scan_completion(fsid, probeInode, dir); return wq_sorted_paths(lists.firstObject); } BOOL wq_stat(NSString *path, struct stat *st) { if (lstat(path.fileSystemRepresentation, st) == 0) return YES; if (!wq_is_denied(errno)) return NO; if (wq_grant(path.stringByDeletingLastPathComponent) && lstat(path.fileSystemRepresentation, st) == 0) return YES; return NO; } WQMeasureResult wq_measure_tree(NSString *dir, WQScanCancellation *cancellation, unsigned long long *bytes) { if (cancellation.isCancelled) return WQMeasureResultCancelled; struct stat st = {0}; if (!wq_stat(dir, &st)) { struct stat direct = {0}; if (lstat(dir.fileSystemRepresentation, &direct) != 0 && (errno == ENOENT || errno == ENOTDIR)) return WQMeasureResultMissing; return WQMeasureResultLimited; } if (!S_ISDIR(st.st_mode)) return WQMeasureResultLimited; if (![NSFileManager.defaultManager contentsOfDirectoryAtPath:dir error:nil]) wq_grant(dir); NSURL *root = [NSURL fileURLWithPath:dir isDirectory:YES]; NSArray *keys = @[NSURLIsRegularFileKey, NSURLIsSymbolicLinkKey, NSURLFileSizeKey, NSURLIsDirectoryKey]; __block BOOL limited = NO; NSDirectoryEnumerator *enumerator = [NSFileManager.defaultManager enumeratorAtURL:root includingPropertiesForKeys:keys options:0 errorHandler:^BOOL(NSURL *errorURL, NSError *error) { limited = YES; wq_log(@"measure: cannot enumerate %@ (%@)", errorURL.path, error.localizedDescription ?: @"unknown error"); return YES; }]; if (!enumerator) return WQMeasureResultLimited; unsigned long long total = 0; for (NSURL *item in enumerator) { if (cancellation.isCancelled) return WQMeasureResultCancelled; NSNumber *isLink = nil, *isRegular = nil, *size = nil; NSError *error = nil; if (![item getResourceValue:&isLink forKey:NSURLIsSymbolicLinkKey error:&error] || ![item getResourceValue:&isRegular forKey:NSURLIsRegularFileKey error:&error]) { limited = YES; continue; } if (isLink.boolValue || !isRegular.boolValue) continue; if (![item getResourceValue:&size forKey:NSURLFileSizeKey error:&error]) { limited = YES; continue; } total += size.unsignedLongLongValue; } if (cancellation.isCancelled) return WQMeasureResultCancelled; if (bytes) *bytes = total; return limited ? WQMeasureResultLimited : WQMeasureResultKnown; } static BOOL wq_directory_current(NSString *path, int fd) { int current = WQOpenDirectoryNoLinksFlags(path, O_SEARCH); struct stat a, b; BOOL ok = current >= 0 && fstat(current, &a) == 0 && fstat(fd, &b) == 0 && WQSameObject(&a, &b); if (current >= 0) close(current); return ok; } static BOOL wq_remove_entry(int parent, NSString *name, NSString *path, unsigned long long *freed, WQScanCancellation *cancellation); // Purge uses fd-relative removal, so the path is only a swap detector. One // lstat is enough; reopening every ancestor for every child is needlessly // expensive on large caches. static BOOL wq_purge_root_matches(NSString *path, int fd) { struct stat pathStat, fdStat; return lstat(path.fileSystemRepresentation, &pathStat) == 0 && fstat(fd, &fdStat) == 0 && WQSameObject(&pathStat, &fdStat); } static BOOL wq_purge_fd(int fd, NSString *path, unsigned long long *freed, WQScanCancellation *cancellation) { int listing = dup(fd); if (listing < 0) return NO; DIR *dir = fdopendir(listing); if (!dir) { close(listing); return NO; } BOOL ok = YES; for (;;) { if (cancellation.isCancelled) { ok = NO; break; } errno = 0; struct dirent *entry = readdir(dir); if (!entry) { if (errno) ok = NO; break; } if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; NSString *name = [NSFileManager.defaultManager stringWithFileSystemRepresentation:entry->d_name length:strlen(entry->d_name)]; if (!name) { ok = NO; break; } WQ_TREE_HOOK(@"purgeChild", path); // Fail closed on changed ancestry; all removal remains fd-relative even // in the final race window. Symlink children are unlinked, never followed. if (!wq_purge_root_matches(path, fd)) { ok = NO; break; } if (!wq_remove_entry(fd, name, [path stringByAppendingPathComponent:name], freed, cancellation)) ok = NO; } if (closedir(dir) != 0) ok = NO; return ok; } static BOOL wq_remove_entry(int parent, NSString *name, NSString *path, unsigned long long *freed, WQScanCancellation *cancellation) { if (cancellation.isCancelled) return NO; const char *item = name.fileSystemRepresentation; struct stat before, current; if (fstatat(parent, item, &before, AT_SYMLINK_NOFOLLOW) != 0) return errno == ENOENT; BOOL directory = S_ISDIR(before.st_mode); if (directory) { int child = openat(parent, item, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); if (child < 0 && wq_is_denied(errno) && wq_grant(path)) child = openat(parent, item, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); if (child < 0) return NO; BOOL ok = fstat(child, ¤t) == 0 && WQSameObject(&before, ¤t) && wq_purge_root_matches(path, child) && wq_purge_fd(child, path, freed, cancellation); close(child); if (!ok) return NO; } if (fstatat(parent, item, ¤t, AT_SYMLINK_NOFOLLOW) != 0 || !WQSameObject(&before, ¤t) || (current.st_mode & S_IFMT) != (before.st_mode & S_IFMT)) return NO; int rc = unlinkat(parent, item, directory ? AT_REMOVEDIR : 0); if (rc != 0 && wq_is_denied(errno) && wq_grant(path.stringByDeletingLastPathComponent)) { if (fstatat(parent, item, ¤t, AT_SYMLINK_NOFOLLOW) != 0 || !WQSameObject(&before, ¤t)) return NO; rc = unlinkat(parent, item, directory ? AT_REMOVEDIR : 0); } // A user-immutable or append-only flag (on the item or its folder) makes // unlink fail with EPERM even for the owner. The owner may clear those // flags, so clear them and try once more. System flags stay untouched. if (rc != 0 && errno == EPERM && fstatat(parent, item, ¤t, AT_SYMLINK_NOFOLLOW) == 0 && WQSameObject(&before, ¤t)) { const uint32_t userLocks = UF_IMMUTABLE | UF_APPEND; struct stat parentStat = {0}; BOOL cleared = NO; if (fstat(parent, &parentStat) == 0 && (parentStat.st_flags & userLocks)) cleared = fchflags(parent, parentStat.st_flags & ~userLocks) == 0; if ((current.st_flags & userLocks) && !S_ISLNK(current.st_mode)) { int flagsFd = openat(parent, item, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC | (directory ? O_DIRECTORY : 0)); struct stat opened; if (flagsFd >= 0 && fstat(flagsFd, &opened) == 0 && WQSameObject(&before, &opened) && fchflags(flagsFd, opened.st_flags & ~userLocks) == 0) cleared = YES; if (flagsFd >= 0) close(flagsFd); } if (cleared) { wq_log(@"purge: cleared lock flags (item 0x%x, folder 0x%x) on %@", current.st_flags, parentStat.st_flags, path); rc = unlinkat(parent, item, directory ? AT_REMOVEDIR : 0); } else { errno = EPERM; } } if (rc == 0 && !directory && freed) *freed += (unsigned long long)before.st_size; if (rc != 0) { int err = errno; wq_log(@"purge: %@ -> %@ (flags 0x%x, uid %u, mode %o)", path, wq_errno(err), current.st_flags, current.st_uid, current.st_mode & 07777); } return rc == 0; } BOOL wq_purge_contents_cancellable(NSString *dir, unsigned long long *freed, WQScanCancellation *cancellation) { if (cancellation.isCancelled) return NO; if (!WQAbsolutePathGrammar(WQCanonicalVarPath(dir))) return NO; int fd = wq_open_directory(dir); if (fd < 0) { if (errno == ENOENT) return YES; wq_log(@"purge: %@ -> unsafe ancestry or inaccessible (%@)", dir, wq_errno(errno)); return NO; } BOOL ok = wq_purge_fd(fd, dir, freed, cancellation); close(fd); return ok; } BOOL wq_purge_contents(NSString *dir, unsigned long long *freed) { return wq_purge_contents_cancellable(dir, freed, nil); } // Flush a completed staging tree without following any links or opening special // files. This does not claim to snapshot a concurrently changing source tree. static BOOL wq_sync_entry(int parent, NSString *name) { struct stat st; if (fstatat(parent, name.fileSystemRepresentation, &st, AT_SYMLINK_NOFOLLOW) != 0) return NO; if (S_ISLNK(st.st_mode)) return YES; if (S_ISREG(st.st_mode)) { int fd = WQOpenRegularAt(parent, name.fileSystemRepresentation, O_RDONLY, 0); if (fd < 0) return NO; struct stat current; BOOL ok = fstat(fd, ¤t) == 0 && WQSameObject(&st, ¤t) && fsync(fd) == 0; close(fd); return ok; } if (!S_ISDIR(st.st_mode)) { errno = EINVAL; return NO; } int fd = openat(parent, name.fileSystemRepresentation, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); if (fd < 0) return NO; int listing = dup(fd); DIR *dir = listing >= 0 ? fdopendir(listing) : NULL; if (!dir) { if (listing >= 0) close(listing); close(fd); return NO; } BOOL ok = YES; for (;;) { errno = 0; struct dirent *entry = readdir(dir); if (!entry) { if (errno) ok = NO; break; } if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; NSString *child = [NSFileManager.defaultManager stringWithFileSystemRepresentation:entry->d_name length:strlen(entry->d_name)]; if (!child || !wq_sync_entry(fd, child)) { ok = NO; break; } } if (closedir(dir) != 0) ok = NO; if (ok && fsync(fd) != 0) ok = NO; close(fd); return ok; } BOOL wq_copy_contents(NSString *srcDir, NSString *dstDir, NSString **errorText) { srcDir = WQCanonicalVarPath(srcDir); dstDir = WQCanonicalVarPath(dstDir); if (!WQAbsolutePathGrammar(srcDir) || !WQAbsolutePathGrammar(dstDir) || [srcDir isEqual:dstDir] || [srcDir hasPrefix:[dstDir stringByAppendingString:@"/"]] || [dstDir hasPrefix:[srcDir stringByAppendingString:@"/"]]) { if (errorText) *errorText = WQLocalized(@"Source and destination trees overlap or have invalid paths"); return NO; } NSFileManager *fm = NSFileManager.defaultManager; int source = wq_open_directory(srcDir); if (source < 0) { if (errorText) *errorText = wq_errno(errno); return NO; } int destination = WQCreateDirectoriesNoLinksMode(dstDir, 0755); if (destination < 0 && wq_is_denied(errno) && wq_grant(dstDir.stringByDeletingLastPathComponent)) destination = WQCreateDirectoriesNoLinksMode(dstDir, 0755); struct stat srcStat, dstStat; if (destination < 0 || fstat(source, &srcStat) != 0 || fstat(destination, &dstStat) != 0 || WQSameObject(&srcStat, &dstStat)) { if (errorText) *errorText = WQLocalized(@"Cannot safely open distinct source and destination folders"); close(source); if (destination >= 0) close(destination); return NO; } NSError *error = nil; NSArray *children = [fm contentsOfDirectoryAtPath:srcDir error:&error]; if (!children) { if (errorText) *errorText = error.localizedDescription ?: WQLocalized(@"Cannot read the source folder"); close(source); close(destination); return NO; } BOOL allOk = YES; NSString *firstFailure = nil; for (NSString *name in children) { if (!wq_directory_current(srcDir, source) || !wq_directory_current(dstDir, destination)) { allOk = NO; firstFailure = firstFailure ?: WQLocalized(@"A copy root changed during copying"); break; } NSString *src = [srcDir stringByAppendingPathComponent:name]; NSString *dst = [dstDir stringByAppendingPathComponent:name]; NSString *stageName = [@".wq-copy-stage-" stringByAppendingString:NSUUID.UUID.UUIDString]; NSString *stagePath = [dstDir stringByAppendingPathComponent:stageName]; if (mkdirat(destination, stageName.fileSystemRepresentation, 0700) != 0) { allOk = NO; firstFailure = firstFailure ?: wq_errno(errno); continue; } int stage = openat(destination, stageName.fileSystemRepresentation, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); if (stage < 0) { allOk = NO; firstFailure = firstFailure ?: wq_errno(errno); continue; } NSString *newPath = [stagePath stringByAppendingPathComponent:@"New"]; WQ_TREE_HOOK(@"copyReady", src); NSError *copyError = nil; BOOL ok = wq_directory_current(stagePath, stage) && [fm copyItemAtPath:src toPath:newPath error:©Error]; if (!ok && wq_grant(src) && wq_directory_current(srcDir, source) && wq_directory_current(stagePath, stage)) { if (wq_remove_entry(stage, @"New", newPath, NULL, nil)) { NSError *retryError = nil; ok = [fm copyItemAtPath:src toPath:newPath error:&retryError]; if (!ok) copyError = retryError ?: copyError; } } if (ok) ok = wq_sync_entry(stage, @"New") && fsync(stage) == 0; WQ_TREE_HOOK(@"copyStaged", dst); if (ok) ok = wq_directory_current(srcDir, source) && wq_directory_current(dstDir, destination) && wq_directory_current(stagePath, stage); BOOL installed = NO; if (ok) { struct stat previous; BOOL exists = fstatat(destination, name.fileSystemRepresentation, &previous, AT_SYMLINK_NOFOLLOW) == 0; if (!exists && errno != ENOENT) ok = NO; if (ok) { // Swap retains the previous destination at stage/New until the // copy has completed and the new namespace has been flushed. installed = renameatx_np(stage, "New", destination, name.fileSystemRepresentation, exists ? RENAME_SWAP : RENAME_EXCL) == 0; ok = installed && fsync(destination) == 0 && fsync(stage) == 0; } } NSString *failure = ok ? nil : copyError.localizedDescription ?: wq_errno(errno); // Never erase the preserved destination if installation durability failed. BOOL cleaned = !installed || ok; if (cleaned) cleaned = wq_remove_entry(stage, @"New", newPath, NULL, nil); close(stage); if (cleaned) cleaned = unlinkat(destination, stageName.fileSystemRepresentation, AT_REMOVEDIR) == 0 && fsync(destination) == 0; if (!ok || !cleaned) { allOk = NO; NSString *message = cleaned ? failure : [NSString stringWithFormat:@"%@; staging/recovery contents retained at %@", failure ?: wq_errno(errno), stagePath]; firstFailure = firstFailure ?: message; wq_log(@"copy: %@ -> %@: %@", src, dst, message); } } close(source); close(destination); if (errorText && !allOk) *errorText = firstFailure; wq_log(@"copy: %@ -> %@ (%lu items) %@", srcDir, dstDir, (unsigned long)children.count, allOk ? @"OK" : @"with errors"); return allOk; } // Container-manager enumeration is a second, permission-independent way to // map a container path to its identifier. The enumeration pattern is the one // used by terminal_worse (GPL-3.0); no part-domain traversal is involved. The UUID xattr and the root-only // metadata plist are the fast paths; this query is the fallback for devices // or containers where either is unavailable. Paths and identifiers stay in a // short-lived cache because labels are requested for every row. static NSDictionary *wq_container_identifier_map(void) { static NSObject *lock; static NSDictionary *cached; static NSTimeInterval cachedAt; static dispatch_once_t once; dispatch_once(&once, ^{ lock = [NSObject new]; }); @synchronized (lock) { NSTimeInterval now = NSDate.date.timeIntervalSinceReferenceDate; if (cached && now - cachedAt < 60) return cached; NSMutableDictionary *map = [NSMutableDictionary new]; void *mgr = dlopen("/usr/lib/system/libsystem_containermanager.dylib", RTLD_NOW | RTLD_LOCAL); if (mgr) { cq_create_fn create = (cq_create_fn)dlsym(mgr, "container_query_create"); cq_set_u64_fn setClass = (cq_set_u64_fn)dlsym(mgr, "container_query_set_class"); cq_iterate_fn iterate = (cq_iterate_fn)dlsym(mgr, "container_query_iterate_results_sync"); cq_free_fn freeQuery = (cq_free_fn)dlsym(mgr, "container_query_free"); co_get_path_fn getPath = (co_get_path_fn)dlsym(mgr, "container_object_get_path"); co_get_id_fn getIdentifier = (co_get_id_fn)dlsym(mgr, "container_object_get_identifier"); if (!getIdentifier) getIdentifier = (co_get_id_fn)dlsym(mgr, "container_result_get_identifier"); if (create && setClass && iterate && freeQuery && getPath) { const uint64_t classes[] = { 0, 2, 5, 7, 8, 10, 11, 13 }; for (size_t i = 0; i < sizeof(classes) / sizeof(classes[0]); i++) { void *query = create(); if (!query) continue; setClass(query, classes[i]); iterate(query, ^bool(void *object) { if (!object) return true; const char *path = getPath(object); const char *ident = getIdentifier ? getIdentifier(object) : NULL; if (!path || !path[0] || !ident || !ident[0]) return true; NSString *p = [NSString stringWithUTF8String:path]; NSString *v = [NSString stringWithUTF8String:ident]; if (!p.length || !v.length) return true; NSString *key = WQCanonicalVarPath(p); if (key.length && !map[key]) map[key] = v; return true; }); freeQuery(query); } } dlclose(mgr); } cached = map; cachedAt = now; return cached; } } static NSString *(*gContainerLabelResolver)(NSString *path); void wq_set_container_label_resolver(NSString *(*resolver)(NSString *path)) { gContainerLabelResolver = resolver; } NSString *wq_container_label(NSString *dir) { char buf[512]; ssize_t len = getxattr(dir.fileSystemRepresentation, "com.apple.containermanager.identifier", buf, sizeof(buf) - 1, 0, 0); if (len > 0) { buf[len] = 0; return [NSString stringWithUTF8String:buf]; } // Reading the attribute can need a token on the container: ask, try again. if (wq_grant(dir)) { len = getxattr(dir.fileSystemRepresentation, "com.apple.containermanager.identifier", buf, sizeof(buf) - 1, 0, 0); if (len > 0) { buf[len] = 0; return [NSString stringWithUTF8String:buf]; } } // Prefer the container-manager query over the metadata plist: it needs no // file permission and can also label containers whose plist is root-only. NSString *fromAPI = nil; @synchronized (NSNull.null) { fromAPI = gLSAPILabels[wq_norm(dir)]; } if (fromAPI.length) return fromAPI; NSString *queried = wq_container_identifier_map()[WQCanonicalVarPath(dir)]; if (queried.length) return queried; // The LaunchServices database maps app and extension data containers to // bundle ids (set by the app; this file doesn't depend on that module). NSString *resolved = gContainerLabelResolver ? gContainerLabelResolver(WQCanonicalVarPath(dir)) : nil; if (resolved.length) return resolved; // The metadata plist is usually root-only (mode 600); only try it when the // owner and mode let this app read it, so the Log isn't flooded. NSString *meta = [dir stringByAppendingPathComponent:@".com.apple.mobile_container_manager.metadata.plist"]; if (wq_permission_hint(meta, NO)) return nil; NSData *data = wq_read(meta, 256 * 1024, NULL); if (!data) return nil; NSDictionary *plist = [NSPropertyListSerialization propertyListWithData:data options:0 format:NULL error:nil]; id ident = [plist isKindOfClass:NSDictionary.class] ? plist[@"MCMMetadataIdentifier"] : nil; return [ident isKindOfClass:NSString.class] ? ident : nil; }