Merge commit from fork

* Admit only valid OCI layout files when loading image.

- Adds `AdmissionMapper` protocol for validating archive
  member paths and normalizing them to relative paths
  under the extraction root directory.
- Renames `Reader.swift` to `ArchiveReader.swift` to
  match type name.
- Rework `TempDir` to address `NSString.utf8String`
  deprecation warning in Swift 6.2.3.
- Rework `ArchiveReader.extractContent()` to use an
  `AdmissionMapper` to validate and remap archive
   members before extracting.
- Adds `IdentityAdmissionWrapper` for naive extraction.
- Adds `NoSymlinkAdmissionWrapper` that only extracts
  regular files and directories under the extraction root.
- Adds `OCIImageAdmissionWrapper` that only extracts
  valid OCI image layout paths.
- Use `OCIImageAdmissionWrapper` for `cctl image load`
  and print rejected paths.

* PR feedback.

* Adds public init() for TrustedAdmissionMapper.

* Replace AdmissionMapper with more secure extraction.

- Adds FileDescriptor.mkdirSecure() to prevent
  root escapes on member pathnames, and to prevent
  symlink traversal.
- Adds FileDescriptor.unlinkRecursive() to
  facilitate overwrites when there are multiple
  archive entries with the same member path.
- Adds FileDescriptor.validateSymlinkTargetInRoot()
  to validate that extracted symlink targets do
  not escape the root.
- Rewrite ArchiveReader.extractContents() to use
  secure path functions.

* Remove unneeded symlink check, rename files.

* Simplify the lexical normalizer workaround.

* Reject member paths containing parent traversal components.

* Remove unused lexical normalization workaround.

* Fix leaking fds, extract absolute members as relative.
This commit is contained in:
J Logan
2026-01-15 12:52:02 -06:00
committed by GitHub
parent bbe4159649
commit 3e93416b9a
11 changed files with 1755 additions and 36 deletions
+3 -3
View File
@@ -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"
}
}
],
+4 -1
View File
@@ -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",
]
@@ -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)")
}
}
}
}
+10 -7
View File
@@ -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
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)
}
}
let pathData = UnsafeMutablePointer(mutating: templatePathData)
mkdtemp(pathData)
return URL(fileURLWithPath: String(cString: pathData), isDirectory: true)
}
@@ -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)"
}
}
}
+4 -1
View File
@@ -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)")
}
}
}
@@ -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)
}
}
}
@@ -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")
@@ -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)
}
@@ -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)
+3
View File
@@ -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",
]