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

RadrootsGovernedFileReader.swift (11081B)


      1 import Darwin
      2 import Foundation
      3 
      4 enum RadrootsGovernedFileReadError: Error, Equatable, Sendable {
      5     case invalidRequest
      6     case unavailable
      7     case invalidObject
      8     case tooLarge
      9     case changedDuringRead
     10     case ioFailure
     11 }
     12 
     13 struct RadrootsGovernedFileReader {
     14     private static let readBufferByteCount = 16 * 1024
     15 
     16     static func read(
     17         root: URL,
     18         relativePath: String,
     19         maximumBytes: Int
     20     ) throws -> Data {
     21         try read(
     22             root: root,
     23             relativePath: relativePath,
     24             maximumBytes: maximumBytes,
     25             afterAdmission: nil
     26         )
     27     }
     28 
     29     static func readForTesting(
     30         root: URL,
     31         relativePath: String,
     32         maximumBytes: Int,
     33         afterAdmission: @escaping () throws -> Void
     34     ) throws -> Data {
     35         try read(
     36             root: root,
     37             relativePath: relativePath,
     38             maximumBytes: maximumBytes,
     39             afterAdmission: afterAdmission
     40         )
     41     }
     42 
     43     private static func read(
     44         root: URL,
     45         relativePath: String,
     46         maximumBytes: Int,
     47         afterAdmission: (() throws -> Void)?
     48     ) throws -> Data {
     49         guard root.isFileURL,
     50             root.path.hasPrefix("/"),
     51             maximumBytes >= 0,
     52             maximumBytes < Int.max
     53         else {
     54             throw RadrootsGovernedFileReadError.invalidRequest
     55         }
     56 
     57         let rootComponents = try components(ofAbsoluteRoot: root)
     58         let relativeComponents = try components(ofRelativePath: relativePath)
     59         let directoryComponents = rootComponents + Array(relativeComponents.dropLast())
     60         let leaf = relativeComponents[relativeComponents.index(before: relativeComponents.endIndex)]
     61 
     62         let admittedTraversal = try openDirectoryTraversal(directoryComponents)
     63         defer { close(admittedTraversal.descriptor) }
     64 
     65         let fileDescriptor = try openComponent(
     66             leaf,
     67             relativeTo: admittedTraversal.descriptor,
     68             expectingDirectory: false
     69         )
     70         defer { close(fileDescriptor) }
     71 
     72         let admittedFile = try fileIdentity(of: fileDescriptor)
     73         guard admittedFile.isRegularFile else {
     74             throw RadrootsGovernedFileReadError.invalidObject
     75         }
     76         guard admittedFile.byteCount <= UInt64(maximumBytes) else {
     77             throw RadrootsGovernedFileReadError.tooLarge
     78         }
     79 
     80         do {
     81             try afterAdmission?()
     82         } catch {
     83             throw RadrootsGovernedFileReadError.ioFailure
     84         }
     85         let bytes = try readBounded(
     86             fileDescriptor,
     87             admittedByteCount: admittedFile.byteCount,
     88             maximumBytes: maximumBytes
     89         )
     90 
     91         let finalFile = try fileIdentity(of: fileDescriptor)
     92         guard finalFile == admittedFile,
     93             UInt64(bytes.count) == admittedFile.byteCount
     94         else {
     95             throw RadrootsGovernedFileReadError.changedDuringRead
     96         }
     97 
     98         try validateCurrentBinding(
     99             directoryComponents: Array(directoryComponents),
    100             admittedDirectories: admittedTraversal.identities,
    101             leaf: leaf,
    102             admittedFile: admittedFile
    103         )
    104         return Data(bytes)
    105     }
    106 
    107     private static func components(ofAbsoluteRoot root: URL) throws -> [String] {
    108         let path = root.path
    109         guard path.hasPrefix("/"),
    110             !path.utf8.contains(0)
    111         else {
    112             throw RadrootsGovernedFileReadError.invalidRequest
    113         }
    114         let components = path.split(separator: "/", omittingEmptySubsequences: true).map(
    115             String.init)
    116         guard components.allSatisfy(isOrdinaryComponent) else {
    117             throw RadrootsGovernedFileReadError.invalidRequest
    118         }
    119         return components
    120     }
    121 
    122     private static func components(ofRelativePath relativePath: String) throws -> [String] {
    123         guard !relativePath.isEmpty,
    124             !relativePath.hasPrefix("/"),
    125             !relativePath.utf8.contains(0)
    126         else {
    127             throw RadrootsGovernedFileReadError.invalidRequest
    128         }
    129         let components = relativePath.split(separator: "/", omittingEmptySubsequences: false).map(
    130             String.init)
    131         guard !components.isEmpty,
    132             components.allSatisfy(isOrdinaryComponent)
    133         else {
    134             throw RadrootsGovernedFileReadError.invalidRequest
    135         }
    136         return components
    137     }
    138 
    139     private static func isOrdinaryComponent(_ component: String) -> Bool {
    140         !component.isEmpty && component != "." && component != ".." && !component.contains("/")
    141     }
    142 
    143     private static func openDirectoryTraversal(
    144         _ components: [String]
    145     ) throws -> (descriptor: Int32, identities: [FileIdentity]) {
    146         let rootDescriptor = Darwin.open(
    147             "/",
    148             O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK
    149         )
    150         guard rootDescriptor >= 0 else {
    151             throw RadrootsGovernedFileReadError.ioFailure
    152         }
    153 
    154         var currentDescriptor = rootDescriptor
    155         var identities: [FileIdentity]
    156         do {
    157             identities = [try fileIdentity(of: rootDescriptor)]
    158         } catch {
    159             close(rootDescriptor)
    160             throw error
    161         }
    162         do {
    163             for component in components {
    164                 let nextDescriptor = try openComponent(
    165                     component,
    166                     relativeTo: currentDescriptor,
    167                     expectingDirectory: true
    168                 )
    169                 close(currentDescriptor)
    170                 currentDescriptor = nextDescriptor
    171                 identities.append(try fileIdentity(of: nextDescriptor))
    172             }
    173             return (currentDescriptor, identities)
    174         } catch {
    175             close(currentDescriptor)
    176             throw error
    177         }
    178     }
    179 
    180     private static func openComponent(
    181         _ component: String,
    182         relativeTo parentDescriptor: Int32,
    183         expectingDirectory: Bool
    184     ) throws -> Int32 {
    185         var flags = O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK
    186         if expectingDirectory {
    187             flags |= O_DIRECTORY
    188         }
    189         let descriptor = component.withCString { pointer in
    190             Darwin.openat(parentDescriptor, pointer, flags)
    191         }
    192         guard descriptor >= 0 else {
    193             throw classifiedOpenError(errno)
    194         }
    195         do {
    196             let identity = try fileIdentity(of: descriptor)
    197             if expectingDirectory, !identity.isDirectory {
    198                 throw RadrootsGovernedFileReadError.invalidObject
    199             }
    200             return descriptor
    201         } catch {
    202             close(descriptor)
    203             throw error
    204         }
    205     }
    206 
    207     private static func classifiedOpenError(_ code: Int32) -> RadrootsGovernedFileReadError {
    208         switch code {
    209         case ENOENT:
    210             .unavailable
    211         case ELOOP, ENOTDIR:
    212             .invalidObject
    213         default:
    214             .ioFailure
    215         }
    216     }
    217 
    218     private static func fileIdentity(of descriptor: Int32) throws -> FileIdentity {
    219         var metadata = stat()
    220         guard Darwin.fstat(descriptor, &metadata) == 0,
    221             metadata.st_dev >= 0,
    222             metadata.st_size >= 0
    223         else {
    224             throw RadrootsGovernedFileReadError.ioFailure
    225         }
    226         return FileIdentity(
    227             device: UInt64(metadata.st_dev),
    228             inode: UInt64(metadata.st_ino),
    229             mode: UInt32(metadata.st_mode),
    230             byteCount: UInt64(metadata.st_size),
    231             modifiedSeconds: Int64(metadata.st_mtimespec.tv_sec),
    232             modifiedNanoseconds: Int64(metadata.st_mtimespec.tv_nsec),
    233             changedSeconds: Int64(metadata.st_ctimespec.tv_sec),
    234             changedNanoseconds: Int64(metadata.st_ctimespec.tv_nsec)
    235         )
    236     }
    237 
    238     private static func readBounded(
    239         _ descriptor: Int32,
    240         admittedByteCount: UInt64,
    241         maximumBytes: Int
    242     ) throws -> [UInt8] {
    243         var bytes: [UInt8] = []
    244         bytes.reserveCapacity(Int(admittedByteCount))
    245         var buffer = [UInt8](repeating: 0, count: readBufferByteCount)
    246         let maximumPlusOne = maximumBytes + 1
    247 
    248         while true {
    249             let remaining = maximumPlusOne - bytes.count
    250             guard remaining > 0 else {
    251                 throw RadrootsGovernedFileReadError.tooLarge
    252             }
    253             let requested = min(buffer.count, remaining)
    254             let count = buffer.withUnsafeMutableBytes { rawBuffer in
    255                 Darwin.read(descriptor, rawBuffer.baseAddress, requested)
    256             }
    257             if count == 0 {
    258                 return bytes
    259             }
    260             if count < 0 {
    261                 if errno == EINTR {
    262                     continue
    263                 }
    264                 throw RadrootsGovernedFileReadError.ioFailure
    265             }
    266             bytes.append(contentsOf: buffer.prefix(count))
    267             if bytes.count > maximumBytes {
    268                 throw RadrootsGovernedFileReadError.tooLarge
    269             }
    270         }
    271     }
    272 
    273     private static func validateCurrentBinding(
    274         directoryComponents: [String],
    275         admittedDirectories: [FileIdentity],
    276         leaf: String,
    277         admittedFile: FileIdentity
    278     ) throws {
    279         let currentTraversal: (descriptor: Int32, identities: [FileIdentity])
    280         do {
    281             currentTraversal = try openDirectoryTraversal(directoryComponents)
    282         } catch {
    283             throw RadrootsGovernedFileReadError.changedDuringRead
    284         }
    285         defer { close(currentTraversal.descriptor) }
    286         guard currentTraversal.identities.count == admittedDirectories.count,
    287             zip(currentTraversal.identities, admittedDirectories).allSatisfy({ current, admitted in
    288                 current.isSameDirectoryObject(as: admitted)
    289             })
    290         else {
    291             throw RadrootsGovernedFileReadError.changedDuringRead
    292         }
    293 
    294         let currentFileDescriptor: Int32
    295         do {
    296             currentFileDescriptor = try openComponent(
    297                 leaf,
    298                 relativeTo: currentTraversal.descriptor,
    299                 expectingDirectory: false
    300             )
    301         } catch {
    302             throw RadrootsGovernedFileReadError.changedDuringRead
    303         }
    304         defer { close(currentFileDescriptor) }
    305         guard try fileIdentity(of: currentFileDescriptor) == admittedFile else {
    306             throw RadrootsGovernedFileReadError.changedDuringRead
    307         }
    308     }
    309 
    310     private static func close(_ descriptor: Int32) {
    311         _ = Darwin.close(descriptor)
    312     }
    313 }
    314 
    315 private struct FileIdentity: Equatable {
    316     let device: UInt64
    317     let inode: UInt64
    318     let mode: UInt32
    319     let byteCount: UInt64
    320     let modifiedSeconds: Int64
    321     let modifiedNanoseconds: Int64
    322     let changedSeconds: Int64
    323     let changedNanoseconds: Int64
    324 
    325     var isDirectory: Bool {
    326         mode & UInt32(S_IFMT) == UInt32(S_IFDIR)
    327     }
    328 
    329     var isRegularFile: Bool {
    330         mode & UInt32(S_IFMT) == UInt32(S_IFREG)
    331     }
    332 
    333     func isSameDirectoryObject(as other: FileIdentity) -> Bool {
    334         isDirectory && other.isDirectory && device == other.device && inode == other.inode
    335     }
    336 }