Skip to content

Commit e36060c

Browse files
committed
ArchiveWriter: support explicit entries and rewrite internal symlinks
1 parent 5691645 commit e36060c

2 files changed

Lines changed: 465 additions & 4 deletions

File tree

Sources/ContainerizationArchive/ArchiveWriter.swift

Lines changed: 333 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,34 @@ public class ArchiveWriterTransaction {
111111
}
112112
}
113113

114+
/// Represents a host filesystem entry to be archived at a specific path.
115+
public struct ArchiveSourceEntry: Sendable {
116+
/// Path to the item on the host filesystem.
117+
public let pathOnHost: URL
118+
/// Path to use for the entry inside the archive.
119+
public let pathInArchive: String
120+
/// Optional owner override for the archived entry.
121+
public let owner: uid_t?
122+
/// Optional group override for the archived entry.
123+
public let group: gid_t?
124+
/// Optional permissions override for the archived entry.
125+
public let permissions: mode_t?
126+
127+
public init(
128+
pathOnHost: URL,
129+
pathInArchive: String,
130+
owner: uid_t? = nil,
131+
group: gid_t? = nil,
132+
permissions: mode_t? = nil
133+
) {
134+
self.pathOnHost = pathOnHost
135+
self.pathInArchive = pathInArchive
136+
self.owner = owner
137+
self.group = group
138+
self.permissions = permissions
139+
}
140+
}
141+
114142
extension ArchiveWriter {
115143
public func makeTransactionWriter() -> ArchiveWriterTransaction {
116144
ArchiveWriterTransaction(writer: self)
@@ -179,6 +207,20 @@ extension ArchiveWriter {
179207
}
180208

181209
extension ArchiveWriter {
210+
/// Archives an explicit, ordered list of host filesystem entries.
211+
public func archiveEntries(_ entries: [ArchiveSourceEntry]) throws {
212+
let archivedPathsByHostPath = entries.reduce(into: [String: [String]]()) { result, entry in
213+
result[entry.pathOnHost.path, default: []].append(entry.pathInArchive)
214+
}
215+
216+
for source in entries {
217+
guard let entry = try Self.makeEntry(from: source, archivedPathsByHostPath: archivedPathsByHostPath) else {
218+
throw ArchiveError.failedToCreateArchive("unsupported file type at '\(source.pathOnHost.path)'")
219+
}
220+
try self.writeSourceEntry(entry: entry, sourcePath: source.pathOnHost.path)
221+
}
222+
}
223+
182224
/// Recursively archives the content of a directory. Regular files, symlinks and directories are added into the archive.
183225
/// Note: Symlinks are added to the archive if both the source and target for the symlink are both contained in the top level directory.
184226
public func archiveDirectory(_ dir: URL) throws {
@@ -253,13 +295,21 @@ extension ArchiveWriter {
253295
let entry = WriteEntry()
254296
if type == .symbolicLink {
255297
let targetPath = try fm.destinationOfSymbolicLink(atPath: fullPath.string)
256-
// Resolve the target relative to the symlink's parent, not the archive root.
257-
let symlinkParent = fullPath.removingLastComponent()
258-
let resolvedFull = symlinkParent.appending(targetPath).lexicallyNormalized()
298+
guard let resolvedFull = Self.resolveArchivedDirectorySymlinkTarget(
299+
targetPath,
300+
symlinkPath: fullPath
301+
) else {
302+
continue
303+
}
259304
guard resolvedFull.starts(with: dirPath) else {
260305
continue
261306
}
262-
entry.symlinkTarget = targetPath
307+
entry.symlinkTarget = Self.rewriteArchivedDirectorySymlinkTarget(
308+
targetPath,
309+
sourceEntryPath: relativePath,
310+
sourceRoot: dirPath,
311+
resolvedTargetPath: resolvedFull
312+
)
263313
}
264314

265315
entry.path = relativePath
@@ -299,4 +349,283 @@ extension ArchiveWriter {
299349
}
300350
}
301351
}
352+
353+
private struct FileStatus {
354+
enum EntryType {
355+
case directory
356+
case regular
357+
case symbolicLink
358+
}
359+
360+
let entryType: EntryType
361+
let permissions: mode_t
362+
let size: Int64
363+
let owner: uid_t
364+
let group: gid_t
365+
let creationDate: Date?
366+
let contentAccessDate: Date?
367+
let modificationDate: Date?
368+
let symlinkTarget: String?
369+
}
370+
371+
private func writeSourceEntry(entry: WriteEntry, sourcePath: String) throws {
372+
guard entry.fileType == .regular else {
373+
try self.writeEntry(entry: entry, data: nil)
374+
return
375+
}
376+
377+
let writer = self.makeTransactionWriter()
378+
let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: Self.chunkSize, alignment: 1)
379+
guard let baseAddress = buffer.baseAddress else {
380+
buffer.deallocate()
381+
throw ArchiveError.failedToCreateArchive("cannot create temporary buffer of size \(Self.chunkSize)")
382+
}
383+
defer { buffer.deallocate() }
384+
385+
let fd = Foundation.open(sourcePath, O_RDONLY)
386+
guard fd >= 0 else {
387+
let err = POSIXErrorCode(rawValue: errno) ?? .EINVAL
388+
throw ArchiveError.failedToCreateArchive("cannot open file \(sourcePath) for reading: \(err)")
389+
}
390+
defer { close(fd) }
391+
392+
try writer.writeHeader(entry: entry)
393+
while true {
394+
let bytesRead = read(fd, baseAddress, Self.chunkSize)
395+
if bytesRead == 0 {
396+
break
397+
}
398+
if bytesRead < 0 {
399+
let err = POSIXErrorCode(rawValue: errno) ?? .EIO
400+
throw ArchiveError.failedToCreateArchive("failed to read from file \(sourcePath): \(err)")
401+
}
402+
try writer.writeChunk(data: UnsafeRawBufferPointer(start: baseAddress, count: bytesRead))
403+
}
404+
try writer.finish()
405+
}
406+
407+
private static func makeEntry(
408+
from source: ArchiveSourceEntry,
409+
archivedPathsByHostPath: [String: [String]]
410+
) throws -> WriteEntry? {
411+
guard let status = try Self.fileStatus(atPath: source.pathOnHost.path) else {
412+
return nil
413+
}
414+
let entry = WriteEntry()
415+
416+
switch status.entryType {
417+
case .directory:
418+
entry.fileType = .directory
419+
entry.size = 0
420+
case .regular:
421+
entry.fileType = .regular
422+
entry.size = status.size
423+
case .symbolicLink:
424+
entry.fileType = .symbolicLink
425+
entry.size = 0
426+
entry.symlinkTarget = Self.rewriteArchivedAbsoluteSymlinkTarget(
427+
status.symlinkTarget ?? "",
428+
sourceEntryPath: source.pathInArchive,
429+
archivedPathsByHostPath: archivedPathsByHostPath
430+
)
431+
}
432+
433+
entry.path = source.pathInArchive
434+
entry.permissions = source.permissions ?? status.permissions
435+
entry.owner = source.owner ?? status.owner
436+
entry.group = source.group ?? status.group
437+
entry.creationDate = status.creationDate
438+
entry.contentAccessDate = status.contentAccessDate
439+
entry.modificationDate = status.modificationDate
440+
return entry
441+
}
442+
443+
private static func fileStatus(atPath path: String) throws -> FileStatus? {
444+
try path.withCString { fileSystemPath in
445+
var status = stat()
446+
guard lstat(fileSystemPath, &status) == 0 else {
447+
let err = POSIXErrorCode(rawValue: errno) ?? .EINVAL
448+
throw ArchiveError.failedToCreateArchive("lstat failed for '\(path)': \(POSIXError(err))")
449+
}
450+
451+
let mode = status.st_mode & S_IFMT
452+
let entryType: FileStatus.EntryType
453+
let symlinkTarget: String?
454+
455+
switch mode {
456+
case S_IFDIR:
457+
entryType = .directory
458+
symlinkTarget = nil
459+
case S_IFREG:
460+
entryType = .regular
461+
symlinkTarget = nil
462+
case S_IFLNK:
463+
entryType = .symbolicLink
464+
symlinkTarget = try Self.symlinkTarget(fileSystemPath: fileSystemPath, path: path, sizeHint: Int(status.st_size))
465+
default:
466+
return nil
467+
}
468+
469+
return FileStatus(
470+
entryType: entryType,
471+
permissions: status.st_mode & 0o7777,
472+
size: Int64(status.st_size),
473+
owner: status.st_uid,
474+
group: status.st_gid,
475+
creationDate: Self.creationDate(from: status),
476+
contentAccessDate: Self.contentAccessDate(from: status),
477+
modificationDate: Self.modificationDate(from: status),
478+
symlinkTarget: symlinkTarget
479+
)
480+
}
481+
}
482+
483+
private static func symlinkTarget(fileSystemPath: UnsafePointer<CChar>, path: String, sizeHint: Int) throws -> String {
484+
let capacity = max(sizeHint + 1, Int(PATH_MAX))
485+
let buffer = UnsafeMutablePointer<CChar>.allocate(capacity: capacity)
486+
defer { buffer.deallocate() }
487+
488+
let count = readlink(fileSystemPath, buffer, capacity - 1)
489+
guard count >= 0 else {
490+
let err = POSIXErrorCode(rawValue: errno) ?? .EINVAL
491+
throw ArchiveError.failedToCreateArchive("readlink failed for '\(path)': \(POSIXError(err))")
492+
}
493+
494+
buffer[count] = 0
495+
return String(cString: buffer)
496+
}
497+
498+
private static func creationDate(from status: stat) -> Date? {
499+
#if os(macOS)
500+
return Date(
501+
timeIntervalSince1970: TimeInterval(status.st_ctimespec.tv_sec)
502+
+ TimeInterval(status.st_ctimespec.tv_nsec) / 1_000_000_000
503+
)
504+
#else
505+
return Date(
506+
timeIntervalSince1970: TimeInterval(status.st_ctim.tv_sec)
507+
+ TimeInterval(status.st_ctim.tv_nsec) / 1_000_000_000
508+
)
509+
#endif
510+
}
511+
512+
private static func contentAccessDate(from status: stat) -> Date? {
513+
#if os(macOS)
514+
return Date(
515+
timeIntervalSince1970: TimeInterval(status.st_atimespec.tv_sec)
516+
+ TimeInterval(status.st_atimespec.tv_nsec) / 1_000_000_000
517+
)
518+
#else
519+
return Date(
520+
timeIntervalSince1970: TimeInterval(status.st_atim.tv_sec)
521+
+ TimeInterval(status.st_atim.tv_nsec) / 1_000_000_000
522+
)
523+
#endif
524+
}
525+
526+
private static func modificationDate(from status: stat) -> Date? {
527+
#if os(macOS)
528+
return Date(
529+
timeIntervalSince1970: TimeInterval(status.st_mtimespec.tv_sec)
530+
+ TimeInterval(status.st_mtimespec.tv_nsec) / 1_000_000_000
531+
)
532+
#else
533+
return Date(
534+
timeIntervalSince1970: TimeInterval(status.st_mtim.tv_sec)
535+
+ TimeInterval(status.st_mtim.tv_nsec) / 1_000_000_000
536+
)
537+
#endif
538+
}
539+
540+
private static func rewriteArchivedAbsoluteSymlinkTarget(
541+
_ symlinkTarget: String,
542+
sourceEntryPath: String,
543+
archivedPathsByHostPath: [String: [String]]
544+
) -> String {
545+
guard symlinkTarget.hasPrefix("/") else {
546+
return symlinkTarget
547+
}
548+
549+
let targetPath = URL(fileURLWithPath: symlinkTarget)
550+
.standardizedFileURL
551+
.resolvingSymlinksInPath()
552+
.path
553+
guard let targetArchivePaths = archivedPathsByHostPath[targetPath],
554+
targetArchivePaths.count == 1,
555+
let targetArchivePath = targetArchivePaths.first
556+
else {
557+
return symlinkTarget
558+
}
559+
560+
let sourceDirectory = (sourceEntryPath as NSString).deletingLastPathComponent
561+
return Self.relativeArchivePath(fromDirectory: sourceDirectory, to: targetArchivePath)
562+
}
563+
564+
private static func resolveArchivedDirectorySymlinkTarget(
565+
_ symlinkTarget: String,
566+
symlinkPath: FilePath
567+
) -> FilePath? {
568+
if symlinkTarget.hasPrefix("/") {
569+
let resolvedTargetPath = URL(fileURLWithPath: symlinkTarget)
570+
.standardizedFileURL
571+
.resolvingSymlinksInPath()
572+
.path
573+
return FilePath(resolvedTargetPath)
574+
}
575+
576+
let symlinkParent = symlinkPath.removingLastComponent()
577+
return symlinkParent.appending(symlinkTarget).lexicallyNormalized()
578+
}
579+
580+
private static func rewriteArchivedDirectorySymlinkTarget(
581+
_ symlinkTarget: String,
582+
sourceEntryPath: String,
583+
sourceRoot: FilePath,
584+
resolvedTargetPath: FilePath
585+
) -> String {
586+
guard symlinkTarget.hasPrefix("/"),
587+
let targetArchivePath = Self.relativePath(path: resolvedTargetPath.string, within: sourceRoot.string)
588+
else {
589+
return symlinkTarget
590+
}
591+
592+
let sourceDirectory = (sourceEntryPath as NSString).deletingLastPathComponent
593+
return Self.relativeArchivePath(fromDirectory: sourceDirectory, to: targetArchivePath)
594+
}
595+
596+
private static func relativePath(path: String, within root: String) -> String? {
597+
if path == root {
598+
return ""
599+
}
600+
601+
let rootPrefix = root.hasSuffix("/") ? root : root + "/"
602+
guard path.hasPrefix(rootPrefix) else {
603+
return nil
604+
}
605+
return String(path.dropFirst(rootPrefix.count))
606+
}
607+
608+
private static func relativeArchivePath(fromDirectory: String, to path: String) -> String {
609+
let fromComponents = Self.archivePathComponents(fromDirectory)
610+
let toComponents = Self.archivePathComponents(path)
611+
612+
var commonPrefixCount = 0
613+
while commonPrefixCount < fromComponents.count,
614+
commonPrefixCount < toComponents.count,
615+
fromComponents[commonPrefixCount] == toComponents[commonPrefixCount]
616+
{
617+
commonPrefixCount += 1
618+
}
619+
620+
let upwardTraversal = Array(repeating: "..", count: fromComponents.count - commonPrefixCount)
621+
let remainder = Array(toComponents.dropFirst(commonPrefixCount))
622+
let relativeComponents = upwardTraversal + remainder
623+
return relativeComponents.isEmpty ? "." : relativeComponents.joined(separator: "/")
624+
}
625+
626+
private static func archivePathComponents(_ path: String) -> [String] {
627+
NSString(string: path).pathComponents.filter { component in
628+
component != "/" && component != "."
629+
}
630+
}
302631
}

0 commit comments

Comments
 (0)