From 9a51ae893ff3551fdbb2572eca8b1befac61df2e Mon Sep 17 00:00:00 2001 From: Jaewon Date: Mon, 20 Apr 2026 16:39:38 -0700 Subject: [PATCH 1/4] Refactor ArchiveWriter to expose archiveURLs API This archiveURLs API is used to selectively archive the objects under the given URLs. --- .../ArchiveWriter.swift | 182 ++++++++++-------- .../ArchiveTests.swift | 154 +++++++++++++++ 2 files changed, 261 insertions(+), 75 deletions(-) diff --git a/Sources/ContainerizationArchive/ArchiveWriter.swift b/Sources/ContainerizationArchive/ArchiveWriter.swift index 7f446bbe..bd3c72ff 100644 --- a/Sources/ContainerizationArchive/ArchiveWriter.swift +++ b/Sources/ContainerizationArchive/ArchiveWriter.swift @@ -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,89 +302,33 @@ 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 archiveURLs(_ urls: [URL], base: URL) throws { + let fm = FileManager.default - 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 { - continue + for url in urls { + guard url.path.starts(with: base.path) else { + throw ArchiveError.failedToCreateArchive("'\(url.path)' is not under '\(base.path)'") } - #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 + let basePathCount = base.path == "/" ? 1 : (base.path.count + 1) + let path = FilePath(String(url.path.dropFirst(basePathCount))) + if url.isDirectory { + guard let enumerator = fm.enumerator(atPath: url.path) else { + throw POSIXError(.ENOTDIR) } - 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)) + for case let child as String in enumerator { + let relativePath = path.appending(child) + + try archive(relativePath, dirPath: FilePath(base.path)) } - try self.finishEntry() } else { - try self.writeEntry(entry: entry, data: nil) + try archive(path, dirPath: FilePath(base.path)) } } } -} +} \ No newline at end of file diff --git a/Tests/ContainerizationArchiveTests/ArchiveTests.swift b/Tests/ContainerizationArchiveTests/ArchiveTests.swift index c94d5ab0..c53633ac 100644 --- a/Tests/ContainerizationArchiveTests/ArchiveTests.swift +++ b/Tests/ContainerizationArchiveTests/ArchiveTests.swift @@ -559,6 +559,160 @@ struct ArchiveTests { #expect(try String(contentsOf: extractDir.appendingPathComponent("only.txt"), encoding: .utf8) == "only file") } + // MARK: - archiveURLs 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 = [ + testDir.appendingPathComponent("alpha.txt"), + testDir.appendingPathComponent("beta.txt"), + ] + let archiveURL = testDir.appendingPathComponent("test.tar.gz") + let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL) + try writer.archiveURLs(files, base: testDir) + 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.archiveURLs([], base: testDir) + } + 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.archiveURLs([testDir.appendingPathComponent("only.txt")], base: testDir) + 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.archiveURLs([execFile], base: testDir) + 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 = [ + sourceDir.appendingPathComponent("top.txt"), + sourceDir.appendingPathComponent("a/deep.txt"), + sourceDir.appendingPathComponent("b/c/nested.txt"), + ] + let archiveURL = testDir.appendingPathComponent("test.tar.gz") + let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL) + try writer.archiveURLs(files, base: 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("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 = [ + sourceDir.appendingPathComponent("top.txt"), + subDir, + ] + let archiveURL = testDir.appendingPathComponent("test.tar.gz") + let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL) + try writer.archiveURLs(urls, base: 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("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 archiveDirectorySymlinkRelativeSubdir() throws { let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirSymlinkRelSubdir")! defer { try? FileManager.default.removeItem(at: testDir) } From 751b7f12f7533735a391a16262a9b05e3f10f793 Mon Sep 17 00:00:00 2001 From: Jaewon Date: Mon, 20 Apr 2026 16:43:21 -0700 Subject: [PATCH 2/4] Make fmt --- Sources/ContainerizationArchive/ArchiveWriter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ContainerizationArchive/ArchiveWriter.swift b/Sources/ContainerizationArchive/ArchiveWriter.swift index bd3c72ff..539ae7f2 100644 --- a/Sources/ContainerizationArchive/ArchiveWriter.swift +++ b/Sources/ContainerizationArchive/ArchiveWriter.swift @@ -331,4 +331,4 @@ extension ArchiveWriter { } } } -} \ No newline at end of file +} From 40a370a8903c497bfd846e236850f604897777b1 Mon Sep 17 00:00:00 2001 From: Jaewon Date: Tue, 21 Apr 2026 13:15:42 -0700 Subject: [PATCH 3/4] Change function name to archive --- .../ArchiveWriter.swift | 26 +++--- .../ArchiveTests.swift | 84 +++++++++++++++---- 2 files changed, 82 insertions(+), 28 deletions(-) diff --git a/Sources/ContainerizationArchive/ArchiveWriter.swift b/Sources/ContainerizationArchive/ArchiveWriter.swift index 539ae7f2..399b878a 100644 --- a/Sources/ContainerizationArchive/ArchiveWriter.swift +++ b/Sources/ContainerizationArchive/ArchiveWriter.swift @@ -306,28 +306,32 @@ extension ArchiveWriter { } } - public func archiveURLs(_ urls: [URL], base: URL) throws { + public func archive(_ paths: [FilePath], base: FilePath) throws { let fm = FileManager.default + let base = base.lexicallyNormalized() - for url in urls { - guard url.path.starts(with: base.path) else { - throw ArchiveError.failedToCreateArchive("'\(url.path)' is not under '\(base.path)'") + for path in paths { + guard path.starts(with: base) else { + throw ArchiveError.failedToCreateArchive("'\(path.string)' is not under '\(base.string)'") } - let basePathCount = base.path == "/" ? 1 : (base.path.count + 1) - let path = FilePath(String(url.path.dropFirst(basePathCount))) - if url.isDirectory { - guard let enumerator = fm.enumerator(atPath: url.path) else { + let relativePath = path.components.dropFirst(base.components.count) + .reduce(into: FilePath("")) { $0.append($1) } + + 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) } for case let child as String in enumerator { - let relativePath = path.appending(child) + let childPath = relativePath.appending(child) - try archive(relativePath, dirPath: FilePath(base.path)) + try archive(childPath, dirPath: base) } } else { - try archive(path, dirPath: FilePath(base.path)) + try archive(relativePath, dirPath: base) } } } diff --git a/Tests/ContainerizationArchiveTests/ArchiveTests.swift b/Tests/ContainerizationArchiveTests/ArchiveTests.swift index c53633ac..24b09afb 100644 --- a/Tests/ContainerizationArchiveTests/ArchiveTests.swift +++ b/Tests/ContainerizationArchiveTests/ArchiveTests.swift @@ -17,6 +17,7 @@ // import Foundation +import SystemPackage import Testing @testable import ContainerizationArchive @@ -559,7 +560,7 @@ struct ArchiveTests { #expect(try String(contentsOf: extractDir.appendingPathComponent("only.txt"), encoding: .utf8) == "only file") } - // MARK: - archiveURLs tests + // MARK: - archive tests @Test func archiveURLsBasic() throws { let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveURLsBasic")! @@ -568,13 +569,13 @@ struct ArchiveTests { 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 = [ - testDir.appendingPathComponent("alpha.txt"), - testDir.appendingPathComponent("beta.txt"), + 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.archiveURLs(files, base: testDir) + try writer.archive(files, base: FilePath(testDir.path)) try writer.finishEncoding() var entries: [String: String] = [:] @@ -595,7 +596,7 @@ struct ArchiveTests { let archiveURL = testDir.appendingPathComponent("test.tar.gz") let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL) #expect(throws: Never.self) { - try writer.archiveURLs([], base: testDir) + try writer.archive([], base: FilePath(testDir.path)) } try writer.finishEncoding() @@ -613,7 +614,7 @@ struct ArchiveTests { let archiveURL = testDir.appendingPathComponent("test.tar.gz") let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL) - try writer.archiveURLs([testDir.appendingPathComponent("only.txt")], base: testDir) + try writer.archive([FilePath(testDir.appendingPathComponent("only.txt").path)], base: FilePath(testDir.path)) try writer.finishEncoding() var entries: [String: String] = [:] @@ -637,7 +638,7 @@ struct ArchiveTests { let archiveURL = testDir.appendingPathComponent("test.tar.gz") let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL) - try writer.archiveURLs([execFile], base: testDir) + try writer.archive([FilePath(execFile.path)], base: FilePath(testDir.path)) try writer.finishEncoding() let extractDir = testDir.appendingPathComponent("extract") @@ -661,14 +662,14 @@ struct ArchiveTests { 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 = [ - sourceDir.appendingPathComponent("top.txt"), - sourceDir.appendingPathComponent("a/deep.txt"), - sourceDir.appendingPathComponent("b/c/nested.txt"), + 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.archiveURLs(files, base: sourceDir) + try writer.archive(files, base: FilePath(sourceDir.path)) try writer.finishEncoding() let extractDir = testDir.appendingPathComponent("extract") @@ -694,13 +695,13 @@ struct ArchiveTests { 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 = [ - sourceDir.appendingPathComponent("top.txt"), - subDir, + 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.archiveURLs(urls, base: sourceDir) + try writer.archive(urls, base: FilePath(sourceDir.path)) try writer.finishEncoding() let extractDir = testDir.appendingPathComponent("extract") @@ -713,6 +714,55 @@ struct ArchiveTests { #expect(try String(contentsOf: extractDir.appendingPathComponent("subdir/nested/deep.txt"), encoding: .utf8) == "deep content") } + @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) } From 8ce3ebf79a567ec46d3f2fc7a58d5531a7605d99 Mon Sep 17 00:00:00 2001 From: Jaewon Date: Tue, 21 Apr 2026 13:31:38 -0700 Subject: [PATCH 4/4] Fix --- Sources/ContainerizationArchive/ArchiveWriter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ContainerizationArchive/ArchiveWriter.swift b/Sources/ContainerizationArchive/ArchiveWriter.swift index 399b878a..ca46a352 100644 --- a/Sources/ContainerizationArchive/ArchiveWriter.swift +++ b/Sources/ContainerizationArchive/ArchiveWriter.swift @@ -319,7 +319,7 @@ extension ArchiveWriter { .reduce(into: FilePath("")) { $0.append($1) } var isDir: ObjCBool = false - fm.fileExists(atPath: path.string, isDirectory: &isDir) + _ = fm.fileExists(atPath: path.string, isDirectory: &isDir) if isDir.boolValue { guard let enumerator = fm.enumerator(atPath: path.string) else { throw POSIXError(.ENOTDIR)