Adds FileDescriptor-based enumerate(). (#746)

- Closes #745.
- Facilitates TOCTOU-safe recursion over directory contents.
- Replace FileDescriptor extensions with a static utility type to
prevent potential namespacing issues as this project and Swift evolve.
This commit is contained in:
J Logan authored and GitHub committed 2026-05-20 15:55:29 -07:00
1 parent c8fe3bbef9
commit d3d7d2143c
4 files changed
+599 -253

No files matched your search

@@ -346,9 +346,9 @@ extension ArchiveReader {
do {
switch type {
case .regular:
try rootFileDescriptor.mkdirSecure(relativePath, makeIntermediates: true) { fd in
try FileDescriptorOps.mkdir(rootFileDescriptor, relativePath, makeIntermediates: true) { fd in
// Remove existing entry if present (mimics containerd's "last entry wins" behavior)
try? fd.unlinkRecursiveSecure(filename: lastComponent)
try? FileDescriptorOps.unlinkRecursive(fd, filename: lastComponent)
// Open file for writing using openat with O_NOFOLLOW to prevent TOC-TOU attacks
let fileMode = entry.permissions & 0o777 // Mask to permission bits only
@@ -362,7 +362,7 @@ extension ArchiveReader {
setFileAttributes(fd: fileFd, entry: entry)
}
case .directory:
try rootFileDescriptor.mkdirSecure(memberPath, makeIntermediates: true) { fd in
try FileDescriptorOps.mkdir(rootFileDescriptor, memberPath, makeIntermediates: true) { fd in
setFileAttributes(fd: fd.rawValue, entry: entry)
}
case .symbolicLink:
@@ -370,9 +370,9 @@ extension ArchiveReader {
return false
}
var symlinkCreated = false
try rootFileDescriptor.mkdirSecure(relativePath, makeIntermediates: true) { fd in
try FileDescriptorOps.mkdir(rootFileDescriptor, relativePath, makeIntermediates: true) { fd in
// Remove existing entry if present (mimics containerd's "last entry wins" behavior)
try? fd.unlinkRecursiveSecure(filename: lastComponent)
try? FileDescriptorOps.unlinkRecursive(fd, filename: lastComponent)
guard symlinkat(targetPath.string, fd.rawValue, lastComponent.string) == 0 else {
throw ArchiveError.failedToExtractArchive("failed to create symlink: \(targetPath) <- \(memberPath)")
@@ -385,7 +385,7 @@ extension ArchiveReader {
}
return true
} catch let error as SecurePathError {
} catch let error as FileDescriptorOps.Error {
// Just reject path validation errors, don't fail the extraction
switch error {
case .systemError:
@@ -1,218 +0,0 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import SystemPackage
#if canImport(Darwin)
import Darwin
let os_dup = Darwin.dup
#elseif canImport(Musl)
import CSystem
import Musl
let os_dup = Musl.dup
#elseif canImport(Glibc)
import Glibc
let os_dup = Glibc.dup
#endif
extension FileDescriptor {
/// Creates a directory relative to the FileDescriptor, rejecting
/// paths that traverse symlinks.
///
/// - Parameters:
/// - relativePath: The path to the directory to create, relative to the FileDescriptor
/// - permissions: The permissions to give the directory (default is 0o755)
/// - makeIntermediates: Create or replace intermediate components as needed
/// - completion: A function that operates on the new directory
/// - Throws: `SecurePathError` if path validation or system errors occur
public func mkdirSecure(
_ relativePath: FilePath,
permissions: FilePermissions? = nil,
makeIntermediates: Bool = false,
completion: (FileDescriptor) throws -> Void = { _ in }
) throws {
try Self.validateRelativePath(relativePath)
try mkdirSecure(
relativePath.components,
permissions: permissions,
makeIntermediates: makeIntermediates,
completion: completion
)
}
/// Recursively removes a direct child of a directory FileDescriptor.
///
/// - Parameters:
/// - filename: The name of the child file
/// - Throws: `SecurePathError` if system errors occur
public func unlinkRecursiveSecure(filename: FilePath.Component) throws {
guard filename.string != "." && filename.string != ".." else {
return
}
// Try to remove as a file, and continue if the remove fails.
guard unlinkat(self.rawValue, filename.string, 0) != 0 else {
return
}
// Return if the file already doesn't exist.
guard errno != ENOENT else {
return
}
// If the file is not a directory, then throw a real error.
guard errno == EPERM || errno == EISDIR else {
throw SecurePathError.systemError("file removal during secure unlink", errno)
}
// Get the fd for the next path component.
let componentFd = openat(self.rawValue, filename.string, O_NOFOLLOW | O_RDONLY | O_DIRECTORY)
guard componentFd >= 0 else {
throw SecurePathError.systemError("directory open during secure unlink", errno)
}
let componentFileDescriptor = FileDescriptor(rawValue: componentFd)
defer { try? componentFileDescriptor.close() }
// Open the directory stream using a duplicate fd that closedir() will close.
let ownedFd = os_dup(componentFd)
guard let dir = fdopendir(ownedFd) else {
throw SecurePathError.systemError("directory opendir during secure unlink", errno)
}
defer { closedir(dir) }
// Recurse into each directory entry.
while let entry = readdir(dir) {
let childComponent = withUnsafePointer(to: entry.pointee.d_name) {
$0.withMemoryRebound(to: UInt8.self, capacity: Int(NAME_MAX) + 1) {
let name = String(decodingCString: $0, as: UTF8.self)
return FilePath.Component(name)
}
}
guard let childComponent else {
throw SecurePathError.systemError("directory entry processing during secure unlink", errno)
}
try componentFileDescriptor.unlinkRecursiveSecure(filename: childComponent)
}
// The current directory is empty now, remove it.
if unlinkat(self.rawValue, filename.string, AT_REMOVEDIR) != 0 {
throw SecurePathError.systemError("directory removal during secure unlink", errno)
}
}
private func mkdirSecure(
_ relativeComponents: FilePath.ComponentView,
permissions: FilePermissions? = nil,
makeIntermediates: Bool,
completion: (FileDescriptor) throws -> Void
) throws {
// If the relative path is empty, call completion with self (the parent directory)
guard let currentComponent = relativeComponents.first else {
try completion(self)
return
}
let childComponents = FilePath.ComponentView(relativeComponents.dropFirst())
// Create or replace the directory as needed.
let parentFd = self.rawValue
var componentFd = openat(parentFd, currentComponent.string, O_NOFOLLOW | O_RDONLY | O_DIRECTORY)
if componentFd < 0 {
// If the non-directory component should be replaced with a directory, remove the component.
guard makeIntermediates || childComponents.isEmpty else {
throw SecurePathError.invalidPathComponent
}
if errno != ENOENT {
try self.unlinkRecursiveSecure(filename: currentComponent)
}
// Create and open an empty directory.
guard mkdirat(parentFd, currentComponent.string, permissions?.rawValue ?? 0o755) == 0 else {
throw SecurePathError.systemError("directory creation during secure mkdir", errno)
}
componentFd = openat(parentFd, currentComponent.string, O_NOFOLLOW | O_RDONLY | O_DIRECTORY)
guard componentFd >= 0 else {
throw SecurePathError.systemError("directory open during secure mkdir", errno)
}
}
let componentFileDescriptor = FileDescriptor(rawValue: componentFd)
defer { try? componentFileDescriptor.close() }
// Call the completion closure for the last component.
guard !childComponents.isEmpty else {
try completion(componentFileDescriptor)
return
}
// Create the directory for the remaining components.
try componentFileDescriptor.mkdirSecure(childComponents, permissions: permissions, makeIntermediates: makeIntermediates, completion: completion)
}
private static func validateRelativePath(_ path: FilePath) throws {
// Allow absolute paths; only the components will be used during traversal.
guard !(path.components.contains { $0 == ".." }) else {
throw SecurePathError.invalidRelativePath
}
}
#if canImport(Darwin)
public func getCanonicalPath() throws -> FilePath {
var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
guard fcntl(self.rawValue, F_GETPATH, &buffer) != -1 else {
throw Errno(rawValue: errno)
}
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
let pathname = String(decoding: bytes, as: UTF8.self)
return FilePath(pathname)
}
#elseif canImport(Glibc) || canImport(Musl)
public func getCanonicalPath() throws -> FilePath {
let fdPath = "/proc/self/fd/\(self.rawValue)"
// Use readlink to resolve the symlink
var buffer = [CChar](repeating: 0, count: 4096)
let len = readlink(fdPath, &buffer, buffer.count - 1)
guard len > 0 else {
throw SecurePathError.systemError("readlink", errno)
}
// Convert to bytes without null termination
let bytes = buffer.prefix(len).map { UInt8(bitPattern: $0) }
let pathname = String(decoding: bytes, as: UTF8.self)
return FilePath(pathname)
}
#endif
}
public enum SecurePathError: Error, CustomStringConvertible, Equatable {
case invalidRelativePath
case invalidPathComponent
case cannotFollowSymlink
case systemError(String, Int32)
public var description: String {
switch self {
case .invalidRelativePath:
return "invalid relative path supplied to secure path operation"
case .invalidPathComponent:
return "an intermediate path component is missing or is not a directory"
case .cannotFollowSymlink:
return "cannot follow a symlink an a secure path operation"
case .systemError(let operation, let err):
return "\(operation) returned error: \(err)"
}
}
}
@@ -0,0 +1,347 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import SystemPackage
#if canImport(Darwin)
import Darwin
private let os_dup = Darwin.dup
private let os_S_IFMT = mode_t(Darwin.S_IFMT)
private let os_S_IFREG = mode_t(Darwin.S_IFREG)
private let os_S_IFDIR = mode_t(Darwin.S_IFDIR)
private let os_S_IFLNK = mode_t(Darwin.S_IFLNK)
#elseif canImport(Musl)
import CSystem
import Musl
private let os_dup = Musl.dup
private let os_S_IFMT = Musl.S_IFMT
private let os_S_IFREG = Musl.S_IFREG
private let os_S_IFDIR = Musl.S_IFDIR
private let os_S_IFLNK = Musl.S_IFLNK
#elseif canImport(Glibc)
import Glibc
private let os_dup = Glibc.dup
private let os_S_IFMT = mode_t(Glibc.S_IFMT)
private let os_S_IFREG = mode_t(Glibc.S_IFREG)
private let os_S_IFDIR = mode_t(Glibc.S_IFDIR)
private let os_S_IFLNK = mode_t(Glibc.S_IFLNK)
#endif
/// Static utility functions for secure, symlink-safe filesystem operations
/// anchored to a file descriptor.
///
/// All operations use `openat`/`mkdirat`/`unlinkat` anchored to the supplied
/// file descriptor, preventing path traversal and TOCTOU races. The type is
/// never instantiated; it exists solely as a namespace.
public struct FileDescriptorOps {
private init() {}
// MARK: - Nested types
public enum Error: Swift.Error, CustomStringConvertible, Equatable {
case invalidRelativePath
case invalidPathComponent
case cannotFollowSymlink
case systemError(String, Int32)
public var description: String {
switch self {
case .invalidRelativePath:
return "invalid relative path supplied to file descriptor operation"
case .invalidPathComponent:
return "an intermediate path component is missing or is not a directory"
case .cannotFollowSymlink:
return "cannot follow a symlink in a file descriptor operation"
case .systemError(let operation, let err):
return "\(operation) returned error: \(err)"
}
}
}
/// The type of a directory entry yielded by ``enumerate(_:_:)``.
public enum EntryType: Sendable, Equatable {
/// A regular file.
case regular
/// A directory. The entry is recursed into; symlinks to directories are
/// reported as `.symlink` and are never recursed.
case directory
/// A symbolic link (to a file or directory).
case symlink
/// Any other entry type (device node, named pipe, socket, etc.).
case other
}
// MARK: - Public API
/// Creates a directory relative to `fd`, rejecting paths that traverse symlinks.
///
/// - Parameters:
/// - fd: An open file descriptor for the parent directory.
/// - relativePath: The path to create, relative to `fd`.
/// - permissions: The permissions to give the directory (default 0o755).
/// - makeIntermediates: Create or replace intermediate components as needed.
/// - completion: A function that operates on the new directory fd.
/// - Throws: `FileDescriptorOps.Error` if path validation or system errors occur.
public static func mkdir(
_ fd: FileDescriptor,
_ relativePath: FilePath,
permissions: FilePermissions? = nil,
makeIntermediates: Bool = false,
completion: (FileDescriptor) throws -> Void = { _ in }
) throws {
try validateRelativePath(relativePath)
try mkdir(
fd,
relativePath.components,
permissions: permissions,
makeIntermediates: makeIntermediates,
completion: completion
)
}
/// Recursively removes a direct child of the directory at `fd`.
///
/// - Parameters:
/// - fd: An open file descriptor for the parent directory.
/// - filename: The name of the child to remove.
/// - Throws: `FileDescriptorOps.Error` if system errors occur.
public static func unlinkRecursive(_ fd: FileDescriptor, filename: FilePath.Component) throws {
guard filename.string != "." && filename.string != ".." else {
return
}
guard unlinkat(fd.rawValue, filename.string, 0) != 0 else {
return
}
guard errno != ENOENT else {
return
}
guard errno == EPERM || errno == EISDIR else {
throw Error.systemError("file removal during file descriptor unlink", errno)
}
let componentFd = openat(fd.rawValue, filename.string, O_NOFOLLOW | O_RDONLY | O_DIRECTORY)
guard componentFd >= 0 else {
throw Error.systemError("directory open during file descriptor unlink", errno)
}
let componentFileDescriptor = FileDescriptor(rawValue: componentFd)
defer { try? componentFileDescriptor.close() }
// Open the directory stream using a duplicate fd that closedir() will close.
let ownedFd = os_dup(componentFd)
guard let dir = fdopendir(ownedFd) else {
throw Error.systemError("directory opendir during file descriptor unlink", errno)
}
defer { closedir(dir) }
while let entry = readdir(dir) {
let childComponent = withUnsafePointer(to: entry.pointee.d_name) {
$0.withMemoryRebound(to: UInt8.self, capacity: Int(NAME_MAX) + 1) {
let name = String(decodingCString: $0, as: UTF8.self)
return FilePath.Component(name)
}
}
guard let childComponent else {
throw Error.systemError("directory entry processing during file descriptor unlink", errno)
}
try unlinkRecursive(componentFileDescriptor, filename: childComponent)
}
if unlinkat(fd.rawValue, filename.string, AT_REMOVEDIR) != 0 {
throw Error.systemError("directory removal during file descriptor unlink", errno)
}
}
/// Recursively enumerates the contents of `fd` without following symbolic links.
///
/// Each entry — file, directory, symlink, or other type — is reported to
/// `body` with a path relative to `fd`. Directories are reported before their
/// contents (pre-order) and then recursed. A symlink whose target is a directory
/// is reported as `.symlink` and is never followed, so traversal cannot escape
/// the tree rooted at `fd` regardless of where symlinks point.
///
/// `fd` must be an open file descriptor for a directory.
///
/// - Parameters:
/// - fd: An open file descriptor for the root directory to enumerate.
/// - body: Called once per entry. `path` is relative to `fd`; `type`
/// identifies the kind of entry; `parentFd` is the open file descriptor
/// for the directory that contains the entry. The last component of `path`
/// is the entry's filename; together with `parentFd` it allows the body to
/// open the entry via
/// `openat(parentFd.rawValue, path.lastComponent!.string, O_NOFOLLOW …)`
/// without reconstructing an absolute path, preserving the TOCTOU safety
/// of the traversal end-to-end. `parentFd` must not be closed within the
/// body call, or used after the call returns. Throw to abort.
/// - Throws: `FileDescriptorOps.Error` on system errors; any error thrown by
/// `body` is propagated unchanged.
public static func enumerate(
_ fd: FileDescriptor,
_ body: (_ path: FilePath, _ type: EntryType, _ parentFd: FileDescriptor) throws -> Void
) throws {
try enumerateHelper(fd, relativePath: FilePath(""), body: body)
}
// MARK: - Canonical path
#if canImport(Darwin)
/// Returns the canonical path for `fd` using `F_GETPATH`.
public static func getCanonicalPath(_ fd: FileDescriptor) throws -> FilePath {
var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
guard fcntl(fd.rawValue, F_GETPATH, &buffer) != -1 else {
throw Errno(rawValue: errno)
}
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
return FilePath(String(decoding: bytes, as: UTF8.self))
}
#elseif canImport(Glibc) || canImport(Musl)
/// Returns the canonical path for `fd` via `/proc/self/fd`.
public static func getCanonicalPath(_ fd: FileDescriptor) throws -> FilePath {
let fdPath = "/proc/self/fd/\(fd.rawValue)"
var buffer = [CChar](repeating: 0, count: 4096)
let len = readlink(fdPath, &buffer, buffer.count - 1)
guard len > 0 else {
throw Error.systemError("readlink", errno)
}
let bytes = buffer.prefix(len).map { UInt8(bitPattern: $0) }
return FilePath(String(decoding: bytes, as: UTF8.self))
}
#endif
// MARK: - Private helpers
private static func mkdir(
_ fd: FileDescriptor,
_ relativeComponents: FilePath.ComponentView,
permissions: FilePermissions? = nil,
makeIntermediates: Bool,
completion: (FileDescriptor) throws -> Void
) throws {
guard let currentComponent = relativeComponents.first else {
try completion(fd)
return
}
let childComponents = FilePath.ComponentView(relativeComponents.dropFirst())
var componentFd = openat(fd.rawValue, currentComponent.string, O_NOFOLLOW | O_RDONLY | O_DIRECTORY)
if componentFd < 0 {
guard makeIntermediates || childComponents.isEmpty else {
throw Error.invalidPathComponent
}
if errno != ENOENT {
try unlinkRecursive(fd, filename: currentComponent)
}
guard mkdirat(fd.rawValue, currentComponent.string, permissions?.rawValue ?? 0o755) == 0 else {
throw Error.systemError("directory creation during file descriptor mkdir", errno)
}
componentFd = openat(fd.rawValue, currentComponent.string, O_NOFOLLOW | O_RDONLY | O_DIRECTORY)
guard componentFd >= 0 else {
throw Error.systemError("directory open during file descriptor mkdir", errno)
}
}
let componentFileDescriptor = FileDescriptor(rawValue: componentFd)
defer { try? componentFileDescriptor.close() }
guard !childComponents.isEmpty else {
try completion(componentFileDescriptor)
return
}
try mkdir(
componentFileDescriptor, childComponents,
permissions: permissions, makeIntermediates: makeIntermediates, completion: completion)
}
private static func enumerateHelper(
_ fd: FileDescriptor,
relativePath: FilePath,
body: (_ path: FilePath, _ type: EntryType, _ parentFd: FileDescriptor) throws -> Void
) throws {
// fdopendir takes ownership of the fd passed to it and closes it via
// closedir. Duplicate so the caller's fd remains open.
let dupFd = os_dup(fd.rawValue)
guard dupFd >= 0 else {
throw Error.systemError("dup during file descriptor enumerate", errno)
}
guard let dir = fdopendir(dupFd) else {
let savedErrno = errno
try? FileDescriptor(rawValue: dupFd).close()
throw Error.systemError("fdopendir during file descriptor enumerate", savedErrno)
}
defer { closedir(dir) }
while let entry = readdir(dir) {
let name = withUnsafePointer(to: entry.pointee.d_name) {
$0.withMemoryRebound(to: UInt8.self, capacity: Int(NAME_MAX) + 1) {
String(decodingCString: $0, as: UTF8.self)
}
}
guard name != "." && name != ".." else { continue }
guard let component = FilePath.Component(name) else { continue }
let entryPath = relativePath.appending(component)
let entryType = resolveEntryType(parentFd: fd.rawValue, name: name, dtype: entry.pointee.d_type)
// Pass fd (the parent directory) so the body can use
// openat(parentFd.rawValue, path.lastComponent!.string, O_NOFOLLOW …)
// rather than reconstructing an absolute path, keeping the fd chain unbroken.
try body(entryPath, entryType, fd)
guard entryType == .directory else { continue }
// Open the child directory with O_NOFOLLOW to guarantee we are
// entering a real directory and not a symlink that was swapped in
// between readdir and here.
let childFd = openat(fd.rawValue, name, O_NOFOLLOW | O_RDONLY | O_DIRECTORY)
guard childFd >= 0 else {
throw Error.systemError("openat during file descriptor enumerate", errno)
}
let childDescriptor = FileDescriptor(rawValue: childFd)
defer { try? childDescriptor.close() }
try enumerateHelper(childDescriptor, relativePath: entryPath, body: body)
}
}
private static func resolveEntryType(parentFd: Int32, name: String, dtype: UInt8) -> EntryType {
switch dtype {
case UInt8(DT_REG): return .regular
case UInt8(DT_DIR): return .directory
case UInt8(DT_LNK): return .symlink
case UInt8(DT_UNKNOWN):
// Some filesystems (NFS, ext2/3) report DT_UNKNOWN; fall back to fstatat.
var stbuf = stat()
guard fstatat(parentFd, name, &stbuf, AT_SYMLINK_NOFOLLOW) == 0 else { return .other }
switch stbuf.st_mode & os_S_IFMT {
case os_S_IFREG: return .regular
case os_S_IFDIR: return .directory
case os_S_IFLNK: return .symlink
default: return .other
}
default: return .other
}
}
private static func validateRelativePath(_ path: FilePath) throws {
guard !(path.components.contains { $0 == ".." }) else {
throw Error.invalidRelativePath
}
}
}
@@ -87,7 +87,7 @@ struct FileDescriptorPathSecureTests {
let stubFileName = "stub.txt"
let stubContent = Data("stub file content".utf8)
try rootFd.mkdirSecure(relativePath, permissions: permissions, makeIntermediates: makeIntermediates) { dirFd in
try FileDescriptorOps.mkdir(rootFd, relativePath, permissions: permissions, makeIntermediates: makeIntermediates) { dirFd in
// Create a stub file in the directory using openat
let fd = openat(
dirFd.rawValue,
@@ -143,22 +143,22 @@ struct FileDescriptorPathSecureTests {
}
@Test(
"Test mkdirSecure error cases",
"Test mkdir error cases",
arguments: [
// Case 1: Path starting with ".." should be rejected
(FilePath("../escape"), false, SecurePathError.invalidRelativePath),
(FilePath("../escape"), false, FileDescriptorOps.Error.invalidRelativePath),
// Case 2: Path with ".." in middle that would escape
(FilePath("foo/../../escape"), false, SecurePathError.invalidRelativePath),
(FilePath("foo/../../escape"), false, FileDescriptorOps.Error.invalidRelativePath),
// Case 3: Missing intermediate without makeIntermediates should fail
(FilePath("missing/intermediate/path"), false, SecurePathError.invalidPathComponent),
(FilePath("missing/intermediate/path"), false, FileDescriptorOps.Error.invalidPathComponent),
// Case 4: Multiple .. that escape
(FilePath("a/b/../../../escape"), false, SecurePathError.invalidRelativePath),
(FilePath("a/b/../../../escape"), false, FileDescriptorOps.Error.invalidRelativePath),
]
)
func testMkdirSecureInvalid(relativePath: FilePath, makeIntermediates: Bool, expectedError: SecurePathError) async throws {
func testMkdirSecureInvalid(relativePath: FilePath, makeIntermediates: Bool, expectedError: FileDescriptorOps.Error) async throws {
let rootPath = try createTempDirectory()
defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
@@ -167,9 +167,9 @@ struct FileDescriptorPathSecureTests {
// Attempt the operation and expect it to throw
#expect {
try rootFd.mkdirSecure(relativePath, makeIntermediates: makeIntermediates) { _ in }
try FileDescriptorOps.mkdir(rootFd, relativePath, makeIntermediates: makeIntermediates) { _ in }
} throws: { error in
guard let securePathError = error as? SecurePathError else {
guard let securePathError = error as? FileDescriptorOps.Error else {
return false
}
// Compare error cases
@@ -204,7 +204,7 @@ struct FileDescriptorPathSecureTests {
let stubFileName = "stub.txt"
let stubContent = Data("stub file content".utf8)
try rootFd.mkdirSecure(FilePath(path), makeIntermediates: true) { dirFd in
try FileDescriptorOps.mkdir(rootFd, FilePath(path), makeIntermediates: true) { dirFd in
// Create a stub file to verify we're in the right place
let fd = openat(
dirFd.rawValue,
@@ -252,8 +252,8 @@ struct FileDescriptorPathSecureTests {
let rootFd = try FileDescriptor.open(rootPath, .readOnly, options: [.directory])
defer { try? rootFd.close() }
#expect(throws: SecurePathError.invalidRelativePath.self) {
try rootFd.mkdirSecure(FilePath(path), makeIntermediates: true)
#expect(throws: FileDescriptorOps.Error.invalidRelativePath.self) {
try FileDescriptorOps.mkdir(rootFd, FilePath(path), makeIntermediates: true)
}
}
@@ -276,7 +276,7 @@ struct FileDescriptorPathSecureTests {
let stubContent = Data("stub file content".utf8)
// Should normalize and succeed (// becomes /)
try rootFd.mkdirSecure(FilePath(path), makeIntermediates: true) { dirFd in
try FileDescriptorOps.mkdir(rootFd, FilePath(path), makeIntermediates: true) { dirFd in
let fd = openat(
dirFd.rawValue,
stubFileName,
@@ -324,7 +324,7 @@ struct FileDescriptorPathSecureTests {
let stubFileName = "deep.txt"
let stubContent = Data("deep file".utf8)
try rootFd.mkdirSecure(FilePath(deepPath), makeIntermediates: true) { dirFd in
try FileDescriptorOps.mkdir(rootFd, FilePath(deepPath), makeIntermediates: true) { dirFd in
let fd = openat(
dirFd.rawValue,
stubFileName,
@@ -365,15 +365,15 @@ struct FileDescriptorPathSecureTests {
// Try to create it - behavior depends on FilePath's null byte handling
// We mainly want to ensure it doesn't bypass security checks
do {
try rootFd.mkdirSecure(FilePath(pathWithNull), makeIntermediates: true) { _ in }
try FileDescriptorOps.mkdir(rootFd, FilePath(pathWithNull), makeIntermediates: true) { _ in }
// If it succeeds, verify it stayed within root
let entries = try FileManager.default.contentsOfDirectory(atPath: rootPath.string)
for entry in entries {
let fullPath = rootPath.appending(entry)
let canonicalRoot = try rootFd.getCanonicalPath()
let canonicalRoot = try FileDescriptorOps.getCanonicalPath(rootFd)
let canonicalEntry = try FileDescriptor.open(fullPath, .readOnly)
let canonicalEntryPath = try canonicalEntry.getCanonicalPath()
let canonicalEntryPath = try FileDescriptorOps.getCanonicalPath(canonicalEntry)
try? canonicalEntry.close()
// Verify entry is under root
@@ -402,7 +402,7 @@ struct FileDescriptorPathSecureTests {
#expect(FileManager.default.fileExists(atPath: filePath.string))
// Remove it
try rootFd.unlinkRecursiveSecure(filename: FilePath.Component("testfile.txt"))
try FileDescriptorOps.unlinkRecursive(rootFd, filename: FilePath.Component("testfile.txt"))
// Verify file is gone
#expect(!FileManager.default.fileExists(atPath: filePath.string))
@@ -426,7 +426,7 @@ struct FileDescriptorPathSecureTests {
#expect(isDir.boolValue)
// Remove it
try rootFd.unlinkRecursiveSecure(filename: FilePath.Component("emptydir"))
try FileDescriptorOps.unlinkRecursive(rootFd, filename: FilePath.Component("emptydir"))
// Verify directory is gone
#expect(!FileManager.default.fileExists(atPath: dirPath.string))
@@ -462,7 +462,7 @@ struct FileDescriptorPathSecureTests {
#expect(FileManager.default.fileExists(atPath: deepdirPath.string))
// Remove entire tree
try rootFd.unlinkRecursiveSecure(filename: FilePath.Component("nested"))
try FileDescriptorOps.unlinkRecursive(rootFd, filename: FilePath.Component("nested"))
// Verify everything is gone
#expect(!FileManager.default.fileExists(atPath: nestedPath.string))
@@ -477,7 +477,7 @@ struct FileDescriptorPathSecureTests {
defer { try? rootFd.close() }
// Remove non-existent file should not throw
try rootFd.unlinkRecursiveSecure(filename: FilePath.Component("nonexistent.txt"))
try FileDescriptorOps.unlinkRecursive(rootFd, filename: FilePath.Component("nonexistent.txt"))
}
@Test("Remove symlink without following it")
@@ -499,7 +499,7 @@ struct FileDescriptorPathSecureTests {
#expect(FileManager.default.fileExists(atPath: linkPath.string))
// Remove symlink
try rootFd.unlinkRecursiveSecure(filename: FilePath.Component("link"))
try FileDescriptorOps.unlinkRecursive(rootFd, filename: FilePath.Component("link"))
// Verify symlink is gone but target remains
#expect(!FileManager.default.fileExists(atPath: linkPath.string))
@@ -533,7 +533,7 @@ struct FileDescriptorPathSecureTests {
#expect(FileManager.default.fileExists(atPath: mixedPath.string))
// Remove entire tree
try rootFd.unlinkRecursiveSecure(filename: FilePath.Component("mixed"))
try FileDescriptorOps.unlinkRecursive(rootFd, filename: FilePath.Component("mixed"))
// Verify everything is gone
#expect(!FileManager.default.fileExists(atPath: mixedPath.string))
@@ -548,7 +548,7 @@ struct FileDescriptorPathSecureTests {
defer { try? rootFd.close() }
// Should return without error and without removing anything
try rootFd.unlinkRecursiveSecure(filename: FilePath.Component("."))
try FileDescriptorOps.unlinkRecursive(rootFd, filename: FilePath.Component("."))
// Verify directory still exists
#expect(FileManager.default.fileExists(atPath: tempPath.string))
@@ -563,14 +563,14 @@ struct FileDescriptorPathSecureTests {
defer { try? rootFd.close() }
// Should return without error and without removing anything
try rootFd.unlinkRecursiveSecure(filename: FilePath.Component(".."))
try FileDescriptorOps.unlinkRecursive(rootFd, filename: FilePath.Component(".."))
// Verify directory still exists
#expect(FileManager.default.fileExists(atPath: tempPath.string))
}
@Test("Test mkdirSecure with empty path calls completion with parent")
func testMkdirSecureEmptyPath() throws {
@Test("Test mkdir with empty path calls completion with parent")
func testMkdirEmptyPath() throws {
let rootPath = try createTempDirectory()
defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
@@ -581,8 +581,8 @@ struct FileDescriptorPathSecureTests {
let stubContent = Data("root level content".utf8)
var completionCalled = false
// Call mkdirSecure with empty path
try rootFd.mkdirSecure(FilePath(""), makeIntermediates: false) { dirFd in
// Call mkdir with empty path
try FileDescriptorOps.mkdir(rootFd, FilePath(""), makeIntermediates: false) { dirFd in
completionCalled = true
// Verify dirFd is the same as rootFd
@@ -679,3 +679,220 @@ enum Entry {
case directory(path: String)
case symlink(target: String, source: String)
}
// MARK: - enumerate tests
extension FileDescriptorPathSecureTests {
// Collect all entries reported by enumerate, keyed by path string.
private func collect(root: FilePath) throws -> [String: FileDescriptorOps.EntryType] {
let rootFd = try FileDescriptor.open(root, .readOnly, options: [.directory])
defer { try? rootFd.close() }
var found: [String: FileDescriptorOps.EntryType] = [:]
try FileDescriptorOps.enumerate(rootFd) { path, type, _ in
found[path.string] = type
}
return found
}
@Test func testEnumerateSecureEmptyDirectory() throws {
let root = try createTempDirectory()
defer { try? FileManager.default.removeItem(atPath: root.string) }
let found = try collect(root: root)
#expect(found.isEmpty)
}
@Test func testEnumerateSecureFlatRegularFiles() throws {
let root = try createTempDirectory()
defer { try? FileManager.default.removeItem(atPath: root.string) }
try createEntries(
rootPath: root,
entries: [
.regular(path: "a.txt"),
.regular(path: "b.txt"),
.regular(path: "c.txt"),
])
let found = try collect(root: root)
#expect(found.count == 3)
#expect(found["a.txt"] == .regular)
#expect(found["b.txt"] == .regular)
#expect(found["c.txt"] == .regular)
}
@Test func testEnumerateSecureRecursesIntoRealDirectories() throws {
let root = try createTempDirectory()
defer { try? FileManager.default.removeItem(atPath: root.string) }
try createEntries(
rootPath: root,
entries: [
.directory(path: "subdir"),
.regular(path: "subdir/file.txt"),
])
let found = try collect(root: root)
#expect(found.count == 2)
#expect(found["subdir"] == .directory)
#expect(found["subdir/file.txt"] == .regular)
}
@Test func testEnumerateSecureReportsFileSymlink() throws {
let root = try createTempDirectory()
defer { try? FileManager.default.removeItem(atPath: root.string) }
try createEntries(
rootPath: root,
entries: [
.regular(path: "target.txt"),
.symlink(target: "target.txt", source: "link.txt"),
])
let found = try collect(root: root)
#expect(found["link.txt"] == .symlink)
#expect(found["target.txt"] == .regular)
}
@Test func testEnumerateSecureDoesNotFollowDirectorySymlink() throws {
let root = try createTempDirectory()
defer { try? FileManager.default.removeItem(atPath: root.string) }
// Create a real directory with content alongside a symlink to it.
try createEntries(
rootPath: root,
entries: [
.directory(path: "real"),
.regular(path: "real/inside.txt"),
.symlink(target: "real", source: "link"),
])
let found = try collect(root: root)
// "link" is reported as a symlink, not followed — "link/inside.txt" absent.
#expect(found["link"] == .symlink)
#expect(found["link/inside.txt"] == nil)
// The real directory and its content are still traversed normally.
#expect(found["real"] == .directory)
#expect(found["real/inside.txt"] == .regular)
}
@Test func testEnumerateSecureDoesNotFollowAbsoluteDirectorySymlinkOutside() throws {
let root = try createTempDirectory()
defer { try? FileManager.default.removeItem(atPath: root.string) }
// Create a directory entirely outside the root.
let outside = try createTempDirectory()
defer { try? FileManager.default.removeItem(atPath: outside.string) }
#expect(FileManager.default.createFile(atPath: outside.appending("secret.txt").string, contents: Data("secret".utf8)))
// Symlink inside root → absolute path outside root.
try createEntries(
rootPath: root,
entries: [
.symlink(target: outside.string, source: "escape")
])
let found = try collect(root: root)
// The symlink itself is reported…
#expect(found["escape"] == .symlink)
// …but nothing inside the outside directory is reachable.
#expect(found["escape/secret.txt"] == nil)
#expect(found.count == 1)
}
@Test func testEnumerateSecureDoesNotFollowRelativeDirectorySymlinkOutside() throws {
// Layout: base/root/ and base/outside/, symlink root/escape → ../outside
let base = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).path
let rootStr = (base as NSString).appendingPathComponent("root")
let outsideStr = (base as NSString).appendingPathComponent("outside")
try FileManager.default.createDirectory(atPath: rootStr, withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: outsideStr, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(atPath: base) }
#expect(FileManager.default.createFile(atPath: (outsideStr as NSString).appendingPathComponent("secret.txt"), contents: Data("secret".utf8)))
try FileManager.default.createSymbolicLink(
atPath: (rootStr as NSString).appendingPathComponent("escape"),
withDestinationPath: "../outside"
)
let rootFd = try FileDescriptor.open(FilePath(rootStr), .readOnly, options: [.directory])
defer { try? rootFd.close() }
var found: [String: FileDescriptorOps.EntryType] = [:]
try FileDescriptorOps.enumerate(rootFd) { path, type, _ in found[path.string] = type }
#expect(found["escape"] == .symlink)
#expect(found["escape/secret.txt"] == nil)
#expect(found.count == 1)
}
@Test func testEnumerateSecureMixedContent() throws {
let root = try createTempDirectory()
defer { try? FileManager.default.removeItem(atPath: root.string) }
try createEntries(
rootPath: root,
entries: [
.regular(path: "readme.txt"),
.directory(path: "src"),
.regular(path: "src/main.swift"),
.directory(path: "src/util"),
.regular(path: "src/util/helper.swift"),
.symlink(target: "readme.txt", source: "link.txt"),
.symlink(target: "src", source: "src-link"),
])
let found = try collect(root: root)
#expect(found["readme.txt"] == .regular)
#expect(found["src"] == .directory)
#expect(found["src/main.swift"] == .regular)
#expect(found["src/util"] == .directory)
#expect(found["src/util/helper.swift"] == .regular)
#expect(found["link.txt"] == .symlink)
// Directory symlink: reported but not followed.
#expect(found["src-link"] == .symlink)
#expect(found["src-link/main.swift"] == nil)
#expect(found.count == 7)
}
@Test func testEnumerateSecurePreOrderDirectoryBeforeContents() throws {
let root = try createTempDirectory()
defer { try? FileManager.default.removeItem(atPath: root.string) }
try createEntries(
rootPath: root,
entries: [
.directory(path: "dir"),
.regular(path: "dir/child.txt"),
])
let rootFd = try FileDescriptor.open(root, .readOnly, options: [.directory])
defer { try? rootFd.close() }
var order: [String] = []
try FileDescriptorOps.enumerate(rootFd) { path, _, _ in order.append(path.string) }
let dirIdx = try #require(order.firstIndex(of: "dir"))
let childIdx = try #require(order.firstIndex(of: "dir/child.txt"))
#expect(dirIdx < childIdx, "directory must be reported before its contents")
}
@Test func testEnumerateSecureParentFdCanOpenEntryWithoutFollowingSymlinks() throws {
let root = try createTempDirectory()
defer { try? FileManager.default.removeItem(atPath: root.string) }
let content = Data("hello".utf8)
try createEntries(rootPath: root, entries: [.regular(path: "file.txt")])
#expect(FileManager.default.createFile(atPath: root.appending("file.txt").string, contents: content))
let rootFd = try FileDescriptor.open(root, .readOnly, options: [.directory])
defer { try? rootFd.close() }
var readContent: Data?
try FileDescriptorOps.enumerate(rootFd) { path, type, parentFd in
guard type == .regular, let name = path.lastComponent?.string else { return }
// Open through the fd chain — no absolute path involved.
let fd = openat(parentFd.rawValue, name, O_RDONLY | O_NOFOLLOW)
guard fd >= 0 else { return }
defer { _ = os_close(fd) }
var buf = [UInt8](repeating: 0, count: 256)
let n = read(fd, &buf, buf.count)
if n > 0 { readContent = Data(buf.prefix(n)) }
}
#expect(readContent == content)
}
}