Skip to content
36 changes: 29 additions & 7 deletions Sources/ContainerizationArchive/ArchiveReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -262,22 +262,28 @@ extension ArchiveReader {
/// Rejects member paths that escape the root directory or traverse
/// symbolic links, and uses a "last entry wins" replacement policy
/// for an existing file at a path to be extracted.
///
/// Absolute member names are extracted beneath `directory` with the leading
/// slash removed, matching the default behavior of `bsdtar`. Member names
/// containing a `..` component are rejected and reported in the returned list.
public func extractContents(to directory: URL) throws -> [String] {
// Create the root directory with standard permissions
// and create a FileDescriptor for secure path traversal.
let fm = FileManager.default
let rootFilePath = FilePath(directory.path)
try fm.createDirectory(atPath: directory.path, withIntermediateDirectories: true)
let rootFileDescriptor = try FileDescriptor.open(rootFilePath, .readOnly)
let rootFileDescriptor = try FileDescriptor.open(rootFilePath, .readOnly, options: [.closeOnExec])
defer { try? rootFileDescriptor.close() }

// Iterate and extract archive entries, collecting rejected paths.
var foundEntry = false
var rejectedPaths = [String]()
for (entry, dataReader) in self.makeStreamingIterator() {
guard let memberPath = (entry.path.map { FilePath($0) }) else {
guard let entryPath = entry.path else {
continue
}
let originalPath = FilePath(entryPath)
let memberPath = Self.relativeMemberPath(entryPath)
foundEntry = true

// Try to extract the entry, catching path validation errors
Expand All @@ -289,7 +295,8 @@ extension ArchiveReader {
)

if !extracted {
rejectedPaths.append(memberPath.string)
// Report the name as it appears in the archive.
rejectedPaths.append(originalPath.string)
}
}
try throwIfStreamFailed()
Expand Down Expand Up @@ -321,6 +328,20 @@ extension ArchiveReader {
throw ArchiveError.failedToExtractArchive(" \(path) not found in archive")
}

/// Turns an archive member name into a path relative to the extraction root.
///
/// Archive formats allow absolute member names. Like the default behavior of
/// `bsdtar`, this strips the leading slash so the member is extracted beneath
/// the root instead of being rejected. It does not remove `..` components:
/// `FileDescriptorOps` rejects those.
private static func relativeMemberPath(_ name: String) -> FilePath {
let path = FilePath(name)
guard path.isAbsolute else {
return path
}
return FilePath(root: nil, path.components)
}

/// Extracts a single archive entry.
/// Returns false if the entry was rejected due to path validation errors.
/// Throws on system errors.
Expand All @@ -345,7 +366,7 @@ extension ArchiveReader {

// Open file for writing using openat with O_NOFOLLOW to prevent TOC-TOU attacks
let fileMode = entry.permissions & 0o777 // Mask to permission bits only
let fileFd = openat(fd.rawValue, lastComponent.string, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, fileMode)
let fileFd = openat(fd.rawValue, lastComponent.string, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, fileMode)
guard fileFd >= 0 else {
throw ArchiveError.failedToExtractArchive("failed to create file: \(memberPath)")
}
Expand Down Expand Up @@ -381,10 +402,11 @@ extension ArchiveReader {
} catch let error as FileDescriptorOps.Error {
// Just reject path validation errors, don't fail the extraction
switch error {
case .systemError:
// Fail for system errors
case .systemError, .notFound, .alreadyExists:
// Fail for system errors, and for entries that appeared or disappeared
// underneath us, which means something else is modifying the tree.
throw error
case .invalidRelativePath, .invalidPathComponent, .cannotFollowSymlink:
case .invalidRelativePath, .invalidPathComponent, .cannotFollowSymlink, .conflict:
return false
}
}
Expand Down
261 changes: 261 additions & 0 deletions Sources/ContainerizationOS/FileDescriptorOps+Composite.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
//===----------------------------------------------------------------------===//
// 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

// Composite operations.
//
// These combine the primitives in `FileDescriptorOps.swift` into sequences that
// several callers need and that are easy to get wrong. They never follow a
// symlink, and their documentation says what, if anything, they replace.
//
// Only the primitives may be used here. Do not call system calls directly, so
// that every path walk goes through code that is written and tested once.

extension FileDescriptorOps {
/// Opens, and where needed creates, the directory at `relativePath` below `fd`,
/// then runs `completion` with a descriptor for it.
///
/// Each component is opened relative to the previous one without following
/// symlinks, so a symlink in the path can never redirect the walk. Existing
/// directories are reused. The descriptor passed to `completion` is
/// close-on-exec and is closed when `completion` returns.
///
/// By default only the last component is created. Pass `makeIntermediates`
/// to create missing parents too.
///
/// **This replaces what is in the way.** A file, a symlink, or anything else that is not a
/// directory, in the place of a directory this call needs, is removed and replaced by one. A
/// symlink is removed and not followed. A directory is never removed, even one that cannot
/// be opened.
///
/// An empty `relativePath` runs `completion` with `fd` itself.
///
/// - Parameters:
/// - fd: An open file descriptor for the directory to start from.
/// - relativePath: The directory to open or create. It must be relative and must not contain a `..` component.
/// - permissions: The permissions for each directory this call creates (default 0o755).
/// - makeIntermediates: Also create missing intermediate directories.
/// - completion: A function that operates on the directory descriptor.
/// - Throws: ``Error/invalidRelativePath`` for an absolute path or one containing `..`;
/// ``Error/invalidPathComponent`` if an intermediate component is missing, or is not a
/// directory, and `makeIntermediates` is false; and ``Error/systemError(_:_:)`` for anything
/// else. Errors thrown by `completion` are propagated.
public static func mkdir(
_ fd: FileDescriptor,
_ relativePath: FilePath,
permissions: FilePermissions? = nil,
makeIntermediates: Bool = false,
completion: (FileDescriptor) throws -> Void = { _ in }
) throws {
try validateRelativePath(relativePath)

let components = Array(relativePath.components)
var current = fd
var ownsCurrent = false
defer {
if ownsCurrent {
try? current.close()
}
}

for (index, component) in components.enumerated() {
let isLast = index == components.count - 1
let next = try openOrCreateDirectory(
current,
component,
permissions: permissions,
allowCreate: makeIntermediates || isLast
)
if ownsCurrent {
try? current.close()
}
current = next
ownsCurrent = true
}

try completion(current)
}

/// What to do when a symlink is found while resolving a path.
public enum SymlinkPolicy: Sendable, Equatable {
/// Refuse any symlink in the path, including the last component. This is the safe choice
/// for anything that does not need symlinks to work.
case refuse

/// Follow a symlink, but only to a place beneath the directory the walk started from.
///
/// Symlinks are resolved here, one component at a time, against directory descriptors
/// that are already open, and never by the kernel. A relative target may use `..` as
/// long as the result stays beneath the starting directory. An absolute target, a target
/// that would leave the starting directory, and a chain of more than
/// ``maximumSymlinksFollowed`` links are all refused as ``Error/cannotFollowSymlink``.
///
/// Use it for trees that legitimately contain relative symlinks, such as a legacy
/// docker-archive where a layer shared between images is a link to another image's copy.
case followBeneath
}

/// The most symlinks that ``SymlinkPolicy/followBeneath`` follows while resolving one path.
public static let maximumSymlinksFollowed = 40

/// Opens the existing regular file at `relativePath` below `fd` for reading.
///
/// Every component is opened relative to the previous one with `O_NOFOLLOW`, and the file is
/// checked after it is open, so a swap of any component for a symlink while this runs
/// cannot redirect the open. How symlinks that are already there are treated is up to `symlinks`.
/// The returned descriptor is close-on-exec and the caller must close it.
///
/// - Parameters:
/// - fd: An open file descriptor for the directory to start from.
/// - relativePath: The file to open. It must be relative, must not contain a `..` component, and must name something.
/// - symlinks: What to do about symlinks in the path, including the last component. There is no default.
/// - Throws: ``Error/invalidRelativePath`` for an absolute path, one containing `..`, or an empty path;
/// ``Error/notFound`` if the file or a parent does not exist; ``Error/cannotFollowSymlink`` if a symlink
/// is refused or cannot be followed beneath `fd`; ``Error/conflict(_:)`` if a parent is not a directory or
/// the path does not end at a regular file; and ``Error/systemError(_:_:)`` for anything else.
public static func openFile(_ fd: FileDescriptor, relativePath: FilePath, symlinks: SymlinkPolicy) throws -> FileDescriptor {
try validateRelativePath(relativePath)
let components = Array(relativePath.components)
guard !components.isEmpty else {
throw Error.invalidRelativePath
}
return try resolveBeneath(
fd, components, symlinks: symlinks,
atEntry: { parent, name in try openFile(parent, name) },
atDirectory: { _ in throw Error.conflict(.directory) })
}

/// Returns the metadata of the entry at `relativePath` below `fd`, or `nil` if the entry or
/// one of its parents does not exist. An empty path describes `fd` itself.
///
/// The entry itself is never followed: for a symlink this is the metadata of the link. How
/// symlinks in the parent positions are treated is up to `symlinks`, so the answer always
/// describes an entry that is really beneath `fd`.
///
/// - Throws: ``Error/invalidRelativePath`` for an absolute path or one containing `..`;
/// ``Error/cannotFollowSymlink`` if a symlink in a parent position is refused or cannot be followed
/// beneath `fd`; ``Error/conflict(_:)`` if a parent is not a directory; and
/// ``Error/systemError(_:_:)`` for anything else.
public static func status(_ fd: FileDescriptor, relativePath: FilePath, symlinks: SymlinkPolicy) throws -> FileStatus? {
try validateRelativePath(relativePath)
let components = Array(relativePath.components)
guard !components.isEmpty else {
return try status(of: fd)
}
do {
return try resolveBeneath(
fd, components, symlinks: symlinks,
atEntry: { parent, name in try status(parent, name) },
atDirectory: { directory in try status(of: directory) })
} catch Error.notFound {
return nil
}
}

/// Walks `components` from `root`, opening each parent with `O_NOFOLLOW`, and calls `atEntry` with
/// the directory that holds the last component. If `atEntry` throws ``Error/cannotFollowSymlink``
/// because the last component is a symlink, and `symlinks` allows it, the link is followed.
///
/// The walk keeps a stack of open directory descriptors. `..` in a symlink target pops the stack and is
/// refused at the bottom, which is what keeps every followed link beneath `root`. If the path resolves
/// to a directory without ever reaching a last component, for example a link to `..`, `atDirectory` runs.
private static func resolveBeneath<T>(
_ root: FileDescriptor,
_ components: [FilePath.Component],
symlinks: SymlinkPolicy,
atEntry: (_ parent: FileDescriptor, _ name: FilePath.Component) throws -> T,
atDirectory: (_ directory: FileDescriptor) throws -> T
) throws -> T {
var pending = Array(components.reversed())
var directories = [root]
var symlinksFollowed = 0
defer {
// The first entry is the caller's descriptor.
for directory in directories.dropFirst() {
try? directory.close()
}
}

func follow(_ name: FilePath.Component, in parent: FileDescriptor) throws {
symlinksFollowed += 1
guard symlinksFollowed <= maximumSymlinksFollowed else {
throw Error.cannotFollowSymlink
}
let target = FilePath(try readSymlink(parent, name))
guard !target.isAbsolute, !target.components.isEmpty else {
throw Error.cannotFollowSymlink
}
// The target is resolved next, relative to the directory that holds the link.
pending.append(contentsOf: target.components.reversed())
}

while let name = pending.popLast() {
if name.string == "." {
continue
}
if name.string == ".." {
guard directories.count > 1, let popped = directories.popLast() else {
throw Error.cannotFollowSymlink
}
try? popped.close()
continue
}

let parent = directories[directories.count - 1]
do {
if pending.isEmpty {
return try atEntry(parent, name)
}
directories.append(try openDirectory(parent, name))
} catch Error.cannotFollowSymlink where symlinks == .followBeneath {
try follow(name, in: parent)
}
}

return try atDirectory(directories[directories.count - 1])
}

private static func openOrCreateDirectory(
_ parent: FileDescriptor,
_ name: FilePath.Component,
permissions: FilePermissions?,
allowCreate: Bool
) throws -> FileDescriptor {
do {
return try openDirectory(parent, name)
} catch let error as Error {
switch error {
case .notFound:
guard allowCreate else {
throw Error.invalidPathComponent
}
case .cannotFollowSymlink, .conflict:
// Something that is not a directory is in the way. Replace it. This removes a symlink
// and not its target, and it never removes a directory.
guard allowCreate else {
throw Error.invalidPathComponent
}
try unlink(parent, name)
default:
throw error
}
}

try makeDirectory(parent, name, permissions: permissions)
return try openDirectory(parent, name)
}
}
Loading
Loading