Skip to content

Commit f543353

Browse files
committed
Rewrite archived absolute symlink targets
1 parent 693d7b1 commit f543353

2 files changed

Lines changed: 97 additions & 3 deletions

File tree

Sources/Services/ContainerAPIService/Client/Archiver.swift

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ public final class Archiver: Sendable {
9595
}
9696
}
9797

98+
let archivedPathsByHostPath = entryInfo.reduce(into: [URL: [URL]]()) { result, info in
99+
result[info.pathOnHost.standardizedFileURL, default: []].append(info.pathInArchive)
100+
}
101+
98102
let archiver = try ArchiveWriter(
99103
configuration: writerConfiguration
100104
)
@@ -104,7 +108,7 @@ public final class Archiver: Sendable {
104108
encoder.outputFormatting = .sortedKeys
105109

106110
for info in entryInfo {
107-
guard let entry = try Self._createEntry(entryInfo: info) else {
111+
guard let entry = try Self._createEntry(entryInfo: info, archivedPathsByHostPath: archivedPathsByHostPath) else {
108112
throw Error.failedToCreateEntry
109113
}
110114
let hashInfo = ArchiveEntryHashInfo(
@@ -252,7 +256,11 @@ public final class Archiver: Sendable {
252256
try writer.finish()
253257
}
254258

255-
private static func _createEntry(entryInfo: ArchiveEntryInfo, pathPrefix: String = "") throws -> WriteEntry? {
259+
private static func _createEntry(
260+
entryInfo: ArchiveEntryInfo,
261+
archivedPathsByHostPath: [URL: [URL]] = [:],
262+
pathPrefix: String = ""
263+
) throws -> WriteEntry? {
256264
let entry = WriteEntry()
257265
let fileManager = FileManager.default
258266
let attributes = try fileManager.attributesOfItem(atPath: entryInfo.pathOnHost.path)
@@ -270,7 +278,11 @@ public final class Archiver: Sendable {
270278
entry.fileType = .symbolicLink
271279
entry.size = 0
272280
let symlinkTarget = try fileManager.destinationOfSymbolicLink(atPath: entryInfo.pathOnHost.path)
273-
entry.symlinkTarget = symlinkTarget
281+
entry.symlinkTarget = Self._rewriteArchivedAbsoluteSymlinkTarget(
282+
symlinkTarget,
283+
entryInfo: entryInfo,
284+
archivedPathsByHostPath: archivedPathsByHostPath
285+
)
274286
default:
275287
return nil
276288
}
@@ -332,6 +344,48 @@ public final class Archiver: Sendable {
332344
return trimmedPath
333345
}
334346

347+
private static func _rewriteArchivedAbsoluteSymlinkTarget(
348+
_ symlinkTarget: String,
349+
entryInfo: ArchiveEntryInfo,
350+
archivedPathsByHostPath: [URL: [URL]]
351+
) -> String {
352+
guard symlinkTarget.hasPrefix("/") else {
353+
return symlinkTarget
354+
}
355+
356+
let targetPath = URL(fileURLWithPath: symlinkTarget).standardizedFileURL
357+
guard let targetArchivePaths = archivedPathsByHostPath[targetPath], targetArchivePaths.count == 1, let targetArchivePath = targetArchivePaths.first else {
358+
return symlinkTarget
359+
}
360+
361+
let sourceDirectory = entryInfo.pathInArchive.deletingLastPathComponent().relativePath
362+
return Self._relativeArchivePath(fromDirectory: sourceDirectory, to: targetArchivePath.relativePath)
363+
}
364+
365+
private static func _relativeArchivePath(fromDirectory: String, to path: String) -> String {
366+
let fromComponents = Self._archivePathComponents(fromDirectory)
367+
let toComponents = Self._archivePathComponents(path)
368+
369+
var commonPrefixCount = 0
370+
while commonPrefixCount < fromComponents.count,
371+
commonPrefixCount < toComponents.count,
372+
fromComponents[commonPrefixCount] == toComponents[commonPrefixCount]
373+
{
374+
commonPrefixCount += 1
375+
}
376+
377+
let upwardTraversal = Array(repeating: "..", count: fromComponents.count - commonPrefixCount)
378+
let remainder = Array(toComponents.dropFirst(commonPrefixCount))
379+
let relativeComponents = upwardTraversal + remainder
380+
return relativeComponents.isEmpty ? "." : relativeComponents.joined(separator: "/")
381+
}
382+
383+
private static func _archivePathComponents(_ path: String) -> [String] {
384+
NSString(string: path).pathComponents.filter { component in
385+
component != "/" && component != "."
386+
}
387+
}
388+
335389
private static func _isSymbolicLink(_ path: URL) throws -> Bool {
336390
let resourceValues = try path.resourceValues(forKeys: [.isSymbolicLinkKey])
337391
if let isSymbolicLink = resourceValues.isSymbolicLink {

Tests/ContainerAPIClientTests/ArchiverTests.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,46 @@ struct ArchiverTests {
9999
#expect(try fileManager.destinationOfSymbolicLink(atPath: extractedLinkURL.path) == externalTargetURL.path)
100100
}
101101

102+
@Test
103+
func testCompressAndUncompressRewritesArchivedAbsoluteSymbolicLinkTarget() throws {
104+
let fileManager = FileManager.default
105+
let tempURL = try fileManager.url(
106+
for: .itemReplacementDirectory,
107+
in: .userDomainMask,
108+
appropriateFor: .temporaryDirectory,
109+
create: true
110+
)
111+
defer { try? fileManager.removeItem(at: tempURL) }
112+
113+
let sourceURL = tempURL.appendingPathComponent("source")
114+
let archiveURL = tempURL.appendingPathComponent("archive.tar.gz")
115+
let destinationURL = tempURL.appendingPathComponent("destination")
116+
try fileManager.createDirectory(at: sourceURL, withIntermediateDirectories: true)
117+
118+
let targetURL = sourceURL.appendingPathComponent("target.txt")
119+
try #require("hello".data(using: .utf8)).write(to: targetURL)
120+
let linkURL = sourceURL.appendingPathComponent("link.txt")
121+
try fileManager.createSymbolicLink(atPath: linkURL.path, withDestinationPath: targetURL.path)
122+
123+
_ = try Archiver.compress(source: sourceURL, destination: archiveURL) { url in
124+
let sourcePath = sourceURL.standardizedFileURL.path
125+
let path = url.standardizedFileURL.path
126+
let relativePath = String(path.dropFirst(sourcePath.count + 1))
127+
return Archiver.ArchiveEntryInfo(
128+
pathOnHost: url,
129+
pathInArchive: URL(fileURLWithPath: relativePath)
130+
)
131+
}
132+
133+
try Archiver.uncompress(source: archiveURL, destination: destinationURL)
134+
135+
let extractedLinkURL = destinationURL.appendingPathComponent("link.txt")
136+
let values = try extractedLinkURL.resourceValues(forKeys: [.isSymbolicLinkKey])
137+
#expect(values.isSymbolicLink == true)
138+
#expect(try fileManager.destinationOfSymbolicLink(atPath: extractedLinkURL.path) == "target.txt")
139+
#expect(try String(contentsOf: extractedLinkURL, encoding: .utf8) == "hello")
140+
}
141+
102142
@Test
103143
func testCompressDigestChangesWhenSymlinkTargetChanges() throws {
104144
let fileManager = FileManager.default

0 commit comments

Comments
 (0)