mirror of
https://github.com/apple/container.git
synced 2026-10-16 11:09:21 +00:00
- Closes apple/container#988. - macOS libarchive is not built with zstd support, so the workaround in ArchiveReader is to attempt to decompress every archive as zstd. If decompression fails, we pass the original archive to libarchive. If it succeeds, we pass the uncompressed archive. - Adds blob media type recognition for zstd to EXT4Unpacker. Tested zstd blob unpack using `image pull tonistiigi/hello-world:zstd-docker`. Co-authored-by: Aditya Ramani <a_ramani@apple.com>
715 lines
31 KiB
Swift
715 lines
31 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// MARK: - Zstd Compression Tests
|
|
|
|
@Test func readZstdCompressedArchive() throws {
|
|
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
|
|
Issue.record("Test resource test.tar.zst not found")
|
|
return
|
|
}
|
|
|
|
let extractDir = try createExtractionDirectory(name: "zstd-test")
|
|
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
|
|
|
|
// Test with explicit filter
|
|
let reader = try ArchiveReader(format: .paxRestricted, filter: .zstd, file: resourceURL)
|
|
let rejectedPaths = try reader.extractContents(to: extractDir)
|
|
|
|
#expect(rejectedPaths.isEmpty, "No paths should be rejected")
|
|
|
|
// Check extracted files
|
|
let testFile = extractDir.appendingPathComponent("test.txt")
|
|
let file2 = extractDir.appendingPathComponent("file2.txt")
|
|
|
|
#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")
|
|
#expect(FileManager.default.fileExists(atPath: file2.path), "file2.txt should exist")
|
|
|
|
let testContent = try String(contentsOf: testFile, encoding: .utf8)
|
|
#expect(testContent == "Hello from zstd compressed archive", "Content should match")
|
|
|
|
let file2Content = try String(contentsOf: file2, encoding: .utf8)
|
|
#expect(file2Content == "Another file", "Content should match")
|
|
}
|
|
|
|
@Test func readZstdCompressedArchiveAutoDetect() throws {
|
|
guard let resourceURL = Bundle.module.url(forResource: "test", withExtension: "tar.zst") else {
|
|
Issue.record("Test resource test.tar.zst not found")
|
|
return
|
|
}
|
|
|
|
let extractDir = try createExtractionDirectory(name: "zstd-auto-test")
|
|
defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) }
|
|
|
|
// Test with auto-detect
|
|
let reader = try ArchiveReader(file: resourceURL)
|
|
let rejectedPaths = try reader.extractContents(to: extractDir)
|
|
|
|
#expect(rejectedPaths.isEmpty, "No paths should be rejected")
|
|
|
|
// Check extracted files
|
|
let testFile = extractDir.appendingPathComponent("test.txt")
|
|
#expect(FileManager.default.fileExists(atPath: testFile.path), "test.txt should exist")
|
|
|
|
let testContent = try String(contentsOf: testFile, encoding: .utf8)
|
|
#expect(testContent == "Hello from zstd compressed archive", "Content should match")
|
|
}
|
|
}
|