680845cb1c
Linux build / Determine Swift version (push) Waiting to run
Linux build / Linux compile check (push) Blocked by required conditions
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
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")
|
|
}
|
|
}
|