RadrootsAppleFileOperations.swift (6524B)
1 import Darwin 2 import Foundation 3 4 /// Module-internal mechanics shared by the file owner; no public API. 5 extension RadrootsAppleFileAccess { 6 func stagedBlobURL(for blob: RadrootsStagedBlobReference) throws -> URL { 7 try roots.stagedBlobURL(for: blob) 8 } 9 10 var preparedExportsRoot: URL { 11 roots.temporaryRoot.appendingPathComponent("prepared_exports", isDirectory: true) 12 .standardizedFileURL 13 } 14 15 func preparedExportDirectoryURL(for preparedExport: RadrootsPreparedExportDocument) throws -> URL { 16 let normalizedPreparedID = try RadrootsPreparedExportDocument.normalizedPreparedID( 17 preparedExport.preparedID 18 ) 19 let directoryURL = preparedExportsRoot.appendingPathComponent( 20 normalizedPreparedID, isDirectory: true 21 ).standardizedFileURL 22 guard preparedExport.fileURL.standardizedFileURL.path.hasPrefix(directoryURL.path + "/") else { 23 throw RadrootsAppleFileError.invalidRequest 24 } 25 return directoryURL 26 } 27 28 func stageFileURL( 29 _ sourceURL: URL, 30 mediaType: String?, 31 filenameHint: String? 32 ) throws -> RadrootsStagedBlobReference { 33 let sizeBytes = try fileSizeInt(at: sourceURL) 34 guard sizeBytes <= Self.maximumGovernedFileBytes else { 35 throw RadrootsAppleFileError.permanentFailure 36 } 37 let blobID = UUID().uuidString.lowercased() 38 let blob = try RadrootsStagedBlobReference( 39 blobID: blobID, 40 sizeBytes: sizeBytes, 41 mediaType: mediaType, 42 filenameHint: filenameHint 43 ) 44 try installStagedBlob(readExternalBytes(sourceURL), reference: blob) 45 return blob 46 } 47 48 func withSecurityScopedFile<T>(_ sourceURL: URL, _ body: (URL) throws -> T) throws -> T { 49 guard sourceURL.isFileURL else { 50 throw RadrootsAppleFileError.invalidRequest 51 } 52 let scopedURL = sourceURL.standardizedFileURL 53 var isDirectory = ObjCBool(false) 54 guard fileManager.fileExists(atPath: scopedURL.path, isDirectory: &isDirectory) else { 55 throw RadrootsAppleFileError.notFound 56 } 57 guard !isDirectory.boolValue else { 58 throw RadrootsAppleFileError.invalidRequest 59 } 60 let didStartScope = scopedURL.startAccessingSecurityScopedResource() 61 defer { 62 if didStartScope { 63 scopedURL.stopAccessingSecurityScopedResource() 64 } 65 } 66 return try body(scopedURL) 67 } 68 69 func copyReplacingItem(from sourceURL: URL, to destinationURL: URL) throws { 70 guard sourceURL.isFileURL, destinationURL.isFileURL else { 71 throw RadrootsAppleFileError.invalidRequest 72 } 73 try RadrootsAtomicFile.install(readExternalBytes(sourceURL), at: destinationURL) 74 } 75 76 func readExternalBytes(_ sourceURL: URL) throws -> Data { 77 // The caller holds the user's security-scoped URL grant. Canonicalize 78 // that explicit external parent, then retain the validated file bytes 79 // through installation; do not copy a changing pathname or a symlink. 80 guard let pointer = sourceURL.deletingLastPathComponent().path.withCString({ Darwin.realpath($0, nil) }) else { 81 throw RadrootsAppleFileError.permanentFailure 82 } 83 defer { Darwin.free(pointer) } 84 let parent = URL(fileURLWithPath: String(cString: pointer), isDirectory: true) 85 do { 86 return try RadrootsGovernedFileReader.read( 87 root: parent, relativePath: sourceURL.lastPathComponent, maximumBytes: Self.maximumGovernedFileBytes 88 ) 89 } catch { throw RadrootsAppleFileError.permanentFailure } 90 } 91 92 func createParentDirectory(for url: URL) throws { 93 try classifiedFileSystemOperation { 94 try fileManager.createDirectory( 95 at: url.deletingLastPathComponent(), withIntermediateDirectories: true 96 ) 97 } 98 } 99 100 func fileSize(at url: URL) throws -> Int { 101 try fileSizeInt(at: url) 102 } 103 104 func fileSizeInt(at url: URL) throws -> Int { 105 let values = try classifiedFileSystemOperation { 106 try url.resourceValues(forKeys: [.fileSizeKey]) 107 } 108 guard let size = values.fileSize else { 109 throw RadrootsAppleFileError.permanentFailure 110 } 111 return size 112 } 113 114 func fileSizeUInt64(at url: URL) throws -> UInt64 { 115 try UInt64(fileSizeInt(at: url)) 116 } 117 118 func readGovernedFile( 119 _ file: RadrootsFileReference, 120 maximumBytes: Int, 121 preserveTooLarge: Bool = false 122 ) throws -> Data { 123 guard (0 ... Self.maximumGovernedFileBytes).contains(maximumBytes) else { 124 throw RadrootsAppleFileError.invalidRequest 125 } 126 let root = roots.root(for: file.scope) 127 let resolved = try roots.resolvedURL(for: file) 128 let relative = try relativePath(for: resolved, under: root) 129 do { 130 return try RadrootsGovernedFileReader.read( 131 root: root, 132 relativePath: relative, 133 maximumBytes: maximumBytes 134 ) 135 } catch let error as RadrootsGovernedFileReadError { 136 if preserveTooLarge, error == .tooLarge { 137 throw error 138 } 139 throw mappedGovernedReadError(error) 140 } 141 } 142 143 func mappedGovernedReadError(_ error: RadrootsGovernedFileReadError) -> RadrootsAppleFileError { 144 switch error { 145 case .unavailable: 146 .notFound 147 case .invalidRequest: 148 .invalidRequest 149 case .tooLarge: 150 .permanentFailure 151 case .invalidObject, .changedDuringRead, .ioFailure: 152 .permanentFailure 153 } 154 } 155 156 func relativePath(for url: URL, under rootURL: URL) throws -> String { 157 let rootPath = rootURL.path 158 let filePath = url.path 159 guard filePath.hasPrefix(rootPath + "/") else { 160 throw RadrootsAppleFileError.invalidRequest 161 } 162 return String(filePath.dropFirst(rootPath.count + 1)) 163 } 164 165 func classifiedFileSystemOperation<T>(_ operation: () throws -> T) throws -> T { 166 do { 167 return try operation() 168 } catch let error as RadrootsAppleFileError { 169 throw error 170 } catch let error as RadrootsDocumentInterchangeError { 171 throw error 172 } catch { 173 throw RadrootsAppleFileError.classified(error) 174 } 175 } 176 }