chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,714 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-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 ArchiveTests {
|
||||
func helperEntry(path: String, data: Data) -> WriteEntry {
|
||||
let entry = WriteEntry()
|
||||
entry.permissions = 0o644
|
||||
entry.fileType = .regular
|
||||
entry.path = path
|
||||
entry.size = numericCast(data.count)
|
||||
entry.owner = 1
|
||||
entry.group = 2
|
||||
entry.xattrs = ["user.data": Data([1, 2, 3])]
|
||||
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")
|
||||
|
||||
defer {
|
||||
let fileManager = FileManager.default
|
||||
try? fileManager.removeItem(at: testDirectory)
|
||||
}
|
||||
|
||||
// this test would failed with ArchiveWriterConfiguration.locale was not set to "en_US.UTF-8"
|
||||
let archiver = try ArchiveWriter(format: .paxRestricted, filter: .gzip, file: archiveURL)
|
||||
|
||||
let data = "blablabla".data(using: .utf8)!
|
||||
|
||||
let normalPathEntry = helperEntry(path: "r", data: data)
|
||||
#expect(throws: Never.self) {
|
||||
try archiver.writeEntry(entry: normalPathEntry, data: data)
|
||||
}
|
||||
|
||||
let weirdPathEntry = helperEntry(path: "ʀ", data: data)
|
||||
#expect(throws: Never.self) {
|
||||
try archiver.writeEntry(entry: weirdPathEntry, data: data)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func tarGzipWithOpenfile() throws {
|
||||
let testDirectory = createTemporaryDirectory(baseName: "ArchiveTests.testTarGzipWithOpenfile")!
|
||||
let archiveURL = testDirectory.appendingPathComponent("test.tgz")
|
||||
|
||||
defer {
|
||||
let fileManager = FileManager.default
|
||||
try? fileManager.removeItem(at: testDirectory)
|
||||
}
|
||||
|
||||
let configuration = ArchiveWriterConfiguration(
|
||||
format: .paxRestricted,
|
||||
filter: .gzip
|
||||
)
|
||||
let archiver = try ArchiveWriter(configuration: configuration)
|
||||
try archiver.open(file: archiveURL)
|
||||
|
||||
let data = "foo".data(using: .utf8)!
|
||||
|
||||
let normalPathEntry = helperEntry(path: "bar", data: data)
|
||||
#expect(throws: Never.self) {
|
||||
try archiver.writeEntry(entry: normalPathEntry, data: data)
|
||||
}
|
||||
|
||||
try archiver.finishEncoding()
|
||||
}
|
||||
|
||||
@Test func writingZip() throws {
|
||||
let testDirectory = createTemporaryDirectory(baseName: "ArchiveTests.testWritingZip")!
|
||||
let archiveURL = testDirectory.appendingPathComponent("test.zip")
|
||||
|
||||
defer {
|
||||
let fileManager = FileManager.default
|
||||
try? fileManager.removeItem(at: testDirectory)
|
||||
}
|
||||
|
||||
// When
|
||||
let archiver = try ArchiveWriter(format: .zip, filter: .none, file: archiveURL)
|
||||
|
||||
var data = "foo".data(using: .utf8)!
|
||||
var entry = helperEntry(path: "foo.txt", data: data)
|
||||
try archiver.writeEntry(entry: entry, data: data)
|
||||
|
||||
data = "bar".data(using: .utf8)!
|
||||
entry = helperEntry(path: "bar.txt", data: data)
|
||||
try archiver.writeEntry(entry: entry, data: data)
|
||||
|
||||
data = Data()
|
||||
entry = helperEntry(path: "empty", data: data)
|
||||
try archiver.writeEntry(entry: entry, data: data)
|
||||
|
||||
try archiver.finishEncoding()
|
||||
|
||||
// Then
|
||||
let unarchiver = try ArchiveReader(format: .zip, filter: .none, file: archiveURL)
|
||||
for (index, (entry, data)) in unarchiver.enumerated() {
|
||||
#expect(entry.owner == 1)
|
||||
#expect(entry.group == 2)
|
||||
switch index {
|
||||
case 0:
|
||||
#expect(entry.path == "foo.txt")
|
||||
#expect(String(data: data, encoding: .utf8) == "foo")
|
||||
case 1:
|
||||
#expect(entry.path == "bar.txt")
|
||||
#expect(String(data: data, encoding: .utf8) == "bar")
|
||||
case 2:
|
||||
#expect(entry.path == "empty")
|
||||
#expect(data.isEmpty)
|
||||
default:
|
||||
Issue.record()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func unarchiving_0bytesEntry() throws {
|
||||
let data = Data(base64Encoded: surveyBundleBase64Encoded)!
|
||||
let unarchiver = try ArchiveReader(name: "survey.zip", bundle: data)
|
||||
for (index, (entry, data)) in unarchiver.enumerated() {
|
||||
switch index {
|
||||
case 0:
|
||||
#expect(entry.path == "healthinvolvement.js")
|
||||
#expect(!data.isEmpty)
|
||||
case 1:
|
||||
#expect(entry.path == "__MACOSX/")
|
||||
#expect(data.isEmpty)
|
||||
case 2:
|
||||
#expect(entry.path == "__MACOSX/._healthinvolvement.js")
|
||||
#expect(!data.isEmpty)
|
||||
default:
|
||||
Issue.record()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func writingReadingTar() throws {
|
||||
let testDirectory = createTemporaryDirectory(baseName: "ArchiveTests.testWritingReadingTar")!
|
||||
let archiveURL = testDirectory.appendingPathComponent("test.tar.gz")
|
||||
defer {
|
||||
let fileManager = FileManager.default
|
||||
try? fileManager.removeItem(at: testDirectory)
|
||||
}
|
||||
|
||||
let archiver = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
let data = "foo".data(using: .utf8)!
|
||||
let entry = helperEntry(path: "foo.txt", data: data)
|
||||
try archiver.writeEntry(entry: entry, data: data)
|
||||
try archiver.finishEncoding()
|
||||
|
||||
let unarchiver = try ArchiveReader(format: .pax, filter: .gzip, file: archiveURL)
|
||||
for (entry, _) in unarchiver {
|
||||
let attrs = entry.xattrs
|
||||
guard let val = attrs["user.data"] else {
|
||||
Issue.record("missing extended attribute [user.data] in file")
|
||||
return
|
||||
}
|
||||
#expect([UInt8](val) == [1, 2, 3])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - archiveDirectory round-trip tests
|
||||
|
||||
@Test func archiveDirectoryBasic() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirBasic")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("source")
|
||||
try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true)
|
||||
try "hello".write(to: sourceDir.appendingPathComponent("file1.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
let subDir = sourceDir.appendingPathComponent("subdir")
|
||||
try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true)
|
||||
try "world".write(to: subDir.appendingPathComponent("file2.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archiveDirectory(sourceDir)
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
|
||||
#expect(rejected.isEmpty)
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent("file1.txt"), encoding: .utf8) == "hello")
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent("subdir/file2.txt"), encoding: .utf8) == "world")
|
||||
}
|
||||
|
||||
@Test func archiveDirectoryEmpty() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirEmpty")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("empty")
|
||||
try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true)
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archiveDirectory(sourceDir)
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
// Empty directory archive should succeed with the leading "./" entry.
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
#expect(rejected.isEmpty)
|
||||
#expect(FileManager.default.fileExists(atPath: extractDir.path))
|
||||
}
|
||||
|
||||
@Test func archiveDirectoryNestedEmpty() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirNestedEmpty")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("source")
|
||||
try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: sourceDir.appendingPathComponent("a/b/c"), withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: sourceDir.appendingPathComponent("empty"), withIntermediateDirectories: true)
|
||||
try "data".write(to: sourceDir.appendingPathComponent("a/file.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archiveDirectory(sourceDir)
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
|
||||
#expect(rejected.isEmpty)
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent("a/file.txt"), encoding: .utf8) == "data")
|
||||
|
||||
var isDir: ObjCBool = false
|
||||
#expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("a/b/c").path, isDirectory: &isDir))
|
||||
#expect(isDir.boolValue)
|
||||
#expect(FileManager.default.fileExists(atPath: extractDir.appendingPathComponent("empty").path, isDirectory: &isDir))
|
||||
#expect(isDir.boolValue)
|
||||
}
|
||||
|
||||
@Test func archiveDirectoryDeepNesting() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirDeep")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("source")
|
||||
var deepPath = sourceDir
|
||||
for i in 0..<20 {
|
||||
deepPath = deepPath.appendingPathComponent("level\(i)")
|
||||
}
|
||||
try FileManager.default.createDirectory(at: deepPath, withIntermediateDirectories: true)
|
||||
try "deep content".write(to: deepPath.appendingPathComponent("deep.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archiveDirectory(sourceDir)
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
|
||||
#expect(rejected.isEmpty)
|
||||
|
||||
var expectedPath = extractDir
|
||||
for i in 0..<20 {
|
||||
expectedPath = expectedPath.appendingPathComponent("level\(i)")
|
||||
}
|
||||
#expect(try String(contentsOf: expectedPath.appendingPathComponent("deep.txt"), encoding: .utf8) == "deep content")
|
||||
}
|
||||
|
||||
@Test func archiveDirectorySymlinkInside() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirSymlinkInside")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("source")
|
||||
try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true)
|
||||
try "target content".write(to: sourceDir.appendingPathComponent("target.txt"), atomically: true, encoding: .utf8)
|
||||
try FileManager.default.createSymbolicLink(
|
||||
atPath: sourceDir.appendingPathComponent("link.txt").path,
|
||||
withDestinationPath: "target.txt"
|
||||
)
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archiveDirectory(sourceDir)
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
|
||||
#expect(rejected.isEmpty)
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent("target.txt"), encoding: .utf8) == "target content")
|
||||
|
||||
let linkDest = try FileManager.default.destinationOfSymbolicLink(atPath: extractDir.appendingPathComponent("link.txt").path)
|
||||
#expect(linkDest == "target.txt")
|
||||
}
|
||||
|
||||
@Test func archiveDirectorySymlinkOutsideExcluded() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirSymlinkOutside")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("source")
|
||||
try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true)
|
||||
try "inside".write(to: sourceDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
// Create a file outside the source directory
|
||||
try "outside".write(to: testDir.appendingPathComponent("outside.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
// Symlink pointing outside the source directory
|
||||
try FileManager.default.createSymbolicLink(
|
||||
atPath: sourceDir.appendingPathComponent("escape.txt").path,
|
||||
withDestinationPath: "../outside.txt"
|
||||
)
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archiveDirectory(sourceDir)
|
||||
try writer.finishEncoding()
|
||||
|
||||
// Verify the archive doesn't contain the escaping symlink
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
var paths: [String] = []
|
||||
for (entry, _) in reader {
|
||||
if let path = entry.path {
|
||||
paths.append(path)
|
||||
}
|
||||
}
|
||||
#expect(!paths.contains("escape.txt"), "Symlink pointing outside should be excluded from archive")
|
||||
#expect(paths.contains("file.txt"), "Regular file should be included")
|
||||
}
|
||||
|
||||
@Test func archiveDirectorySpecialCharacters() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirSpecialChars")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("source")
|
||||
try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true)
|
||||
try "spaces".write(to: sourceDir.appendingPathComponent("file with spaces.txt"), atomically: true, encoding: .utf8)
|
||||
try "unicode".write(to: sourceDir.appendingPathComponent("日本語.txt"), atomically: true, encoding: .utf8)
|
||||
try "dashes".write(to: sourceDir.appendingPathComponent("file-name_v2.0.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archiveDirectory(sourceDir)
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
|
||||
#expect(rejected.isEmpty)
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent("file with spaces.txt"), encoding: .utf8) == "spaces")
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent("日本語.txt"), encoding: .utf8) == "unicode")
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent("file-name_v2.0.txt"), encoding: .utf8) == "dashes")
|
||||
}
|
||||
|
||||
@Test func archiveDirectoryDotfiles() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirDotfiles")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("source")
|
||||
try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true)
|
||||
try "hidden".write(to: sourceDir.appendingPathComponent(".hidden"), atomically: true, encoding: .utf8)
|
||||
try "gitignore".write(to: sourceDir.appendingPathComponent(".gitignore"), atomically: true, encoding: .utf8)
|
||||
try "visible".write(to: sourceDir.appendingPathComponent("visible.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
let hiddenDir = sourceDir.appendingPathComponent(".config")
|
||||
try FileManager.default.createDirectory(at: hiddenDir, withIntermediateDirectories: true)
|
||||
try "config".write(to: hiddenDir.appendingPathComponent("settings.json"), atomically: true, encoding: .utf8)
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archiveDirectory(sourceDir)
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
|
||||
#expect(rejected.isEmpty)
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent(".hidden"), encoding: .utf8) == "hidden")
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent(".gitignore"), encoding: .utf8) == "gitignore")
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent("visible.txt"), encoding: .utf8) == "visible")
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent(".config/settings.json"), encoding: .utf8) == "config")
|
||||
}
|
||||
|
||||
@Test func archiveDirectoryPreservesPermissions() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirPerms")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("source")
|
||||
try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true)
|
||||
|
||||
let execFile = sourceDir.appendingPathComponent("script.sh")
|
||||
try "#!/bin/sh\necho hi".write(to: execFile, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: execFile.path)
|
||||
|
||||
let readOnly = sourceDir.appendingPathComponent("readonly.txt")
|
||||
try "secret".write(to: readOnly, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o444], ofItemAtPath: readOnly.path)
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archiveDirectory(sourceDir)
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
|
||||
#expect(rejected.isEmpty)
|
||||
|
||||
let execAttrs = try FileManager.default.attributesOfItem(atPath: extractDir.appendingPathComponent("script.sh").path)
|
||||
let execPerms = (execAttrs[.posixPermissions] as? NSNumber)?.uint16Value ?? 0
|
||||
#expect((execPerms & 0o777) == 0o755, "Executable permissions should be preserved")
|
||||
|
||||
let roAttrs = try FileManager.default.attributesOfItem(atPath: extractDir.appendingPathComponent("readonly.txt").path)
|
||||
let roPerms = (roAttrs[.posixPermissions] as? NSNumber)?.uint16Value ?? 0
|
||||
#expect((roPerms & 0o777) == 0o444, "Read-only permissions should be preserved")
|
||||
}
|
||||
|
||||
@Test func archiveDirectoryLargeFile() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirLargeFile")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("source")
|
||||
try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true)
|
||||
|
||||
// 2MB file with repeating pattern
|
||||
let pattern = Data("ContainerizationArchiveTestPattern\n".utf8)
|
||||
var largeData = Data(capacity: 2 * 1024 * 1024)
|
||||
while largeData.count < 2 * 1024 * 1024 {
|
||||
largeData.append(pattern)
|
||||
}
|
||||
largeData = largeData.prefix(2 * 1024 * 1024)
|
||||
try largeData.write(to: sourceDir.appendingPathComponent("large.bin"))
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archiveDirectory(sourceDir)
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
|
||||
#expect(rejected.isEmpty)
|
||||
|
||||
let extracted = try Data(contentsOf: extractDir.appendingPathComponent("large.bin"))
|
||||
#expect(extracted == largeData, "Large file content should match after round-trip")
|
||||
}
|
||||
|
||||
@Test func archiveDirectoryManyFiles() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirManyFiles")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("source")
|
||||
try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true)
|
||||
|
||||
// Create 100 files
|
||||
for i in 0..<100 {
|
||||
try "content \(i)".write(
|
||||
to: sourceDir.appendingPathComponent("file_\(String(format: "%03d", i)).txt"),
|
||||
atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archiveDirectory(sourceDir)
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
|
||||
#expect(rejected.isEmpty)
|
||||
|
||||
for i in 0..<100 {
|
||||
let content = try String(
|
||||
contentsOf: extractDir.appendingPathComponent("file_\(String(format: "%03d", i)).txt"),
|
||||
encoding: .utf8)
|
||||
#expect(content == "content \(i)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func archiveDirectorySingleFile() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirSingle")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("source")
|
||||
try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true)
|
||||
try "only file".write(to: sourceDir.appendingPathComponent("only.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archiveDirectory(sourceDir)
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
|
||||
#expect(rejected.isEmpty)
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent("only.txt"), encoding: .utf8) == "only file")
|
||||
}
|
||||
|
||||
// MARK: - archive tests
|
||||
|
||||
@Test func archiveURLsBasic() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveURLsBasic")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
try "alpha content".write(to: testDir.appendingPathComponent("alpha.txt"), atomically: true, encoding: .utf8)
|
||||
try "beta content".write(to: testDir.appendingPathComponent("beta.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
let files: [FilePath] = [
|
||||
FilePath(testDir.appendingPathComponent("alpha.txt").path),
|
||||
FilePath(testDir.appendingPathComponent("beta.txt").path),
|
||||
]
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archive(files, base: FilePath(testDir.path))
|
||||
try writer.finishEncoding()
|
||||
|
||||
var entries: [String: String] = [:]
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
for (entry, data) in reader {
|
||||
if let path = entry.path, let content = String(data: data, encoding: .utf8) {
|
||||
entries[path] = content
|
||||
}
|
||||
}
|
||||
#expect(entries["alpha.txt"] == "alpha content")
|
||||
#expect(entries["beta.txt"] == "beta content")
|
||||
}
|
||||
|
||||
@Test func archiveURLsEmpty() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveURLsEmpty")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
#expect(throws: Never.self) {
|
||||
try writer.archive([], base: FilePath(testDir.path))
|
||||
}
|
||||
try writer.finishEncoding()
|
||||
|
||||
var count = 0
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
for _ in reader { count += 1 }
|
||||
#expect(count == 0)
|
||||
}
|
||||
|
||||
@Test func archiveURLsSingle() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveURLsSingle")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
try "only content".write(to: testDir.appendingPathComponent("only.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archive([FilePath(testDir.appendingPathComponent("only.txt").path)], base: FilePath(testDir.path))
|
||||
try writer.finishEncoding()
|
||||
|
||||
var entries: [String: String] = [:]
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
for (entry, data) in reader {
|
||||
if let path = entry.path, let content = String(data: data, encoding: .utf8) {
|
||||
entries[path] = content
|
||||
}
|
||||
}
|
||||
#expect(entries.count == 1)
|
||||
#expect(entries["only.txt"] == "only content")
|
||||
}
|
||||
|
||||
@Test func archiveURLsPreservesPermissions() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveURLsPerms")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let execFile = testDir.appendingPathComponent("script.sh")
|
||||
try "#!/bin/sh\necho hi".write(to: execFile, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: execFile.path)
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archive([FilePath(execFile.path)], base: FilePath(testDir.path))
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
|
||||
#expect(rejected.isEmpty)
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: extractDir.appendingPathComponent("script.sh").path)
|
||||
let perms = (attrs[.posixPermissions] as? NSNumber)?.uint16Value ?? 0
|
||||
#expect((perms & 0o777) == 0o755)
|
||||
}
|
||||
|
||||
@Test func archiveURLsNestedDirectories() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveURLsNested")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("source")
|
||||
try FileManager.default.createDirectory(at: sourceDir.appendingPathComponent("a"), withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(at: sourceDir.appendingPathComponent("b/c"), withIntermediateDirectories: true)
|
||||
try "top content".write(to: sourceDir.appendingPathComponent("top.txt"), atomically: true, encoding: .utf8)
|
||||
try "a content".write(to: sourceDir.appendingPathComponent("a/deep.txt"), atomically: true, encoding: .utf8)
|
||||
try "nested content".write(to: sourceDir.appendingPathComponent("b/c/nested.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
let files: [FilePath] = [
|
||||
FilePath(sourceDir.appendingPathComponent("top.txt").path),
|
||||
FilePath(sourceDir.appendingPathComponent("a/deep.txt").path),
|
||||
FilePath(sourceDir.appendingPathComponent("b/c/nested.txt").path),
|
||||
]
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archive(files, base: FilePath(sourceDir.path))
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
|
||||
#expect(rejected.isEmpty)
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent("top.txt"), encoding: .utf8) == "top content")
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent("a/deep.txt"), encoding: .utf8) == "a content")
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent("b/c/nested.txt"), encoding: .utf8) == "nested content")
|
||||
}
|
||||
|
||||
@Test func archiveURLsWithDirectoryURL() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveURLsWithDir")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("source")
|
||||
try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true)
|
||||
try "top content".write(to: sourceDir.appendingPathComponent("top.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
let subDir = sourceDir.appendingPathComponent("subdir")
|
||||
try FileManager.default.createDirectory(at: subDir.appendingPathComponent("nested"), withIntermediateDirectories: true)
|
||||
try "sub content".write(to: subDir.appendingPathComponent("sub.txt"), atomically: true, encoding: .utf8)
|
||||
try "deep content".write(to: subDir.appendingPathComponent("nested/deep.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
let urls: [FilePath] = [
|
||||
FilePath(sourceDir.appendingPathComponent("top.txt").path),
|
||||
FilePath(subDir.path),
|
||||
]
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archive(urls, base: FilePath(sourceDir.path))
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
|
||||
#expect(rejected.isEmpty)
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent("top.txt"), encoding: .utf8) == "top content")
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent("subdir/sub.txt"), encoding: .utf8) == "sub content")
|
||||
#expect(try String(contentsOf: extractDir.appendingPathComponent("subdir/nested/deep.txt"), encoding: .utf8) == "deep content")
|
||||
}
|
||||
|
||||
@Test func archiveURLsDirectoryPreservesPermissions() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveURLsDirPerms")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("source")
|
||||
let readonlyDir = sourceDir.appendingPathComponent("readonly")
|
||||
try FileManager.default.createDirectory(at: readonlyDir, withIntermediateDirectories: true)
|
||||
try "content".write(to: readonlyDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o777], ofItemAtPath: readonlyDir.path)
|
||||
defer {
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: readonlyDir.path)
|
||||
}
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archive([FilePath(readonlyDir.path)], base: FilePath(sourceDir.path))
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
|
||||
#expect(rejected.isEmpty)
|
||||
#expect(
|
||||
try String(contentsOf: extractDir.appendingPathComponent("readonly/file.txt"), encoding: .utf8)
|
||||
== "content")
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: extractDir.appendingPathComponent("readonly").path)
|
||||
let perms = (attrs[.posixPermissions] as? NSNumber)?.uint16Value ?? 0
|
||||
#expect((perms & 0o777) == 0o777, "Read-only directory permissions should be preserved")
|
||||
}
|
||||
|
||||
@Test func archiveURLsSymlinks() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveURLsSymlinks")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("source")
|
||||
try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true)
|
||||
|
||||
let fileURL = sourceDir.appendingPathComponent("file.txt")
|
||||
try "symlink content".write(to: fileURL, atomically: true, encoding: .utf8)
|
||||
|
||||
try FileManager.default.createSymbolicLink(
|
||||
atPath: sourceDir.appendingPathComponent("absolute").path,
|
||||
withDestinationPath: fileURL.path
|
||||
)
|
||||
try FileManager.default.createSymbolicLink(
|
||||
atPath: sourceDir.appendingPathComponent("relative").path,
|
||||
withDestinationPath: "file.txt"
|
||||
)
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archive([FilePath(sourceDir.path)], base: FilePath(testDir.path))
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
|
||||
#expect(rejected.isEmpty)
|
||||
|
||||
let extractedSource = extractDir.appendingPathComponent("source")
|
||||
#expect(
|
||||
try String(contentsOf: extractedSource.appendingPathComponent("file.txt"), encoding: .utf8)
|
||||
== "symlink content")
|
||||
|
||||
let relTarget = try FileManager.default.destinationOfSymbolicLink(
|
||||
atPath: extractedSource.appendingPathComponent("relative").path)
|
||||
#expect(relTarget == "file.txt")
|
||||
#expect(
|
||||
try String(contentsOf: extractedSource.appendingPathComponent("relative"), encoding: .utf8)
|
||||
== "symlink content")
|
||||
|
||||
let absTarget = try FileManager.default.destinationOfSymbolicLink(
|
||||
atPath: extractedSource.appendingPathComponent("absolute").path)
|
||||
|
||||
print("absTarget: \(absTarget), fileURL: \(fileURL.path)")
|
||||
#expect(absTarget == fileURL.path)
|
||||
}
|
||||
|
||||
@Test func archiveDirectorySymlinkRelativeSubdir() throws {
|
||||
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirSymlinkRelSubdir")!
|
||||
defer { try? FileManager.default.removeItem(at: testDir) }
|
||||
|
||||
let sourceDir = testDir.appendingPathComponent("source")
|
||||
let subA = sourceDir.appendingPathComponent("a")
|
||||
let subB = sourceDir.appendingPathComponent("b")
|
||||
try FileManager.default.createDirectory(at: subA, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(at: subB, withIntermediateDirectories: true)
|
||||
try "in a".write(to: subA.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
// Symlink from b/link.txt -> ../a/file.txt (relative, stays inside)
|
||||
try FileManager.default.createSymbolicLink(
|
||||
atPath: subB.appendingPathComponent("link.txt").path,
|
||||
withDestinationPath: "../a/file.txt"
|
||||
)
|
||||
|
||||
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
|
||||
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
|
||||
try writer.archiveDirectory(sourceDir)
|
||||
try writer.finishEncoding()
|
||||
|
||||
let extractDir = testDir.appendingPathComponent("extract")
|
||||
let reader = try ArchiveReader(file: archiveURL)
|
||||
let rejected = try reader.extractContents(to: extractDir)
|
||||
|
||||
#expect(rejected.isEmpty)
|
||||
|
||||
let linkDest = try FileManager.default.destinationOfSymbolicLink(
|
||||
atPath: extractDir.appendingPathComponent("b/link.txt").path)
|
||||
#expect(linkDest == "../a/file.txt")
|
||||
|
||||
// Verify the symlink resolves correctly
|
||||
let content = try String(contentsOf: extractDir.appendingPathComponent("b/link.txt"), encoding: .utf8)
|
||||
#expect(content == "in a")
|
||||
}
|
||||
}
|
||||
|
||||
private let surveyBundleBase64Encoded = """
|
||||
UEsDBBQACAAIAA17o04AAAAAAAAAAAAAAAAUABAAaGVhbHRoaW52b2x2ZW1lbnQuanNVWAwAQ8XMXJm/zFz1ARQAnVVRa9swEH73rzjylMLwmu3NJexhDLqxroPAYIxRFPsca5UlT5LteSH/fSdZdu02LqMiEEl3uvv06e5zwzRwyS1n4q4qmEHYwjECGn6VwKrSXGluu9Urv50rXSbBxWBquZImgR9+/Wgcz226IVTKBP+L2We2R0E5rlULrapFBp2qYY/GQoYm1XyPbsdBbJRosERpaQ7cmBoNaBTMYgZW9V4FMmGLdwBfBbqrpIVS9KckxgH9mXGPHSGYJFh2ZdK0qJPli9mucpTtuDwIfF8onuJytNTbl8ibj+NTzr4oC4x+QgzsZDHcdIGElGmESquGZ6gh45qeykDpyAgeI3s9mcQQNEzUhP8STougn4W0UyW2BbMTQB8h9e9KD5kpogVKRcDowUom2QGhHABP8m9emv8b6m6W29KacrVK3wMzwMAiK6HldPvyPFPPI3vzUmQf/lhNxSXm8FQrH9JQdWVA6JgEljUUwKJrNnIwKPIJiLf/BeLnksvyYW5uK9fPjBDnTBg853h6vNknOkWnKMpr6QUB0EGlC6yxoYa6CA3TkNYMGuMNsV9dRd7Kc1gH63brGtKL0upi0m0aba3lXK9C9mhiP47alVHrrxaw3Wk0FYkXmvU4G5KFQOP+LADVyoEsZn65cOR2/4s6LSZRCfb4IXgsUB7ooV/DxgV0dPymznNBJaMOHaWXKlFannOnNatUlTFLWxRCUtJ4diLuS+epeFluhSPgxtWya7vvTh+vvXdw6QXWP7hTYG/q4BP5SexgV+sGB81vUJvebRNfDt+BQEcyEtrvh5XSyRmme5eBwGScOTqi2c2uon9QSwcIxOijbWkCAACaBgAAUEsDBAoAAAAAAFV+o04AAAAAAAAAAAAAAAAJABAAX19NQUNPU1gvVVgMAMHFzFzBxcxc9QEUAFBLAwQUAAgACAANe6NOAAAAAAAAAAAAAAAAHwAQAF9fTUFDT1NYLy5faGVhbHRoaW52b2x2ZW1lbnQuanNVWAwAQ8XMXJm/zFz1ARQAjY/NSsNAEMcnRfHjVBA9eLGiHjy0m5qkDa2XtGlrwVKxAUUUWZMpiW4+mmzrxZtP4pOINw8efQXx6BMIbmigUBAd2P/MDr8/MwOLG0uQA+hRu9AfFM4LWaQ9WBHvAED6Eln8c9vwrzAs63RapQ5pVxTfc8hC1s8DbNqhX6JRxLDEaMLHCToO5bhzMpiikipEA9ifcT5yKhhau+uZXY6+Gd4HLKQOOqZwph5PyAPA3u+eMxdjbMehn6T8h5BDgPUZPxrTmAbcCxDen98u002cz9dymm8i5iVclp8kxXghV7j1eLO8mi0rZQfm5g5em5mu8z2X8yipERJhFGFsu5RnU8V8MvQYJqRMNLVK/LDV71iMWW583OyY5Agp4243mIRsgj4GvHSb/Dn7YkRkWVfqmk1RV3RaH9Ahjb16y6hUKxVNK8rtcqOo6kIastooNlXT1IyWoTYVA34AUEsHCAK+cV1ZAQAAIQIAAFBLAQIVAxQACAAIAA17o07E6KNtaQIAAJoGAAAUAAwAAAAAAAAAAECkgQAAAABoZWFsdGhpbnZvbHZlbWVudC5qc1VYCABDxcxcmb/MXFBLAQIVAwoAAAAAAFV+o04AAAAAAAAAAAAAAAAJAAwAAAAAAAAAAED9QbsCAABfX01BQ09TWC9VWAgAwcXMXMHFzFxQSwECFQMUAAgACAANe6NOAr5xXVkBAAAhAgAAHwAMAAAAAAAAAABApIHyAgAAX19NQUNPU1gvLl9oZWFsdGhpbnZvbHZlbWVudC5qc1VYCABDxcxcmb/MXFBLBQYAAAAAAwADAOoAAACoBAAAAAA=
|
||||
"""
|
||||
Binary file not shown.
Reference in New Issue
Block a user