RadrootsAppleFileAccess.swift (12523B)
1 import Darwin 2 import Foundation 3 4 public final class RadrootsAppleFileAccess: RadrootsFileAccess { 5 static let maximumGovernedFileBytes = 512 * 1024 * 1024 6 7 public let roots: RadrootsAppleFileRoots 8 let fileManager: FileManager 9 let persistence: RadrootsFilePersistence 10 11 public init(roots: RadrootsAppleFileRoots, fileManager: FileManager = .default) { 12 self.roots = roots 13 self.fileManager = fileManager 14 persistence = .live 15 } 16 17 init(roots: RadrootsAppleFileRoots, persistence: RadrootsFilePersistence) { 18 self.roots = roots 19 fileManager = .default 20 self.persistence = persistence 21 } 22 23 public func write(_ payload: RadrootsFilePayload, to file: RadrootsFileReference) throws { 24 let url = try roots.resolvedURL(for: file) 25 try createParentDirectory(for: url) 26 switch payload { 27 case let .inline(inlineData): 28 try classifiedFileSystemOperation { 29 try inlineData.write(to: url, options: [.atomic]) 30 } 31 case let .stagedBlob(stagedBlob): 32 let data = try readStagedBlob(stagedBlob) 33 try classifiedFileSystemOperation { 34 try data.write(to: url, options: [.atomic]) 35 } 36 } 37 } 38 39 public func read( 40 _ file: RadrootsFileReference, mode: RadrootsFileReadMode 41 ) throws -> RadrootsFileReadResult { 42 switch mode { 43 case let .inline(maxBytes): 44 return try .inline(readGovernedFile(file, maximumBytes: maxBytes)) 45 case let .preferInline(maxBytes): 46 do { 47 return try .inline( 48 readGovernedFile(file, maximumBytes: maxBytes, preserveTooLarge: true) 49 ) 50 } catch RadrootsGovernedFileReadError.tooLarge { 51 let url = try roots.resolvedURL(for: file) 52 let staged = try stageFile(file, mediaType: nil, filenameHint: url.lastPathComponent) 53 return .stagedBlob(staged) 54 } 55 case .stagedBlob: 56 let url = try roots.resolvedURL(for: file) 57 let staged = try stageFile(file, mediaType: nil, filenameHint: url.lastPathComponent) 58 return .stagedBlob(staged) 59 } 60 } 61 62 public func delete(_ file: RadrootsFileReference) throws { 63 let url = try roots.resolvedURL(for: file) 64 guard fileManager.fileExists(atPath: url.path) else { 65 return 66 } 67 try classifiedFileSystemOperation { 68 try fileManager.removeItem(at: url) 69 } 70 } 71 72 public func list(_ directory: RadrootsFileReference) throws -> [RadrootsFileEntry] { 73 let rootURL = roots.root(for: directory.scope).standardizedFileURL 74 let directoryURL = try roots.resolvedURL(for: directory, allowRootDirectory: true) 75 var isDirectory = ObjCBool(false) 76 guard fileManager.fileExists(atPath: directoryURL.path, isDirectory: &isDirectory) else { 77 return [] 78 } 79 guard isDirectory.boolValue else { 80 throw RadrootsAppleFileError.invalidRequest 81 } 82 return try classifiedFileSystemOperation { 83 let urls = try fileManager.contentsOfDirectory( 84 at: directoryURL, 85 includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey], 86 options: [] 87 ) 88 return try urls.map { url in 89 let values = try url.resourceValues( 90 forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey] 91 ) 92 let relativePath = try relativePath(for: url.standardizedFileURL, under: rootURL) 93 return RadrootsFileEntry( 94 file: RadrootsFileReference(scope: directory.scope, relativePath: relativePath), 95 name: url.lastPathComponent, 96 isDirectory: values.isDirectory ?? false, 97 sizeBytes: values.fileSize, 98 modifiedAt: values.contentModificationDate 99 ) 100 } 101 .sorted { left, right in 102 left.file.relativePath < right.file.relativePath 103 } 104 } 105 } 106 107 public func reset(scope: RadrootsFileScope) throws { 108 let url = roots.root(for: scope) 109 try classifiedFileSystemOperation { 110 if fileManager.fileExists(atPath: url.path) { 111 try fileManager.removeItem(at: url) 112 } 113 try fileManager.createDirectory(at: url, withIntermediateDirectories: true) 114 } 115 } 116 } 117 118 public extension RadrootsAppleFileAccess { 119 @discardableResult 120 func stageBlob( 121 _ data: Data, 122 mediaType: String? = nil, 123 filenameHint: String? = nil 124 ) throws -> RadrootsStagedBlobReference { 125 guard data.count <= Self.maximumGovernedFileBytes else { 126 throw RadrootsAppleFileError.invalidRequest 127 } 128 let blobID = UUID().uuidString.lowercased() 129 let blob = try RadrootsStagedBlobReference( 130 blobID: blobID, 131 sizeBytes: data.count, 132 mediaType: mediaType, 133 filenameHint: filenameHint 134 ) 135 try installStagedBlob(data, reference: blob) 136 return blob 137 } 138 139 @discardableResult 140 func stageFile( 141 _ file: RadrootsFileReference, 142 mediaType: String? = nil, 143 filenameHint: String? = nil 144 ) throws -> RadrootsStagedBlobReference { 145 let sourceURL = try roots.resolvedURL(for: file) 146 return try stageBlob( 147 readGovernedFile(file, maximumBytes: Self.maximumGovernedFileBytes), 148 mediaType: mediaType, 149 filenameHint: filenameHint ?? sourceURL.lastPathComponent 150 ) 151 } 152 153 @discardableResult 154 func stageExternalFile( 155 _ sourceURL: URL, 156 mediaType: String? = nil, 157 filenameHint: String? = nil 158 ) throws -> RadrootsStagedBlobReference { 159 try withSecurityScopedFile(sourceURL) { scopedURL in 160 try stageFileURL( 161 scopedURL, 162 mediaType: mediaType, 163 filenameHint: filenameHint ?? scopedURL.lastPathComponent 164 ) 165 } 166 } 167 168 @discardableResult 169 func copyExternalFile( 170 _ sourceURL: URL, 171 to file: RadrootsFileReference, 172 mediaType: String? = nil, 173 suggestedFilename: String? = nil 174 ) throws -> RadrootsImportedDocument { 175 try withSecurityScopedFile(sourceURL) { scopedURL in 176 let destinationURL = try roots.resolvedURL(for: file) 177 try createParentDirectory(for: destinationURL) 178 try copyReplacingItem(from: scopedURL, to: destinationURL) 179 let sizeBytes = try fileSizeUInt64(at: destinationURL) 180 return try RadrootsImportedDocument( 181 file: file, 182 originalURL: scopedURL, 183 suggestedFilename: suggestedFilename ?? scopedURL.lastPathComponent, 184 mediaType: mediaType, 185 sizeBytes: sizeBytes 186 ) 187 } 188 } 189 190 @discardableResult 191 func prepareExport( 192 _ request: RadrootsExportDocumentRequest 193 ) throws -> RadrootsPreparedExportDocument { 194 let preparedID = UUID().uuidString.lowercased() 195 let directoryURL = preparedExportsRoot.appendingPathComponent(preparedID, isDirectory: true) 196 let fileURL = directoryURL.appendingPathComponent(request.suggestedFilename).standardizedFileURL 197 try classifiedFileSystemOperation { 198 try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) 199 } 200 let preparedData: Data = switch request.source { 201 case let .inlineData(data): 202 data 203 case let .file(file): 204 try readGovernedFile(file, maximumBytes: Self.maximumGovernedFileBytes) 205 case let .stagedBlob(stagedBlob): 206 try readStagedBlob(stagedBlob) 207 } 208 try classifiedFileSystemOperation { 209 try preparedData.write(to: fileURL, options: [.atomic]) 210 } 211 let sizeBytes: UInt64 = 212 if let requestSizeBytes = request.sizeBytes { 213 requestSizeBytes 214 } else { 215 try fileSizeUInt64(at: fileURL) 216 } 217 return try RadrootsPreparedExportDocument( 218 preparedID: preparedID, 219 fileURL: fileURL, 220 suggestedFilename: request.suggestedFilename, 221 mediaType: request.mediaType, 222 sizeBytes: sizeBytes 223 ) 224 } 225 226 func readStagedBlob(_ blob: RadrootsStagedBlobReference) throws -> Data { 227 guard (0 ... Self.maximumGovernedFileBytes).contains(blob.sizeBytes) else { 228 throw RadrootsAppleFileError.invalidRequest 229 } 230 let data: Data 231 do { 232 data = try RadrootsGovernedFileReader.read( 233 root: roots.stagedBlobsRoot, 234 relativePath: blob.blobID, 235 maximumBytes: blob.sizeBytes 236 ) 237 } catch let error as RadrootsGovernedFileReadError { 238 throw mappedGovernedReadError(error) 239 } 240 guard data.count == blob.sizeBytes else { 241 throw RadrootsAppleFileError.permanentFailure 242 } 243 return data 244 } 245 246 func releaseStagedBlob(_ blob: RadrootsStagedBlobReference) throws { 247 let url = try stagedBlobURL(for: blob) 248 if fileManager.fileExists(atPath: url.path) { 249 try classifiedFileSystemOperation { 250 try fileManager.removeItem(at: url) 251 } 252 } 253 } 254 255 func preparedExportExists(_ preparedExport: RadrootsPreparedExportDocument) throws -> Bool { 256 let directoryURL = try preparedExportDirectoryURL(for: preparedExport) 257 return fileManager.fileExists(atPath: directoryURL.path) 258 && fileManager.fileExists(atPath: preparedExport.fileURL.path) 259 } 260 261 func releasePreparedExport(_ preparedExport: RadrootsPreparedExportDocument) throws { 262 let directoryURL = try preparedExportDirectoryURL(for: preparedExport) 263 if fileManager.fileExists(atPath: directoryURL.path) { 264 try classifiedFileSystemOperation { 265 try fileManager.removeItem(at: directoryURL) 266 } 267 } 268 } 269 270 @discardableResult 271 func sweepStagedBlobs(olderThan cutoff: Date) throws -> [RadrootsStagedBlobReference] { 272 guard fileManager.fileExists(atPath: roots.stagedBlobsRoot.path) else { 273 return [] 274 } 275 return try classifiedFileSystemOperation { 276 let urls = try fileManager.contentsOfDirectory( 277 at: roots.stagedBlobsRoot, 278 includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey], 279 options: [] 280 ) 281 var released: [RadrootsStagedBlobReference] = [] 282 for url in urls { 283 // Interrupted atomic outputs are identifiable orphans, not valid 284 // blob references. Their owner reconciles them separately. 285 guard !url.lastPathComponent.hasPrefix(".radroots_pending_") else { continue } 286 let values = try url.resourceValues( 287 forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey] 288 ) 289 guard values.isDirectory != true else { 290 continue 291 } 292 guard let modifiedAt = values.contentModificationDate, modifiedAt < cutoff else { 293 continue 294 } 295 let blob = try RadrootsStagedBlobReference( 296 blobID: url.lastPathComponent, 297 sizeBytes: values.fileSize ?? 0 298 ) 299 try fileManager.removeItem(at: url) 300 released.append(blob) 301 } 302 return released.sorted { left, right in 303 left.blobID < right.blobID 304 } 305 } 306 } 307 308 func resetStagedBlobs() throws { 309 try classifiedFileSystemOperation { 310 if fileManager.fileExists(atPath: roots.stagedBlobsRoot.path) { 311 try fileManager.removeItem(at: roots.stagedBlobsRoot) 312 } 313 try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true) 314 } 315 } 316 317 func resetFileRoots() throws { 318 for scope in RadrootsFileScope.allCases { 319 try reset(scope: scope) 320 } 321 try resetStagedBlobs() 322 } 323 }