Files
container/Sources/ContainerBuild/BuildFSSync.swift
T
Arnav 305cb6ed0a Fix empty followpaths handling in BuildFSSync (#2414)
- Resolves #2405 in conjunction with
  apple/container-builder-shim#105 which
  resolves the root cause in the builder shim
  and #2434 which brings in the new shim.
- When BuildKit evaluates instructions requiring
  the full build context (such as steps with tmpfs
  mounts), it emits a Walk request with empty
  or omitted followpaths metadata. BuildFSSync
  previously defaulted missing followpaths to [],
  resulting in no context files being packed into
  the archive.
- This change defaults omitted or empty
  followpaths to ["."], ensuring the entire
  context is served as expected by BuildKit's
  filesync protocol.
2026-10-28 15:25:41 -07:00

621 lines
24 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import Collections
import ContainerAPIClient
import ContainerizationArchive
import ContainerizationOCI
import ContainerizationOS
import CryptoKit
import Foundation
import GRPCCore
import SystemPackage
/// Handles the `fssync` stage of the build protocol.
///
/// When BuildKit needs build-context files it sends `Walk`, `Read`, and `Info`
/// requests to the shim, which proxies them over the gRPC stream to this actor.
///
/// ## Primary path: Walk (tar mode)
///
/// `Walk` is the primary data path. The host packs all requested context paths
/// into a tar archive and streams it to the shim. The shim unpacks the tar to a
/// local cache and presents the files to BuildKit via `DiffCopy`. BuildKit then
/// issues `PACKET_REQ` for regular files it needs; the shim serves those from
/// the local cache without any further calls to the host.
///
/// When a context path is a symlink whose target lies within the context root,
/// ``walk(_:_:_:)`` adds the target to the archive alongside the symlink so
/// BuildKit can dereference it during `COPY`/`ADD` processing.
///
/// ## Fallback path: Info + Read
///
/// `FS.Open()` in the shim falls back to `Info` followed by `Read` calls when
/// its local checksum cache is unpopulated (a narrow race window at the start of
/// a build). These paths are not exercised during a normal build.
///
/// ## Symlink safety
///
/// The host enforces that no file served to the builder resolves to a path
/// outside the context root. If any component of a requested path is a symlink
/// whose target lies outside the context root the request is rejected.
///
/// ``read(_:_:_:)`` and ``info(_:_:_:)`` enforce this by walking the path
/// component by component from a directory descriptor pinned on the context
/// root (``FileDescriptorOps``), so there is no window between checking a path
/// and using it in which a symlink can be swapped in. Symlinks are followed
/// only to relative targets that stay beneath the context root.
/// Dockerignore filtering is **not** applied here; the shim applies it after
/// unpacking the tar.
actor BuildFSSync: BuildPipelineHandler {
let contextDir: URL
/// Descriptor for `contextDir`, opened once so every request is resolved
/// relative to the same directory regardless of later changes to its path.
private let contextFD: FileDescriptor
init(_ contextDir: URL) throws {
let resolved = contextDir.resolvingSymlinksInPath()
guard FileManager.default.fileExists(atPath: contextDir.cleanPath) else {
throw Error.contextNotFound(contextDir.cleanPath)
}
guard resolved.isDirectory else {
throw Error.contextIsNotDirectory(contextDir.cleanPath)
}
self.contextDir = resolved
self.contextFD = try FileDescriptor.open(
FilePath(resolved.cleanPath), .readOnly, options: [.directory, .closeOnExec])
}
deinit {
try? contextFD.close()
}
nonisolated func accept(_ packet: ServerStream) throws -> Bool {
guard let buildTransfer = packet.getBuildTransfer() else {
return false
}
guard buildTransfer.stage() == "fssync" else {
return false
}
return true
}
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws {
guard let buildTransfer = packet.getBuildTransfer() else {
throw Error.buildTransferMissing
}
guard let method = buildTransfer.method() else {
throw Error.methodMissing
}
switch try FSSyncMethod(method) {
case .read:
try await self.read(sender, buildTransfer, packet.buildID)
case .info:
try await self.info(sender, buildTransfer, packet.buildID)
case .walk:
try await self.walk(sender, buildTransfer, packet.buildID)
}
}
/// Serves the content of a single context file to the shim.
///
/// Called only via the shim's `FS.Open()` fallback path, not during a
/// normal `Walk`-based build. The file is opened by walking its path from
/// the pinned context descriptor, following only symlinks that stay beneath
/// the context root; anything else is rejected with ``Error/pathIsNotChild``.
func read(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let offset: UInt64 = packet.offset() ?? 0
let size: Int = packet.len() ?? 0
let relative = try self.relativePath(for: packet.source)
let (data, status) = try self.confined(packet.source) { () -> (Data, FileDescriptorOps.FileStatus) in
do {
let fd = try FileDescriptorOps.openFile(self.contextFD, relativePath: relative, symlinks: .followBeneath)
defer { try? fd.close() }
// Metadata and content both come from the descriptor, so they
// describe the same inode.
let status = try FileDescriptorOps.status(of: fd)
let handle = FileHandle(fileDescriptor: fd.rawValue, closeOnDealloc: false)
try handle.seek(toOffset: offset)
let data = size == 0 ? try handle.readToEnd() : try handle.read(upToCount: size)
return (data ?? Data(), status)
} catch FileDescriptorOps.Error.conflict(.directory) {
return (Data(), try self.entryStatus(relative))
}
}
let transfer = BuildTransfer(id: packet.id, source: relative.string, status: status, complete: true, data: data)
var response = ClientStream()
response.buildID = buildID
response.buildTransfer = transfer
response.packetType = .buildTransfer(transfer)
sender.yield(response)
}
/// Returns metadata (mode, size, modification time, uid/gid) for a single
/// context path.
///
/// Called only via the shim's `FS.Open()` fallback path, not during a
/// normal `Walk`-based build. Applies the same descriptor-relative
/// confinement as ``read(_:_:_:)``. The metadata is that of the entry
/// itself (a symlink is not dereferenced), but a symlink is rejected unless
/// it also resolves beneath the context root.
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let relative = try self.relativePath(for: packet.source)
let status = try self.confined(packet.source) { () -> FileDescriptorOps.FileStatus in
let status = try self.entryStatus(relative)
if status.type == .symlink {
do {
try FileDescriptorOps.openFile(self.contextFD, relativePath: relative, symlinks: .followBeneath).close()
} catch FileDescriptorOps.Error.conflict, FileDescriptorOps.Error.notFound {
// The link stays beneath the context; its target is just not a regular file.
}
}
return status
}
let transfer = BuildTransfer(id: packet.id, source: relative.string, status: status, complete: true)
var response = ClientStream()
response.buildID = buildID
response.buildTransfer = transfer
response.packetType = .buildTransfer(transfer)
sender.yield(response)
}
/// Converts a request `source` into a path relative to the context root.
///
/// Absolute sources inside the context are made relative to it; other
/// absolute sources are taken to be relative to the context root, as the
/// shim sometimes sends them. `..` is collapsed lexically and a result
/// that leaves the context is rejected. This is only normalisation: the
/// real confinement is done by the descriptor walk.
private func relativePath(for source: String) throws -> FilePath {
if source.hasPrefix("/") {
let url = URL(fileURLWithPath: source).standardizedFileURL
if let relative = try? url.relativeChildPath(to: self.contextDir) {
return FilePath(relative)
}
let fallback = self.contextDir.appending(components: source.cleanPathComponent).standardizedFileURL
return FilePath(try fallback.relativeChildPath(to: self.contextDir))
}
let url = self.contextDir.appendingPathComponent(source).standardizedFileURL
return FilePath(try url.relativeChildPath(to: self.contextDir))
}
/// Metadata of the entry at `relative` without dereferencing it.
private func entryStatus(_ relative: FilePath) throws -> FileDescriptorOps.FileStatus {
guard let status = try FileDescriptorOps.status(self.contextFD, relativePath: relative, symlinks: .followBeneath) else {
throw FileDescriptorOps.Error.notFound
}
return status
}
/// Runs `body`, reporting a refused symlink or path as ``Error/pathIsNotChild``.
private func confined<T>(_ source: String, _ body: () throws -> T) throws -> T {
do {
return try body()
} catch FileDescriptorOps.Error.cannotFollowSymlink, FileDescriptorOps.Error.invalidRelativePath {
throw Error.pathIsNotChild(source, self.contextDir.cleanPath)
}
}
private struct DirEntry: Hashable {
let url: URL
let isDirectory: Bool
let relativePath: String
func hash(into hasher: inout Hasher) {
hasher.combine(relativePath)
}
static func == (lhs: DirEntry, rhs: DirEntry) -> Bool {
lhs.relativePath == rhs.relativePath
}
}
/// Packs requested context paths into a tar archive and streams it to the shim.
///
/// This is the primary data path for build-context transfer. BuildKit sends
/// a `Walk` request whose `followpaths` field names the context paths needed
/// for the current build step (e.g. the source of a `COPY` instruction).
/// If `followpaths` is omitted or empty (meaning the build step requires the
/// entire context, such as a step with a tmpfs mount), all files in the
/// context root are included.
/// The host resolves those globs, builds an entry set, and passes it to
/// `Archiver.compress` to produce the tar.
///
/// For any symlink in the entry set whose target lies within the context
/// root, the target is added to the entry set so BuildKit can dereference
/// the symlink during `COPY`/`ADD` processing without a separate request.
/// Symlinks whose targets lie outside the context root are included as
/// symlink entries but their targets are not; BuildKit will resolve them
/// against the shim's local filesystem on Linux, not the macOS host.
///
/// Dockerignore filtering is the shim's responsibility and is applied after
/// the tar is unpacked; this method has no knowledge of `.dockerignore`.
func walk(
_ sender: AsyncStream<ClientStream>.Continuation,
_ packet: BuildTransfer,
_ buildID: String
) async throws {
let wantsTar = packet.mode() == "tar"
var entries: [String: Set<DirEntry>] = [:]
let followPaths: [String] = packet.followPaths().flatMap { $0.isEmpty ? nil : $0 } ?? ["."]
let followPathsWalked = try walk(root: self.contextDir, includePatterns: followPaths)
for url in followPathsWalked {
guard self.contextDir.absoluteURL.cleanPath != url.absoluteURL.cleanPath else {
continue
}
guard self.contextDir.parentOf(url) else {
continue
}
let relPath = try url.relativeChildPath(to: contextDir)
let parentPath = try url.deletingLastPathComponent().relativeChildPath(to: contextDir)
let entry = DirEntry(url: url, isDirectory: url.hasDirectoryPath, relativePath: relPath)
entries[parentPath, default: []].insert(entry)
if url.isSymlink {
let target: URL = url.resolvingSymlinksInPath()
if self.contextDir.parentOf(target) {
let relPath = try target.relativeChildPath(to: self.contextDir)
let entry = DirEntry(url: target, isDirectory: target.hasDirectoryPath, relativePath: relPath)
let parentPath: String = try target.deletingLastPathComponent().relativeChildPath(to: self.contextDir)
entries[parentPath, default: []].insert(entry)
}
}
}
var fileOrder = [String]()
try processDirectory("", inputEntries: entries, processedPaths: &fileOrder)
if !wantsTar {
let fileInfos = try fileOrder.map { rel -> FileInfo in
try FileInfo(path: contextDir.appendingPathComponent(rel), contextDir: contextDir)
}
let data = try JSONEncoder().encode(fileInfos)
let transfer = BuildTransfer(
id: packet.id,
source: packet.source,
complete: true,
isDir: false,
metadata: [
"os": "linux",
"stage": "fssync",
"mode": "json",
],
data: data
)
var resp = ClientStream()
resp.buildID = buildID
resp.buildTransfer = transfer
resp.packetType = .buildTransfer(transfer)
sender.yield(resp)
return
}
let tarURL = URL.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ".tar")
defer { try? FileManager.default.removeItem(at: tarURL) }
let writerCfg = ArchiveWriterConfiguration(
format: .paxRestricted,
filter: .none)
let tarHash = try Archiver.compress(
source: contextDir,
destination: tarURL,
writerConfiguration: writerCfg
) { url in
guard let rel = try? url.relativeChildPath(to: contextDir) else {
return nil
}
guard let parent = try? url.deletingLastPathComponent().relativeChildPath(to: self.contextDir) else {
return nil
}
guard let items = entries[parent] else {
return nil
}
let include = items.contains { item in
item.relativePath == rel
}
guard include else {
return nil
}
return Archiver.ArchiveEntryInfo(
pathOnHost: url,
pathInArchive: URL(fileURLWithPath: rel))
}
let hash = tarHash.compactMap { String(format: "%02x", $0) }.joined()
let header = BuildTransfer(
id: packet.id,
source: tarURL.path,
complete: false,
isDir: false,
metadata: [
"os": "linux",
"stage": "fssync",
"mode": "tar",
"hash": hash,
]
)
var resp = ClientStream()
resp.buildID = buildID
resp.buildTransfer = header
resp.packetType = .buildTransfer(header)
sender.yield(resp)
for try await chunk in try tarURL.bufferedCopyReader() {
let part = BuildTransfer(
id: packet.id,
source: tarURL.path,
complete: false,
isDir: false,
metadata: [
"os": "linux",
"stage": "fssync",
"mode": "tar",
],
data: chunk
)
var resp = ClientStream()
resp.buildID = buildID
resp.buildTransfer = part
resp.packetType = .buildTransfer(part)
sender.yield(resp)
}
let done = BuildTransfer(
id: packet.id,
source: tarURL.path,
complete: true,
isDir: false,
metadata: [
"os": "linux",
"stage": "fssync",
"mode": "tar",
],
data: Data()
)
var finalResp = ClientStream()
finalResp.buildID = buildID
finalResp.buildTransfer = done
finalResp.packetType = .buildTransfer(done)
sender.yield(finalResp)
}
func walk(root: URL, includePatterns: [String]) throws -> [URL] {
let globber = Globber(root)
for p in includePatterns {
try globber.match(p)
}
return Array(globber.results)
}
private func processDirectory(
_ currentDir: String,
inputEntries: [String: Set<DirEntry>],
processedPaths: inout [String]
) throws {
guard let entries = inputEntries[currentDir] else {
return
}
// Sort purely by lexicographical order of relativePath
let sortedEntries = entries.sorted { $0.relativePath < $1.relativePath }
for entry in sortedEntries {
processedPaths.append(entry.relativePath)
if entry.isDirectory {
try processDirectory(
entry.relativePath,
inputEntries: inputEntries,
processedPaths: &processedPaths
)
}
}
}
struct FileInfo: Codable {
let name: String
let modTime: String
let mode: UInt32
let size: UInt64
let isDir: Bool
let uid: UInt32
let gid: UInt32
let target: String
init(path: URL, contextDir: URL) throws {
// Always report the literal, unresolved on-disk symlink target —
// the same value tar mode provides via Archiver's use of
// destinationOfSymbolicLink — rather than a host-resolved path.
self.target = path.isSymlink ? try FileManager.default.destinationOfSymbolicLink(atPath: path.cleanPath) : ""
self.name = try path.relativeChildPath(to: contextDir)
self.modTime = try path.modTime()
self.mode = try path.mode()
self.size = try path.size()
self.isDir = path.hasDirectoryPath
self.uid = 0
self.gid = 0
}
}
enum FSSyncMethod: String {
case read = "Read"
case info = "Info"
case walk = "Walk"
init(_ method: String) throws {
switch method {
case "Read":
self = .read
case "Info":
self = .info
case "Walk":
self = .walk
default:
throw Error.unknownMethod(method)
}
}
}
}
extension BuildFSSync {
enum Error: Swift.Error, CustomStringConvertible, Equatable {
case buildTransferMissing
case methodMissing
case unknownMethod(String)
case contextNotFound(String)
case contextIsNotDirectory(String)
case couldNotDetermineFileSize(String)
case couldNotDetermineModTime(String)
case couldNotDetermineFileMode(String)
case invalidOffsetSizeForFile(String, UInt64, Int)
case couldNotDetermineUID(String)
case couldNotDetermineGID(String)
case pathIsNotChild(String, String)
var description: String {
switch self {
case .buildTransferMissing:
return "buildTransfer field missing in packet"
case .methodMissing:
return "method is missing in request"
case .unknownMethod(let m):
return "unknown content-store method \(m)"
case .contextNotFound(let path):
return "context dir \(path) not found"
case .contextIsNotDirectory(let path):
return "context \(path) not a directory"
case .couldNotDetermineFileSize(let path):
return "could not determine size of file \(path)"
case .couldNotDetermineModTime(let path):
return "could not determine last modified time of \(path)"
case .couldNotDetermineFileMode(let path):
return "could not determine posix permissions (FileMode) of \(path)"
case .invalidOffsetSizeForFile(let digest, let offset, let size):
return "invalid request for file: \(digest) with offset: \(offset) size: \(size)"
case .couldNotDetermineUID(let path):
return "could not determine UID of file at path: \(path)"
case .couldNotDetermineGID(let path):
return "could not determine GID of file at path: \(path)"
case .pathIsNotChild(let path, let parent):
return "\(path) is not a child of \(parent)"
}
}
}
}
extension BuildTransfer {
fileprivate init(id: String, source: String, complete: Bool, isDir: Bool, metadata: [String: String], data: Data? = nil) {
self.init()
self.id = id
self.source = source
self.direction = .outof
self.complete = complete
self.metadata = metadata
self.isDirectory = isDir
if let data {
self.data = data
}
}
}
extension BuildTransfer {
fileprivate init(id: String, source: String, status: FileDescriptorOps.FileStatus, complete: Bool, data: Data = Data()) {
self.init(
id: id,
source: source,
complete: complete,
isDir: status.type == .directory,
metadata: [
"os": "linux",
"stage": "fssync",
"mode": String(status.permissions.rawValue),
"size": String(status.size),
"modified_at": Date(timeIntervalSince1970: TimeInterval(status.modificationSeconds)).rfc3339(),
"uid": String(status.userID),
"gid": String(status.groupID),
],
data: data
)
}
}
extension URL {
fileprivate func size() throws -> UInt64 {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let size = attrs[FileAttributeKey.size] as? UInt64 {
return size
}
throw BuildFSSync.Error.couldNotDetermineFileSize(self.cleanPath)
}
fileprivate func modTime() throws -> String {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let date = attrs[FileAttributeKey.modificationDate] as? Date {
return date.rfc3339()
}
throw BuildFSSync.Error.couldNotDetermineModTime(self.cleanPath)
}
fileprivate func mode() throws -> UInt32 {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let mode = attrs[FileAttributeKey.posixPermissions] as? NSNumber {
return mode.uint32Value
}
throw BuildFSSync.Error.couldNotDetermineFileMode(self.cleanPath)
}
}
extension Date {
fileprivate func rfc3339() -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) // Adjust if necessary
return dateFormatter.string(from: self)
}
}
extension String {
var cleanPathComponent: String {
let trimmed = self.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if let clean = trimmed.removingPercentEncoding {
return clean
}
return trimmed
}
}