From e36060c8dc208b9723169e5e5fbfc9dbff47321b Mon Sep 17 00:00:00 2001 From: mazdak Date: Sat, 4 Apr 2026 20:38:34 -0400 Subject: [PATCH 1/2] ArchiveWriter: support explicit entries and rewrite internal symlinks --- .../ArchiveWriter.swift | 337 +++++++++++++++++- .../ArchiveTests.swift | 132 +++++++ 2 files changed, 465 insertions(+), 4 deletions(-) diff --git a/Sources/ContainerizationArchive/ArchiveWriter.swift b/Sources/ContainerizationArchive/ArchiveWriter.swift index 7f446bbe..bfef215e 100644 --- a/Sources/ContainerizationArchive/ArchiveWriter.swift +++ b/Sources/ContainerizationArchive/ArchiveWriter.swift @@ -111,6 +111,34 @@ public class ArchiveWriterTransaction { } } +/// Represents a host filesystem entry to be archived at a specific path. +public struct ArchiveSourceEntry: Sendable { + /// Path to the item on the host filesystem. + public let pathOnHost: URL + /// Path to use for the entry inside the archive. + public let pathInArchive: String + /// Optional owner override for the archived entry. + public let owner: uid_t? + /// Optional group override for the archived entry. + public let group: gid_t? + /// Optional permissions override for the archived entry. + public let permissions: mode_t? + + public init( + pathOnHost: URL, + pathInArchive: String, + owner: uid_t? = nil, + group: gid_t? = nil, + permissions: mode_t? = nil + ) { + self.pathOnHost = pathOnHost + self.pathInArchive = pathInArchive + self.owner = owner + self.group = group + self.permissions = permissions + } +} + extension ArchiveWriter { public func makeTransactionWriter() -> ArchiveWriterTransaction { ArchiveWriterTransaction(writer: self) @@ -179,6 +207,20 @@ extension ArchiveWriter { } extension ArchiveWriter { + /// Archives an explicit, ordered list of host filesystem entries. + public func archiveEntries(_ entries: [ArchiveSourceEntry]) throws { + let archivedPathsByHostPath = entries.reduce(into: [String: [String]]()) { result, entry in + result[entry.pathOnHost.path, default: []].append(entry.pathInArchive) + } + + for source in entries { + guard let entry = try Self.makeEntry(from: source, archivedPathsByHostPath: archivedPathsByHostPath) else { + throw ArchiveError.failedToCreateArchive("unsupported file type at '\(source.pathOnHost.path)'") + } + try self.writeSourceEntry(entry: entry, sourcePath: source.pathOnHost.path) + } + } + /// 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 { @@ -253,13 +295,21 @@ extension ArchiveWriter { 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 let resolvedFull = Self.resolveArchivedDirectorySymlinkTarget( + targetPath, + symlinkPath: fullPath + ) else { + continue + } guard resolvedFull.starts(with: dirPath) else { continue } - entry.symlinkTarget = targetPath + entry.symlinkTarget = Self.rewriteArchivedDirectorySymlinkTarget( + targetPath, + sourceEntryPath: relativePath, + sourceRoot: dirPath, + resolvedTargetPath: resolvedFull + ) } entry.path = relativePath @@ -299,4 +349,283 @@ extension ArchiveWriter { } } } + + private struct FileStatus { + enum EntryType { + case directory + case regular + case symbolicLink + } + + let entryType: EntryType + let permissions: mode_t + let size: Int64 + let owner: uid_t + let group: gid_t + let creationDate: Date? + let contentAccessDate: Date? + let modificationDate: Date? + let symlinkTarget: String? + } + + private func writeSourceEntry(entry: WriteEntry, sourcePath: String) throws { + guard entry.fileType == .regular else { + try self.writeEntry(entry: entry, data: nil) + return + } + + let writer = self.makeTransactionWriter() + let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: Self.chunkSize, alignment: 1) + guard let baseAddress = buffer.baseAddress else { + buffer.deallocate() + throw ArchiveError.failedToCreateArchive("cannot create temporary buffer of size \(Self.chunkSize)") + } + defer { buffer.deallocate() } + + let fd = Foundation.open(sourcePath, O_RDONLY) + guard fd >= 0 else { + let err = POSIXErrorCode(rawValue: errno) ?? .EINVAL + throw ArchiveError.failedToCreateArchive("cannot open file \(sourcePath) for reading: \(err)") + } + defer { close(fd) } + + try writer.writeHeader(entry: entry) + while true { + let bytesRead = read(fd, baseAddress, Self.chunkSize) + if bytesRead == 0 { + break + } + if bytesRead < 0 { + let err = POSIXErrorCode(rawValue: errno) ?? .EIO + throw ArchiveError.failedToCreateArchive("failed to read from file \(sourcePath): \(err)") + } + try writer.writeChunk(data: UnsafeRawBufferPointer(start: baseAddress, count: bytesRead)) + } + try writer.finish() + } + + private static func makeEntry( + from source: ArchiveSourceEntry, + archivedPathsByHostPath: [String: [String]] + ) throws -> WriteEntry? { + guard let status = try Self.fileStatus(atPath: source.pathOnHost.path) else { + return nil + } + let entry = WriteEntry() + + switch status.entryType { + case .directory: + entry.fileType = .directory + entry.size = 0 + case .regular: + entry.fileType = .regular + entry.size = status.size + case .symbolicLink: + entry.fileType = .symbolicLink + entry.size = 0 + entry.symlinkTarget = Self.rewriteArchivedAbsoluteSymlinkTarget( + status.symlinkTarget ?? "", + sourceEntryPath: source.pathInArchive, + archivedPathsByHostPath: archivedPathsByHostPath + ) + } + + entry.path = source.pathInArchive + entry.permissions = source.permissions ?? status.permissions + entry.owner = source.owner ?? status.owner + entry.group = source.group ?? status.group + entry.creationDate = status.creationDate + entry.contentAccessDate = status.contentAccessDate + entry.modificationDate = status.modificationDate + return entry + } + + private static func fileStatus(atPath path: String) throws -> FileStatus? { + try path.withCString { fileSystemPath in + var status = stat() + guard lstat(fileSystemPath, &status) == 0 else { + let err = POSIXErrorCode(rawValue: errno) ?? .EINVAL + throw ArchiveError.failedToCreateArchive("lstat failed for '\(path)': \(POSIXError(err))") + } + + let mode = status.st_mode & S_IFMT + let entryType: FileStatus.EntryType + let symlinkTarget: String? + + switch mode { + case S_IFDIR: + entryType = .directory + symlinkTarget = nil + case S_IFREG: + entryType = .regular + symlinkTarget = nil + case S_IFLNK: + entryType = .symbolicLink + symlinkTarget = try Self.symlinkTarget(fileSystemPath: fileSystemPath, path: path, sizeHint: Int(status.st_size)) + default: + return nil + } + + return FileStatus( + entryType: entryType, + permissions: status.st_mode & 0o7777, + size: Int64(status.st_size), + owner: status.st_uid, + group: status.st_gid, + creationDate: Self.creationDate(from: status), + contentAccessDate: Self.contentAccessDate(from: status), + modificationDate: Self.modificationDate(from: status), + symlinkTarget: symlinkTarget + ) + } + } + + private static func symlinkTarget(fileSystemPath: UnsafePointer, path: String, sizeHint: Int) throws -> String { + let capacity = max(sizeHint + 1, Int(PATH_MAX)) + let buffer = UnsafeMutablePointer.allocate(capacity: capacity) + defer { buffer.deallocate() } + + let count = readlink(fileSystemPath, buffer, capacity - 1) + guard count >= 0 else { + let err = POSIXErrorCode(rawValue: errno) ?? .EINVAL + throw ArchiveError.failedToCreateArchive("readlink failed for '\(path)': \(POSIXError(err))") + } + + buffer[count] = 0 + return String(cString: buffer) + } + + private static func creationDate(from status: stat) -> Date? { + #if os(macOS) + return Date( + timeIntervalSince1970: TimeInterval(status.st_ctimespec.tv_sec) + + TimeInterval(status.st_ctimespec.tv_nsec) / 1_000_000_000 + ) + #else + return Date( + timeIntervalSince1970: TimeInterval(status.st_ctim.tv_sec) + + TimeInterval(status.st_ctim.tv_nsec) / 1_000_000_000 + ) + #endif + } + + private static func contentAccessDate(from status: stat) -> Date? { + #if os(macOS) + return Date( + timeIntervalSince1970: TimeInterval(status.st_atimespec.tv_sec) + + TimeInterval(status.st_atimespec.tv_nsec) / 1_000_000_000 + ) + #else + return Date( + timeIntervalSince1970: TimeInterval(status.st_atim.tv_sec) + + TimeInterval(status.st_atim.tv_nsec) / 1_000_000_000 + ) + #endif + } + + private static func modificationDate(from status: stat) -> Date? { + #if os(macOS) + return Date( + timeIntervalSince1970: TimeInterval(status.st_mtimespec.tv_sec) + + TimeInterval(status.st_mtimespec.tv_nsec) / 1_000_000_000 + ) + #else + return Date( + timeIntervalSince1970: TimeInterval(status.st_mtim.tv_sec) + + TimeInterval(status.st_mtim.tv_nsec) / 1_000_000_000 + ) + #endif + } + + private static func rewriteArchivedAbsoluteSymlinkTarget( + _ symlinkTarget: String, + sourceEntryPath: String, + archivedPathsByHostPath: [String: [String]] + ) -> String { + guard symlinkTarget.hasPrefix("/") else { + return symlinkTarget + } + + let targetPath = URL(fileURLWithPath: symlinkTarget) + .standardizedFileURL + .resolvingSymlinksInPath() + .path + guard let targetArchivePaths = archivedPathsByHostPath[targetPath], + targetArchivePaths.count == 1, + let targetArchivePath = targetArchivePaths.first + else { + return symlinkTarget + } + + let sourceDirectory = (sourceEntryPath as NSString).deletingLastPathComponent + return Self.relativeArchivePath(fromDirectory: sourceDirectory, to: targetArchivePath) + } + + private static func resolveArchivedDirectorySymlinkTarget( + _ symlinkTarget: String, + symlinkPath: FilePath + ) -> FilePath? { + if symlinkTarget.hasPrefix("/") { + let resolvedTargetPath = URL(fileURLWithPath: symlinkTarget) + .standardizedFileURL + .resolvingSymlinksInPath() + .path + return FilePath(resolvedTargetPath) + } + + let symlinkParent = symlinkPath.removingLastComponent() + return symlinkParent.appending(symlinkTarget).lexicallyNormalized() + } + + private static func rewriteArchivedDirectorySymlinkTarget( + _ symlinkTarget: String, + sourceEntryPath: String, + sourceRoot: FilePath, + resolvedTargetPath: FilePath + ) -> String { + guard symlinkTarget.hasPrefix("/"), + let targetArchivePath = Self.relativePath(path: resolvedTargetPath.string, within: sourceRoot.string) + else { + return symlinkTarget + } + + let sourceDirectory = (sourceEntryPath as NSString).deletingLastPathComponent + return Self.relativeArchivePath(fromDirectory: sourceDirectory, to: targetArchivePath) + } + + private static func relativePath(path: String, within root: String) -> String? { + if path == root { + return "" + } + + let rootPrefix = root.hasSuffix("/") ? root : root + "/" + guard path.hasPrefix(rootPrefix) else { + return nil + } + return String(path.dropFirst(rootPrefix.count)) + } + + private static func relativeArchivePath(fromDirectory: String, to path: String) -> String { + let fromComponents = Self.archivePathComponents(fromDirectory) + let toComponents = Self.archivePathComponents(path) + + var commonPrefixCount = 0 + while commonPrefixCount < fromComponents.count, + commonPrefixCount < toComponents.count, + fromComponents[commonPrefixCount] == toComponents[commonPrefixCount] + { + commonPrefixCount += 1 + } + + let upwardTraversal = Array(repeating: "..", count: fromComponents.count - commonPrefixCount) + let remainder = Array(toComponents.dropFirst(commonPrefixCount)) + let relativeComponents = upwardTraversal + remainder + return relativeComponents.isEmpty ? "." : relativeComponents.joined(separator: "/") + } + + private static func archivePathComponents(_ path: String) -> [String] { + NSString(string: path).pathComponents.filter { component in + component != "/" && component != "." + } + } } diff --git a/Tests/ContainerizationArchiveTests/ArchiveTests.swift b/Tests/ContainerizationArchiveTests/ArchiveTests.swift index c94d5ab0..2d39822b 100644 --- a/Tests/ContainerizationArchiveTests/ArchiveTests.swift +++ b/Tests/ContainerizationArchiveTests/ArchiveTests.swift @@ -34,6 +34,10 @@ struct ArchiveTests { return entry } + func archiveSourceEntry(pathOnHost: URL, pathInArchive: String) -> ArchiveSourceEntry { + ArchiveSourceEntry(pathOnHost: pathOnHost, pathInArchive: pathInArchive) + } + @Test func createTemporaryDirectorySuccess() throws { // Test that createTemporaryDirectory creates a directory with randomized suffix let baseName = "ArchiveTests.testTempDir" @@ -350,6 +354,35 @@ struct ArchiveTests { #expect(linkDest == "target.txt") } + @Test func archiveDirectoryRewritesInternalAbsoluteSymlink() throws { + let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirAbsoluteSymlink")! + defer { try? FileManager.default.removeItem(at: testDir) } + + let sourceDir = testDir.appendingPathComponent("source") + try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true) + let targetURL = sourceDir.appendingPathComponent("target.txt") + try "target content".write(to: targetURL, atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink( + atPath: sourceDir.appendingPathComponent("link.txt").path, + withDestinationPath: targetURL.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 extractedLink = extractDir.appendingPathComponent("link.txt") + let linkDest = try FileManager.default.destinationOfSymbolicLink(atPath: extractedLink.path) + #expect(linkDest == "target.txt") + #expect(try String(contentsOf: extractedLink, encoding: .utf8) == "target content") + } + @Test func archiveDirectorySymlinkOutsideExcluded() throws { let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirSymlinkOutside")! defer { try? FileManager.default.removeItem(at: testDir) } @@ -595,6 +628,105 @@ struct ArchiveTests { let content = try String(contentsOf: extractDir.appendingPathComponent("b/link.txt"), encoding: .utf8) #expect(content == "in a") } + + @Test func archiveEntriesRewritesInternalAbsoluteSymlink() throws { + let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveEntriesInternalAbsolute")! + defer { try? FileManager.default.removeItem(at: testDir) } + + let sourceDir = testDir.appendingPathComponent("source") + try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true) + let targetURL = sourceDir.appendingPathComponent("target.txt") + try "hello".write(to: targetURL, atomically: true, encoding: .utf8) + let linkURL = sourceDir.appendingPathComponent("link.txt") + try FileManager.default.createSymbolicLink(atPath: linkURL.path, withDestinationPath: targetURL.path) + + let archiveURL = testDir.appendingPathComponent("test.tar.gz") + let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL) + try writer.archiveEntries([ + archiveSourceEntry(pathOnHost: targetURL, pathInArchive: "target.txt"), + archiveSourceEntry(pathOnHost: linkURL, pathInArchive: "link.txt"), + ]) + 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 extractedLink = extractDir.appendingPathComponent("link.txt") + let linkDest = try FileManager.default.destinationOfSymbolicLink(atPath: extractedLink.path) + #expect(linkDest == "target.txt") + #expect(try String(contentsOf: extractedLink, encoding: .utf8) == "hello") + } + + @Test func archiveEntriesPreservesExternalAbsoluteSymlink() throws { + let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveEntriesExternalAbsolute")! + defer { try? FileManager.default.removeItem(at: testDir) } + + let sourceDir = testDir.appendingPathComponent("source") + try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true) + let externalTargetURL = testDir.appendingPathComponent("external-target.txt") + try "external".write(to: externalTargetURL, atomically: true, encoding: .utf8) + let linkURL = sourceDir.appendingPathComponent("absolute-link.txt") + try FileManager.default.createSymbolicLink(atPath: linkURL.path, withDestinationPath: externalTargetURL.path) + + let archiveURL = testDir.appendingPathComponent("test.tar.gz") + let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL) + try writer.archiveEntries([ + archiveSourceEntry(pathOnHost: linkURL, pathInArchive: "absolute-link.txt") + ]) + 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 extractedLink = extractDir.appendingPathComponent("absolute-link.txt") + let linkDest = try FileManager.default.destinationOfSymbolicLink(atPath: extractedLink.path) + #expect(linkDest == externalTargetURL.path) + } + + @Test func archiveEntriesCanonicalizesInternalAbsoluteSymlinkThroughAncestorSymlink() throws { + let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveEntriesCanonicalAbsolute")! + defer { try? FileManager.default.removeItem(at: testDir) } + + let sourceDir = testDir.appendingPathComponent("source") + try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true) + + let realDirURL = sourceDir.appendingPathComponent("real") + try FileManager.default.createDirectory(at: realDirURL, withIntermediateDirectories: true) + let targetURL = realDirURL.appendingPathComponent("target.txt") + try "hello".write(to: targetURL, atomically: true, encoding: .utf8) + + let aliasURL = sourceDir.appendingPathComponent("alias") + try FileManager.default.createSymbolicLink(atPath: aliasURL.path, withDestinationPath: "real") + + let linkURL = sourceDir.appendingPathComponent("link.txt") + try FileManager.default.createSymbolicLink( + atPath: linkURL.path, + withDestinationPath: sourceDir.appendingPathComponent("alias/target.txt").path + ) + + let archiveURL = testDir.appendingPathComponent("test.tar.gz") + let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL) + try writer.archiveEntries([ + archiveSourceEntry(pathOnHost: realDirURL, pathInArchive: "real"), + archiveSourceEntry(pathOnHost: targetURL, pathInArchive: "real/target.txt"), + archiveSourceEntry(pathOnHost: linkURL, pathInArchive: "link.txt"), + ]) + 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 extractedLink = extractDir.appendingPathComponent("link.txt") + let linkDest = try FileManager.default.destinationOfSymbolicLink(atPath: extractedLink.path) + #expect(linkDest == "real/target.txt") + #expect(try String(contentsOf: extractedLink, encoding: .utf8) == "hello") + } } private let surveyBundleBase64Encoded = """ From a25f55e5d88eba4492e76b82eb66ba193278fcd5 Mon Sep 17 00:00:00 2001 From: mazdak Date: Sun, 5 Apr 2026 17:27:37 -0400 Subject: [PATCH 2/2] ArchiveWriter: preserve symlink targets verbatim --- .../ArchiveWriter.swift | 100 ++---------------- .../ArchiveTests.swift | 12 +-- 2 files changed, 12 insertions(+), 100 deletions(-) diff --git a/Sources/ContainerizationArchive/ArchiveWriter.swift b/Sources/ContainerizationArchive/ArchiveWriter.swift index bfef215e..2d9cc89d 100644 --- a/Sources/ContainerizationArchive/ArchiveWriter.swift +++ b/Sources/ContainerizationArchive/ArchiveWriter.swift @@ -209,12 +209,8 @@ extension ArchiveWriter { extension ArchiveWriter { /// Archives an explicit, ordered list of host filesystem entries. public func archiveEntries(_ entries: [ArchiveSourceEntry]) throws { - let archivedPathsByHostPath = entries.reduce(into: [String: [String]]()) { result, entry in - result[entry.pathOnHost.path, default: []].append(entry.pathInArchive) - } - for source in entries { - guard let entry = try Self.makeEntry(from: source, archivedPathsByHostPath: archivedPathsByHostPath) else { + guard let entry = try Self.makeEntry(from: source) else { throw ArchiveError.failedToCreateArchive("unsupported file type at '\(source.pathOnHost.path)'") } try self.writeSourceEntry(entry: entry, sourcePath: source.pathOnHost.path) @@ -304,12 +300,8 @@ extension ArchiveWriter { guard resolvedFull.starts(with: dirPath) else { continue } - entry.symlinkTarget = Self.rewriteArchivedDirectorySymlinkTarget( - targetPath, - sourceEntryPath: relativePath, - sourceRoot: dirPath, - resolvedTargetPath: resolvedFull - ) + // Match Docker build-context semantics and preserve the original target verbatim. + entry.symlinkTarget = targetPath } entry.path = relativePath @@ -405,8 +397,7 @@ extension ArchiveWriter { } private static func makeEntry( - from source: ArchiveSourceEntry, - archivedPathsByHostPath: [String: [String]] + from source: ArchiveSourceEntry ) throws -> WriteEntry? { guard let status = try Self.fileStatus(atPath: source.pathOnHost.path) else { return nil @@ -423,11 +414,8 @@ extension ArchiveWriter { case .symbolicLink: entry.fileType = .symbolicLink entry.size = 0 - entry.symlinkTarget = Self.rewriteArchivedAbsoluteSymlinkTarget( - status.symlinkTarget ?? "", - sourceEntryPath: source.pathInArchive, - archivedPathsByHostPath: archivedPathsByHostPath - ) + // Match Docker build-context semantics and preserve the original target verbatim. + entry.symlinkTarget = status.symlinkTarget } entry.path = source.pathInArchive @@ -537,30 +525,6 @@ extension ArchiveWriter { #endif } - private static func rewriteArchivedAbsoluteSymlinkTarget( - _ symlinkTarget: String, - sourceEntryPath: String, - archivedPathsByHostPath: [String: [String]] - ) -> String { - guard symlinkTarget.hasPrefix("/") else { - return symlinkTarget - } - - let targetPath = URL(fileURLWithPath: symlinkTarget) - .standardizedFileURL - .resolvingSymlinksInPath() - .path - guard let targetArchivePaths = archivedPathsByHostPath[targetPath], - targetArchivePaths.count == 1, - let targetArchivePath = targetArchivePaths.first - else { - return symlinkTarget - } - - let sourceDirectory = (sourceEntryPath as NSString).deletingLastPathComponent - return Self.relativeArchivePath(fromDirectory: sourceDirectory, to: targetArchivePath) - } - private static func resolveArchivedDirectorySymlinkTarget( _ symlinkTarget: String, symlinkPath: FilePath @@ -576,56 +540,4 @@ extension ArchiveWriter { let symlinkParent = symlinkPath.removingLastComponent() return symlinkParent.appending(symlinkTarget).lexicallyNormalized() } - - private static func rewriteArchivedDirectorySymlinkTarget( - _ symlinkTarget: String, - sourceEntryPath: String, - sourceRoot: FilePath, - resolvedTargetPath: FilePath - ) -> String { - guard symlinkTarget.hasPrefix("/"), - let targetArchivePath = Self.relativePath(path: resolvedTargetPath.string, within: sourceRoot.string) - else { - return symlinkTarget - } - - let sourceDirectory = (sourceEntryPath as NSString).deletingLastPathComponent - return Self.relativeArchivePath(fromDirectory: sourceDirectory, to: targetArchivePath) - } - - private static func relativePath(path: String, within root: String) -> String? { - if path == root { - return "" - } - - let rootPrefix = root.hasSuffix("/") ? root : root + "/" - guard path.hasPrefix(rootPrefix) else { - return nil - } - return String(path.dropFirst(rootPrefix.count)) - } - - private static func relativeArchivePath(fromDirectory: String, to path: String) -> String { - let fromComponents = Self.archivePathComponents(fromDirectory) - let toComponents = Self.archivePathComponents(path) - - var commonPrefixCount = 0 - while commonPrefixCount < fromComponents.count, - commonPrefixCount < toComponents.count, - fromComponents[commonPrefixCount] == toComponents[commonPrefixCount] - { - commonPrefixCount += 1 - } - - let upwardTraversal = Array(repeating: "..", count: fromComponents.count - commonPrefixCount) - let remainder = Array(toComponents.dropFirst(commonPrefixCount)) - let relativeComponents = upwardTraversal + remainder - return relativeComponents.isEmpty ? "." : relativeComponents.joined(separator: "/") - } - - private static func archivePathComponents(_ path: String) -> [String] { - NSString(string: path).pathComponents.filter { component in - component != "/" && component != "." - } - } } diff --git a/Tests/ContainerizationArchiveTests/ArchiveTests.swift b/Tests/ContainerizationArchiveTests/ArchiveTests.swift index 2d39822b..b18e4537 100644 --- a/Tests/ContainerizationArchiveTests/ArchiveTests.swift +++ b/Tests/ContainerizationArchiveTests/ArchiveTests.swift @@ -354,7 +354,7 @@ struct ArchiveTests { #expect(linkDest == "target.txt") } - @Test func archiveDirectoryRewritesInternalAbsoluteSymlink() throws { + @Test func archiveDirectoryPreservesInternalAbsoluteSymlink() throws { let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirAbsoluteSymlink")! defer { try? FileManager.default.removeItem(at: testDir) } @@ -379,7 +379,7 @@ struct ArchiveTests { #expect(rejected.isEmpty) let extractedLink = extractDir.appendingPathComponent("link.txt") let linkDest = try FileManager.default.destinationOfSymbolicLink(atPath: extractedLink.path) - #expect(linkDest == "target.txt") + #expect(linkDest == targetURL.path) #expect(try String(contentsOf: extractedLink, encoding: .utf8) == "target content") } @@ -629,7 +629,7 @@ struct ArchiveTests { #expect(content == "in a") } - @Test func archiveEntriesRewritesInternalAbsoluteSymlink() throws { + @Test func archiveEntriesPreservesInternalAbsoluteSymlink() throws { let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveEntriesInternalAbsolute")! defer { try? FileManager.default.removeItem(at: testDir) } @@ -655,7 +655,7 @@ struct ArchiveTests { #expect(rejected.isEmpty) let extractedLink = extractDir.appendingPathComponent("link.txt") let linkDest = try FileManager.default.destinationOfSymbolicLink(atPath: extractedLink.path) - #expect(linkDest == "target.txt") + #expect(linkDest == targetURL.path) #expect(try String(contentsOf: extractedLink, encoding: .utf8) == "hello") } @@ -687,7 +687,7 @@ struct ArchiveTests { #expect(linkDest == externalTargetURL.path) } - @Test func archiveEntriesCanonicalizesInternalAbsoluteSymlinkThroughAncestorSymlink() throws { + @Test func archiveEntriesPreservesAbsoluteSymlinkThroughAncestorSymlink() throws { let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveEntriesCanonicalAbsolute")! defer { try? FileManager.default.removeItem(at: testDir) } @@ -724,7 +724,7 @@ struct ArchiveTests { #expect(rejected.isEmpty) let extractedLink = extractDir.appendingPathComponent("link.txt") let linkDest = try FileManager.default.destinationOfSymbolicLink(atPath: extractedLink.path) - #expect(linkDest == "real/target.txt") + #expect(linkDest == sourceDir.appendingPathComponent("alias/target.txt").path) #expect(try String(contentsOf: extractedLink, encoding: .utf8) == "hello") } }