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.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/apple/containerizationSwiftURL | < 0.21.0 | 0.21.0 |
github.com/apple/containerSwiftURL | < 0.8.0 | 0.8.0 |
Affected products
1- Range: 0.1.0, 0.1.1, 0.10.0, …
Patches
13e93416b9a6dMerge 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
3News mentions
0No linked articles in our index yet.