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:
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)
+}