apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

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 }