@@ -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+
114142extension ArchiveWriter {
115143 public func makeTransactionWriter( ) -> ArchiveWriterTransaction {
116144 ArchiveWriterTransaction ( writer: self )
@@ -179,6 +207,20 @@ extension ArchiveWriter {
179207}
180208
181209extension 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