Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 244 additions & 3 deletions Sources/ContainerizationArchive/ArchiveWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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<CChar>, path: String, sizeHint: Int) throws -> String {
let capacity = max(sizeHint + 1, Int(PATH_MAX))
let buffer = UnsafeMutablePointer<CChar>.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()
}
}
Loading