RadrootsFileAccess.swift (26103B)
1 import Foundation 2 3 public enum RadrootsFileScope: Sendable, Equatable, CaseIterable, Codable { 4 case data 5 case cache 6 case temporary 7 case logs 8 } 9 10 public struct RadrootsFileReference: Sendable, Equatable, Hashable, Codable { 11 public let scope: RadrootsFileScope 12 public let relativePath: String 13 14 public init(scope: RadrootsFileScope, relativePath: String) { 15 self.scope = scope 16 self.relativePath = relativePath 17 } 18 } 19 20 public struct RadrootsFileEntry: Sendable, Equatable, Hashable { 21 public let file: RadrootsFileReference 22 public let name: String 23 public let isDirectory: Bool 24 public let sizeBytes: Int? 25 public let modifiedAt: Date? 26 27 public init( 28 file: RadrootsFileReference, 29 name: String, 30 isDirectory: Bool, 31 sizeBytes: Int?, 32 modifiedAt: Date? 33 ) { 34 self.file = file 35 self.name = name 36 self.isDirectory = isDirectory 37 self.sizeBytes = sizeBytes 38 self.modifiedAt = modifiedAt 39 } 40 } 41 42 public struct RadrootsStagedBlobReference: Sendable, Equatable, Hashable, Codable { 43 public let blobID: String 44 public let sizeBytes: Int 45 public let mediaType: String? 46 public let filenameHint: String? 47 48 public init( 49 blobID: String, 50 sizeBytes: Int, 51 mediaType: String? = nil, 52 filenameHint: String? = nil 53 ) throws { 54 let normalizedBlobID = try Self.normalizedBlobID(blobID) 55 guard sizeBytes >= 0 else { 56 throw RadrootsAppleFileError.invalidRequest("staged blob size cannot be negative") 57 } 58 self.blobID = normalizedBlobID 59 self.sizeBytes = sizeBytes 60 self.mediaType = try Self.normalizedMediaType(mediaType) 61 self.filenameHint = try Self.normalizedFilenameHint(filenameHint) 62 } 63 64 public static func normalizedBlobID(_ blobID: String) throws -> String { 65 let trimmed = blobID.trimmingCharacters(in: .whitespacesAndNewlines) 66 guard !trimmed.isEmpty else { 67 throw RadrootsAppleFileError.invalidRequest("staged blob id cannot be empty") 68 } 69 let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") 70 guard trimmed.rangeOfCharacter(from: allowed.inverted) == nil else { 71 throw RadrootsAppleFileError.invalidRequest("staged blob id contains invalid characters") 72 } 73 return trimmed 74 } 75 76 public static func normalizedMediaType(_ mediaType: String?) throws -> String? { 77 guard let mediaType else { 78 return nil 79 } 80 let trimmed = mediaType.trimmingCharacters(in: .whitespacesAndNewlines) 81 guard !trimmed.isEmpty else { 82 throw RadrootsAppleFileError.invalidRequest("staged blob media type cannot be empty") 83 } 84 guard trimmed.rangeOfCharacter(from: .newlines) == nil else { 85 throw RadrootsAppleFileError.invalidRequest("staged blob media type cannot contain newlines") 86 } 87 return trimmed 88 } 89 90 public static func normalizedFilenameHint(_ filenameHint: String?) throws -> String? { 91 guard let filenameHint else { 92 return nil 93 } 94 let trimmed = filenameHint.trimmingCharacters(in: .whitespacesAndNewlines) 95 guard !trimmed.isEmpty else { 96 throw RadrootsAppleFileError.invalidRequest("staged blob filename hint cannot be empty") 97 } 98 guard !trimmed.contains("/") && !trimmed.contains("\\") && !trimmed.contains("\0") else { 99 throw RadrootsAppleFileError.invalidRequest("staged blob filename hint cannot contain path separators") 100 } 101 return trimmed 102 } 103 } 104 105 public enum RadrootsFilePayload: Sendable, Equatable { 106 case inline(Data) 107 case stagedBlob(RadrootsStagedBlobReference) 108 } 109 110 public enum RadrootsFileReadMode: Sendable, Equatable { 111 case inline 112 case preferInline(maxBytes: Int) 113 case stagedBlob 114 } 115 116 public enum RadrootsFileReadResult: Sendable, Equatable { 117 case inline(Data) 118 case stagedBlob(RadrootsStagedBlobReference) 119 } 120 121 public protocol RadrootsFileAccess { 122 func write(_ payload: RadrootsFilePayload, to file: RadrootsFileReference) throws 123 func read(_ file: RadrootsFileReference, mode: RadrootsFileReadMode) throws -> RadrootsFileReadResult 124 func delete(_ file: RadrootsFileReference) throws 125 func list(_ directory: RadrootsFileReference) throws -> [RadrootsFileEntry] 126 func reset(scope: RadrootsFileScope) throws 127 @discardableResult func stageBlob(_ data: Data, mediaType: String?, filenameHint: String?) throws -> RadrootsStagedBlobReference 128 @discardableResult func stageFile(_ file: RadrootsFileReference, mediaType: String?, filenameHint: String?) throws -> RadrootsStagedBlobReference 129 @discardableResult func stageExternalFile(_ sourceURL: URL, mediaType: String?, filenameHint: String?) throws -> RadrootsStagedBlobReference 130 @discardableResult func copyExternalFile( 131 _ sourceURL: URL, 132 to file: RadrootsFileReference, 133 mediaType: String?, 134 suggestedFilename: String? 135 ) throws -> RadrootsImportedDocument 136 @discardableResult func prepareExport(_ request: RadrootsExportDocumentRequest) throws -> RadrootsPreparedExportDocument 137 func preparedExportExists(_ preparedExport: RadrootsPreparedExportDocument) throws -> Bool 138 func readStagedBlob(_ blob: RadrootsStagedBlobReference) throws -> Data 139 func releaseStagedBlob(_ blob: RadrootsStagedBlobReference) throws 140 func releasePreparedExport(_ preparedExport: RadrootsPreparedExportDocument) throws 141 @discardableResult func sweepStagedBlobs(olderThan cutoff: Date) throws -> [RadrootsStagedBlobReference] 142 func resetStagedBlobs() throws 143 } 144 145 public final class RadrootsAppleFileAccess: RadrootsFileAccess { 146 public let roots: RadrootsAppleFileRoots 147 private let fileManager: FileManager 148 149 public init(roots: RadrootsAppleFileRoots, fileManager: FileManager = .default) { 150 self.roots = roots 151 self.fileManager = fileManager 152 } 153 154 public func write(_ payload: RadrootsFilePayload, to file: RadrootsFileReference) throws { 155 let url = try roots.resolvedURL(for: file) 156 try createParentDirectory(for: url) 157 switch payload { 158 case .inline(let inlineData): 159 try inlineData.write(to: url, options: [.atomic]) 160 case .stagedBlob(let stagedBlob): 161 try copyReplacingItem(from: try stagedBlobURL(for: stagedBlob), to: url) 162 } 163 } 164 165 public func read(_ file: RadrootsFileReference, mode: RadrootsFileReadMode) throws -> RadrootsFileReadResult { 166 let url = try roots.resolvedURL(for: file) 167 guard fileManager.fileExists(atPath: url.path) else { 168 throw RadrootsAppleFileError.notFound("file not found") 169 } 170 switch mode { 171 case .inline: 172 return .inline(try Data(contentsOf: url)) 173 case .preferInline(let maxBytes): 174 guard maxBytes >= 0 else { 175 throw RadrootsAppleFileError.invalidRequest("inline byte limit cannot be negative") 176 } 177 let size = try fileSize(at: url) 178 if size <= maxBytes { 179 return .inline(try Data(contentsOf: url)) 180 } 181 let staged = try stageFile(file, mediaType: nil, filenameHint: url.lastPathComponent) 182 return .stagedBlob(staged) 183 case .stagedBlob: 184 let staged = try stageFile(file, mediaType: nil, filenameHint: url.lastPathComponent) 185 return .stagedBlob(staged) 186 } 187 } 188 189 public func delete(_ file: RadrootsFileReference) throws { 190 let url = try roots.resolvedURL(for: file) 191 guard fileManager.fileExists(atPath: url.path) else { 192 return 193 } 194 try fileManager.removeItem(at: url) 195 } 196 197 public func list(_ directory: RadrootsFileReference) throws -> [RadrootsFileEntry] { 198 let rootURL = roots.root(for: directory.scope).standardizedFileURL 199 let directoryURL = try roots.resolvedURL(for: directory, allowRootDirectory: true) 200 var isDirectory = ObjCBool(false) 201 guard fileManager.fileExists(atPath: directoryURL.path, isDirectory: &isDirectory) else { 202 return [] 203 } 204 guard isDirectory.boolValue else { 205 throw RadrootsAppleFileError.invalidRequest("file list target must be a directory") 206 } 207 let urls = try fileManager.contentsOfDirectory( 208 at: directoryURL, 209 includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey], 210 options: [] 211 ) 212 return try urls.map { url in 213 let values = try url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]) 214 let relativePath = try relativePath(for: url.standardizedFileURL, under: rootURL) 215 return RadrootsFileEntry( 216 file: RadrootsFileReference(scope: directory.scope, relativePath: relativePath), 217 name: url.lastPathComponent, 218 isDirectory: values.isDirectory ?? false, 219 sizeBytes: values.fileSize, 220 modifiedAt: values.contentModificationDate 221 ) 222 } 223 .sorted { left, right in 224 left.file.relativePath < right.file.relativePath 225 } 226 } 227 228 public func reset(scope: RadrootsFileScope) throws { 229 let url = roots.root(for: scope) 230 if fileManager.fileExists(atPath: url.path) { 231 try fileManager.removeItem(at: url) 232 } 233 try fileManager.createDirectory(at: url, withIntermediateDirectories: true) 234 } 235 236 @discardableResult 237 public func stageBlob( 238 _ data: Data, 239 mediaType: String? = nil, 240 filenameHint: String? = nil 241 ) throws -> RadrootsStagedBlobReference { 242 let blobID = UUID().uuidString.lowercased() 243 let blob = try RadrootsStagedBlobReference( 244 blobID: blobID, 245 sizeBytes: data.count, 246 mediaType: mediaType, 247 filenameHint: filenameHint 248 ) 249 let url = try stagedBlobURL(for: blob) 250 try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true) 251 try data.write(to: url, options: [.atomic]) 252 return blob 253 } 254 255 @discardableResult 256 public func stageFile( 257 _ file: RadrootsFileReference, 258 mediaType: String? = nil, 259 filenameHint: String? = nil 260 ) throws -> RadrootsStagedBlobReference { 261 let sourceURL = try roots.resolvedURL(for: file) 262 guard fileManager.fileExists(atPath: sourceURL.path) else { 263 throw RadrootsAppleFileError.notFound("file not found") 264 } 265 return try stageFileURL( 266 sourceURL, 267 mediaType: mediaType, 268 filenameHint: filenameHint ?? sourceURL.lastPathComponent 269 ) 270 } 271 272 @discardableResult 273 public func stageExternalFile( 274 _ sourceURL: URL, 275 mediaType: String? = nil, 276 filenameHint: String? = nil 277 ) throws -> RadrootsStagedBlobReference { 278 try withSecurityScopedFile(sourceURL) { scopedURL in 279 try stageFileURL( 280 scopedURL, 281 mediaType: mediaType, 282 filenameHint: filenameHint ?? scopedURL.lastPathComponent 283 ) 284 } 285 } 286 287 @discardableResult 288 public func copyExternalFile( 289 _ sourceURL: URL, 290 to file: RadrootsFileReference, 291 mediaType: String? = nil, 292 suggestedFilename: String? = nil 293 ) throws -> RadrootsImportedDocument { 294 try withSecurityScopedFile(sourceURL) { scopedURL in 295 let destinationURL = try roots.resolvedURL(for: file) 296 try createParentDirectory(for: destinationURL) 297 try copyReplacingItem(from: scopedURL, to: destinationURL) 298 let sizeBytes = try fileSizeUInt64(at: destinationURL) 299 return try RadrootsImportedDocument( 300 file: file, 301 originalURL: scopedURL, 302 suggestedFilename: suggestedFilename ?? scopedURL.lastPathComponent, 303 mediaType: mediaType, 304 sizeBytes: sizeBytes 305 ) 306 } 307 } 308 309 @discardableResult 310 public func prepareExport(_ request: RadrootsExportDocumentRequest) throws -> RadrootsPreparedExportDocument { 311 let preparedID = UUID().uuidString.lowercased() 312 let directoryURL = preparedExportsRoot.appendingPathComponent(preparedID, isDirectory: true) 313 let fileURL = directoryURL.appendingPathComponent(request.suggestedFilename).standardizedFileURL 314 try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) 315 switch request.source { 316 case .inlineData(let data): 317 try data.write(to: fileURL, options: [.atomic]) 318 case .file(let file): 319 try copyReplacingItem(from: try roots.resolvedURL(for: file), to: fileURL) 320 case .stagedBlob(let stagedBlob): 321 try copyReplacingItem(from: try stagedBlobURL(for: stagedBlob), to: fileURL) 322 } 323 let sizeBytes: UInt64 324 if let requestSizeBytes = request.sizeBytes { 325 sizeBytes = requestSizeBytes 326 } else { 327 sizeBytes = try fileSizeUInt64(at: fileURL) 328 } 329 return try RadrootsPreparedExportDocument( 330 preparedID: preparedID, 331 fileURL: fileURL, 332 suggestedFilename: request.suggestedFilename, 333 mediaType: request.mediaType, 334 sizeBytes: sizeBytes 335 ) 336 } 337 338 public func readStagedBlob(_ blob: RadrootsStagedBlobReference) throws -> Data { 339 let url = try stagedBlobURL(for: blob) 340 guard fileManager.fileExists(atPath: url.path) else { 341 throw RadrootsAppleFileError.notFound("staged blob not found") 342 } 343 let data = try Data(contentsOf: url) 344 guard data.count == blob.sizeBytes else { 345 throw RadrootsAppleFileError.permanentFailure("staged blob size does not match reference") 346 } 347 return data 348 } 349 350 public func releaseStagedBlob(_ blob: RadrootsStagedBlobReference) throws { 351 let url = try stagedBlobURL(for: blob) 352 if fileManager.fileExists(atPath: url.path) { 353 try fileManager.removeItem(at: url) 354 } 355 } 356 357 public func preparedExportExists(_ preparedExport: RadrootsPreparedExportDocument) throws -> Bool { 358 let directoryURL = try preparedExportDirectoryURL(for: preparedExport) 359 return fileManager.fileExists(atPath: directoryURL.path) && 360 fileManager.fileExists(atPath: preparedExport.fileURL.path) 361 } 362 363 public func releasePreparedExport(_ preparedExport: RadrootsPreparedExportDocument) throws { 364 let directoryURL = try preparedExportDirectoryURL(for: preparedExport) 365 if fileManager.fileExists(atPath: directoryURL.path) { 366 try fileManager.removeItem(at: directoryURL) 367 } 368 } 369 370 @discardableResult 371 public func sweepStagedBlobs(olderThan cutoff: Date) throws -> [RadrootsStagedBlobReference] { 372 guard fileManager.fileExists(atPath: roots.stagedBlobsRoot.path) else { 373 return [] 374 } 375 let urls = try fileManager.contentsOfDirectory( 376 at: roots.stagedBlobsRoot, 377 includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey], 378 options: [] 379 ) 380 var released: [RadrootsStagedBlobReference] = [] 381 for url in urls { 382 let values = try url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]) 383 guard values.isDirectory != true else { 384 continue 385 } 386 guard let modifiedAt = values.contentModificationDate, modifiedAt < cutoff else { 387 continue 388 } 389 let blob = try RadrootsStagedBlobReference( 390 blobID: url.lastPathComponent, 391 sizeBytes: values.fileSize ?? 0 392 ) 393 try fileManager.removeItem(at: url) 394 released.append(blob) 395 } 396 return released.sorted { left, right in 397 left.blobID < right.blobID 398 } 399 } 400 401 public func resetStagedBlobs() throws { 402 if fileManager.fileExists(atPath: roots.stagedBlobsRoot.path) { 403 try fileManager.removeItem(at: roots.stagedBlobsRoot) 404 } 405 try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true) 406 } 407 408 public func resetFileRoots() throws { 409 for scope in RadrootsFileScope.allCases { 410 try reset(scope: scope) 411 } 412 try resetStagedBlobs() 413 } 414 415 private func stagedBlobURL(for blob: RadrootsStagedBlobReference) throws -> URL { 416 try roots.stagedBlobURL(for: blob) 417 } 418 419 private var preparedExportsRoot: URL { 420 roots.temporaryRoot.appendingPathComponent("prepared_exports", isDirectory: true).standardizedFileURL 421 } 422 423 private func preparedExportDirectoryURL(for preparedExport: RadrootsPreparedExportDocument) throws -> URL { 424 let normalizedPreparedID = try RadrootsPreparedExportDocument.normalizedPreparedID(preparedExport.preparedID) 425 let directoryURL = preparedExportsRoot.appendingPathComponent(normalizedPreparedID, isDirectory: true).standardizedFileURL 426 guard preparedExport.fileURL.standardizedFileURL.path.hasPrefix(directoryURL.path + "/") else { 427 throw RadrootsAppleFileError.invalidRequest("prepared export file escaped its directory") 428 } 429 return directoryURL 430 } 431 432 private func stageFileURL( 433 _ sourceURL: URL, 434 mediaType: String?, 435 filenameHint: String? 436 ) throws -> RadrootsStagedBlobReference { 437 let sizeBytes = try fileSizeInt(at: sourceURL) 438 let blobID = UUID().uuidString.lowercased() 439 let blob = try RadrootsStagedBlobReference( 440 blobID: blobID, 441 sizeBytes: sizeBytes, 442 mediaType: mediaType, 443 filenameHint: filenameHint 444 ) 445 let destinationURL = try stagedBlobURL(for: blob) 446 try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true) 447 try copyReplacingItem(from: sourceURL, to: destinationURL) 448 return blob 449 } 450 451 private func withSecurityScopedFile<T>(_ sourceURL: URL, _ body: (URL) throws -> T) throws -> T { 452 guard sourceURL.isFileURL else { 453 throw RadrootsAppleFileError.invalidRequest("external file url must be a file url") 454 } 455 let scopedURL = sourceURL.standardizedFileURL 456 var isDirectory = ObjCBool(false) 457 guard fileManager.fileExists(atPath: scopedURL.path, isDirectory: &isDirectory) else { 458 throw RadrootsAppleFileError.notFound("external file not found") 459 } 460 guard !isDirectory.boolValue else { 461 throw RadrootsAppleFileError.invalidRequest("external file url must reference a file") 462 } 463 let didStartScope = scopedURL.startAccessingSecurityScopedResource() 464 defer { 465 if didStartScope { 466 scopedURL.stopAccessingSecurityScopedResource() 467 } 468 } 469 return try body(scopedURL) 470 } 471 472 private func copyReplacingItem(from sourceURL: URL, to destinationURL: URL) throws { 473 guard sourceURL.isFileURL, destinationURL.isFileURL else { 474 throw RadrootsAppleFileError.invalidRequest("copy source and destination must be file urls") 475 } 476 try createParentDirectory(for: destinationURL) 477 if fileManager.fileExists(atPath: destinationURL.path) { 478 try fileManager.removeItem(at: destinationURL) 479 } 480 try fileManager.copyItem(at: sourceURL, to: destinationURL) 481 } 482 483 private func createParentDirectory(for url: URL) throws { 484 try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) 485 } 486 487 private func fileSize(at url: URL) throws -> Int { 488 try fileSizeInt(at: url) 489 } 490 491 private func fileSizeInt(at url: URL) throws -> Int { 492 let values = try url.resourceValues(forKeys: [.fileSizeKey]) 493 guard let size = values.fileSize else { 494 throw RadrootsAppleFileError.permanentFailure("file size is unavailable") 495 } 496 return size 497 } 498 499 private func fileSizeUInt64(at url: URL) throws -> UInt64 { 500 UInt64(try fileSizeInt(at: url)) 501 } 502 503 private func relativePath(for url: URL, under rootURL: URL) throws -> String { 504 let rootPath = rootURL.standardizedFileURL.path 505 let filePath = url.standardizedFileURL.path 506 guard filePath.hasPrefix(rootPath + "/") else { 507 throw RadrootsAppleFileError.invalidRequest("file list entry escaped its scope") 508 } 509 return String(filePath.dropFirst(rootPath.count + 1)) 510 } 511 } 512 513 public struct RadrootsAppleFileRoots: Sendable, Equatable { 514 public let appIdentifier: String 515 public let dataRoot: URL 516 public let cacheRoot: URL 517 public let temporaryRoot: URL 518 public let logsRoot: URL 519 public let stagedBlobsRoot: URL 520 521 public init( 522 appIdentifier: String, 523 dataRoot: URL, 524 cacheRoot: URL, 525 temporaryRoot: URL, 526 logsRoot: URL? = nil, 527 stagedBlobsRoot: URL? = nil 528 ) throws { 529 let normalizedAppIdentifier = try Self.normalizedAppIdentifier(appIdentifier) 530 let normalizedDataRoot = try Self.normalizedRootURL(dataRoot, field: "dataRoot") 531 let normalizedCacheRoot = try Self.normalizedRootURL(cacheRoot, field: "cacheRoot") 532 let normalizedTemporaryRoot = try Self.normalizedRootURL(temporaryRoot, field: "temporaryRoot") 533 self.appIdentifier = normalizedAppIdentifier 534 self.dataRoot = normalizedDataRoot 535 self.cacheRoot = normalizedCacheRoot 536 self.temporaryRoot = normalizedTemporaryRoot 537 self.logsRoot = try Self.normalizedRootURL( 538 logsRoot ?? normalizedCacheRoot.appendingPathComponent("Logs", isDirectory: true), 539 field: "logsRoot" 540 ) 541 self.stagedBlobsRoot = try Self.normalizedRootURL( 542 stagedBlobsRoot ?? normalizedTemporaryRoot.appendingPathComponent("staged_blobs", isDirectory: true), 543 field: "stagedBlobsRoot" 544 ) 545 } 546 547 public static func appContainer( 548 appIdentifier: String, 549 fileManager: FileManager = .default 550 ) throws -> Self { 551 let normalizedAppIdentifier = try normalizedAppIdentifier(appIdentifier) 552 let dataBaseURL = try fileManager.url( 553 for: .applicationSupportDirectory, 554 in: .userDomainMask, 555 appropriateFor: nil, 556 create: true 557 ) 558 let cacheBaseURL = try fileManager.url( 559 for: .cachesDirectory, 560 in: .userDomainMask, 561 appropriateFor: nil, 562 create: true 563 ) 564 let dataRoot = dataBaseURL.appendingPathComponent(normalizedAppIdentifier, isDirectory: true) 565 let cacheRoot = cacheBaseURL.appendingPathComponent(normalizedAppIdentifier, isDirectory: true) 566 let temporaryRoot = fileManager.temporaryDirectory 567 .appendingPathComponent(normalizedAppIdentifier, isDirectory: true) 568 return try Self( 569 appIdentifier: normalizedAppIdentifier, 570 dataRoot: dataRoot, 571 cacheRoot: cacheRoot, 572 temporaryRoot: temporaryRoot 573 ) 574 } 575 576 public func root(for scope: RadrootsFileScope) -> URL { 577 switch scope { 578 case .data: 579 dataRoot 580 case .cache: 581 cacheRoot 582 case .temporary: 583 temporaryRoot 584 case .logs: 585 logsRoot 586 } 587 } 588 589 public func resolvedURL( 590 for file: RadrootsFileReference, 591 allowRootDirectory: Bool = false 592 ) throws -> URL { 593 let rootURL = root(for: file.scope).standardizedFileURL 594 let trimmedPath = file.relativePath.trimmingCharacters(in: .whitespacesAndNewlines) 595 if trimmedPath.isEmpty { 596 if allowRootDirectory { 597 return rootURL 598 } 599 throw RadrootsAppleFileError.invalidRequest("file relative path cannot be empty") 600 } 601 if NSString(string: trimmedPath).isAbsolutePath { 602 throw RadrootsAppleFileError.invalidRequest("file relative path must not be absolute") 603 } 604 605 let candidateURL = rootURL.appendingPathComponent(trimmedPath).standardizedFileURL 606 if candidateURL.path == rootURL.path { 607 if allowRootDirectory { 608 return candidateURL 609 } 610 throw RadrootsAppleFileError.invalidRequest("file relative path cannot resolve to its root") 611 } 612 guard candidateURL.path.hasPrefix(rootURL.path + "/") else { 613 throw RadrootsAppleFileError.invalidRequest("file relative path must not escape its scope") 614 } 615 return candidateURL 616 } 617 618 public func stagedBlobURL(for blob: RadrootsStagedBlobReference) throws -> URL { 619 let normalizedBlobID = try RadrootsStagedBlobReference.normalizedBlobID(blob.blobID) 620 return stagedBlobsRoot.appendingPathComponent(normalizedBlobID).standardizedFileURL 621 } 622 623 public static func normalizedAppIdentifier(_ appIdentifier: String) throws -> String { 624 let trimmed = appIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) 625 guard !trimmed.isEmpty else { 626 throw RadrootsAppleFileError.invalidRequest("app identifier cannot be empty") 627 } 628 return trimmed 629 } 630 631 public static func normalizedRootURL(_ rootURL: URL, field: String) throws -> URL { 632 guard rootURL.isFileURL else { 633 throw RadrootsAppleFileError.invalidRequest("\(field) must be a file URL") 634 } 635 let standardized = rootURL.standardizedFileURL 636 guard standardized.path.hasPrefix("/") else { 637 throw RadrootsAppleFileError.invalidRequest("\(field) must be absolute") 638 } 639 return standardized 640 } 641 }