diff --git a/Sources/ContainerizationArchive/ArchiveWriter.swift b/Sources/ContainerizationArchive/ArchiveWriter.swift index 7f446bbe..2d9cc89d 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,16 @@ extension ArchiveWriter { } extension ArchiveWriter { + /// Archives an explicit, ordered list of host filesystem entries. + public func archiveEntries(_ entries: [ArchiveSourceEntry]) throws { + for source in entries { + 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) + } + } + /// 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,12 +291,16 @@ 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 } + // Match Docker build-context semantics and preserve the original target verbatim. entry.symlinkTarget = targetPath } @@ -299,4 +341,203 @@ 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 + ) 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 + // Match Docker build-context semantics and preserve the original target verbatim. + entry.symlinkTarget = status.symlinkTarget + } + + 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 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() + } } diff --git a/Tests/ContainerizationArchiveTests/ArchiveTests.swift b/Tests/ContainerizationArchiveTests/ArchiveTests.swift index c94d5ab0..b18e4537 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 archiveDirectoryPreservesInternalAbsoluteSymlink() 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 == targetURL.path) + #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 archiveEntriesPreservesInternalAbsoluteSymlink() 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 == targetURL.path) + #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 archiveEntriesPreservesAbsoluteSymlinkThroughAncestorSymlink() 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 == sourceDir.appendingPathComponent("alias/target.txt").path) + #expect(try String(contentsOf: extractedLink, encoding: .utf8) == "hello") + } } private let surveyBundleBase64Encoded = """