Skip to content

Commit 7020082

Browse files
authored
[Build] purely-lexical URL operations for resolving symlinks (#31)
`BuildFSSync.walk` relied on an implementation detail to skip traversing the path ".", relative to the build context dir. Specifically, it assumed that: - `URL.parentOf()` would return false when the parent argument was relative. - `URL.path(percentEncoded: false)` would preserve the relativity of an input path. These assumptions held on earlier macOS releases, so the context directory silently remained untouched. In some versions of macOS , `URL.path(percentEncoded: false)` always returns an absolute path, regardless of how the URL was created. Because of this change, `URL.parentOf()` now returns true for ".", and BuildFSSync.walk begins descending into the context directory—something it was never meant to do. This solution: - Remove the hidden dependency on `URL.parentOf()` for context‑directory detection. - Add an explicit check for "." inside BuildFSSync.walk and bail out early. Replace fragile calls to `URL.path(percentEncoded: false`) with the stable, documented `URL.relativePath`, which preserves relativity when the original path was relative.
1 parent f86dcef commit 7020082

File tree

3 files changed

+77
-67
lines changed

3 files changed

+77
-67
lines changed

Sources/ContainerBuild/BuildFSSync.swift

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,15 @@ actor BuildFSSync: BuildPipelineHandler {
6565
func read(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
6666
let offset: UInt64 = packet.offset() ?? 0
6767
let size: Int = packet.len() ?? 0
68-
var path: URL = URL(filePath: packet.source.cleanPathComponent)
68+
var path: URL
69+
if packet.source.hasPrefix("/") {
70+
path = URL(fileURLWithPath: packet.source).standardizedFileURL
71+
} else {
72+
path =
73+
contextDir
74+
.appendingPathComponent(packet.source)
75+
.standardizedFileURL
76+
}
6977
if !FileManager.default.fileExists(atPath: path.cleanPath) {
7078
path = URL(filePath: self.contextDir.cleanPath)
7179
path.append(components: packet.source.cleanPathComponent)
@@ -87,8 +95,15 @@ actor BuildFSSync: BuildPipelineHandler {
8795
}
8896

8997
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
90-
var path = self.contextDir
91-
path.append(components: packet.source.cleanPathComponent)
98+
let path: URL
99+
if packet.source.hasPrefix("/") {
100+
path = URL(fileURLWithPath: packet.source).standardizedFileURL
101+
} else {
102+
path =
103+
contextDir
104+
.appendingPathComponent(packet.source)
105+
.standardizedFileURL
106+
}
92107
let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true)
93108
var response = ClientStream()
94109
response.buildID = buildID
@@ -123,6 +138,9 @@ actor BuildFSSync: BuildPipelineHandler {
123138

124139
let followPathsWalked = try walk(root: self.contextDir, includePatterns: followPaths)
125140
for url in followPathsWalked {
141+
guard self.contextDir.absoluteURL.cleanPath != url.absoluteURL.cleanPath else {
142+
continue
143+
}
126144
guard self.contextDir.parentOf(url) else {
127145
continue
128146
}

Sources/ContainerBuild/URL+Extensions.swift

Lines changed: 56 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -14,77 +14,82 @@
1414
// limitations under the License.
1515
//===----------------------------------------------------------------------===//
1616

17-
//
18-
1917
import Foundation
2018

21-
extension URL {
22-
func parentOf(_ url: URL) -> Bool {
23-
// if self is a relative path
24-
guard self.cleanPath.hasPrefix("/") else {
25-
return true
26-
}
27-
let pathItems = self.standardizedFileURL.absoluteURL.pathComponents.map { $0.cleanPathComponent }
28-
let urlItems = url.standardizedFileURL.absoluteURL.pathComponents.map { $0.cleanPathComponent }
19+
extension String {
20+
fileprivate var fs_cleaned: String {
21+
var value = self
2922

30-
if pathItems.count > urlItems.count {
31-
return false
32-
}
33-
for (index, pathItem) in pathItems.enumerated() {
34-
if urlItems[index] != pathItem {
35-
return false
36-
}
23+
if value.hasPrefix("file://") {
24+
value.removeFirst("file://".count)
3725
}
38-
return true
39-
}
4026

41-
func relativeChildPath(to context: URL) throws -> String {
42-
if !context.parentOf(self.absoluteURL.standardizedFileURL) {
43-
throw BuildFSSync.Error.pathIsNotChild(self.cleanPath, context.cleanPath)
27+
if value.count > 1 && value.last == "/" {
28+
value.removeLast()
4429
}
4530

46-
let pathItems = context.standardizedFileURL.pathComponents.map { $0.cleanPathComponent }
47-
let urlItems = self.standardizedFileURL.pathComponents.map { $0.cleanPathComponent }
31+
return value.removingPercentEncoding ?? value
32+
}
4833

49-
return String(urlItems.dropFirst(pathItems.count).joined(separator: "/").trimming { $0 == "/" })
34+
fileprivate var fs_components: [String] {
35+
var parts: [String] = []
36+
for segment in self.split(separator: "/", omittingEmptySubsequences: true) {
37+
switch segment {
38+
case ".":
39+
continue
40+
case "..":
41+
if !parts.isEmpty { parts.removeLast() }
42+
default:
43+
parts.append(String(segment))
44+
}
45+
}
46+
return parts
5047
}
5148

49+
fileprivate var fs_isAbsolute: Bool { first == "/" }
50+
}
51+
52+
extension URL {
5253
var cleanPath: String {
53-
let pathStr = self.path(percentEncoded: false)
54-
if let cleanPath = pathStr.removingPercentEncoding {
55-
return cleanPath
56-
}
57-
return pathStr
54+
self.path.fs_cleaned
5855
}
5956

60-
func relativePathFrom(from base: URL) -> String {
61-
let destComponents = self.standardizedFileURL.pathComponents.map { $0.cleanPathComponent }
62-
let baseComponents = base.standardizedFileURL.pathComponents.map { $0.cleanPathComponent }
57+
func parentOf(_ url: URL) -> Bool {
58+
let parentPath = self.absoluteURL.cleanPath
59+
let childPath = url.absoluteURL.cleanPath
6360

64-
// Find the last common path between the two
65-
var lastCommon: Int = 0
66-
while lastCommon < baseComponents.count && lastCommon < destComponents.count && baseComponents[lastCommon] == destComponents[lastCommon] {
67-
lastCommon += 1
61+
guard parentPath.fs_isAbsolute else {
62+
return true
6863
}
6964

70-
if lastCommon == 0 {
71-
return self.path
72-
}
65+
let parentParts = parentPath.fs_components
66+
let childParts = childPath.fs_components
7367

74-
var relPath: [String] = []
68+
guard parentParts.count <= childParts.count else { return false }
69+
return zip(parentParts, childParts).allSatisfy { $0 == $1 }
70+
}
7571

76-
// Add "../" for each component that's a directory after the common prefix
77-
for i in lastCommon..<baseComponents.count {
78-
let sub = baseComponents[0...i]
79-
let currentPath = URL(filePath: sub.joined(separator: "/"))
80-
let resourceValues: URLResourceValues? = try? currentPath.resourceValues(forKeys: [.isDirectoryKey])
81-
if case let isDirectory = resourceValues?.isDirectory, isDirectory == true {
82-
relPath.append("..")
83-
}
72+
func relativeChildPath(to context: URL) throws -> String {
73+
guard context.parentOf(self) else {
74+
throw BuildFSSync.Error.pathIsNotChild(cleanPath, context.cleanPath)
8475
}
8576

86-
relPath.append(contentsOf: destComponents[lastCommon...])
87-
return relPath.joined(separator: "/")
77+
let ctxParts = context.cleanPath.fs_components
78+
let selfParts = cleanPath.fs_components
79+
80+
return selfParts.dropFirst(ctxParts.count).joined(separator: "/")
81+
}
82+
83+
func relativePathFrom(from base: URL) -> String {
84+
let destParts = cleanPath.fs_components
85+
let baseParts = base.cleanPath.fs_components
86+
87+
let common = zip(destParts, baseParts).prefix { $0 == $1 }.count
88+
guard common > 0 else { return cleanPath }
89+
90+
let ups = Array(repeating: "..", count: baseParts.count - common)
91+
let remainder = destParts.dropFirst(common)
92+
return (ups + remainder).joined(separator: "/")
8893
}
8994

9095
func zeroCopyReader(

Tests/ContainerBuildTests/BuilderExtensionsTests.swift

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@
1414
// limitations under the License.
1515
//===----------------------------------------------------------------------===//
1616

17-
//
18-
1917
import Foundation
2018
import Testing
2119

@@ -185,17 +183,6 @@ import Testing
185183
#expect(false == fileURL.parentOf(httpURL))
186184
}
187185

188-
@Test func testParentOfRelativePaths() throws {
189-
let absoluteChildDir = baseTempURL.appendingPathComponent("someDir")
190-
try createDirectory(at: absoluteChildDir)
191-
let relativeSelfURL = URL(fileURLWithPath: "a/relative/path")
192-
#expect(relativeSelfURL.parentOf(absoluteChildDir))
193-
let potentiallyParentRelative = URL(fileURLWithPath: baseTempURL.lastPathComponent)
194-
#expect(potentiallyParentRelative.parentOf(absoluteChildDir))
195-
}
196-
197-
// MARK: - relativeChildPath Tests
198-
199186
@Test func testRelativeChildPathDirectChild() throws {
200187
let parentDir = baseTempURL.appendingPathComponent("dir1")
201188
let childFile = parentDir.appendingPathComponent("dir2").appendingPathComponent("file")

0 commit comments

Comments
 (0)