mirror of
https://github.com/apple/container.git
synced 2026-09-26 01:25:52 +00:00
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:
+3
-3
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
+132
-22
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user