VYPR
Low severityOSV Advisory· Published Jan 22, 2026· Updated Jan 23, 2026

CVE-2026-20613

CVE-2026-20613

Description

The ArchiveReader.extractContents() function used by cctl image load and container image load performs no pathname validation before extracting an archive member. This means that a carelessly or maliciously constructed archive can extract a file into any user-writable location on the system using relative pathnames. This issue is addressed in container 0.8.0 and containerization 0.21.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/apple/containerizationSwiftURL
< 0.21.00.21.0
github.com/apple/containerSwiftURL
< 0.8.00.8.0

Affected products

1

Patches

1
3e93416b9a6d

Merge commit from fork

11 files changed · +1755 36
  • Package.resolved+3 3 modified
    @@ -1,5 +1,5 @@
     {
    -  "originHash" : "c82be4e21117351bb3f942869ce90d35dcd0dd0223dc1c49ce7a56b52709e836",
    +  "originHash" : "dcc639b6cdf875204fc0d722e0eae8f11a5b19fb8517998bd776a9b76b48c3e8",
       "pins" : [
         {
           "identity" : "async-http-client",
    @@ -213,8 +213,8 @@
           "kind" : "remoteSourceControl",
           "location" : "https://github.com/apple/swift-system.git",
           "state" : {
    -        "revision" : "61e4ca4b81b9e09e2ec863b00c340eb13497dac6",
    -        "version" : "1.5.0"
    +        "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
    +        "version" : "1.6.3"
           }
         }
       ],
    
  • Package.swift+4 1 modified
    @@ -130,9 +130,10 @@ let package = Package(
             .target(
                 name: "ContainerizationArchive",
                 dependencies: [
    -                "CArchive",
                     .product(name: "SystemPackage", package: "swift-system"),
    +                "CArchive",
                     "ContainerizationExtras",
    +                "ContainerizationOS",
                 ],
                 exclude: [
                     "CArchive"
    @@ -203,6 +204,7 @@ let package = Package(
                 name: "ContainerizationOS",
                 dependencies: [
                     .product(name: "Logging", package: "swift-log"),
    +                .product(name: "SystemPackage", package: "swift-system"),
                     "CShim",
                     "ContainerizationError",
                 ],
    @@ -213,6 +215,7 @@ let package = Package(
             .testTarget(
                 name: "ContainerizationOSTests",
                 dependencies: [
    +                .product(name: "SystemPackage", package: "swift-system"),
                     "ContainerizationOS",
                     "ContainerizationExtras",
                 ]
    
  • Sources/cctl/ImageCommand.swift+4 1 modified
    @@ -252,11 +252,14 @@ extension Application {
                     defer {
                         try? FileManager.default.removeItem(at: tempDir)
                     }
    -                try reader.extractContents(to: tempDir)
    +                let rejectedPaths = try reader.extractContents(to: tempDir)
                     let imported = try await store.load(from: tempDir)
                     for image in imported {
                         print("imported \(image.reference)")
                     }
    +                for rejectedPath in rejectedPaths {
    +                    print("warning: skipped image archive member \(rejectedPath)")
    +                }
                 }
             }
     
    
  • Sources/ContainerizationArchive/ArchiveReader.swift+132 22 renamed
    @@ -1,5 +1,5 @@
     //===----------------------------------------------------------------------===//
    -// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
    +// 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.
    @@ -15,7 +15,9 @@
     //===----------------------------------------------------------------------===//
     
     import CArchive
    +import ContainerizationOS
     import Foundation
    +import SystemPackage
     
     /// A protocol for reading data in chunks, compatible with both `InputStream` and zero-allocation archive readers.
     public protocol ReadableStream {
    @@ -45,6 +47,8 @@ public struct ArchiveEntryReader: ReadableStream {
     
     /// A class responsible for reading entries from an archive file.
     public final class ArchiveReader {
    +    private static let blockSize = 65536
    +
         /// A pointer to the underlying `archive` C structure.
         var underlying: OpaquePointer?
         /// The file handle associated with the archive file being read.
    @@ -176,36 +180,44 @@ extension ArchiveReader {
         }
     
         /// Extracts the contents of an archive to the provided directory.
    -    /// Currently only handles regular files and directories present in the archive.
    -    public func extractContents(to directory: URL) throws {
    +    /// 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.
    +    public func extractContents(to directory: URL) throws -> [String] {
    +        // Create the root directory with standard permissions
    +        // and create a FileDescriptor for secure path traveral.
             let fm = FileManager.default
    +        let rootFilePath = FilePath(directory.path)
    +        try fm.createDirectory(atPath: directory.path, withIntermediateDirectories: true)
    +        let rootFileDescriptor = try FileDescriptor.open(rootFilePath, .readOnly)
    +        defer { try? rootFileDescriptor.close() }
    +
    +        // Iterate and extract archive entries, collecting rejected paths.
             var foundEntry = false
    -        for (entry, data) in self {
    -            guard let p = entry.path else { continue }
    -            foundEntry = true
    -            let type = entry.fileType
    -            let target = directory.appending(path: p)
    -            switch type {
    -            case .regular:
    -                try data.write(to: target, options: .atomic)
    -            case .directory:
    -                try fm.createDirectory(at: target, withIntermediateDirectories: true)
    -            case .symbolicLink:
    -                guard let symlinkTarget = entry.symlinkTarget, let linkTargetURL = URL(string: symlinkTarget, relativeTo: target) else {
    -                    continue
    -                }
    -                try fm.createSymbolicLink(at: target, withDestinationURL: linkTargetURL)
    -            default:
    +        var rejectedPaths = [String]()
    +        for (entry, dataReader) in self.makeStreamingIterator() {
    +            guard let memberPath = (entry.path.map { FilePath($0) }) else {
                     continue
                 }
    -            chmod(target.path(), entry.permissions)
    -            if let owner = entry.owner, let group = entry.group {
    -                chown(target.path(), owner, group)
    +            foundEntry = true
    +
    +            // Try to extract the entry, catching path validation errors
    +            let extracted = try extractEntry(
    +                entry: entry,
    +                dataReader: dataReader,
    +                memberPath: memberPath,
    +                rootFileDescriptor: rootFileDescriptor
    +            )
    +
    +            if !extracted {
    +                rejectedPaths.append(memberPath.string)
                 }
             }
             guard foundEntry else {
                 throw ArchiveError.failedToExtractArchive("no entries found in archive")
             }
    +
    +        return rejectedPaths
         }
     
         /// This method extracts a given file from the archive.
    @@ -226,4 +238,102 @@ extension ArchiveReader {
             }
             throw ArchiveError.failedToExtractArchive(" \(path) not found in archive")
         }
    +
    +    /// Extracts a single archive entry.
    +    /// Returns false if the entry was rejected due to path validation errors.
    +    /// Throws on system errors.
    +    private func extractEntry(
    +        entry: WriteEntry,
    +        dataReader: ArchiveEntryReader,
    +        memberPath: FilePath,
    +        rootFileDescriptor: FileDescriptor
    +    ) throws -> Bool {
    +        guard let lastComponent = memberPath.lastComponent else {
    +            return false
    +        }
    +        let relativePath = memberPath.removingLastComponent()
    +        let type = entry.fileType
    +
    +        do {
    +            switch type {
    +            case .regular:
    +                try rootFileDescriptor.mkdirSecure(relativePath, makeIntermediates: true) { fd in
    +                    // Remove existing entry if present (mimics containerd's "last entry wins" behavior)
    +                    try? fd.unlinkRecursiveSecure(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
    +                    let fileFd = openat(fd.rawValue, lastComponent.string, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, fileMode)
    +                    guard fileFd >= 0 else {
    +                        throw ArchiveError.failedToExtractArchive("failed to create file: \(memberPath)")
    +                    }
    +                    defer { close(fileFd) }
    +
    +                    try Self.copyDataReaderToFd(dataReader: dataReader, fileFd: fileFd, memberPath: memberPath)
    +                    setFileAttributes(fd: fileFd, entry: entry)
    +                }
    +            case .directory:
    +                try rootFileDescriptor.mkdirSecure(memberPath, makeIntermediates: true) { fd in
    +                    setFileAttributes(fd: fd.rawValue, entry: entry)
    +                }
    +            case .symbolicLink:
    +                guard let targetPath = (entry.symlinkTarget.map { FilePath($0) }) else {
    +                    return false
    +                }
    +                var symlinkCreated = false
    +                try rootFileDescriptor.mkdirSecure(relativePath, makeIntermediates: true) { fd in
    +                    // Remove existing entry if present (mimics containerd's "last entry wins" behavior)
    +                    try? fd.unlinkRecursiveSecure(filename: lastComponent)
    +
    +                    guard symlinkat(targetPath.string, fd.rawValue, lastComponent.string) == 0 else {
    +                        throw ArchiveError.failedToExtractArchive("failed to create symlink: \(targetPath) <- \(memberPath)")
    +                    }
    +                    symlinkCreated = true
    +                }
    +                return symlinkCreated
    +            default:
    +                return false
    +            }
    +
    +            return true
    +        } catch let error as SecurePathError {
    +            // Just reject path validation errors, don't fail the extraction
    +            switch error {
    +            case .systemError:
    +                // Fail for system errors
    +                throw error
    +            case .invalidRelativePath, .invalidPathComponent, .cannotFollowSymlink:
    +                return false
    +            }
    +        }
    +    }
    +
    +    private func setFileAttributes(fd: Int32, entry: WriteEntry) {
    +        fchmod(fd, entry.permissions)
    +        if let owner = entry.owner, let group = entry.group {
    +            fchown(fd, owner, group)
    +        }
    +    }
    +
    +    private static func copyDataReaderToFd(dataReader: ArchiveEntryReader, fileFd: Int32, memberPath: FilePath) throws {
    +        var buffer = [UInt8](repeating: 0, count: ArchiveReader.blockSize)
    +        while true {
    +            let bytesRead = buffer.withUnsafeMutableBufferPointer { bufferPtr in
    +                guard let baseAddress = bufferPtr.baseAddress else { return 0 }
    +                return dataReader.read(baseAddress, maxLength: bufferPtr.count)
    +            }
    +
    +            if bytesRead < 0 {
    +                throw ArchiveError.failedToExtractArchive("failed to read data for: \(memberPath)")
    +            }
    +            if bytesRead == 0 {
    +                break  // EOF
    +            }
    +
    +            let bytesWritten = write(fileFd, buffer, bytesRead)
    +            guard bytesWritten == bytesRead else {
    +                throw ArchiveError.failedToExtractArchive("failed to write data for: \(memberPath)")
    +            }
    +        }
    +    }
     }
    
  • Sources/ContainerizationArchive/TempDir.swift+10 7 modified
    @@ -20,12 +20,15 @@ import Foundation
     internal func createTemporaryDirectory(baseName: String) -> URL? {
         let url = FileManager.default.uniqueTemporaryDirectory().appendingPathComponent(
             "\(baseName).XXXXXX")
    -    guard let templatePathData = (url.absoluteURL.path as NSString).utf8String else {
    -        return nil
    -    }
    -
    -    let pathData = UnsafeMutablePointer(mutating: templatePathData)
    -    mkdtemp(pathData)
     
    -    return URL(fileURLWithPath: String(cString: pathData), isDirectory: true)
    +    var path = url.absoluteURL.path
    +    return path.withUTF8 { utf8Bytes in
    +        var mutablePath = Array(utf8Bytes) + [0]
    +        return mutablePath.withUnsafeMutableBufferPointer { buffer -> URL? in
    +            guard let baseAddress = buffer.baseAddress else { return nil }
    +            mkdtemp(baseAddress)
    +            let resultPath = String(decoding: buffer[..<(buffer.count - 1)], as: UTF8.self)
    +            return URL(fileURLWithPath: resultPath, isDirectory: true)
    +        }
    +    }
     }
    
  • Sources/ContainerizationOS/FileDescriptor+SecurePath.swift+218 0 added
    @@ -0,0 +1,218 @@
    +//===----------------------------------------------------------------------===//
    +// 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)"
    +        }
    +    }
    +}
    
  • Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift+660 0 added
    @@ -0,0 +1,660 @@
    +//===----------------------------------------------------------------------===//
    +// 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 Foundation
    +import SystemPackage
    +import Testing
    +
    +@testable import ContainerizationArchive
    +
    +struct ArchiveReaderTests {
    +    // MARK: - Helper Methods
    +
    +    func createTestArchive(name: String, entries: [(path: String, type: EntryType, target: String?)]) throws -> URL {
    +        let testDirectory = createTemporaryDirectory(baseName: "ArchiveReaderTests")!
    +        let archiveURL = testDirectory.appendingPathComponent("\(name).tar")
    +
    +        let archiver = try ArchiveWriter(format: .paxRestricted, filter: .none, file: archiveURL)
    +
    +        for entry in entries {
    +            let writeEntry = WriteEntry()
    +            writeEntry.path = entry.path
    +            writeEntry.permissions = 0o644
    +            writeEntry.owner = 1000
    +            writeEntry.group = 1000
    +
    +            switch entry.type {
    +            case .regular(let content):
    +                writeEntry.fileType = .regular
    +                let data = content.data(using: .utf8)!
    +                writeEntry.size = numericCast(data.count)
    +                try archiver.writeEntry(entry: writeEntry, data: data)
    +            case .directory:
    +                writeEntry.fileType = .directory
    +                writeEntry.permissions = 0o755
    +                writeEntry.size = 0
    +                try archiver.writeEntry(entry: writeEntry, data: nil)
    +            case .symlink:
    +                guard let target = entry.target else {
    +                    throw ArchiveError.failedToExtractArchive("symlink requires target")
    +                }
    +                writeEntry.fileType = .symbolicLink
    +                writeEntry.symlinkTarget = target
    +                writeEntry.size = 0
    +                try archiver.writeEntry(entry: writeEntry, data: nil)
    +            }
    +        }
    +
    +        try archiver.finishEncoding()
    +        return archiveURL
    +    }
    +
    +    func createExtractionDirectory(name: String) throws -> URL {
    +        let testDirectory = createTemporaryDirectory(baseName: "ArchiveReaderTests.\(name)")!
    +        return testDirectory.appendingPathComponent("extract")
    +    }
    +
    +    enum EntryType {
    +        case regular(String)  // Content
    +        case directory
    +        case symlink
    +    }
    +
    +    // MARK: - Benign Archive Tests
    +
    +    @Test func extractBenignArchive() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "benign",
    +            entries: [
    +                ("dir/", .directory, nil),
    +                ("dir/file.txt", .regular("test content"), nil),
    +                ("dir/subdir/", .directory, nil),
    +                ("dir/subdir/file2.txt", .regular("more content"), nil),
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "benign")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        #expect(rejectedPaths.isEmpty, "Benign archive should not reject any entries")
    +
    +        // Verify files were extracted
    +        #expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("dir/file.txt").path))
    +        #expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("dir/subdir/file2.txt").path))
    +
    +        // Verify content
    +        let content1 = try String(contentsOf: extractDir.appendingPathComponent("dir/file.txt"), encoding: .utf8)
    +        #expect(content1 == "test content")
    +
    +        let content2 = try String(contentsOf: extractDir.appendingPathComponent("dir/subdir/file2.txt"), encoding: .utf8)
    +        #expect(content2 == "more content")
    +    }
    +
    +    @Test func extractRootLevelFile() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "root-level",
    +            entries: [
    +                ("file.txt", .regular("root file"), nil)
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "root-level")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        #expect(rejectedPaths.isEmpty)
    +        #expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("file.txt").path))
    +
    +        let content = try String(contentsOf: extractDir.appendingPathComponent("file.txt"), encoding: .utf8)
    +        #expect(content == "root file")
    +    }
    +
    +    // MARK: - Absolute Path Tests
    +
    +    @Test func convertAbsolutePathToRelative() throws {
    +        let filename1: String = "/tmp/\(UUID())"
    +        let filename2: String = "//tmp//\(UUID())"
    +        let archiveURL = try createTestArchive(
    +            name: "benign-absolute",
    +            entries: [
    +                ("/tmp/\(filename1)", .regular("hello"), nil),
    +                ("//tmp//\(filename2)", .regular("world"), nil),
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "benign-absolute")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        // Absolute paths should be rejected
    +        #expect(
    +            rejectedPaths.isEmpty,
    +            "Expected absolute paths allowed, but got rejected paths \(rejectedPaths)")
    +
    +        // Verify nothing was extracted to /tmp or /etc
    +        #expect(!FileManager.default.fileExists(atPath: filename1))
    +        #expect(!FileManager.default.fileExists(atPath: filename2))
    +        #expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("tmp/\(filename1)").path))
    +        #expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("tmp/\(filename2)").path))
    +    }
    +
    +    // MARK: - Path Traversal Attack Tests
    +
    +    @Test func rejectPathTraversal() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "evil-traversal",
    +            entries: [
    +                ("../etc/pwned", .regular("evil"), nil),
    +                ("foo/../../etc/pwned", .regular("evil"), nil),
    +                ("dir/../../../etc/pwned", .regular("evil"), nil),
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "evil-traversal")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        // Path traversal entries should be rejected
    +        #expect(
    +            Set(rejectedPaths) == Set(["../etc/pwned", "foo/../../etc/pwned", "dir/../../../etc/pwned"]),
    +            "Expected path traversal entries to be rejected, got \(rejectedPaths)")
    +
    +        // Verify nothing escaped
    +        let parentDir = extractDir.deletingLastPathComponent()
    +        #expect(!FileManager.default.fileExists(atPath: parentDir.appendingPathComponent("etc/pwned").path))
    +    }
    +
    +    @Test func rejectPathTraversalWithValidEntries() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "mixed-traversal",
    +            entries: [
    +                ("safe.txt", .regular("safe content"), nil),
    +                ("dir/", .directory, nil),
    +                ("dir/file.txt", .regular("also safe"), nil),
    +                ("../etc/pwned", .regular("evil"), nil),
    +                ("more/safe.txt", .regular("still safe"), nil),
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "mixed-traversal")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        // Only the path traversal entry should be rejected
    +        #expect(
    +            rejectedPaths == ["../etc/pwned"],
    +            "Expected only path traversal entry to be rejected, got \(rejectedPaths)")
    +
    +        // Valid entries should have been extracted
    +        #expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("safe.txt").path))
    +        #expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("dir/file.txt").path))
    +        #expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("more/safe.txt").path))
    +
    +        // Verify nothing escaped
    +        let parentDir = extractDir.deletingLastPathComponent()
    +        #expect(!FileManager.default.fileExists(atPath: parentDir.appendingPathComponent("etc/pwned").path))
    +    }
    +
    +    @Test func rejectDotDotInMiddle() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "evil-dotdot-middle",
    +            entries: [
    +                ("safe/../pwned.txt", .regular("evil"), nil)
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "evil-dotdot-middle")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        #expect(rejectedPaths == ["safe/../pwned.txt"])
    +        #expect(!FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("pwned.txt").path))
    +    }
    +
    +    // MARK: - Symlink Attack Tests
    +
    +    @Test func allowValidSymlink() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "safe-symlink",
    +            entries: [
    +                ("dir/", .directory, nil),
    +                ("dir/target.txt", .regular("target content"), nil),
    +                ("dir/link", .symlink, "target.txt"),
    +                ("link2", .symlink, "dir/target.txt"),
    +                ("dir/passwd", .symlink, "/etc/passwd"),
    +                ("dir2/passwd", .symlink, "../../../../etc/passwd"),
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "safe-symlink")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        #expect(rejectedPaths.isEmpty, "Valid symlinks should be allowed")
    +
    +        // Verify symlinks were created
    +        let linkPath = extractDir.appendingPathComponent("dir/link").path
    +        #expect(FileManager.default.fileExists(atPath: linkPath))
    +
    +        let link2Path = extractDir.appendingPathComponent("link2").path
    +        #expect(FileManager.default.fileExists(atPath: link2Path))
    +
    +        // Verify symlinks point to correct targets
    +        let linkTarget = try FileManager.default.destinationOfSymbolicLink(atPath: linkPath)
    +        #expect(linkTarget == "target.txt")
    +
    +        let link2Target = try FileManager.default.destinationOfSymbolicLink(atPath: link2Path)
    +        #expect(link2Target == "dir/target.txt")
    +    }
    +
    +    @Test func allowSymlinkWithDotDot() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "safe-symlink-dotdot",
    +            entries: [
    +                ("dir/", .directory, nil),
    +                ("dir/subdir/", .directory, nil),
    +                ("target.txt", .regular("target"), nil),
    +                ("dir/subdir/link", .symlink, "../../target.txt"),
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "safe-symlink-dotdot")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        #expect(rejectedPaths.isEmpty, "Symlink with .. that stays in root should be allowed")
    +
    +        let linkPath = extractDir.appendingPathComponent("dir/subdir/link").path
    +        #expect(FileManager.default.fileExists(atPath: linkPath))
    +
    +        let linkTarget = try FileManager.default.destinationOfSymbolicLink(atPath: linkPath)
    +        #expect(linkTarget == "../../target.txt")
    +    }
    +
    +    // MARK: - Deep Nesting Tests
    +
    +    @Test func extractDeepNesting() throws {
    +        var entries: [(String, EntryType, String?)] = []
    +
    +        // Create 50 levels deep
    +        var path = ""
    +        for i in 0..<50 {
    +            if i > 0 { path += "/" }
    +            path += "level\(i)"
    +            entries.append((path + "/", .directory, nil))
    +        }
    +        entries.append((path + "/deep.txt", .regular("deep file"), nil))
    +
    +        let archiveURL = try createTestArchive(name: "deep-nesting", entries: entries)
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "deep-nesting")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        #expect(rejectedPaths.isEmpty)
    +
    +        // Verify deep file exists
    +        let deepFilePath = extractDir.appendingPathComponent(path + "/deep.txt").path
    +        #expect(FileManager.default.fileExists(atPath: deepFilePath))
    +
    +        let content = try String(contentsOfFile: deepFilePath, encoding: .utf8)
    +        #expect(content == "deep file")
    +    }
    +
    +    // MARK: - Normalization Tests
    +
    +    @Test func handleDotSlashPrefix() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "dot-slash",
    +            entries: [
    +                ("./safe.txt", .regular("content"), nil),
    +                ("./dir/", .directory, nil),
    +                ("./dir/file.txt", .regular("more content"), nil),
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "dot-slash")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        #expect(rejectedPaths.isEmpty, "./ prefix should be normalized and allowed")
    +        #expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("safe.txt").path))
    +        #expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("dir/file.txt").path))
    +    }
    +
    +    @Test func handleDoubleSlashes() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "double-slash",
    +            entries: [
    +                ("dir//subdir/", .directory, nil),
    +                ("dir//subdir//file.txt", .regular("content"), nil),
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "double-slash")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        #expect(rejectedPaths.isEmpty, "Double slashes should be normalized")
    +
    +        // Verify file exists at normalized path
    +        let normalizedPath = "dir/subdir/file.txt"
    +        #expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent(normalizedPath).path))
    +    }
    +
    +    // MARK: - File Permissions Tests
    +
    +    @Test func preserveFilePermissions() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "permissions",
    +            entries: [
    +                ("executable.sh", .regular("#!/bin/bash\necho test"), nil)
    +            ])
    +
    +        // Manually set executable permissions
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        for (entry, _) in reader {
    +            if entry.path == "executable.sh" {
    +                entry.permissions = 0o755
    +            }
    +        }
    +
    +        // Re-create archive with proper permissions
    +        let testDirectory = createTemporaryDirectory(baseName: "ArchiveReaderTests")!
    +        let archiveURL2 = testDirectory.appendingPathComponent("permissions2.tar")
    +        let archiver = try ArchiveWriter(format: .paxRestricted, filter: .none, file: archiveURL2)
    +
    +        let writeEntry = WriteEntry()
    +        writeEntry.path = "executable.sh"
    +        writeEntry.fileType = .regular
    +        writeEntry.permissions = 0o755
    +        let data = "#!/bin/bash\necho test".data(using: .utf8)!
    +        writeEntry.size = numericCast(data.count)
    +        try archiver.writeEntry(entry: writeEntry, data: data)
    +        try archiver.finishEncoding()
    +
    +        defer { try? FileManager.default.removeItem(at: testDirectory) }
    +
    +        let extractDir = try createExtractionDirectory(name: "permissions")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader2 = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL2)
    +        let rejectedPaths = try reader2.extractContents(to: extractDir)
    +
    +        #expect(rejectedPaths.isEmpty)
    +
    +        // Verify permissions were preserved
    +        let filePath = extractDir.appendingPathComponent("executable.sh").path
    +        let attrs = try FileManager.default.attributesOfItem(atPath: filePath)
    +        let perms = (attrs[.posixPermissions] as? NSNumber)?.uint16Value ?? 0
    +        let permMask: UInt16 = 0o777
    +        #expect((perms & permMask) == 0o755, "Permissions should be preserved")
    +    }
    +
    +    // MARK: - Duplicate Entry Tests
    +
    +    @Test func duplicateRegularFiles() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "duplicate-regular",
    +            entries: [
    +                ("file.txt", .regular("first content"), nil),
    +                ("file.txt", .regular("second content"), nil),
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "duplicate-regular")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        // Last entry wins - second file should replace first
    +        #expect(rejectedPaths.isEmpty, "Duplicate files follow last-entry-wins")
    +
    +        #expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("file.txt").path))
    +        let content = try String(contentsOf: extractDir.appendingPathComponent("file.txt"), encoding: .utf8)
    +        #expect(content == "second content", "Last entry should win")
    +    }
    +
    +    @Test func duplicateDirectories() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "duplicate-dirs",
    +            entries: [
    +                ("dir/", .directory, nil),
    +                ("dir/", .directory, nil),
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "duplicate-dirs")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        // Both directories should be accepted (merged)
    +        #expect(rejectedPaths.isEmpty, "Duplicate directories should be merged")
    +        #expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("dir").path))
    +    }
    +
    +    @Test func regularFileToDirectory() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "file-to-dir",
    +            entries: [
    +                ("path", .regular("content"), nil),
    +                ("path/", .directory, nil),
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "file-to-dir")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        // Directory should replace the file
    +        #expect(rejectedPaths.isEmpty, "Directory should replace regular file")
    +
    +        let attrs = try FileManager.default.attributesOfItem(atPath: extractDir.appendingPathComponent("path").path)
    +        let fileType = attrs[.type] as? FileAttributeType
    +        #expect(fileType == .typeDirectory, "Path should be a directory")
    +    }
    +
    +    @Test func directoryToRegularFile() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "dir-to-file",
    +            entries: [
    +                ("path/", .directory, nil),
    +                ("path", .regular("content"), nil),
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "dir-to-file")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        // Last entry wins - file should replace directory
    +        #expect(rejectedPaths.isEmpty, "Regular file should replace directory")
    +
    +        // Should now be a regular file
    +        #expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("path").path))
    +        let content = try String(contentsOf: extractDir.appendingPathComponent("path"), encoding: .utf8)
    +        #expect(content == "content", "Should have file content")
    +    }
    +
    +    @Test func regularFileToSymlink() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "file-to-symlink",
    +            entries: [
    +                ("target.txt", .regular("target"), nil),
    +                ("path", .regular("content"), nil),
    +                ("path", .symlink, "target.txt"),
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "file-to-symlink")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        // Last entry wins - symlink should replace file
    +        #expect(rejectedPaths.isEmpty, "Symlink should replace regular file")
    +
    +        // Should now be a symlink
    +        #expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("path").path))
    +        let linkTarget = try FileManager.default.destinationOfSymbolicLink(atPath: extractDir.appendingPathComponent("path").path)
    +        #expect(linkTarget == "target.txt")
    +    }
    +
    +    @Test func symlinkToRegularFile() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "symlink-to-file",
    +            entries: [
    +                ("target.txt", .regular("target"), nil),
    +                ("path", .symlink, "target.txt"),
    +                ("path", .regular("new content"), nil),
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "symlink-to-file")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        // Last entry wins - file should replace symlink
    +        #expect(rejectedPaths.isEmpty, "Regular file should replace symlink")
    +
    +        // Should now be a regular file
    +        #expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("path").path))
    +        let content = try String(contentsOf: extractDir.appendingPathComponent("path"), encoding: .utf8)
    +        #expect(content == "new content")
    +    }
    +
    +    @Test func symlinkToDirectory() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "symlink-to-dir",
    +            entries: [
    +                ("target/", .directory, nil),
    +                ("path", .symlink, "target"),
    +                ("path/", .directory, nil),
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "symlink-to-dir")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        // Directory should replace symlink
    +        #expect(rejectedPaths.isEmpty, "Directory should replace symlink")
    +
    +        // Path should now be a directory
    +        let attrs = try FileManager.default.attributesOfItem(atPath: extractDir.appendingPathComponent("path").path)
    +        let fileType = attrs[.type] as? FileAttributeType
    +        #expect(fileType == .typeDirectory, "Path should be a directory")
    +    }
    +
    +    @Test func duplicateSymlinks() throws {
    +        let archiveURL = try createTestArchive(
    +            name: "duplicate-symlinks",
    +            entries: [
    +                ("target1.txt", .regular("target1"), nil),
    +                ("target2.txt", .regular("target2"), nil),
    +                ("link", .symlink, "target1.txt"),
    +                ("link", .symlink, "target2.txt"),
    +            ])
    +
    +        defer { try? FileManager.default.removeItem(at: archiveURL.deletingLastPathComponent()) }
    +
    +        let extractDir = try createExtractionDirectory(name: "duplicate-symlinks")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +        let rejectedPaths = try reader.extractContents(to: extractDir)
    +
    +        // Last entry wins - second symlink should replace first
    +        #expect(rejectedPaths.isEmpty, "Second symlink should replace first")
    +
    +        let linkTarget = try FileManager.default.destinationOfSymbolicLink(atPath: extractDir.appendingPathComponent("link").path)
    +        #expect(linkTarget == "target2.txt", "Last symlink should win")
    +    }
    +
    +    // MARK: - Empty Archive Tests
    +
    +    @Test func rejectEmptyArchive() throws {
    +        let testDirectory = createTemporaryDirectory(baseName: "ArchiveReaderTests")!
    +        let archiveURL = testDirectory.appendingPathComponent("empty.tar")
    +
    +        let archiver = try ArchiveWriter(format: .paxRestricted, filter: .none, file: archiveURL)
    +        try archiver.finishEncoding()
    +
    +        defer { try? FileManager.default.removeItem(at: testDirectory) }
    +
    +        let extractDir = try createExtractionDirectory(name: "empty")
    +        defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
    +
    +        let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL)
    +
    +        #expect(throws: ArchiveError.self) {
    +            _ = try reader.extractContents(to: extractDir)
    +        }
    +    }
    +}
    
  • Tests/ContainerizationArchiveTests/ArchiveTests.swift+36 0 modified
    @@ -34,6 +34,42 @@ struct ArchiveTests {
             return entry
         }
     
    +    @Test func createTemporaryDirectorySuccess() throws {
    +        // Test that createTemporaryDirectory creates a directory with randomized suffix
    +        let baseName = "ArchiveTests.testTempDir"
    +        guard let tempDir = createTemporaryDirectory(baseName: baseName) else {
    +            Issue.record("createTemporaryDirectory returned nil")
    +            return
    +        }
    +
    +        defer {
    +            let fileManager = FileManager.default
    +            try? fileManager.removeItem(at: tempDir)
    +        }
    +
    +        // Verify the directory exists
    +        var isDirectory: ObjCBool = false
    +        let fileManager = FileManager.default
    +        let exists = fileManager.fileExists(atPath: tempDir.path, isDirectory: &isDirectory)
    +        #expect(exists)
    +        #expect(isDirectory.boolValue)
    +
    +        // Verify the directory name starts with the base name
    +        let lastComponent = tempDir.lastPathComponent
    +        #expect(lastComponent.starts(with: baseName))
    +
    +        // Verify that mkdtemp replaced the X's with random characters
    +        // (should be 6 random alphanumeric characters after the base name and dot)
    +        let suffix = String(lastComponent.dropFirst(baseName.count + 1))  // +1 for the dot
    +        #expect(suffix.count == 6, "Expected 6 character suffix, got \(suffix.count)")
    +        #expect(suffix != "XXXXXX", "mkdtemp did not replace X's with random characters")
    +
    +        // Verify we can write to the directory
    +        let testFile = tempDir.appendingPathComponent("test.txt")
    +        try "test content".write(toFile: testFile.path, atomically: true, encoding: .utf8)
    +        #expect(fileManager.fileExists(atPath: testFile.path))
    +    }
    +
         @Test func tarUTF8() throws {
             let testDirectory = createTemporaryDirectory(baseName: "ArchiveTests.testTarUTF8")!
             let archiveURL = testDirectory.appendingPathComponent("test.tgz")
    
  • Tests/ContainerizationOSTests/FileDescriptor+SecurePathTests.swift+681 0 added
    @@ -0,0 +1,681 @@
    +//===----------------------------------------------------------------------===//
    +// 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 Foundation
    +import SystemPackage
    +import Testing
    +
    +@testable import ContainerizationOS
    +
    +#if canImport(Darwin)
    +import Darwin
    +let os_close = Darwin.close
    +#elseif canImport(Musl)
    +import Musl
    +let os_close = Musl.close
    +#elseif canImport(Glibc)
    +import Glibc
    +let os_close = Glibc.close
    +#endif
    +
    +struct FileDescriptorPathSecureTests {
    +    @Test(
    +        "Test creation of stub file under directory successfully created by secure mkdir",
    +        arguments: [
    +            // Case 1: Single component, no intermediates needed, default permissions
    +            ([Entry](), FilePath("foo"), nil as FilePermissions?, false),
    +
    +            // Case 2: Single component with explicit permissions
    +            ([Entry](), FilePath("foo"), FilePermissions(rawValue: 0o755), false),
    +
    +            // Case 3: Two components, parent exists, no intermediates
    +            ([Entry.directory(path: "foo")], FilePath("foo/bar"), nil as FilePermissions?, false),
    +
    +            // Case 4: Two components, parent missing, makeIntermediates true
    +            ([Entry](), FilePath("foo/bar"), nil as FilePermissions?, true),
    +
    +            // Case 5: Three components, makeIntermediates true, custom permissions
    +            ([Entry](), FilePath("foo/bar/baz"), FilePermissions(rawValue: 0o700), true),
    +
    +            // Case 6: Replace existing file with directory (single component)
    +            ([Entry.regular(path: "foo")], FilePath("foo"), nil as FilePermissions?, false),
    +
    +            // Case 7: Replace existing file with directory path (makeIntermediates true)
    +            ([Entry.regular(path: "foo")], FilePath("foo/bar"), nil as FilePermissions?, true),
    +
    +            // Case 8: Replace existing directory with new directory (should be idempotent)
    +            ([Entry.directory(path: "foo")], FilePath("foo"), nil as FilePermissions?, false),
    +
    +            // Case 9: Replace nested directory structure
    +            (
    +                [
    +                    Entry.directory(path: "foo/bar"),
    +                    Entry.regular(path: "foo/bar/file.txt"),
    +                ], FilePath("foo/bar"), nil as FilePermissions?, false
    +            ),
    +
    +            // Case 10: Replace symlink with directory
    +            ([Entry.symlink(target: "target", source: "foo")], FilePath("foo"), nil as FilePermissions?, false),
    +
    +            // Case 11: Multi-level with some intermediates existing
    +            ([Entry.directory(path: "foo")], FilePath("foo/bar/baz"), nil as FilePermissions?, true),
    +
    +            // Case 12: Deep nesting with makeIntermediates
    +            ([Entry](), FilePath("a/b/c/d/e"), nil as FilePermissions?, true),
    +        ]
    +    )
    +    func testMkdirSecureValid(entries: [Entry], relativePath: FilePath, permissions: FilePermissions?, makeIntermediates: Bool) async throws {
    +        let rootPath = try createTempDirectory()
    +        defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
    +        try createEntries(rootPath: rootPath, entries: entries, permissions: permissions)
    +        let rootFd = try FileDescriptor.open(rootPath, .readOnly, options: [.directory])
    +        defer { try? rootFd.close() }
    +
    +        let stubFileName = "stub.txt"
    +        let stubContent = Data("stub file content".utf8)
    +
    +        try rootFd.mkdirSecure(relativePath, permissions: permissions, makeIntermediates: makeIntermediates) { dirFd in
    +            // Create a stub file in the directory using openat
    +            let fd = openat(
    +                dirFd.rawValue,
    +                stubFileName,
    +                O_WRONLY | O_CREAT | O_TRUNC,
    +                0o644
    +            )
    +            guard fd >= 0 else {
    +                throw Errno(rawValue: errno)
    +            }
    +            defer { close(fd) }
    +
    +            try stubContent.withUnsafeBytes { buffer in
    +                guard let baseAddress = buffer.baseAddress else { return }
    +                let written = write(fd, baseAddress, buffer.count)
    +                guard written == buffer.count else {
    +                    throw Errno(rawValue: errno)
    +                }
    +            }
    +        }
    +
    +        // Check stub file existence at expected location
    +        let expectedStubPath = rootPath.appending(relativePath.string).appending(stubFileName)
    +        #expect(FileManager.default.fileExists(atPath: expectedStubPath.string))
    +
    +        // Verify stub file content
    +        let readContent = try Data(contentsOf: URL(fileURLWithPath: expectedStubPath.string))
    +        #expect(readContent == stubContent)
    +
    +        // Check directory permissions if specified
    +        if let permissions = permissions {
    +            // Check each component of the path
    +            let components = relativePath.components
    +            var currentPath = ""
    +            for (index, component) in components.enumerated() {
    +                if index > 0 {
    +                    currentPath += "/"
    +                }
    +                currentPath += component.string
    +
    +                let dirPath = rootPath.appending(currentPath)
    +                let attrs = try FileManager.default.attributesOfItem(atPath: dirPath.string)
    +                let posixPerms = attrs[.posixPermissions] as? NSNumber
    +                // Mask to permission bits only (not file type bits)
    +                let permMask: UInt16 = 0o777
    +                let actualPerms = (posixPerms?.uint16Value ?? 0) & permMask
    +                let expectedPerms = permissions.rawValue & permMask
    +                #expect(
    +                    actualPerms == expectedPerms,
    +                    "Directory '\(currentPath)' has permissions 0o\(String(actualPerms, radix: 8)) but expected 0o\(String(expectedPerms, radix: 8))")
    +            }
    +        }
    +    }
    +
    +    @Test(
    +        "Test mkdirSecure error cases",
    +        arguments: [
    +            // Case 1: Path starting with ".." should be rejected
    +            (FilePath("../escape"), false, SecurePathError.invalidRelativePath),
    +
    +            // Case 2: Path with ".." in middle that would escape
    +            (FilePath("foo/../../escape"), false, SecurePathError.invalidRelativePath),
    +
    +            // Case 3: Missing intermediate without makeIntermediates should fail
    +            (FilePath("missing/intermediate/path"), false, SecurePathError.invalidPathComponent),
    +
    +            // Case 4: Multiple .. that escape
    +            (FilePath("a/b/../../../escape"), false, SecurePathError.invalidRelativePath),
    +        ]
    +    )
    +    func testMkdirSecureInvalid(relativePath: FilePath, makeIntermediates: Bool, expectedError: SecurePathError) async throws {
    +        let rootPath = try createTempDirectory()
    +        defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
    +
    +        let rootFd = try FileDescriptor.open(rootPath, .readOnly, options: [.directory])
    +        defer { try? rootFd.close() }
    +
    +        // Attempt the operation and expect it to throw
    +        #expect {
    +            try rootFd.mkdirSecure(relativePath, makeIntermediates: makeIntermediates) { _ in }
    +        } throws: { error in
    +            guard let securePathError = error as? SecurePathError else {
    +                return false
    +            }
    +            // Compare error cases
    +            switch (securePathError, expectedError) {
    +            case (.invalidRelativePath, .invalidRelativePath),
    +                (.invalidPathComponent, .invalidPathComponent),
    +                (.cannotFollowSymlink, .cannotFollowSymlink):
    +                return true
    +            case (.systemError(let op1, let err1), .systemError(let op2, let err2)):
    +                return op1 == op2 && err1 == err2
    +            default:
    +                return false
    +            }
    +        }
    +    }
    +
    +    @Test(
    +        "Test paths with .. that normalize to valid paths",
    +        arguments: [
    +            // Paths with .. that should normalize and succeed
    +            ("./safe", "safe"),  // Leading ./ normalizes to safe
    +            ("./a/./b", "a/b"),  // Multiple ./ normalize away
    +        ]
    +    )
    +    func testPathsWithDotNormalization(path: String, expectedNormalized: String) async throws {
    +        let rootPath = try createTempDirectory()
    +        defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
    +
    +        let rootFd = try FileDescriptor.open(rootPath, .readOnly, options: [.directory])
    +        defer { try? rootFd.close() }
    +
    +        let stubFileName = "stub.txt"
    +        let stubContent = Data("stub file content".utf8)
    +
    +        try rootFd.mkdirSecure(FilePath(path), makeIntermediates: true) { dirFd in
    +            // Create a stub file to verify we're in the right place
    +            let fd = openat(
    +                dirFd.rawValue,
    +                stubFileName,
    +                O_WRONLY | O_CREAT | O_TRUNC,
    +                0o644
    +            )
    +            guard fd >= 0 else {
    +                throw Errno(rawValue: errno)
    +            }
    +            defer { close(fd) }
    +
    +            try stubContent.withUnsafeBytes { buffer in
    +                guard let baseAddress = buffer.baseAddress else { return }
    +                let written = write(fd, baseAddress, buffer.count)
    +                guard written == buffer.count else {
    +                    throw Errno(rawValue: errno)
    +                }
    +            }
    +        }
    +
    +        // Verify stub file exists at the normalized location
    +        let expectedPath =
    +            expectedNormalized.isEmpty
    +            ? rootPath.appending(stubFileName)
    +            : rootPath.appending(expectedNormalized).appending(stubFileName)
    +        #expect(
    +            FileManager.default.fileExists(atPath: expectedPath.string),
    +            "Expected file at normalized path: \(expectedPath.string)")
    +    }
    +
    +    @Test(
    +        "Test paths with .. that normalize to valid paths",
    +        arguments: [
    +            // Paths with .. that should fail
    +            ("safe/.."),  // Normalizes to empty (current dir)
    +            ("a/../b"),  // Normalizes to b
    +            ("a/b/../c"),  // Normalizes to a/c
    +        ]
    +    )
    +    func testPathsWithDotDotNormalization(path: String) async throws {
    +        let rootPath = try createTempDirectory()
    +        defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
    +
    +        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)
    +        } 
    +    }
    +
    +    @Test(
    +        "Test paths with empty components (double slashes)",
    +        arguments: [
    +            "a//b",  // Double slash in middle
    +            "a///b",  // Triple slash
    +            "a//b//c",  // Multiple double slashes
    +        ]
    +    )
    +    func testPathsWithEmptyComponents(path: String) async throws {
    +        let rootPath = try createTempDirectory()
    +        defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
    +
    +        let rootFd = try FileDescriptor.open(rootPath, .readOnly, options: [.directory])
    +        defer { try? rootFd.close() }
    +
    +        let stubFileName = "stub.txt"
    +        let stubContent = Data("stub file content".utf8)
    +
    +        // Should normalize and succeed (// becomes /)
    +        try rootFd.mkdirSecure(FilePath(path), makeIntermediates: true) { dirFd in
    +            let fd = openat(
    +                dirFd.rawValue,
    +                stubFileName,
    +                O_WRONLY | O_CREAT | O_TRUNC,
    +                0o644
    +            )
    +            guard fd >= 0 else {
    +                throw Errno(rawValue: errno)
    +            }
    +            defer { close(fd) }
    +
    +            try stubContent.withUnsafeBytes { buffer in
    +                guard let baseAddress = buffer.baseAddress else { return }
    +                let written = write(fd, baseAddress, buffer.count)
    +                guard written == buffer.count else {
    +                    throw Errno(rawValue: errno)
    +                }
    +            }
    +        }
    +
    +        // Verify the file exists somewhere under root (normalization should handle it)
    +        // The exact location depends on how FilePath normalizes empty components
    +        let normalizedPath = FilePath(path).lexicallyNormalized()
    +        let expectedPath = rootPath.appending(normalizedPath.string).appending(stubFileName)
    +        #expect(
    +            FileManager.default.fileExists(atPath: expectedPath.string),
    +            "Expected file at normalized path: \(expectedPath.string)")
    +    }
    +
    +    @Test("Test very deep nesting")
    +    func testDeepNesting() async throws {
    +        let rootPath = try createTempDirectory()
    +        defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
    +
    +        let rootFd = try FileDescriptor.open(rootPath, .readOnly, options: [.directory])
    +        defer { try? rootFd.close() }
    +
    +        // Create a 100-level deep path
    +        var deepPath = ""
    +        for i in 0..<100 {
    +            if i > 0 { deepPath += "/" }
    +            deepPath += "level\(i)"
    +        }
    +
    +        let stubFileName = "deep.txt"
    +        let stubContent = Data("deep file".utf8)
    +
    +        try rootFd.mkdirSecure(FilePath(deepPath), makeIntermediates: true) { dirFd in
    +            let fd = openat(
    +                dirFd.rawValue,
    +                stubFileName,
    +                O_WRONLY | O_CREAT | O_TRUNC,
    +                0o644
    +            )
    +            guard fd >= 0 else {
    +                throw Errno(rawValue: errno)
    +            }
    +            defer { close(fd) }
    +
    +            try stubContent.withUnsafeBytes { buffer in
    +                guard let baseAddress = buffer.baseAddress else { return }
    +                let written = write(fd, baseAddress, buffer.count)
    +                guard written == buffer.count else {
    +                    throw Errno(rawValue: errno)
    +                }
    +            }
    +        }
    +
    +        // Verify the deep file exists
    +        let expectedPath = rootPath.appending(deepPath).appending(stubFileName)
    +        #expect(FileManager.default.fileExists(atPath: expectedPath.string))
    +    }
    +
    +    @Test("Test path with null byte")
    +    func testNullByteInPath() async throws {
    +        let rootPath = try createTempDirectory()
    +        defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
    +
    +        let rootFd = try FileDescriptor.open(rootPath, .readOnly, options: [.directory])
    +        defer { try? rootFd.close() }
    +
    +        // Path with null byte - FilePath may handle this differently
    +        // This tests that we don't crash or have unexpected behavior
    +        let pathWithNull = "file\u{0000}.txt"
    +
    +        // 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 }
    +
    +            // 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 canonicalEntry = try FileDescriptor.open(fullPath, .readOnly)
    +                let canonicalEntryPath = try canonicalEntry.getCanonicalPath()
    +                try? canonicalEntry.close()
    +
    +                // Verify entry is under root
    +                #expect(
    +                    canonicalEntryPath.string.hasPrefix(canonicalRoot.string + "/") || canonicalEntryPath.string == canonicalRoot.string,
    +                    "Entry escaped root: \(canonicalEntryPath.string)")
    +            }
    +        } catch {
    +            // If it fails, that's also acceptable - just don't crash
    +        }
    +    }
    +
    +    @Test("Remove a regular file")
    +    func testRemoveRegularFile() throws {
    +        let tempPath = try createTempDirectory()
    +        defer { try? FileManager.default.removeItem(atPath: tempPath.string) }
    +
    +        let rootFd = try FileDescriptor.open(tempPath, .readOnly, options: [.directory])
    +        defer { try? rootFd.close() }
    +
    +        // Create a regular file
    +        let filePath = tempPath.appending("testfile.txt")
    +        FileManager.default.createFile(atPath: filePath.string, contents: Data("test".utf8))
    +
    +        // Verify file exists
    +        #expect(FileManager.default.fileExists(atPath: filePath.string))
    +
    +        // Remove it
    +        try rootFd.unlinkRecursiveSecure(filename: FilePath.Component("testfile.txt"))
    +
    +        // Verify file is gone
    +        #expect(!FileManager.default.fileExists(atPath: filePath.string))
    +    }
    +
    +    @Test("Remove an empty directory")
    +    func testRemoveEmptyDirectory() throws {
    +        let tempPath = try createTempDirectory()
    +        defer { try? FileManager.default.removeItem(atPath: tempPath.string) }
    +
    +        let rootFd = try FileDescriptor.open(tempPath, .readOnly, options: [.directory])
    +        defer { try? rootFd.close() }
    +
    +        // Create an empty directory
    +        let dirPath = tempPath.appending("emptydir")
    +        try FileManager.default.createDirectory(atPath: dirPath.string, withIntermediateDirectories: false)
    +
    +        // Verify directory exists
    +        var isDir: ObjCBool = false
    +        #expect(FileManager.default.fileExists(atPath: dirPath.string, isDirectory: &isDir))
    +        #expect(isDir.boolValue)
    +
    +        // Remove it
    +        try rootFd.unlinkRecursiveSecure(filename: FilePath.Component("emptydir"))
    +
    +        // Verify directory is gone
    +        #expect(!FileManager.default.fileExists(atPath: dirPath.string))
    +    }
    +
    +    @Test("Remove a directory with nested files and subdirectories")
    +    func testRemoveNestedDirectory() throws {
    +        let tempPath = try createTempDirectory()
    +        defer { try? FileManager.default.removeItem(atPath: tempPath.string) }
    +
    +        let rootFd = try FileDescriptor.open(tempPath, .readOnly, options: [.directory])
    +        defer { try? rootFd.close() }
    +
    +        // Create nested structure:
    +        // nested/
    +        //   file1.txt
    +        //   subdir/
    +        //     file2.txt
    +        //     deepdir/
    +        //       file3.txt
    +        let nestedPath = tempPath.appending("nested")
    +        let subdirPath = nestedPath.appending("subdir")
    +        let deepdirPath = subdirPath.appending("deepdir")
    +
    +        try FileManager.default.createDirectory(atPath: deepdirPath.string, withIntermediateDirectories: true)
    +        FileManager.default.createFile(atPath: nestedPath.appending("file1.txt").string, contents: Data("1".utf8))
    +        FileManager.default.createFile(atPath: subdirPath.appending("file2.txt").string, contents: Data("2".utf8))
    +        FileManager.default.createFile(atPath: deepdirPath.appending("file3.txt").string, contents: Data("3".utf8))
    +
    +        // Verify structure exists
    +        #expect(FileManager.default.fileExists(atPath: nestedPath.string))
    +        #expect(FileManager.default.fileExists(atPath: subdirPath.string))
    +        #expect(FileManager.default.fileExists(atPath: deepdirPath.string))
    +
    +        // Remove entire tree
    +        try rootFd.unlinkRecursiveSecure(filename: FilePath.Component("nested"))
    +
    +        // Verify everything is gone
    +        #expect(!FileManager.default.fileExists(atPath: nestedPath.string))
    +    }
    +
    +    @Test("Remove non-existent file returns without error")
    +    func testRemoveNonExistent() throws {
    +        let tempPath = try createTempDirectory()
    +        defer { try? FileManager.default.removeItem(atPath: tempPath.string) }
    +
    +        let rootFd = try FileDescriptor.open(tempPath, .readOnly, options: [.directory])
    +        defer { try? rootFd.close() }
    +
    +        // Remove non-existent file should not throw
    +        try rootFd.unlinkRecursiveSecure(filename: FilePath.Component("nonexistent.txt"))
    +    }
    +
    +    @Test("Remove symlink without following it")
    +    func testRemoveSymlink() throws {
    +        let tempPath = try createTempDirectory()
    +        defer { try? FileManager.default.removeItem(atPath: tempPath.string) }
    +
    +        let rootFd = try FileDescriptor.open(tempPath, .readOnly, options: [.directory])
    +        defer { try? rootFd.close() }
    +
    +        // Create target file and symlink
    +        let targetPath = tempPath.appending("target.txt")
    +        let linkPath = tempPath.appending("link")
    +        FileManager.default.createFile(atPath: targetPath.string, contents: Data("target".utf8))
    +        try FileManager.default.createSymbolicLink(atPath: linkPath.string, withDestinationPath: "target.txt")
    +
    +        // Verify both exist
    +        #expect(FileManager.default.fileExists(atPath: targetPath.string))
    +        #expect(FileManager.default.fileExists(atPath: linkPath.string))
    +
    +        // Remove symlink
    +        try rootFd.unlinkRecursiveSecure(filename: FilePath.Component("link"))
    +
    +        // Verify symlink is gone but target remains
    +        #expect(!FileManager.default.fileExists(atPath: linkPath.string))
    +        #expect(FileManager.default.fileExists(atPath: targetPath.string))
    +    }
    +
    +    @Test("Remove directory with mixed content (files, dirs, symlinks)")
    +    func testRemoveMixedDirectory() throws {
    +        let tempPath = try createTempDirectory()
    +        defer { try? FileManager.default.removeItem(atPath: tempPath.string) }
    +
    +        let rootFd = try FileDescriptor.open(tempPath, .readOnly, options: [.directory])
    +        defer { try? rootFd.close() }
    +
    +        // Create mixed structure:
    +        // mixed/
    +        //   file.txt
    +        //   subdir/
    +        //   link -> file.txt
    +        let mixedPath = tempPath.appending("mixed")
    +        let subdirPath = mixedPath.appending("subdir")
    +
    +        try FileManager.default.createDirectory(atPath: subdirPath.string, withIntermediateDirectories: true)
    +        FileManager.default.createFile(atPath: mixedPath.appending("file.txt").string, contents: Data("test".utf8))
    +        try FileManager.default.createSymbolicLink(
    +            atPath: mixedPath.appending("link").string,
    +            withDestinationPath: "file.txt"
    +        )
    +
    +        // Verify structure exists
    +        #expect(FileManager.default.fileExists(atPath: mixedPath.string))
    +
    +        // Remove entire tree
    +        try rootFd.unlinkRecursiveSecure(filename: FilePath.Component("mixed"))
    +
    +        // Verify everything is gone
    +        #expect(!FileManager.default.fileExists(atPath: mixedPath.string))
    +    }
    +
    +    @Test("Guards against removing '.' component")
    +    func testGuardDotComponent() throws {
    +        let tempPath = try createTempDirectory()
    +        defer { try? FileManager.default.removeItem(atPath: tempPath.string) }
    +
    +        let rootFd = try FileDescriptor.open(tempPath, .readOnly, options: [.directory])
    +        defer { try? rootFd.close() }
    +
    +        // Should return without error and without removing anything
    +        try rootFd.unlinkRecursiveSecure(filename: FilePath.Component("."))
    +
    +        // Verify directory still exists
    +        #expect(FileManager.default.fileExists(atPath: tempPath.string))
    +    }
    +
    +    @Test("Guards against removing '..' component")
    +    func testGuardDotDotComponent() throws {
    +        let tempPath = try createTempDirectory()
    +        defer { try? FileManager.default.removeItem(atPath: tempPath.string) }
    +
    +        let rootFd = try FileDescriptor.open(tempPath, .readOnly, options: [.directory])
    +        defer { try? rootFd.close() }
    +
    +        // Should return without error and without removing anything
    +        try rootFd.unlinkRecursiveSecure(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 {
    +        let rootPath = try createTempDirectory()
    +        defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
    +
    +        let rootFd = try FileDescriptor.open(rootPath, .readOnly, options: [.directory])
    +        defer { try? rootFd.close() }
    +
    +        let stubFileName = "root-level-file.txt"
    +        let stubContent = Data("root level content".utf8)
    +        var completionCalled = false
    +
    +        // Call mkdirSecure with empty path
    +        try rootFd.mkdirSecure(FilePath(""), makeIntermediates: false) { dirFd in
    +            completionCalled = true
    +
    +            // Verify dirFd is the same as rootFd
    +            #expect(dirFd.rawValue == rootFd.rawValue, "Completion should receive the parent directory FD")
    +
    +            // Create a file in the directory to verify we got the right FD
    +            let fd = openat(
    +                dirFd.rawValue,
    +                stubFileName,
    +                O_WRONLY | O_CREAT | O_TRUNC,
    +                0o644
    +            )
    +            guard fd >= 0 else {
    +                throw Errno(rawValue: errno)
    +            }
    +            defer { close(fd) }
    +
    +            try stubContent.withUnsafeBytes { buffer in
    +                guard let baseAddress = buffer.baseAddress else { return }
    +                let written = write(fd, baseAddress, buffer.count)
    +                guard written == buffer.count else {
    +                    throw Errno(rawValue: errno)
    +                }
    +            }
    +        }
    +
    +        // Verify completion was called
    +        #expect(completionCalled, "Completion handler should be called for empty path")
    +
    +        // Verify file was created at root level
    +        let expectedPath = rootPath.appending(stubFileName)
    +        #expect(FileManager.default.fileExists(atPath: expectedPath.string))
    +
    +        // Verify content
    +        let readContent = try Data(contentsOf: URL(fileURLWithPath: expectedPath.string))
    +        #expect(readContent == stubContent)
    +    }
    +
    +    private func createTempDirectory() throws -> FilePath {
    +        let tempURL = FileManager.default.temporaryDirectory
    +            .appendingPathComponent(UUID().uuidString)
    +        try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true)
    +        return FilePath(tempURL.path)
    +
    +    }
    +
    +    private func createEntries(rootPath: FilePath, entries: [Entry], permissions: FilePermissions? = nil) throws {
    +        for entry in entries {
    +            switch entry {
    +            case .regular(let path):
    +                let fullPath = rootPath.appending(path)
    +                // Create parent directories if needed
    +                let parentPath = FilePath(fullPath.string).removingLastComponent()
    +                if !FileManager.default.fileExists(atPath: parentPath.string) {
    +                    try FileManager.default.createDirectory(
    +                        atPath: parentPath.string,
    +                        withIntermediateDirectories: true,
    +                        attributes: permissions.map { [.posixPermissions: $0.rawValue] }
    +                    )
    +                }
    +                FileManager.default.createFile(
    +                    atPath: fullPath.string,
    +                    contents: Data("test".utf8)
    +                )
    +            case .directory(let path):
    +                let fullPath = rootPath.appending(path)
    +                try FileManager.default.createDirectory(
    +                    atPath: fullPath.string,
    +                    withIntermediateDirectories: true,
    +                    attributes: permissions.map { [.posixPermissions: $0.rawValue] }
    +                )
    +            case .symlink(let target, let source):
    +                let sourcePath = rootPath.appending(source)
    +                // Create parent directories for source if needed
    +                let parentPath = FilePath(sourcePath.string).removingLastComponent()
    +                if !FileManager.default.fileExists(atPath: parentPath.string) {
    +                    try FileManager.default.createDirectory(
    +                        atPath: parentPath.string,
    +                        withIntermediateDirectories: true,
    +                        attributes: permissions.map { [.posixPermissions: $0.rawValue] }
    +                    )
    +                }
    +                try FileManager.default.createSymbolicLink(
    +                    atPath: sourcePath.string,
    +                    withDestinationPath: target
    +                )
    +            }
    +        }
    +    }
    +}
    +
    +enum Entry {
    +    case regular(path: String)
    +    case directory(path: String)
    +    case symlink(target: String, source: String)
    +}
    
  • Tests/ContainerizationTests/ImageTests/ImageStoreTests.swift+4 2 modified
    @@ -50,7 +50,8 @@ public class ImageStoreTests: ContainsAuth {
     
             let tarPath = Foundation.Bundle.module.url(forResource: "scratch", withExtension: "tar")!
             let reader = try ArchiveReader(format: .pax, filter: .none, file: tarPath)
    -        try reader.extractContents(to: tempDir)
    +        let rejectedPaths = try reader.extractContents(to: tempDir)
    +        #expect(rejectedPaths.count == 0, "unexpected rejected paths [\(rejectedPaths)]")
     
             let _ = try await self.store.load(from: tempDir)
             let loaded = try await self.store.load(from: tempDir)
    @@ -97,7 +98,8 @@ public class ImageStoreTests: ContainsAuth {
     
             let tarPath = Foundation.Bundle.module.url(forResource: "scratch_no_annotations", withExtension: "tar")!
             let reader = try ArchiveReader(format: .pax, filter: .none, file: tarPath)
    -        try reader.extractContents(to: tempDir)
    +        let rejectedPaths = try reader.extractContents(to: tempDir)
    +        #expect(rejectedPaths.count == 0, "unexpected rejected paths [\(rejectedPaths)]")
     
             let loaded = try await self.store.load(from: tempDir)
     
    
  • vminitd/Package.swift+3 0 modified
    @@ -29,6 +29,7 @@ let package = Package(
         dependencies: [
             .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"),
             .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"),
    +        .package(url: "https://github.com/apple/swift-system.git", from: "1.4.0"),
             .package(name: "containerization", path: "../"),
         ],
         targets: [
    @@ -41,6 +42,7 @@ let package = Package(
                     .product(name: "Logging", package: "swift-log"),
                     .product(name: "ContainerizationOCI", package: "containerization"),
                     .product(name: "ContainerizationOS", package: "containerization"),
    +                .product(name: "SystemPackage", package: "swift-system"),
                 ]
             ),
             .executableTarget(
    @@ -51,6 +53,7 @@ let package = Package(
                     .product(name: "ContainerizationNetlink", package: "containerization"),
                     .product(name: "ContainerizationIO", package: "containerization"),
                     .product(name: "ContainerizationOS", package: "containerization"),
    +                .product(name: "SystemPackage", package: "swift-system"),
                     "LCShim",
                     "Cgroup",
                 ]
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.