diff --git a/Sources/ContainerBuild/BuildFSSync.swift b/Sources/ContainerBuild/BuildFSSync.swift index c258f22f3..15305f367 100644 --- a/Sources/ContainerBuild/BuildFSSync.swift +++ b/Sources/ContainerBuild/BuildFSSync.swift @@ -23,6 +23,8 @@ import Foundation import GRPC actor BuildFSSync: BuildPipelineHandler { + private static let archiveRootURL = URL(filePath: "/", directoryHint: .isDirectory) + let contextDir: URL init(_ contextDir: URL) throws { @@ -114,7 +116,6 @@ actor BuildFSSync: BuildPipelineHandler { } private struct DirEntry: Hashable { - let url: URL let isDirectory: Bool let relativePath: String @@ -133,30 +134,32 @@ actor BuildFSSync: BuildPipelineHandler { _ buildID: String ) async throws { let wantsTar = packet.mode() == "tar" + let contextPath = contextDir.path + let contextPrefix = contextPath.hasSuffix("/") ? contextPath : contextPath + "/" var entries: [String: Set] = [:] let followPaths: [String] = packet.followPaths() ?? [] let followPathsWalked = try walk(root: self.contextDir, includePatterns: followPaths) for url in followPathsWalked { - guard self.contextDir.absoluteURL.cleanPath != url.absoluteURL.cleanPath else { + let path = url.path + guard path != contextPath else { continue } - guard self.contextDir.parentOf(url) else { + guard let relPath = Self.relativeChildPath(path: path, contextPath: contextPath, contextPrefix: contextPrefix) else { continue } - let relPath = try url.relativeChildPath(to: contextDir) - let parentPath = try url.deletingLastPathComponent().relativeChildPath(to: contextDir) - let entry = DirEntry(url: url, isDirectory: url.hasDirectoryPath, relativePath: relPath) + let parentPath = Self.relativeParentPath(path: path, contextPath: contextPath, contextPrefix: contextPrefix) + let entry = DirEntry(isDirectory: url.hasDirectoryPath, relativePath: relPath) entries[parentPath, default: []].insert(entry) if url.isSymlink { let target: URL = url.resolvingSymlinksInPath() - if self.contextDir.parentOf(target) { - let relPath = try target.relativeChildPath(to: self.contextDir) - let entry = DirEntry(url: target, isDirectory: target.hasDirectoryPath, relativePath: relPath) - let parentPath: String = try target.deletingLastPathComponent().relativeChildPath(to: self.contextDir) + let targetPath = target.path + if let relPath = Self.relativeChildPath(path: targetPath, contextPath: contextPath, contextPrefix: contextPrefix) { + let entry = DirEntry(isDirectory: target.hasDirectoryPath, relativePath: relPath) + let parentPath = Self.relativeParentPath(path: targetPath, contextPath: contextPath, contextPrefix: contextPrefix) entries[parentPath, default: []].insert(entry) } } @@ -200,35 +203,18 @@ actor BuildFSSync: BuildPipelineHandler { format: .paxRestricted, filter: .none) + let archiveEntries = fileOrder.map { rel in + Archiver.ArchiveEntryInfo( + pathOnHost: contextDir.appending(path: rel, directoryHint: .notDirectory), + pathInArchive: URL(filePath: rel, directoryHint: .notDirectory, relativeTo: Self.archiveRootURL) + ) + } + let tarHash = try Archiver.compress( - source: contextDir, + entries: archiveEntries, destination: tarURL, writerConfiguration: writerCfg - ) { url in - guard let rel = try? url.relativeChildPath(to: contextDir) else { - return nil - } - - guard let parent = try? url.deletingLastPathComponent().relativeChildPath(to: self.contextDir) else { - return nil - } - - guard let items = entries[parent] else { - return nil - } - - let include = items.contains { item in - item.relativePath == rel - } - - guard include else { - return nil - } - - return Archiver.ArchiveEntryInfo( - pathOnHost: url, - pathInArchive: URL(fileURLWithPath: rel)) - } + ) let hash = tarHash.compactMap { String(format: "%02x", $0) }.joined() let header = BuildTransfer( @@ -323,6 +309,21 @@ actor BuildFSSync: BuildPipelineHandler { } } + private static func relativeChildPath(path: String, contextPath: String, contextPrefix: String) -> String? { + if path == contextPath { + return "" + } + guard path.hasPrefix(contextPrefix) else { + return nil + } + return String(path.dropFirst(contextPrefix.count)) + } + + private static func relativeParentPath(path: String, contextPath: String, contextPrefix: String) -> String { + let parentPath = (path as NSString).deletingLastPathComponent + return relativeChildPath(path: parentPath, contextPath: contextPath, contextPrefix: contextPrefix) ?? "" + } + struct FileInfo: Codable { let name: String let modTime: String diff --git a/Sources/Services/ContainerAPIService/Client/Archiver.swift b/Sources/Services/ContainerAPIService/Client/Archiver.swift index d20fb6323..70c121ec6 100644 --- a/Sources/Services/ContainerAPIService/Client/Archiver.swift +++ b/Sources/Services/ContainerAPIService/Client/Archiver.swift @@ -17,9 +17,27 @@ import ContainerizationArchive import ContainerizationOS import CryptoKit +import Darwin import Foundation public final class Archiver: Sendable { + private struct FileStatus { + enum EntryType { + case directory + case regular + case symbolicLink + } + + let entryType: EntryType + let permissions: UInt16 + let size: Int64 + let owner: UInt32 + let group: UInt32 + let creationDate: Date? + let modificationDate: Date? + let symlinkTarget: String? + } + public struct ArchiveEntryInfo: Sendable, Codable { public let pathOnHost: URL public let pathInArchive: URL @@ -43,6 +61,17 @@ public final class Archiver: Sendable { } } + private struct ArchiveEntryHashInfo: Encodable { + let pathOnHost: String + let pathInArchive: String + let owner: UInt32? + let group: UInt32? + let permissions: UInt16? + let fileType: String + let symlinkTarget: String? + let size: Int64? + } + public static func compress( source: URL, destination: URL, @@ -83,23 +112,40 @@ public final class Archiver: Sendable { entryInfo.append(info) } } - - let archiver = try ArchiveWriter( - configuration: writerConfiguration + try Self._compressEntries( + entryInfo, + destination: destination, + writerConfiguration: writerConfiguration, + hasher: &hasher ) - try archiver.open(file: destination) + } catch { + try? fileManager.removeItem(at: destination) + throw error + } + + return hasher.finalize() + } + + public static func compress( + entries: [ArchiveEntryInfo], + destination: URL, + writerConfiguration: ArchiveWriterConfiguration = ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip) + ) throws -> SHA256.Digest { + let destination = destination.standardizedFileURL + let fileManager = FileManager.default + try? fileManager.removeItem(at: destination) - let encoder = JSONEncoder() - encoder.outputFormatting = .sortedKeys + var hasher = SHA256() - for info in entryInfo { - guard let entry = try Self._createEntry(entryInfo: info) else { - throw Error.failedToCreateEntry - } - hasher.update(data: try encoder.encode(info)) - try Self._compressFile(item: info.pathOnHost, entry: entry, archiver: archiver, hasher: &hasher) - } - try archiver.finishEncoding() + do { + let directory = destination.deletingLastPathComponent() + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + try Self._compressEntries( + entries, + destination: destination, + writerConfiguration: writerConfiguration, + hasher: &hasher + ) } catch { try? fileManager.removeItem(at: destination) throw error @@ -196,8 +242,47 @@ public final class Archiver: Sendable { } // MARK: private functions - private static func _compressFile(item: URL, entry: WriteEntry, archiver: ArchiveWriter, hasher: inout SHA256) throws { - guard let stream = InputStream(url: item) else { + private static func _compressEntries( + _ entryInfo: [ArchiveEntryInfo], + destination: URL, + writerConfiguration: ArchiveWriterConfiguration, + hasher: inout SHA256 + ) throws { + let archivedPathsByHostPath = entryInfo.reduce(into: [String: [URL]]()) { result, info in + result[info.pathOnHost.path, default: []].append(info.pathInArchive) + } + + let archiver = try ArchiveWriter(configuration: writerConfiguration) + try archiver.open(file: destination) + + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + + for info in entryInfo { + guard let entry = try Self._createEntry(entryInfo: info, archivedPathsByHostPath: archivedPathsByHostPath) else { + throw Error.failedToCreateEntry + } + let hashInfo = ArchiveEntryHashInfo( + pathOnHost: info.pathOnHost.path, + pathInArchive: info.pathInArchive.relativePath, + owner: info.owner, + group: info.group, + permissions: info.permissions, + fileType: entry.fileType.rawValue, + symlinkTarget: entry.symlinkTarget, + size: entry.size + ) + hasher.update(data: try encoder.encode(hashInfo)) + try Self._compressFile(itemPath: info.pathOnHost.path, entry: entry, archiver: archiver, hasher: &hasher) + } + try archiver.finishEncoding() + } + + private static func _compressFile(itemPath: String, entry: WriteEntry, archiver: ArchiveWriter, hasher: inout SHA256) throws { + guard entry.fileType == .regular else { + let writer = archiver.makeTransactionWriter() + try writer.writeHeader(entry: entry) + try writer.finish() return } @@ -205,69 +290,74 @@ public final class Archiver: Sendable { let bufferSize = Int(1.mib()) let readBuffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { readBuffer.deallocate() } - stream.open() try writer.writeHeader(entry: entry) - while true { - let byteRead = stream.read(readBuffer, maxLength: bufferSize) - if byteRead <= 0 { - break - } else { - let data = Data(bytes: readBuffer, count: byteRead) + try itemPath.withCString { fileSystemPath in + let fd = open(fileSystemPath, O_RDONLY) + guard fd >= 0 else { + throw POSIXError.fromErrno() + } + defer { close(fd) } + + while true { + let bytesRead = read(fd, readBuffer, bufferSize) + if bytesRead < 0 { + if errno == EINTR { + continue + } + throw POSIXError.fromErrno() + } + if bytesRead == 0 { + break + } + + let data = Data(bytesNoCopy: readBuffer, count: bytesRead, deallocator: .none) hasher.update(data: data) try data.withUnsafeBytes { pointer in try writer.writeChunk(data: pointer) } } } - stream.close() try writer.finish() } - private static func _createEntry(entryInfo: ArchiveEntryInfo, pathPrefix: String = "") throws -> WriteEntry? { + private static func _createEntry( + entryInfo: ArchiveEntryInfo, + archivedPathsByHostPath: [String: [URL]] = [:], + pathPrefix: String = "" + ) throws -> WriteEntry? { let entry = WriteEntry() - let fileManager = FileManager.default - let attributes = try fileManager.attributesOfItem(atPath: entryInfo.pathOnHost.path) - - if let fileType = attributes[.type] as? FileAttributeType { - switch fileType { - case .typeBlockSpecial, .typeCharacterSpecial, .typeSocket: - return nil - case .typeDirectory: - entry.fileType = .directory - case .typeRegular: - entry.fileType = .regular - case .typeSymbolicLink: - entry.fileType = .symbolicLink - let symlinkTarget = try fileManager.destinationOfSymbolicLink(atPath: entryInfo.pathOnHost.path) - entry.symlinkTarget = symlinkTarget - default: - return nil - } - } - if let posixPermissions = attributes[.posixPermissions] as? NSNumber { - #if os(macOS) - entry.permissions = posixPermissions.uint16Value - #else - entry.permissions = posixPermissions.uint32Value - #endif - } - if let fileSize = attributes[.size] as? UInt64 { - entry.size = Int64(fileSize) - } - if let uid = attributes[.ownerAccountID] as? NSNumber { - entry.owner = uid.uint32Value - } - if let gid = attributes[.groupOwnerAccountID] as? NSNumber { - entry.group = gid.uint32Value - } - if let creationDate = attributes[.creationDate] as? Date { - entry.creationDate = creationDate - } - if let modificationDate = attributes[.modificationDate] as? Date { - entry.modificationDate = modificationDate + let hostPath = entryInfo.pathOnHost.path + let status = try Self._fileStatus(atPath: hostPath) + + 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 ?? "", + entryInfo: entryInfo, + archivedPathsByHostPath: archivedPathsByHostPath + ) } + #if os(macOS) + entry.permissions = status.permissions + #else + entry.permissions = UInt32(status.permissions) + #endif + entry.owner = status.owner + entry.group = status.group + entry.creationDate = status.creationDate + entry.modificationDate = status.modificationDate + // Apply explicit overrides from ArchiveEntryInfo when provided if let overrideOwner = entryInfo.owner { entry.owner = overrideOwner @@ -288,6 +378,83 @@ public final class Archiver: Sendable { 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 { + throw POSIXError.fromErrno() + } + + 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, sizeHint: Int(status.st_size)) + default: + throw Error.failedToCreateEntry + } + + return FileStatus( + entryType: entryType, + permissions: UInt16(status.st_mode & 0o7777), + size: Int64(status.st_size), + owner: status.st_uid, + group: status.st_gid, + creationDate: Self._creationDate(from: status), + modificationDate: Self._modificationDate(from: status), + symlinkTarget: symlinkTarget + ) + } + } + + private static func _symlinkTarget(fileSystemPath: UnsafePointer, 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 { + throw POSIXError.fromErrno() + } + + buffer[count] = 0 + return String(cString: buffer) + } + + private static func _creationDate(from status: stat) -> Date? { + #if os(macOS) + return Date( + timeIntervalSince1970: TimeInterval(status.st_birthtimespec.tv_sec) + + TimeInterval(status.st_birthtimespec.tv_nsec) / 1_000_000_000 + ) + #else + return nil + #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 _trimPathPrefix(_ path: String, pathPrefix: String) -> String { guard !path.isEmpty && !pathPrefix.isEmpty else { return path @@ -302,6 +469,51 @@ public final class Archiver: Sendable { return trimmedPath } + private static func _rewriteArchivedAbsoluteSymlinkTarget( + _ symlinkTarget: String, + entryInfo: ArchiveEntryInfo, + archivedPathsByHostPath: [String: [URL]] + ) -> 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 = entryInfo.pathInArchive.deletingLastPathComponent().relativePath + return Self._relativeArchivePath(fromDirectory: sourceDirectory, to: targetArchivePath.relativePath) + } + + 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 != "." + } + } + private static func _isSymbolicLink(_ path: URL) throws -> Bool { let resourceValues = try path.resourceValues(forKeys: [.isSymbolicLinkKey]) if let isSymbolicLink = resourceValues.isSymbolicLink { diff --git a/Tests/ContainerAPIClientTests/ArchiverTests.swift b/Tests/ContainerAPIClientTests/ArchiverTests.swift new file mode 100644 index 000000000..5c5b10452 --- /dev/null +++ b/Tests/ContainerAPIClientTests/ArchiverTests.swift @@ -0,0 +1,275 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container 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 Testing + +@testable import ContainerAPIClient + +struct ArchiverTests { + @Test + func testCompressAndUncompressPreservesRelativeSymbolicLink() throws { + let fileManager = FileManager.default + let tempURL = try fileManager.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? fileManager.removeItem(at: tempURL) } + + let sourceURL = tempURL.appendingPathComponent("source") + let archiveURL = tempURL.appendingPathComponent("archive.tar.gz") + let destinationURL = tempURL.appendingPathComponent("destination") + try fileManager.createDirectory(at: sourceURL, withIntermediateDirectories: true) + + let targetURL = sourceURL.appendingPathComponent("target.txt") + try #require("hello".data(using: .utf8)).write(to: targetURL) + let linkURL = sourceURL.appendingPathComponent("link.txt") + try fileManager.createSymbolicLink(atPath: linkURL.path, withDestinationPath: "target.txt") + + _ = try Archiver.compress(source: sourceURL, destination: archiveURL) { url in + let sourcePath = sourceURL.standardizedFileURL.path + let path = url.standardizedFileURL.path + let relativePath = String(path.dropFirst(sourcePath.count + 1)) + return Archiver.ArchiveEntryInfo( + pathOnHost: url, + pathInArchive: URL(fileURLWithPath: relativePath) + ) + } + + try Archiver.uncompress(source: archiveURL, destination: destinationURL) + + let extractedLinkURL = destinationURL.appendingPathComponent("link.txt") + let values = try extractedLinkURL.resourceValues(forKeys: [.isSymbolicLinkKey]) + #expect(values.isSymbolicLink == true) + #expect(try fileManager.destinationOfSymbolicLink(atPath: extractedLinkURL.path) == "target.txt") + #expect(try String(contentsOf: destinationURL.appendingPathComponent("target.txt"), encoding: .utf8) == "hello") + } + + @Test + func testCompressAndUncompressPreservesAbsoluteSymbolicLink() throws { + let fileManager = FileManager.default + let tempURL = try fileManager.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? fileManager.removeItem(at: tempURL) } + + let sourceURL = tempURL.appendingPathComponent("source") + let archiveURL = tempURL.appendingPathComponent("archive.tar.gz") + let destinationURL = tempURL.appendingPathComponent("destination") + try fileManager.createDirectory(at: sourceURL, withIntermediateDirectories: true) + + let externalTargetURL = tempURL.appendingPathComponent("external-target.txt") + try #require("external".data(using: .utf8)).write(to: externalTargetURL) + let linkURL = sourceURL.appendingPathComponent("absolute-link.txt") + try fileManager.createSymbolicLink(atPath: linkURL.path, withDestinationPath: externalTargetURL.path) + + _ = try Archiver.compress(source: sourceURL, destination: archiveURL) { url in + let sourcePath = sourceURL.standardizedFileURL.path + let path = url.standardizedFileURL.path + let relativePath = String(path.dropFirst(sourcePath.count + 1)) + return Archiver.ArchiveEntryInfo( + pathOnHost: url, + pathInArchive: URL(fileURLWithPath: relativePath) + ) + } + + try Archiver.uncompress(source: archiveURL, destination: destinationURL) + + let extractedLinkURL = destinationURL.appendingPathComponent("absolute-link.txt") + let values = try extractedLinkURL.resourceValues(forKeys: [.isSymbolicLinkKey]) + #expect(values.isSymbolicLink == true) + #expect(try fileManager.destinationOfSymbolicLink(atPath: extractedLinkURL.path) == externalTargetURL.path) + } + + @Test + func testCompressAndUncompressRewritesArchivedAbsoluteSymbolicLinkTarget() throws { + let fileManager = FileManager.default + let tempURL = try fileManager.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? fileManager.removeItem(at: tempURL) } + + let sourceURL = tempURL.appendingPathComponent("source") + let archiveURL = tempURL.appendingPathComponent("archive.tar.gz") + let destinationURL = tempURL.appendingPathComponent("destination") + try fileManager.createDirectory(at: sourceURL, withIntermediateDirectories: true) + + let targetURL = sourceURL.appendingPathComponent("target.txt") + try #require("hello".data(using: .utf8)).write(to: targetURL) + let linkURL = sourceURL.appendingPathComponent("link.txt") + try fileManager.createSymbolicLink(atPath: linkURL.path, withDestinationPath: targetURL.path) + + _ = try Archiver.compress(source: sourceURL, destination: archiveURL) { url in + let sourcePath = sourceURL.standardizedFileURL.path + let path = url.standardizedFileURL.path + let relativePath = String(path.dropFirst(sourcePath.count + 1)) + return Archiver.ArchiveEntryInfo( + pathOnHost: url, + pathInArchive: URL(fileURLWithPath: relativePath) + ) + } + + try Archiver.uncompress(source: archiveURL, destination: destinationURL) + + let extractedLinkURL = destinationURL.appendingPathComponent("link.txt") + let values = try extractedLinkURL.resourceValues(forKeys: [.isSymbolicLinkKey]) + #expect(values.isSymbolicLink == true) + #expect(try fileManager.destinationOfSymbolicLink(atPath: extractedLinkURL.path) == "target.txt") + #expect(try String(contentsOf: extractedLinkURL, encoding: .utf8) == "hello") + } + + @Test + func testCompressAndUncompressRewritesArchivedAbsoluteSymbolicLinkTargetThroughSymlinkedAncestor() throws { + let fileManager = FileManager.default + let tempURL = try fileManager.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? fileManager.removeItem(at: tempURL) } + + let sourceURL = tempURL.appendingPathComponent("source") + let archiveURL = tempURL.appendingPathComponent("archive.tar.gz") + let destinationURL = tempURL.appendingPathComponent("destination") + try fileManager.createDirectory(at: sourceURL, withIntermediateDirectories: true) + + let realDirectoryURL = sourceURL.appendingPathComponent("real") + try fileManager.createDirectory(at: realDirectoryURL, withIntermediateDirectories: true) + let targetURL = realDirectoryURL.appendingPathComponent("target.txt") + try #require("hello".data(using: .utf8)).write(to: targetURL) + + let aliasURL = sourceURL.appendingPathComponent("alias") + try fileManager.createSymbolicLink(atPath: aliasURL.path, withDestinationPath: "real") + + let linkURL = sourceURL.appendingPathComponent("link.txt") + try fileManager.createSymbolicLink( + atPath: linkURL.path, + withDestinationPath: sourceURL.appendingPathComponent("alias/target.txt").path + ) + + _ = try Archiver.compress(source: sourceURL, destination: archiveURL) { url in + let sourcePath = sourceURL.standardizedFileURL.path + let path = url.standardizedFileURL.path + let relativePath = String(path.dropFirst(sourcePath.count + 1)) + return Archiver.ArchiveEntryInfo( + pathOnHost: url, + pathInArchive: URL(fileURLWithPath: relativePath) + ) + } + + try Archiver.uncompress(source: archiveURL, destination: destinationURL) + + let extractedLinkURL = destinationURL.appendingPathComponent("link.txt") + let values = try extractedLinkURL.resourceValues(forKeys: [.isSymbolicLinkKey]) + #expect(values.isSymbolicLink == true) + #expect(try fileManager.destinationOfSymbolicLink(atPath: extractedLinkURL.path) == "real/target.txt") + #expect(try String(contentsOf: extractedLinkURL, encoding: .utf8) == "hello") + } + + @Test + func testCompressDigestChangesWhenSymlinkTargetChanges() throws { + let fileManager = FileManager.default + let tempURL = try fileManager.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? fileManager.removeItem(at: tempURL) } + + let sourceURL = tempURL.appendingPathComponent("source") + try fileManager.createDirectory(at: sourceURL, withIntermediateDirectories: true) + + let firstTargetURL = sourceURL.appendingPathComponent("first.txt") + let secondTargetURL = sourceURL.appendingPathComponent("second.txt") + try #require("first".data(using: .utf8)).write(to: firstTargetURL) + try #require("second".data(using: .utf8)).write(to: secondTargetURL) + + let linkURL = sourceURL.appendingPathComponent("link.txt") + try fileManager.createSymbolicLink(atPath: linkURL.path, withDestinationPath: "first.txt") + + let firstArchiveURL = tempURL.appendingPathComponent("first.tar.gz") + let firstDigest = try archiveDigest(sourceURL: sourceURL, destinationURL: firstArchiveURL) + + try fileManager.removeItem(at: linkURL) + try fileManager.createSymbolicLink(atPath: linkURL.path, withDestinationPath: "second.txt") + + let secondArchiveURL = tempURL.appendingPathComponent("second.tar.gz") + let secondDigest = try archiveDigest(sourceURL: sourceURL, destinationURL: secondArchiveURL) + + #expect(firstDigest != secondDigest) + } + + @Test + func testCompressEntriesOnlyArchivesExplicitInputs() throws { + let fileManager = FileManager.default + let tempURL = try fileManager.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? fileManager.removeItem(at: tempURL) } + + let sourceURL = tempURL.appendingPathComponent("source") + let archiveURL = tempURL.appendingPathComponent("archive.tar.gz") + let destinationURL = tempURL.appendingPathComponent("destination") + try fileManager.createDirectory(at: sourceURL, withIntermediateDirectories: true) + + let includeURL = sourceURL.appendingPathComponent("include.txt") + let excludeURL = sourceURL.appendingPathComponent("exclude.txt") + try #require("keep".data(using: .utf8)).write(to: includeURL) + try #require("drop".data(using: .utf8)).write(to: excludeURL) + + _ = try Archiver.compress( + entries: [ + Archiver.ArchiveEntryInfo( + pathOnHost: includeURL, + pathInArchive: URL(fileURLWithPath: "include.txt") + ) + ], + destination: archiveURL + ) + + try Archiver.uncompress(source: archiveURL, destination: destinationURL) + + #expect(fileManager.fileExists(atPath: destinationURL.appendingPathComponent("include.txt").path)) + #expect(!fileManager.fileExists(atPath: destinationURL.appendingPathComponent("exclude.txt").path)) + } + + private func archiveDigest(sourceURL: URL, destinationURL: URL) throws -> String { + let digest = try Archiver.compress(source: sourceURL, destination: destinationURL) { url in + let sourcePath = sourceURL.standardizedFileURL.path + let path = url.standardizedFileURL.path + let relativePath = String(path.dropFirst(sourcePath.count + 1)) + return Archiver.ArchiveEntryInfo( + pathOnHost: url, + pathInArchive: URL(fileURLWithPath: relativePath) + ) + } + + return digest.compactMap { String(format: "%02x", $0) }.joined() + } +}