680845cb1c
Linux build / Determine Swift version (push) Waiting to run
Linux build / Linux compile check (push) Blocked by required conditions
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
347 lines
14 KiB
Swift
347 lines
14 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// 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 enum FileDescriptorOps {
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|