@@ -22,18 +22,49 @@ import CryptoKit
2222import Foundation
2323import GRPCCore
2424
25+ /// Handles the `fssync` stage of the build protocol.
26+ ///
27+ /// When BuildKit needs build-context files it sends `Walk`, `Read`, and `Info`
28+ /// requests to the shim, which proxies them over the gRPC stream to this actor.
29+ ///
30+ /// ## Primary path: Walk (tar mode)
31+ ///
32+ /// `Walk` is the primary data path. The host packs all requested context paths
33+ /// into a tar archive and streams it to the shim. The shim unpacks the tar to a
34+ /// local cache and presents the files to BuildKit via `DiffCopy`. BuildKit then
35+ /// issues `PACKET_REQ` for regular files it needs; the shim serves those from
36+ /// the local cache without any further calls to the host.
37+ ///
38+ /// When a context path is a symlink whose target lies within the context root,
39+ /// ``walk(_:_:_:)`` adds the target to the archive alongside the symlink so
40+ /// BuildKit can dereference it during `COPY`/`ADD` processing.
41+ ///
42+ /// ## Fallback path: Info + Read
43+ ///
44+ /// `FS.Open()` in the shim falls back to `Info` followed by `Read` calls when
45+ /// its local checksum cache is unpopulated (a narrow race window at the start of
46+ /// a build). These paths are not exercised during a normal build.
47+ ///
48+ /// ## Symlink safety
49+ ///
50+ /// The host enforces that no file served to the builder resolves to a path
51+ /// outside the context root. If any component of a requested path is a symlink
52+ /// whose target lies outside the context root the request is rejected.
53+ /// Dockerignore filtering is **not** applied here; the shim applies it after
54+ /// unpacking the tar.
2555actor BuildFSSync : BuildPipelineHandler {
2656 let contextDir : URL
2757
2858 init ( _ contextDir: URL ) throws {
59+ let resolved = contextDir. resolvingSymlinksInPath ( )
2960 guard FileManager . default. fileExists ( atPath: contextDir. cleanPath) else {
3061 throw Error . contextNotFound ( contextDir. cleanPath)
3162 }
32- guard try contextDir . isDir ( ) else {
63+ guard resolved . isDirectory else {
3364 throw Error . contextIsNotDirectory ( contextDir. cleanPath)
3465 }
3566
36- self . contextDir = contextDir
67+ self . contextDir = resolved
3768 }
3869
3970 nonisolated func accept( _ packet: ServerStream ) throws -> Bool {
@@ -63,6 +94,11 @@ actor BuildFSSync: BuildPipelineHandler {
6394 }
6495 }
6596
97+ /// Serves the content of a single context file to the shim.
98+ ///
99+ /// Called only via the shim's `FS.Open()` fallback path, not during a
100+ /// normal `Walk`-based build. Rejects any path whose symlink chain resolves
101+ /// outside the context root.
66102 func read( _ sender: AsyncStream < ClientStream > . Continuation , _ packet: BuildTransfer , _ buildID: String ) async throws {
67103 let offset : UInt64 = packet. offset ( ) ?? 0
68104 let size : Int = packet. len ( ) ?? 0
@@ -79,6 +115,10 @@ actor BuildFSSync: BuildPipelineHandler {
79115 path = URL ( filePath: self . contextDir. cleanPath)
80116 path. append ( components: packet. source. cleanPathComponent)
81117 }
118+ let resolved = path. resolvingSymlinksInPath ( )
119+ guard self . contextDir. parentOf ( resolved) else {
120+ throw Error . pathIsNotChild ( resolved. cleanPath, self . contextDir. cleanPath)
121+ }
82122 let data = try {
83123 if try path. isDir ( ) {
84124 return Data ( )
@@ -95,6 +135,12 @@ actor BuildFSSync: BuildPipelineHandler {
95135 sender. yield ( response)
96136 }
97137
138+ /// Returns metadata (mode, size, modification time, uid/gid) for a single
139+ /// context path.
140+ ///
141+ /// Called only via the shim's `FS.Open()` fallback path, not during a
142+ /// normal `Walk`-based build. Must reject paths that escape the context root
143+ /// via symlinks for the same reasons as ``read(_:_:_:)``.
98144 func info( _ sender: AsyncStream < ClientStream > . Continuation , _ packet: BuildTransfer , _ buildID: String ) async throws {
99145 let path : URL
100146 if packet. source. hasPrefix ( " / " ) {
@@ -105,6 +151,10 @@ actor BuildFSSync: BuildPipelineHandler {
105151 . appendingPathComponent ( packet. source)
106152 . standardizedFileURL
107153 }
154+ let resolved = path. resolvingSymlinksInPath ( )
155+ guard self . contextDir. parentOf ( resolved) else {
156+ throw Error . pathIsNotChild ( resolved. cleanPath, self . contextDir. cleanPath)
157+ }
108158 let transfer = try path. buildTransfer ( id: packet. id, contextDir: self . contextDir, complete: true )
109159 var response = ClientStream ( )
110160 response. buildID = buildID
@@ -127,6 +177,23 @@ actor BuildFSSync: BuildPipelineHandler {
127177 }
128178 }
129179
180+ /// Packs requested context paths into a tar archive and streams it to the shim.
181+ ///
182+ /// This is the primary data path for build-context transfer. BuildKit sends
183+ /// a `Walk` request whose `followpaths` field names the context paths needed
184+ /// for the current build step (e.g. the source of a `COPY` instruction).
185+ /// The host resolves those globs, builds an entry set, and passes it to
186+ /// `Archiver.compress` to produce the tar.
187+ ///
188+ /// For any symlink in the entry set whose target lies within the context
189+ /// root, the target is added to the entry set so BuildKit can dereference
190+ /// the symlink during `COPY`/`ADD` processing without a separate request.
191+ /// Symlinks whose targets lie outside the context root are included as
192+ /// symlink entries but their targets are not; BuildKit will resolve them
193+ /// against the shim's local filesystem on Linux, not the macOS host.
194+ ///
195+ /// Dockerignore filtering is the shim's responsibility and is applied after
196+ /// the tar is unpacked; this method has no knowledge of `.dockerignore`.
130197 func walk(
131198 _ sender: AsyncStream < ClientStream > . Continuation ,
132199 _ packet: BuildTransfer ,
@@ -334,16 +401,10 @@ actor BuildFSSync: BuildPipelineHandler {
334401 let target : String
335402
336403 init ( path: URL , contextDir: URL ) throws {
337- if path. isSymlink {
338- let target : URL = path. resolvingSymlinksInPath ( )
339- if contextDir. parentOf ( target) {
340- self . target = target. relativePathFrom ( from: path)
341- } else {
342- self . target = target. cleanPath
343- }
344- } else {
345- self . target = " "
346- }
404+ // Always report the literal, unresolved on-disk symlink target —
405+ // the same value tar mode provides via Archiver's use of
406+ // destinationOfSymbolicLink — rather than a host-resolved path.
407+ self . target = path. isSymlink ? try FileManager . default. destinationOfSymbolicLink ( atPath: path. cleanPath) : " "
347408
348409 self . name = try path. relativeChildPath ( to: contextDir)
349410 self . modTime = try path. modTime ( )
0 commit comments