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 }