mirror of
https://github.com/apple/container.git
synced 2026-10-18 20:19:13 +00:00
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:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+246
-29
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user