From d8bce4d0c23a2bd42eadc53a3c70e08bc001e245 Mon Sep 17 00:00:00 2001 From: John Logan Date: Wed, 20 May 2026 11:29:43 -0700 Subject: [PATCH] Adds FileDescriptor-based `enumerate()`. - 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. --- .../ArchiveReader.swift | 12 +- .../FileDescriptor+SecurePath.swift | 218 ----------- .../FileDescriptorOps.swift | 347 ++++++++++++++++++ ...sts.swift => FileDescriptorOpsTests.swift} | 275 ++++++++++++-- 4 files changed, 599 insertions(+), 253 deletions(-) delete mode 100644 Sources/ContainerizationOS/FileDescriptor+SecurePath.swift create mode 100644 Sources/ContainerizationOS/FileDescriptorOps.swift rename Tests/ContainerizationOSTests/{FileDescriptor+SecurePathTests.swift => FileDescriptorOpsTests.swift} (69%) diff --git a/Sources/ContainerizationArchive/ArchiveReader.swift b/Sources/ContainerizationArchive/ArchiveReader.swift index 30273898..21d7dade 100644 --- a/Sources/ContainerizationArchive/ArchiveReader.swift +++ b/Sources/ContainerizationArchive/ArchiveReader.swift @@ -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: diff --git a/Sources/ContainerizationOS/FileDescriptor+SecurePath.swift b/Sources/ContainerizationOS/FileDescriptor+SecurePath.swift deleted file mode 100644 index d79e107e..00000000 --- a/Sources/ContainerizationOS/FileDescriptor+SecurePath.swift +++ /dev/null @@ -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)" - } - } -} diff --git a/Sources/ContainerizationOS/FileDescriptorOps.swift b/Sources/ContainerizationOS/FileDescriptorOps.swift new file mode 100644 index 00000000..b6cc1492 --- /dev/null +++ b/Sources/ContainerizationOS/FileDescriptorOps.swift @@ -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 + } + } +} diff --git a/Tests/ContainerizationOSTests/FileDescriptor+SecurePathTests.swift b/Tests/ContainerizationOSTests/FileDescriptorOpsTests.swift similarity index 69% rename from Tests/ContainerizationOSTests/FileDescriptor+SecurePathTests.swift rename to Tests/ContainerizationOSTests/FileDescriptorOpsTests.swift index 445f6ab6..6ac47410 100644 --- a/Tests/ContainerizationOSTests/FileDescriptor+SecurePathTests.swift +++ b/Tests/ContainerizationOSTests/FileDescriptorOpsTests.swift @@ -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) + } +}