From 2437f8e72008e0c12bbd8435c74beabf25c64f9a Mon Sep 17 00:00:00 2001 From: "Dr. Sergey Pogodin" Date: Mon, 29 Jan 2024 22:59:13 +0100 Subject: [PATCH] [#28] iOS/macOS: Fixes the handling of security scopes by pickFile() and other methods --- README.md | 12 ++ example/ios/Podfile.lock | 6 +- example/src/TestBaseMethods.tsx | 2 +- ios/RNFSException.h | 5 +- ios/RNFSException.mm | 15 ++- ios/ReactNativeFs.mm | 212 ++++++++++++++++++++------------ 6 files changed, 166 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index 9e24087a..489abad3 100644 --- a/README.md +++ b/README.md @@ -855,6 +855,18 @@ crash the app. allowing a direct access to them with other methods in this library (_e.g._ [readFile()]), even if the file is outside the app sandbox. + **NOTE:** On **iOS** & **macOS** it resolve to special values with the format + «`bookmark://`», rather than normal URIs. + It is necessary for the support of security scopes + (see [Bookmarks and Security Scopes](https://developer.apple.com/documentation/foundation/nsurl#1663783)) in library methods. The «``» + in this case is a Base64-encoded binary representation of the URL bookmark, + along with its security scope data. Other methods of the library are expected + to automatically handle such special URIs as needed. + + **BEWARE:** It has not been thoroughly verified yet that all library methods + support these «Bookmark URLs» correctly. The expected error in + such case is a failure to access the URLs as non-existing. + ### read() [read()]: #read ```ts diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index ec67427d..d745db4a 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2,7 +2,7 @@ PODS: - boost (1.83.0) - CocoaAsyncSocket (7.6.5) - DoubleConversion (1.1.6) - - dr-pogodin-react-native-fs (2.22.1): + - dr-pogodin-react-native-fs (2.22.2): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -1402,7 +1402,7 @@ SPEC CHECKSUMS: boost: d3f49c53809116a5d38da093a8aa78bf551aed09 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 DoubleConversion: fea03f2699887d960129cc54bba7e52542b6f953 - dr-pogodin-react-native-fs: 60d98fc542ed310f57d9b497628f6e72fa2d5e2a + dr-pogodin-react-native-fs: 6aaacfa553d2a4be14cf32e4053725b7057155ee dr-pogodin-react-native-static-server: a0ab88663817dfc8791b3e88295d0d9b6d212c5f FBLazyVector: fbc4957d9aa695250b55d879c1d86f79d7e69ab4 Flipper: c7a0093234c4bdd456e363f2f19b2e4b27652d44 @@ -1468,4 +1468,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 2673e1121fca9666d2df5c7ba3b5b287e79ba95f -COCOAPODS: 1.14.3 +COCOAPODS: 1.15.0 diff --git a/example/src/TestBaseMethods.tsx b/example/src/TestBaseMethods.tsx index bd204549..d72a7daa 100644 --- a/example/src/TestBaseMethods.tsx +++ b/example/src/TestBaseMethods.tsx @@ -919,7 +919,7 @@ const tests: { [name: string]: StatusOrEvaluator } = { } else { if ( !isMatch(e, { - code: 'ENSCOCOAERRORDOMAIN260', + code: 'NSCocoaErrorDomain:260', message: 'The file “non-existing-file.txt” couldn’t be opened because there is no such file.', }) diff --git a/ios/RNFSException.h b/ios/RNFSException.h index 3a2c564a..30a493a4 100644 --- a/ios/RNFSException.h +++ b/ios/RNFSException.h @@ -6,7 +6,10 @@ - (RNFSException*) log; - (void) reject:(RCTPromiseRejectBlock)reject; - (void) reject:(RCTPromiseRejectBlock)reject details:(NSString*)details; -+ (RNFSException*) from: (NSException*)exception; + ++ (RNFSException*) fromError:(NSError*)error; ++ (RNFSException*) fromException:(NSException*)exception; + + (RNFSException*) name: (NSString*)name; + (RNFSException*) name: (NSString*)name details: (NSString*)details; diff --git a/ios/RNFSException.mm b/ios/RNFSException.mm index d4a7bab5..62967dad 100644 --- a/ios/RNFSException.mm +++ b/ios/RNFSException.mm @@ -40,13 +40,22 @@ - (void) reject: (RCTPromiseRejectBlock)reject details: (NSString*) details reject(self.name, reason, [self error]); } -+ (RNFSException*) from: (NSException*)exception ++ (RNFSException*) fromError:(NSError *)error +{ + NSString *name = [NSString stringWithFormat:@"%@:%ld", + error.domain, error.code]; + return [[RNFSException alloc] + initWithName:name + reason:error.localizedDescription + userInfo:error.userInfo]; +} + ++ (RNFSException*) fromException:(NSException *)exception { return [[RNFSException alloc] initWithName: exception.name reason: exception.reason - userInfo: exception.userInfo - ]; + userInfo: exception.userInfo]; } + (RNFSException*) name: (NSString*)name diff --git a/ios/ReactNativeFs.mm b/ios/ReactNativeFs.mm index 7deff62f..e0226e05 100644 --- a/ios/ReactNativeFs.mm +++ b/ios/ReactNativeFs.mm @@ -23,6 +23,15 @@ typedef void (^CompletionHandler)(void); @implementation ReactNativeFs + +// The prefix of "Bookmark URLs". +// See https://developer.apple.com/documentation/foundation/nsurl#1663783 +// to learn about bookmark objects in iOS / macOS. To such object between +// native and JS layers, we convert it into binary (NSData) representation, +// then encode it into Base64 string, prefix it with this BOOKMARK prefix, +// and pass the resulting "Bookmark URLs" string around. +static NSString *BOOKMARK = @"bookmark://"; + static NSMutableDictionary *completionHandlers; NSMutableDictionary *pendingPickFilePromises; @@ -58,9 +67,7 @@ - (instancetype) init } } - if (error) { - return [self reject:reject withError:error]; - } + if (error) return [[RNFSException fromError:error] reject:reject]; resolve(tagetContents); } @@ -69,11 +76,14 @@ - (instancetype) init resolve:(RCTPromiseResolveBlock)resolve reject:(__unused RCTPromiseRejectBlock)reject) { - NSURL *url = [NSURL fileURLWithPath:filepath]; + NSError *error = nil; + NSURL *url = [ReactNativeFs pathToUrl:filepath error:&error]; + if (error) return [[RNFSException fromError:error] reject:reject]; + BOOL allowed = [url startAccessingSecurityScopedResource]; @try { - BOOL fileExists = [[NSFileManager defaultManager] fileExistsAtPath:filepath]; + BOOL fileExists = [url checkResourceIsReachableAndReturnError:&error]; resolve([NSNumber numberWithBool:fileExists]); } @finally { @@ -85,16 +95,17 @@ - (instancetype) init resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { - NSURL *url = [NSURL fileURLWithPath:filepath]; + NSError *error = nil; + NSURL *url = [ReactNativeFs pathToUrl:filepath error:&error]; + if (error) return [[RNFSException fromError:error] reject:reject]; + BOOL allowed = [url startAccessingSecurityScopedResource]; @try { NSError *error = nil; NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:filepath error:&error]; - if (error) { - return [self reject:reject withError:error]; - } + if (error) return [[RNFSException fromError:error] reject:reject]; attributes = @{ @"ctime": [self dateToTimeIntervalNumber:(NSDate *)[attributes objectForKey:NSFileCreationDate]], @@ -140,7 +151,10 @@ - (instancetype) init resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { - NSURL *url = [NSURL fileURLWithPath:filepath]; + NSError *error = nil; + NSURL *url = [ReactNativeFs pathToUrl:filepath error:&error]; + if (error) return [[RNFSException fromError:error] reject:reject]; + BOOL allowed = [url startAccessingSecurityScopedResource]; @try { @@ -174,7 +188,8 @@ - (instancetype) init [info setValue:exception.callStackSymbols forKey:@"ExceptionCallStackSymbols"]; [info setValue:exception.userInfo forKey:@"ExceptionUserInfo"]; NSError *err = [NSError errorWithDomain:@"RNFS" code:0 userInfo:info]; - return [self reject:reject withError:err]; + + return [[RNFSException fromError:err] reject:reject]; } } @finally { @@ -223,7 +238,10 @@ - (instancetype) init resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { - NSURL *url = [NSURL fileURLWithPath:filepath]; + NSError *error = nil; + NSURL *url = [ReactNativeFs pathToUrl:filepath error:&error]; + if (error) return [[RNFSException fromError:error] reject:reject]; + BOOL allowed = [url startAccessingSecurityScopedResource]; @try { @@ -237,9 +255,7 @@ - (instancetype) init NSError *error = nil; BOOL success = [manager removeItemAtPath:filepath error:&error]; - if (!success) { - return [self reject:reject withError:error]; - } + if (!success) return [[RNFSException fromError:error] reject:reject]; resolve(nil); } @@ -264,9 +280,7 @@ - (instancetype) init NSError *error = nil; BOOL success = [manager createDirectoryAtPath:filepath withIntermediateDirectories:YES attributes:attributes error:&error]; - if (!success) { - return [self reject:reject withError:error]; - } + if (!success) return [[RNFSException fromError:error] reject:reject]; NSURL *url = [NSURL fileURLWithPath:filepath]; @@ -274,9 +288,7 @@ - (instancetype) init NSNumber *value = [NSNumber numberWithBool:*options.NSURLIsExcludedFromBackupKey()]; success = [url setResourceValue: value forKey: NSURLIsExcludedFromBackupKey error: &error]; - if (!success) { - return [self reject:reject withError:error]; - } + if (!success) return [[RNFSException fromError:error] reject:reject]; } resolve(nil); @@ -286,7 +298,10 @@ - (instancetype) init resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { - NSURL *url = [NSURL fileURLWithPath:filepath]; + NSError *error = nil; + NSURL *url = [ReactNativeFs pathToUrl:filepath error:&error]; + if (error) return [[RNFSException fromError:error] reject:reject]; + BOOL allowed = [url startAccessingSecurityScopedResource]; @try { @@ -300,9 +315,7 @@ - (instancetype) init NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:filepath error:&error]; - if (error) { - return [self reject:reject withError:error]; - } + if (error) return [[RNFSException fromError:error] reject:reject]; if ([attributes objectForKey:NSFileType] == NSFileTypeDirectory) { return reject(@"EISDIR", @"EISDIR: illegal operation on a directory, read", nil); @@ -324,11 +337,14 @@ - (instancetype) init resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { - NSURL *url = [NSURL fileURLWithPath:path]; + NSError *error = nil; + NSURL *url = [ReactNativeFs pathToUrl:path error:&error]; + if (error) return [[RNFSException fromError:error] reject:reject]; + BOOL allowed = [url startAccessingSecurityScopedResource]; @try{ - BOOL fileExists = [[NSFileManager defaultManager] fileExistsAtPath:path]; + BOOL fileExists = [url checkResourceIsReachableAndReturnError:&error]; if (!fileExists) { return reject(@"ENOENT", [NSString stringWithFormat:@"ENOENT: no such file or directory, open '%@'", path], nil); @@ -336,20 +352,18 @@ - (instancetype) init NSError *error = nil; - NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:path error:&error]; - - if (error) { - return [self reject:reject withError:error]; - } + NSFileAttributeType type; + BOOL success = [url getResourceValue:&type forKey:NSFileType error:&error]; + if (!success) return [[RNFSException fromError:error] reject:reject]; - if ([attributes objectForKey:NSFileType] == NSFileTypeDirectory) { + if (type == NSFileTypeDirectory) { return reject(@"EISDIR", @"EISDIR: illegal operation on a directory, read", nil); } // Open the file handler. - NSFileHandle *file = [NSFileHandle fileHandleForReadingAtPath:path]; + NSFileHandle *file = [NSFileHandle fileHandleForReadingFromURL:url error:&error]; if (file == nil) { - return reject(@"EISDIR", @"EISDIR: Could not open file for reading", nil); + return reject(@"EISDIR", @"EISDIR: Could not open file for reading", error); } // Seek to the position if there is one. @@ -376,7 +390,10 @@ - (instancetype) init resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { - NSURL *url = [NSURL fileURLWithPath:filepath]; + NSError *error = nil; + NSURL *url = [ReactNativeFs pathToUrl:filepath error:&error]; + if (error) return [[RNFSException fromError:error] reject:reject]; + BOOL allowed = [url startAccessingSecurityScopedResource]; @try { @@ -390,9 +407,7 @@ - (instancetype) init NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:filepath error:&error]; - if (error) { - return [self reject:reject withError:error]; - } + if (error) return [[RNFSException fromError:error] reject:reject]; if ([attributes objectForKey:NSFileType] == NSFileTypeDirectory) { return reject(@"EISDIR", @"EISDIR: illegal operation on a directory, read", nil); @@ -448,14 +463,16 @@ - (instancetype) init } } - RCT_EXPORT_METHOD(moveFile:(NSString *)from into:(NSString *)into options:(JS::NativeReactNativeFs::FileOptionsT &)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { - NSURL *url = [NSURL fileURLWithPath:from]; + NSError *error = nil; + NSURL *url = [ReactNativeFs pathToUrl:from error:&error]; + if (error) return [[RNFSException fromError:error] reject:reject]; + BOOL allowed = [url startAccessingSecurityScopedResource]; @try { @@ -464,18 +481,14 @@ - (instancetype) init NSError *error = nil; BOOL success = [manager moveItemAtPath:from toPath:into error:&error]; - if (!success) { - return [self reject:reject withError:error]; - } + if (!success) return [[RNFSException fromError:error] reject:reject]; if (options.NSFileProtectionKey()) { NSMutableDictionary *attributes = [[NSMutableDictionary alloc] init]; [attributes setValue:options.NSFileProtectionKey() forKey:@"NSFileProtectionKey"]; BOOL updateSuccess = [manager setAttributes:attributes ofItemAtPath:into error:&error]; - if (!updateSuccess) { - return [self reject:reject withError:error]; - } + if (!updateSuccess) return [[RNFSException fromError:error] reject:reject]; } resolve(nil); @@ -492,27 +505,26 @@ - (instancetype) init resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { - NSURL *url = [NSURL fileURLWithPath:from]; + NSError *error = nil; + NSURL *url = [ReactNativeFs pathToUrl:from error:&error]; + if (error) return [[RNFSException fromError:error] reject:reject]; + BOOL allowed = [url startAccessingSecurityScopedResource]; @try { NSFileManager *manager = [NSFileManager defaultManager]; - NSError *error = nil; - BOOL success = [manager copyItemAtPath:from toPath:into error:&error]; + BOOL success = [manager copyItemAtURL:url + toURL:[NSURL fileURLWithPath:into] + error:&error]; - if (!success) { - return [self reject:reject withError:error]; - } + if (!success) return [[RNFSException fromError:error] reject:reject]; if (options.NSFileProtectionKey()) { NSMutableDictionary *attributes = [[NSMutableDictionary alloc] init]; [attributes setValue:options.NSFileProtectionKey() forKey:@"NSFileProtectionKey"]; - BOOL updateSuccess = [manager setAttributes:attributes ofItemAtPath:into error:&error]; - - if (!updateSuccess) { - return [self reject:reject withError:error]; - } + success = [manager setAttributes:attributes ofItemAtPath:into error:&error]; + if (!success) return [[RNFSException fromError:error] reject:reject]; } resolve(nil); @@ -579,7 +591,7 @@ - (instancetype) init return; } callbackFired = YES; - return [self reject:reject withError:error]; + return [[RNFSException fromError:error] reject:reject]; }; if (hasBeginCallback) { @@ -704,7 +716,7 @@ - (instancetype) init params.errorCallback = ^(NSError* error) { [self.uploaders removeObjectForKey:[jobId stringValue]]; - return [self reject:reject withError:error]; + return [[RNFSException fromError:error] reject:reject]; }; if (hasBeginCallback) { @@ -766,7 +778,7 @@ - (instancetype) init code:NSFileNoSuchFileError userInfo:nil]; - [self reject:reject withError:error]; + return [[RNFSException fromError:error] reject:reject]; } } @@ -803,7 +815,7 @@ - (instancetype) init @"freeSpace": [NSNumber numberWithUnsignedLongLong:totalFreeSpace] }); } else { - [self reject:reject withError:error]; + [[RNFSException fromError:error] reject:reject]; } } @@ -851,8 +863,7 @@ - (instancetype) init NSMutableDictionary* details = [NSMutableDictionary dictionary]; [details setValue:errorText forKey:NSLocalizedDescriptionKey]; NSError *error = [NSError errorWithDomain:@"RNFS" code:500 userInfo:details]; - [self reject: reject withError:error]; - return; + return [[RNFSException fromError:error] reject:reject]; } PHAsset *asset = [results firstObject]; @@ -897,8 +908,7 @@ - (instancetype) init NSMutableDictionary* details = [NSMutableDictionary dictionary]; [details setValue:info[PHImageErrorKey] forKey:NSLocalizedDescriptionKey]; NSError *error = [NSError errorWithDomain:@"RNFS" code:501 userInfo:details]; - [self reject: reject withError:error]; - + [[RNFSException fromError:error] reject:reject]; } }]; # else @@ -968,7 +978,7 @@ - (instancetype) init if (error) { NSLog(@"RNFS: %@", error); - return [self reject:reject withError:error]; + return [[RNFSException fromError:error] reject:reject]; } return resolve(destination); @@ -1005,9 +1015,7 @@ - (instancetype) init NSError *error = nil; BOOL success = [manager setAttributes:attr ofItemAtPath:filepath error:&error]; - if (!success) { - return [self reject:reject withError:error]; - } + if (!success) return [[RNFSException fromError:error] reject:reject]; resolve(nil); } @@ -1017,12 +1025,6 @@ - (NSNumber *)dateToTimeIntervalNumber:(NSDate *)date return @([date timeIntervalSince1970]); } -- (void)reject:(RCTPromiseRejectBlock)reject withError:(NSError *)error -{ - NSString *codeWithDomain = [NSString stringWithFormat:@"E%@%zd", error.domain.uppercaseString, error.code]; - reject(codeWithDomain, error.localizedDescription, error); -} - - (NSString *)getPathForDirectory:(NSSearchPathDirectory)directory { NSArray *paths = NSSearchPathForDirectoriesInDomains(directory, NSUserDomainMask, YES); @@ -1111,7 +1113,28 @@ - (void)documentPicker:(UIDocumentPickerViewController *)picker RCTPromiseResolveBlock resolve = promise[0]; NSMutableArray *res = [NSMutableArray arrayWithCapacity:urls.count]; for (int i = 0; i < urls.count; ++i) { - [res addObject:urls[i].absoluteString]; + NSURL *url = urls[i]; + + BOOL allowed = [url startAccessingSecurityScopedResource]; + + NSURLBookmarkCreationOptions options = 0; + +# if TARGET_OS_MACCATALYST + options = NSURLBookmarkCreationWithSecurityScope; +# endif // TARGET_OS_MACCATALYST + + NSError *error = nil; + NSData *data = [url bookmarkDataWithOptions:options + includingResourceValuesForKeys:nil + relativeToURL:nil + error:&error]; + + if (allowed) [url stopAccessingSecurityScopedResource]; + if (error) return [[RNFSException fromError:error] reject:promise[1]]; + + NSString *bookmark = [data base64EncodedStringWithOptions:0]; + bookmark = [NSString stringWithFormat:@"%@%@", BOOKMARK, bookmark]; + [res addObject:bookmark]; } resolve(res); } @@ -1189,11 +1212,44 @@ - (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)picker [root presentViewController:picker animated:YES completion:nil]; } @catch (NSException *e) { - [[RNFSException from:e] reject:reject]; + [[RNFSException fromException:e] reject:reject]; } }); } +/** + * Given a path string converts it into NSURL object. + */ ++ (NSURL*) pathToUrl:(NSString*)path error:(NSError**)error +{ + NSURL *res = nil; + + if ([path hasPrefix:BOOKMARK]) { + // If path is a "Bookmark URL". + // See BOOKMARK description for details. + path = [path substringFromIndex:BOOKMARK.length]; + NSData *data = [[NSData alloc] initWithBase64EncodedString:path options:0]; + + NSURLBookmarkResolutionOptions options = 0; + +# if TARGET_OS_MACCATALYST + options = NSURLBookmarkResolutionWithSecurityScope; +# endif // TARGET_OS_MACCATALYST + + BOOL isStale = NO; + res = [NSURL URLByResolvingBookmarkData:data + options:options + relativeToURL:nil + bookmarkDataIsStale:&isStale + error:error]; + } else { + // If path is just a regular path. + res = [NSURL fileURLWithPath:path]; + } + + return res; +} + +(void)setCompletionHandlerForIdentifier: (NSString *)identifier completionHandler: (CompletionHandler)completionHandler { if (!completionHandlers) completionHandlers = [[NSMutableDictionary alloc] init];