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

commit fe3afbd679dbdcaae6baba2a95b256238fbb3f82
parent e61386529422a6413a852f0233a196a88f4ded51
Author: triesap <tyson@radroots.org>
Date:   Tue,  1 Sep 2026 00:33:35 +0000

security: add governed file reader

- traverse absolute roots descriptor-relatively without following symlinks
- cap reads before allocation and reject non-regular filesystem objects
- revalidate file and directory identities after every admitted read
- cover bounds, symlinks, FIFOs, mutation, and replacement races

Diffstat:
ASources/RadrootsKit/RadrootsGovernedFileReader.swift | 336+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsGovernedFileReaderTests.swift | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 537 insertions(+), 0 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsGovernedFileReader.swift b/Sources/RadrootsKit/RadrootsGovernedFileReader.swift @@ -0,0 +1,336 @@ +import Darwin +import Foundation + +enum RadrootsGovernedFileReadError: Error, Equatable, Sendable { + case invalidRequest + case unavailable + case invalidObject + case tooLarge + case changedDuringRead + case ioFailure +} + +struct RadrootsGovernedFileReader { + private static let readBufferByteCount = 16 * 1024 + + static func read( + root: URL, + relativePath: String, + maximumBytes: Int + ) throws -> Data { + try read( + root: root, + relativePath: relativePath, + maximumBytes: maximumBytes, + afterAdmission: nil + ) + } + + static func readForTesting( + root: URL, + relativePath: String, + maximumBytes: Int, + afterAdmission: @escaping () throws -> Void + ) throws -> Data { + try read( + root: root, + relativePath: relativePath, + maximumBytes: maximumBytes, + afterAdmission: afterAdmission + ) + } + + private static func read( + root: URL, + relativePath: String, + maximumBytes: Int, + afterAdmission: (() throws -> Void)? + ) throws -> Data { + guard root.isFileURL, + root.path.hasPrefix("/"), + maximumBytes >= 0, + maximumBytes < Int.max + else { + throw RadrootsGovernedFileReadError.invalidRequest + } + + let rootComponents = try components(ofAbsoluteRoot: root) + let relativeComponents = try components(ofRelativePath: relativePath) + let directoryComponents = rootComponents + Array(relativeComponents.dropLast()) + let leaf = relativeComponents[relativeComponents.index(before: relativeComponents.endIndex)] + + let admittedTraversal = try openDirectoryTraversal(directoryComponents) + defer { close(admittedTraversal.descriptor) } + + let fileDescriptor = try openComponent( + leaf, + relativeTo: admittedTraversal.descriptor, + expectingDirectory: false + ) + defer { close(fileDescriptor) } + + let admittedFile = try fileIdentity(of: fileDescriptor) + guard admittedFile.isRegularFile else { + throw RadrootsGovernedFileReadError.invalidObject + } + guard admittedFile.byteCount <= UInt64(maximumBytes) else { + throw RadrootsGovernedFileReadError.tooLarge + } + + do { + try afterAdmission?() + } catch { + throw RadrootsGovernedFileReadError.ioFailure + } + let bytes = try readBounded( + fileDescriptor, + admittedByteCount: admittedFile.byteCount, + maximumBytes: maximumBytes + ) + + let finalFile = try fileIdentity(of: fileDescriptor) + guard finalFile == admittedFile, + UInt64(bytes.count) == admittedFile.byteCount + else { + throw RadrootsGovernedFileReadError.changedDuringRead + } + + try validateCurrentBinding( + directoryComponents: Array(directoryComponents), + admittedDirectories: admittedTraversal.identities, + leaf: leaf, + admittedFile: admittedFile + ) + return Data(bytes) + } + + private static func components(ofAbsoluteRoot root: URL) throws -> [String] { + let path = root.path + guard path.hasPrefix("/"), + !path.utf8.contains(0) + else { + throw RadrootsGovernedFileReadError.invalidRequest + } + let components = path.split(separator: "/", omittingEmptySubsequences: true).map( + String.init) + guard components.allSatisfy(isOrdinaryComponent) else { + throw RadrootsGovernedFileReadError.invalidRequest + } + return components + } + + private static func components(ofRelativePath relativePath: String) throws -> [String] { + guard !relativePath.isEmpty, + !relativePath.hasPrefix("/"), + !relativePath.utf8.contains(0) + else { + throw RadrootsGovernedFileReadError.invalidRequest + } + let components = relativePath.split(separator: "/", omittingEmptySubsequences: false).map( + String.init) + guard !components.isEmpty, + components.allSatisfy(isOrdinaryComponent) + else { + throw RadrootsGovernedFileReadError.invalidRequest + } + return components + } + + private static func isOrdinaryComponent(_ component: String) -> Bool { + !component.isEmpty && component != "." && component != ".." && !component.contains("/") + } + + private static func openDirectoryTraversal( + _ components: [String] + ) throws -> (descriptor: Int32, identities: [FileIdentity]) { + let rootDescriptor = Darwin.open( + "/", + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK + ) + guard rootDescriptor >= 0 else { + throw RadrootsGovernedFileReadError.ioFailure + } + + var currentDescriptor = rootDescriptor + var identities: [FileIdentity] + do { + identities = [try fileIdentity(of: rootDescriptor)] + } catch { + close(rootDescriptor) + throw error + } + do { + for component in components { + let nextDescriptor = try openComponent( + component, + relativeTo: currentDescriptor, + expectingDirectory: true + ) + close(currentDescriptor) + currentDescriptor = nextDescriptor + identities.append(try fileIdentity(of: nextDescriptor)) + } + return (currentDescriptor, identities) + } catch { + close(currentDescriptor) + throw error + } + } + + private static func openComponent( + _ component: String, + relativeTo parentDescriptor: Int32, + expectingDirectory: Bool + ) throws -> Int32 { + var flags = O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK + if expectingDirectory { + flags |= O_DIRECTORY + } + let descriptor = component.withCString { pointer in + Darwin.openat(parentDescriptor, pointer, flags) + } + guard descriptor >= 0 else { + throw classifiedOpenError(errno) + } + do { + let identity = try fileIdentity(of: descriptor) + if expectingDirectory, !identity.isDirectory { + throw RadrootsGovernedFileReadError.invalidObject + } + return descriptor + } catch { + close(descriptor) + throw error + } + } + + private static func classifiedOpenError(_ code: Int32) -> RadrootsGovernedFileReadError { + switch code { + case ENOENT: + .unavailable + case ELOOP, ENOTDIR: + .invalidObject + default: + .ioFailure + } + } + + private static func fileIdentity(of descriptor: Int32) throws -> FileIdentity { + var metadata = stat() + guard Darwin.fstat(descriptor, &metadata) == 0, + metadata.st_dev >= 0, + metadata.st_size >= 0 + else { + throw RadrootsGovernedFileReadError.ioFailure + } + return FileIdentity( + device: UInt64(metadata.st_dev), + inode: UInt64(metadata.st_ino), + mode: UInt32(metadata.st_mode), + byteCount: UInt64(metadata.st_size), + modifiedSeconds: Int64(metadata.st_mtimespec.tv_sec), + modifiedNanoseconds: Int64(metadata.st_mtimespec.tv_nsec), + changedSeconds: Int64(metadata.st_ctimespec.tv_sec), + changedNanoseconds: Int64(metadata.st_ctimespec.tv_nsec) + ) + } + + private static func readBounded( + _ descriptor: Int32, + admittedByteCount: UInt64, + maximumBytes: Int + ) throws -> [UInt8] { + var bytes: [UInt8] = [] + bytes.reserveCapacity(Int(admittedByteCount)) + var buffer = [UInt8](repeating: 0, count: readBufferByteCount) + let maximumPlusOne = maximumBytes + 1 + + while true { + let remaining = maximumPlusOne - bytes.count + guard remaining > 0 else { + throw RadrootsGovernedFileReadError.tooLarge + } + let requested = min(buffer.count, remaining) + let count = buffer.withUnsafeMutableBytes { rawBuffer in + Darwin.read(descriptor, rawBuffer.baseAddress, requested) + } + if count == 0 { + return bytes + } + if count < 0 { + if errno == EINTR { + continue + } + throw RadrootsGovernedFileReadError.ioFailure + } + bytes.append(contentsOf: buffer.prefix(count)) + if bytes.count > maximumBytes { + throw RadrootsGovernedFileReadError.tooLarge + } + } + } + + private static func validateCurrentBinding( + directoryComponents: [String], + admittedDirectories: [FileIdentity], + leaf: String, + admittedFile: FileIdentity + ) throws { + let currentTraversal: (descriptor: Int32, identities: [FileIdentity]) + do { + currentTraversal = try openDirectoryTraversal(directoryComponents) + } catch { + throw RadrootsGovernedFileReadError.changedDuringRead + } + defer { close(currentTraversal.descriptor) } + guard currentTraversal.identities.count == admittedDirectories.count, + zip(currentTraversal.identities, admittedDirectories).allSatisfy({ current, admitted in + current.isSameDirectoryObject(as: admitted) + }) + else { + throw RadrootsGovernedFileReadError.changedDuringRead + } + + let currentFileDescriptor: Int32 + do { + currentFileDescriptor = try openComponent( + leaf, + relativeTo: currentTraversal.descriptor, + expectingDirectory: false + ) + } catch { + throw RadrootsGovernedFileReadError.changedDuringRead + } + defer { close(currentFileDescriptor) } + guard try fileIdentity(of: currentFileDescriptor) == admittedFile else { + throw RadrootsGovernedFileReadError.changedDuringRead + } + } + + private static func close(_ descriptor: Int32) { + _ = Darwin.close(descriptor) + } +} + +private struct FileIdentity: Equatable { + let device: UInt64 + let inode: UInt64 + let mode: UInt32 + let byteCount: UInt64 + let modifiedSeconds: Int64 + let modifiedNanoseconds: Int64 + let changedSeconds: Int64 + let changedNanoseconds: Int64 + + var isDirectory: Bool { + mode & UInt32(S_IFMT) == UInt32(S_IFDIR) + } + + var isRegularFile: Bool { + mode & UInt32(S_IFMT) == UInt32(S_IFREG) + } + + func isSameDirectoryObject(as other: FileIdentity) -> Bool { + isDirectory && other.isDirectory && device == other.device && inode == other.inode + } +} diff --git a/Tests/RadrootsKitTests/RadrootsGovernedFileReaderTests.swift b/Tests/RadrootsKitTests/RadrootsGovernedFileReaderTests.swift @@ -0,0 +1,201 @@ +import Darwin +import Foundation +import Testing + +@testable import RadrootsKit + +@Test func governedFileReaderReadsAnExactBoundedRegularFile() throws { + try withGovernedFileFixture { root in + let directory = root.appendingPathComponent("config", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + let payload = Data("governed".utf8) + try payload.write(to: directory.appendingPathComponent("control.json")) + + #expect( + try RadrootsGovernedFileReader.read( + root: root, + relativePath: "config/control.json", + maximumBytes: payload.count + ) == payload + ) + } +} + +@Test(arguments: ["", "/absolute", ".", "..", "config/", "config//control", "config/../control"]) +func governedFileReaderRejectsInvalidRelativePaths(_ relativePath: String) throws { + try withGovernedFileFixture { root in + #expect(throws: RadrootsGovernedFileReadError.invalidRequest) { + _ = try RadrootsGovernedFileReader.read( + root: root, + relativePath: relativePath, + maximumBytes: 16 + ) + } + } +} + +@Test func governedFileReaderRejectsRootIntermediateAndLeafSymlinks() throws { + try withGovernedFileFixture { root in + let actual = root.appendingPathComponent("actual", isDirectory: true) + try FileManager.default.createDirectory(at: actual, withIntermediateDirectories: false) + try Data("value".utf8).write(to: actual.appendingPathComponent("control")) + + let rootLink = root.deletingLastPathComponent() + .appendingPathComponent("\(root.lastPathComponent)-link") + defer { try? FileManager.default.removeItem(at: rootLink) } + try FileManager.default.createSymbolicLink(at: rootLink, withDestinationURL: root) + #expect(throws: RadrootsGovernedFileReadError.invalidObject) { + _ = try RadrootsGovernedFileReader.read( + root: rootLink, + relativePath: "actual/control", + maximumBytes: 16 + ) + } + + let directoryLink = root.appendingPathComponent("directory-link") + try FileManager.default.createSymbolicLink(at: directoryLink, withDestinationURL: actual) + #expect(throws: RadrootsGovernedFileReadError.invalidObject) { + _ = try RadrootsGovernedFileReader.read( + root: root, + relativePath: "directory-link/control", + maximumBytes: 16 + ) + } + + let leafLink = root.appendingPathComponent("leaf-link") + try FileManager.default.createSymbolicLink( + at: leafLink, + withDestinationURL: actual.appendingPathComponent("control") + ) + #expect(throws: RadrootsGovernedFileReadError.invalidObject) { + _ = try RadrootsGovernedFileReader.read( + root: root, + relativePath: "leaf-link", + maximumBytes: 16 + ) + } + } +} + +@Test func governedFileReaderRejectsDirectoriesFifosAndOversizedFiles() throws { + try withGovernedFileFixture { root in + let directory = root.appendingPathComponent("directory", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + #expect(throws: RadrootsGovernedFileReadError.invalidObject) { + _ = try RadrootsGovernedFileReader.read( + root: root, + relativePath: "directory", + maximumBytes: 16 + ) + } + + let fifo = root.appendingPathComponent("control.fifo") + let fifoResult = fifo.path.withCString { Darwin.mkfifo($0, S_IRUSR | S_IWUSR) } + #expect(fifoResult == 0) + #expect(throws: RadrootsGovernedFileReadError.invalidObject) { + _ = try RadrootsGovernedFileReader.read( + root: root, + relativePath: "control.fifo", + maximumBytes: 16 + ) + } + + try Data(repeating: 0x41, count: 17).write(to: root.appendingPathComponent("oversized")) + #expect(throws: RadrootsGovernedFileReadError.tooLarge) { + _ = try RadrootsGovernedFileReader.read( + root: root, + relativePath: "oversized", + maximumBytes: 16 + ) + } + } +} + +@Test func governedFileReaderRejectsLeafReplacementAfterAdmission() throws { + try withGovernedFileFixture { root in + let file = root.appendingPathComponent("control") + let retained = root.appendingPathComponent("retained") + try Data("original".utf8).write(to: file) + + #expect(throws: RadrootsGovernedFileReadError.changedDuringRead) { + _ = try RadrootsGovernedFileReader.readForTesting( + root: root, + relativePath: "control", + maximumBytes: 16 + ) { + try FileManager.default.moveItem(at: file, to: retained) + try Data("foreign".utf8).write(to: file) + } + } + } +} + +@Test func governedFileReaderRejectsDirectoryReplacementAfterAdmission() throws { + try withGovernedFileFixture { root in + let directory = root.appendingPathComponent("config", isDirectory: true) + let retained = root.appendingPathComponent("retained", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + try Data("original".utf8).write(to: directory.appendingPathComponent("control")) + + #expect(throws: RadrootsGovernedFileReadError.changedDuringRead) { + _ = try RadrootsGovernedFileReader.readForTesting( + root: root, + relativePath: "config/control", + maximumBytes: 16 + ) { + try FileManager.default.moveItem(at: directory, to: retained) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: false) + try Data("foreign".utf8).write(to: directory.appendingPathComponent("control")) + } + } + } +} + +@Test func governedFileReaderRejectsInPlaceMutationAfterAdmission() throws { + try withGovernedFileFixture { root in + let file = root.appendingPathComponent("control") + try Data("original".utf8).write(to: file) + + #expect(throws: RadrootsGovernedFileReadError.changedDuringRead) { + _ = try RadrootsGovernedFileReader.readForTesting( + root: root, + relativePath: "control", + maximumBytes: 16 + ) { + try Data("mutated-longer".utf8).write(to: file) + } + } + } +} + +@Test func governedFileReaderErrorsContainNoPathOrContent() throws { + let canaries = ["/private/sensitive/control.json", "secret-canary"] + let errors: [RadrootsGovernedFileReadError] = [ + .invalidRequest, + .unavailable, + .invalidObject, + .tooLarge, + .changedDuringRead, + .ioFailure, + ] + for error in errors { + let rendered = String(reflecting: error) + #expect(canaries.allSatisfy { !rendered.contains($0) }) + } +} + +private func withGovernedFileFixture( + _ body: (URL) throws -> Void +) throws { + let unresolvedRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("radroots-governed-file-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: unresolvedRoot, withIntermediateDirectories: false) + guard let resolvedPointer = unresolvedRoot.path.withCString({ Darwin.realpath($0, nil) }) else { + throw RadrootsGovernedFileReadError.ioFailure + } + defer { Darwin.free(resolvedPointer) } + let root = URL(fileURLWithPath: String(cString: resolvedPointer), isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try body(root) +}