Refactor ArchiveWriter to expose archiving each path independently (#693)

This PR refactors `ArchiveWriter` to add an API `archive(_ paths:
base:)`. This API is used to archive the contents at each path
independently, similar to doing `tar -cvf archive.tar foo.bin
/bar/baz.txt`.
This commit is contained in:
jwhur authored and GitHub committed 2026-04-30 18:23:47 -07:00
1 parent df125a2300
commit f2c42402e7
2 files changed
+349 -77

No files matched your search

@@ -179,6 +179,94 @@ extension ArchiveWriter {
}
extension ArchiveWriter {
private func archive(_ relativePath: FilePath, dirPath: FilePath) throws {
let fm = FileManager.default
let fullPath = dirPath.appending(relativePath.string)
var statInfo = stat()
guard lstat(fullPath.string, &statInfo) == 0 else {
let errNo = errno
let err = POSIXErrorCode(rawValue: errNo) ?? .EINVAL
throw ArchiveError.failedToCreateArchive("lstat failed for '\(fullPath)': \(POSIXError(err))")
}
let mode = statInfo.st_mode
let uid = statInfo.st_uid
let gid = statInfo.st_gid
var size: Int64 = 0
let type: URLFileResourceType
if (mode & S_IFMT) == S_IFREG {
type = .regular
size = Int64(statInfo.st_size)
} else if (mode & S_IFMT) == S_IFDIR {
type = .directory
} else if (mode & S_IFMT) == S_IFLNK {
type = .symbolicLink
} else {
return
}
#if os(macOS)
let created = Date(timeIntervalSince1970: Double(statInfo.st_ctimespec.tv_sec))
let access = Date(timeIntervalSince1970: Double(statInfo.st_atimespec.tv_sec))
let modified = Date(timeIntervalSince1970: Double(statInfo.st_mtimespec.tv_sec))
#else
let created = Date(timeIntervalSince1970: Double(statInfo.st_ctim.tv_sec))
let access = Date(timeIntervalSince1970: Double(statInfo.st_atim.tv_sec))
let modified = Date(timeIntervalSince1970: Double(statInfo.st_mtim.tv_sec))
#endif
let entry = WriteEntry()
if type == .symbolicLink {
let targetPath = try fm.destinationOfSymbolicLink(atPath: fullPath.string)
// Resolve the target relative to the symlink's parent, not the archive root.
let symlinkParent = fullPath.removingLastComponent()
let resolvedFull = symlinkParent.appending(targetPath).lexicallyNormalized()
guard resolvedFull.starts(with: dirPath) else {
return
}
entry.symlinkTarget = targetPath
}
entry.path = relativePath.string
entry.size = size
entry.creationDate = created
entry.modificationDate = modified
entry.contentAccessDate = access
entry.fileType = type
entry.group = gid
entry.owner = uid
entry.permissions = mode
if type == .regular {
let buf = UnsafeMutableRawBufferPointer.allocate(byteCount: Self.chunkSize, alignment: 1)
guard let baseAddress = buf.baseAddress else {
throw ArchiveError.failedToCreateArchive("cannot create temporary buffer of size \(Self.chunkSize)")
}
defer { buf.deallocate() }
let fd = Foundation.open(fullPath.string, O_RDONLY)
guard fd >= 0 else {
let err = POSIXErrorCode(rawValue: errno) ?? .EINVAL
throw ArchiveError.failedToCreateArchive("cannot open file \(fullPath.string) for reading: \(err)")
}
defer { close(fd) }
try self.writeHeader(entry: entry)
while true {
let n = read(fd, baseAddress, Self.chunkSize)
if n == 0 { break }
if n < 0 {
let err = POSIXErrorCode(rawValue: errno) ?? .EIO
throw ArchiveError.failedToCreateArchive("failed to read from file \(fullPath.string): \(err)")
}
try self.writeData(data: UnsafeRawBufferPointer(start: baseAddress, count: n))
}
try self.finishEntry()
} else {
try self.writeEntry(entry: entry, data: nil)
}
}
/// Recursively archives the content of a directory. Regular files, symlinks and directories are added into the archive.
/// Note: Symlinks are added to the archive if both the source and target for the symlink are both contained in the top level directory.
public func archiveDirectory(_ dir: URL) throws {
@@ -214,88 +302,37 @@ extension ArchiveWriter {
try self.writeHeader(entry: rootEntry)
for case let relativePath as String in enumerator {
let fullPath = dirPath.appending(relativePath)
try archive(FilePath(relativePath), dirPath: dirPath)
}
}
var statInfo = stat()
guard lstat(fullPath.string, &statInfo) == 0 else {
let errNo = errno
let err = POSIXErrorCode(rawValue: errNo) ?? .EINVAL
throw ArchiveError.failedToCreateArchive("lstat failed for '\(fullPath)': \(POSIXError(err))")
public func archive(_ paths: [FilePath], base: FilePath) throws {
let fm = FileManager.default
let base = base.lexicallyNormalized()
for path in paths {
guard path.starts(with: base) else {
throw ArchiveError.failedToCreateArchive("'\(path.string)' is not under '\(base.string)'")
}
let mode = statInfo.st_mode
let uid = statInfo.st_uid
let gid = statInfo.st_gid
var size: Int64 = 0
let type: URLFileResourceType
let relativePath = path.components.dropFirst(base.components.count)
.reduce(into: FilePath("")) { $0.append($1) }
if (mode & S_IFMT) == S_IFREG {
type = .regular
size = Int64(statInfo.st_size)
} else if (mode & S_IFMT) == S_IFDIR {
type = .directory
} else if (mode & S_IFMT) == S_IFLNK {
type = .symbolicLink
var isDir: ObjCBool = false
_ = fm.fileExists(atPath: path.string, isDirectory: &isDir)
if isDir.boolValue {
guard let enumerator = fm.enumerator(atPath: path.string) else {
throw POSIXError(.ENOTDIR)
}
try archive(relativePath, dirPath: base)
for case let child as String in enumerator {
let childPath = relativePath.appending(child)
try archive(childPath, dirPath: base)
}
} else {
continue
}
#if os(macOS)
let created = Date(timeIntervalSince1970: Double(statInfo.st_ctimespec.tv_sec))
let access = Date(timeIntervalSince1970: Double(statInfo.st_atimespec.tv_sec))
let modified = Date(timeIntervalSince1970: Double(statInfo.st_mtimespec.tv_sec))
#else
let created = Date(timeIntervalSince1970: Double(statInfo.st_ctim.tv_sec))
let access = Date(timeIntervalSince1970: Double(statInfo.st_atim.tv_sec))
let modified = Date(timeIntervalSince1970: Double(statInfo.st_mtim.tv_sec))
#endif
let entry = WriteEntry()
if type == .symbolicLink {
let targetPath = try fm.destinationOfSymbolicLink(atPath: fullPath.string)
// Resolve the target relative to the symlink's parent, not the archive root.
let symlinkParent = fullPath.removingLastComponent()
let resolvedFull = symlinkParent.appending(targetPath).lexicallyNormalized()
guard resolvedFull.starts(with: dirPath) else {
continue
}
entry.symlinkTarget = targetPath
}
entry.path = relativePath
entry.size = size
entry.creationDate = created
entry.modificationDate = modified
entry.contentAccessDate = access
entry.fileType = type
entry.group = gid
entry.owner = uid
entry.permissions = mode
if type == .regular {
let buf = UnsafeMutableRawBufferPointer.allocate(byteCount: Self.chunkSize, alignment: 1)
guard let baseAddress = buf.baseAddress else {
throw ArchiveError.failedToCreateArchive("cannot create temporary buffer of size \(Self.chunkSize)")
}
defer { buf.deallocate() }
let fd = Foundation.open(fullPath.string, O_RDONLY)
guard fd >= 0 else {
let err = POSIXErrorCode(rawValue: errno) ?? .EINVAL
throw ArchiveError.failedToCreateArchive("cannot open file \(fullPath.string) for reading: \(err)")
}
defer { close(fd) }
try self.writeHeader(entry: entry)
while true {
let n = read(fd, baseAddress, Self.chunkSize)
if n == 0 { break }
if n < 0 {
let err = POSIXErrorCode(rawValue: errno) ?? .EIO
throw ArchiveError.failedToCreateArchive("failed to read from file \(fullPath.string): \(err)")
}
try self.writeData(data: UnsafeRawBufferPointer(start: baseAddress, count: n))
}
try self.finishEntry()
} else {
try self.writeEntry(entry: entry, data: nil)
try archive(relativePath, dirPath: base)
}
}
}
@@ -17,6 +17,7 @@
//
import Foundation
import SystemPackage
import Testing
@testable import ContainerizationArchive
@@ -559,6 +560,240 @@ struct ArchiveTests {
#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) }