Files
AppsManager27/source/hook/src/WQAccess.m
T

2588 lines
119 KiB
Objective-C

//
// 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 <dlfcn.h>
#import <objc/message.h>
#import <dirent.h>
#import <errno.h>
#import <fcntl.h>
#import <string.h>
#import <stdatomic.h>
#import <sys/clonefile.h>
#import <sys/stat.h>
#import <sys/mount.h>
#import <sys/xattr.h>
#import <unistd.h>
#import <xpc/xpc.h>
@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<NSString *, NSNumber *> *gGrants;
static NSMutableArray<NSString *> *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<NSString *> *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<NSString *, NSNumber *> *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<NSString *, NSNumber *> *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, &currentParent) == 0 &&
fstat(mutation->parent, &parent) == 0 && WQSameObject(&parent, &currentParent) &&
fstat(mutation->fd, &current) == 0 && S_ISREG(current.st_mode) &&
fstatat(mutation->parent, path.lastPathComponent.fileSystemRepresentation, &entry, AT_SYMLINK_NOFOLLOW) == 0 &&
S_ISREG(entry.st_mode) && WQSameObject(&entry, &current) && WQSameObject(&current, &mutation->before);
if (checkParent >= 0) close(checkParent);
if (ok && version) ok = [wq_version_for_stat(&current) 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<NSNumber *, NSData *> *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<NSNumber *, NSData *> *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<NSString *> *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/<stamp>/<original path>: 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<NSNumber *, NSData *> *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<NSNumber *, NSData *> *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<NSNumber *, NSData *> *runs,
NSDictionary *expected, NSString **errorText)
{
return wq_patch_checked(path, runs, expected, nil, NO, errorText);
}
BOOL wq_patch(NSString *path, NSDictionary<NSNumber *, NSData *> *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<NSString *> *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<NSMutableDictionary<NSString *, NSNumber *> *> *wq_scan_children(fsid_t fsid, int64_t firstId, int64_t lastId,
NSArray<NSString *> *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<NSMutableDictionary<NSString *, NSNumber *> *> *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<NSString *> *wq_sorted_paths(NSDictionary<NSString *, NSNumber *> *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<NSString *> *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<NSString *, NSNumber *> *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<NSString *> *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<NSString *, NSString *> *gLSAPILabels;
static NSDictionary<NSString *, NSArray<NSString *> *> *wq_lsapi_list_containers(NSArray<NSString *> *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<NSString *, NSString *> *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<NSString *, NSMutableArray *> *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<NSString *, NSArray<NSString *> *> *wq_mcm_list_containers(NSArray<NSString *> *roots, NSString **summary)
{
NSMutableDictionary<NSString *, NSMutableSet<NSString *> *> *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<NSString *> *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<NSString *> *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<NSString *> *(*gContainerListProvider)(NSString *storeHint);
void wq_set_container_list_provider(NSArray<NSString *> *(*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<NSString *> *wq_existing_containers(NSArray<NSString *> *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<NSString *, NSArray<NSString *> *> *wq_containers_from_ls_store(NSArray<NSString *> *roots, fsid_t fsid,
qos_class_t qos, WQScanProgress progress)
{
if (!gContainerListProvider) return nil;
CFAbsoluteTime started = CFAbsoluteTimeGetCurrent();
NSArray<NSString *> *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<NSString *, NSMutableArray *> *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<NSString *, NSArray<NSString *> *> *wq_run_shared_scan(fsid_t fsid, qos_class_t qos, BOOL inodeOnly)
{
NSArray<NSString *> *roots = wq_shared_scan_roots();
atomic_store(&gFullScanDeclined, NO);
if (inodeOnly) goto inodeScan;
{
NSString *mcmSummary = nil;
NSDictionary<NSString *, NSArray<NSString *> *> *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<NSString *, NSArray<NSString *> *> *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<NSMutableDictionary<NSString *, NSNumber *> *> *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<NSString *, NSArray<NSString *> *> *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<NSString *> *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<NSString *> *result = nil;
@synchronized (gSharedScanGuard) {
if (gScanProgressOwner == owner) { gScanProgress = nil; gScanProgressOwner = nil; }
if (!cancelled) result = box[@"scan"][base];
}
return result;
}
NSArray<NSString *> *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<NSString *> *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<NSURLResourceKey> *keys = @[NSURLIsRegularFileKey, NSURLIsSymbolicLinkKey,
NSURLFileSizeKey, NSURLIsDirectoryKey];
__block BOOL limited = NO;
NSDirectoryEnumerator<NSURL *> *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, &current) == 0 && WQSameObject(&before, &current) &&
wq_purge_root_matches(path, child) && wq_purge_fd(child, path, freed, cancellation);
close(child);
if (!ok) return NO;
}
if (fstatat(parent, item, &current, AT_SYMLINK_NOFOLLOW) != 0 || !WQSameObject(&before, &current) ||
(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, &current, AT_SYMLINK_NOFOLLOW) != 0 || !WQSameObject(&before, &current)) 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, &current, AT_SYMLINK_NOFOLLOW) == 0 && WQSameObject(&before, &current)) {
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, &current) == 0 && WQSameObject(&st, &current) && 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:&copyError];
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<NSString *, NSString *> *wq_container_identifier_map(void)
{
static NSObject *lock;
static NSDictionary<NSString *, NSString *> *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<NSString *, NSString *> *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;
}