Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,6 +143,7 @@ let package = Package(
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationArchive", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "GRPCCore", package: "grpc-swift-2"),
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
Expand Down
85 changes: 73 additions & 12 deletions Sources/ContainerBuild/BuildFSSync.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,18 +22,49 @@ import CryptoKit
import Foundation
import GRPCCore

/// 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.
/// Dockerignore filtering is **not** applied here; the shim applies it after
/// unpacking the tar.
actor BuildFSSync: BuildPipelineHandler {
let contextDir: URL

init(_ contextDir: URL) throws {
let resolved = contextDir.resolvingSymlinksInPath()
guard FileManager.default.fileExists(atPath: contextDir.cleanPath) else {
throw Error.contextNotFound(contextDir.cleanPath)
}
guard try contextDir.isDir() else {
guard resolved.isDirectory else {
throw Error.contextIsNotDirectory(contextDir.cleanPath)
}

self.contextDir = contextDir
self.contextDir = resolved
}

nonisolated func accept(_ packet: ServerStream) throws -> Bool {
Expand DownExpand Up@@ -63,6 +94,11 @@ actor BuildFSSync: BuildPipelineHandler {
}
}

/// 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. Rejects any path whose symlink chain resolves
/// outside the context root.
func read(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let offset: UInt64 = packet.offset() ?? 0
let size: Int = packet.len() ?? 0
Expand All@@ -79,6 +115,10 @@ actor BuildFSSync: BuildPipelineHandler {
path = URL(filePath: self.contextDir.cleanPath)
path.append(components: packet.source.cleanPathComponent)
}
let resolved = path.resolvingSymlinksInPath()
guard self.contextDir.parentOf(resolved) else {
throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath)
}
let data = try {
if try path.isDir() {
return Data()
Expand All@@ -95,6 +135,12 @@ actor BuildFSSync: BuildPipelineHandler {
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. Must reject paths that escape the context root
/// via symlinks for the same reasons as ``read(_:_:_:)``.
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let path: URL
if packet.source.hasPrefix("/") {
Expand All@@ -105,6 +151,10 @@ actor BuildFSSync: BuildPipelineHandler {
.appendingPathComponent(packet.source)
.standardizedFileURL
}
let resolved = path.resolvingSymlinksInPath()
guard self.contextDir.parentOf(resolved) else {
throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath)
}
let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true)
var response = ClientStream()
response.buildID = buildID
Expand All@@ -127,6 +177,23 @@ actor BuildFSSync: BuildPipelineHandler {
}
}

/// 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).
/// 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,
Expand DownExpand Up@@ -334,16 +401,10 @@ actor BuildFSSync: BuildPipelineHandler {
let target: String

init(path: URL, contextDir: URL) throws {
if path.isSymlink {
let target: URL = path.resolvingSymlinksInPath()
if contextDir.parentOf(target) {
self.target = target.relativePathFrom(from: path)
} else {
self.target = target.cleanPath
}
} else {
self.target = ""
}
// 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()
Expand Down
7 changes: 7 additions & 0 deletions Sources/ContainerBuild/BuildImageResolver.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,13 @@ import GRPCCore
import Logging
import TerminalProgress

/// Handles the `resolver` stage of the build protocol.
///
/// Resolves image references on behalf of BuildKit: authenticates with
/// registries, pulls missing base-image manifests and layers, and stores
/// them in the local content store. BuildKit delegates these operations to
/// the host because registry credentials and network access live on the
/// macOS side, not inside the builder VM.
struct BuildImageResolver: BuildPipelineHandler {
let contentStore: ContentStore
let quiet: Bool
Expand Down
50 changes: 50 additions & 0 deletions Sources/ContainerBuild/BuildPipelineHandler.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,61 @@ import Foundation
import GRPCCore
import NIO

/// A handler for one stage of the build protocol.
///
/// The build pipeline multiplexes a single bidirectional gRPC stream between
/// the macOS host and the builder shim. Each packet carries a stage tag;
/// a handler claims packets for its stage via ``accept(_:)`` and processes
/// them via ``handle(_:_:)``.
protocol BuildPipelineHandler: Sendable {
func accept(_ packet: ServerStream) throws -> Bool
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws
}

/// Drives a build session by routing packets from the builder shim to the
/// appropriate handler.
///
/// ## Three-tier architecture
///
/// Builds involve three components with distinct responsibilities:
///
/// **macOS host (`BuildPipeline` / its handlers)**
/// Serves resources to the builder shim over a bidirectional gRPC stream.
/// Responsibilities include:
/// - Packing requested build-context files into a tar archive (``BuildFSSync``).
/// - Proxying image-layer blobs from the local content store (``BuildRemoteContentProxy``).
/// - Resolving and pulling base images (``BuildImageResolver``).
/// - Relaying builder stdout/stderr to the terminal (``BuildStdio``).
/// - Enforcing the context root boundary: directory traversal uses `openat(O_NOFOLLOW)`
/// at every descent step, and every individual file request resolves symlinks to their
/// canonical path before verifying containment within the context root.
///
/// **Builder shim (`container-builder-shim`)**
/// A Go process running inside a Linux VM that bridges the host gRPC stream
/// and BuildKit's `filesync` gRPC interface. Responsibilities include:
/// - Receiving the context tar from the host, unpacking it to a local cache,
/// and presenting the result to BuildKit via `DiffCopy`.
/// - Applying dockerignore exclusions (received from BuildKit as
/// `exclude-patterns` metadata) when walking the unpacked cache.
/// - Passing `followpaths` from BuildKit to the host so the host knows which
/// context paths to include in the tar.
///
/// **BuildKit**
/// Parses and executes the Dockerfile. Responsibilities include:
/// - Sending `Walk` requests with `followpaths` derived from each `COPY`/`ADD`
/// source and `exclude-patterns` derived from `.dockerignore`.
/// - Dereferencing symlinks, recursing into directories, and applying all
/// other COPY/ADD transfer semantics on the unpacked context the shim provides.
///
/// ## Packet flow
///
/// ```
/// BuildKit ──► shim DiffCopy ──► host Walk (tar of context files)
/// ◄── tar archive
/// ◄── PACKET_STAT per file (after shim unpacks + filters)
/// ──► PACKET_REQ for each regular file
/// ◄── PACKET_DATA (shim reads from local unpacked cache)
/// ```
public actor BuildPipeline {
let handlers: [BuildPipelineHandler]
public init(_ config: Builder.BuildConfig) async throws {
Expand Down
5 changes: 5 additions & 0 deletions Sources/ContainerBuild/BuildRemoteContentProxy.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,11 @@ import ContainerizationOCI
import Foundation
import GRPCCore

/// Handles the `content-store` stage of the build protocol.
///
/// Proxies image-layer blob requests from BuildKit to the host's local
/// containerd content store. BuildKit issues these requests when it needs
/// base-image layers that are not already present in the builder VM.
struct BuildRemoteContentProxy: BuildPipelineHandler {
let local: ContentStore

Expand Down
5 changes: 5 additions & 0 deletions Sources/ContainerBuild/BuildStdio.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,11 @@ import Foundation
import GRPCCore
import NIO

/// Handles the stdio stage of the build protocol.
///
/// Relays builder stdout/stderr from the shim to the client terminal.
/// Build output (layer download progress, `RUN` command output, etc.) flows
/// through this handler and is written directly to the configured file handle.
actor BuildStdio: BuildPipelineHandler {
public let quiet: Bool
public let handle: FileHandle
Expand Down
83 changes: 59 additions & 24 deletions Sources/ContainerBuild/Globber.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,9 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerizationOS
import Foundation
import SystemPackage

public class Globber {
let input: URL
Expand All@@ -33,7 +35,7 @@ public class Globber {
.replacingOccurrences(of: "[/]\\*{2,}([^/])", with: "/**/*$1", options: .regularExpression)
.replacingOccurrences(of: "^\\*{2,}([^/])", with: "**/*$1", options: .regularExpression)

for child in input.children {
for child in self.children(of: input) {
try self.match(input: child, components: adjustedPattern.split(separator: "/").map(String.init))
}
}
Expand All@@ -47,7 +49,7 @@ public class Globber {
guard dir.pathComponents.count > 1 else { break }
dir.deleteLastPathComponent()
}
return input.childrenRecursive.forEach { results.insert($0) }
return self.childrenRecursive(of: input).forEach { results.insert($0) }
}

let head = components.first ?? ""
Expand All@@ -59,7 +61,7 @@ public class Globber {
tail = tail.tail
}
try self.match(input: input, components: tail)
for child in input.children {
for child in self.children(of: input) {
try self.match(input: child, components: components)
}
return
Expand All@@ -68,13 +70,66 @@ public class Globber {
if try glob(input.lastPathComponent, head) {
try self.match(input: input, components: tail)

for child in input.children where try glob(child.lastPathComponent, tail.first ?? "") {
for child in self.children(of: input) where try glob(child.lastPathComponent, tail.first ?? "") {
try self.match(input: child, components: tail)
}
return
}
}

/// Returns the direct children of `url`, following `url` itself when it is
/// a directory symlink whose fully-resolved target stays within the match
/// root. A symlink that escapes the root is treated as having no children
/// (same as a regular file) so pattern components after it never match —
/// mirrors the containment check `BuildFSSync` applies before reading.
///
/// Children are named by their resolved (physical) path, not by `url`, so
/// that `walk(root:includePatterns:)`'s later filter — which is driven by
/// `Archiver.compress`'s own physical directory walk — reliably finds a
/// matching entry regardless of whether that walk itself follows `url`'s
/// symlink. `url` is separately inserted into `results` so the symlink
/// entry is still present in the tar for the builder to resolve the
/// original path against.
private func children(of url: URL) -> [URL] {
// TODO: modifying object state and returning results is odd, rework
guard let dir = self.resolvedDirectory(of: url) else { return [] }
if url.isSymlink { self.results.insert(url) }
return (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))
?? []
}

/// Recursive form of ``children(of:)``, used once a full pattern (or `**`)
/// has matched `url` and every descendant needs to be collected. Nested
/// directory symlinks are resolved and boundary-checked the same way, one
/// level at a time, via ``FileDescriptorOps/enumerate`` which never follows
/// symlinks it encounters mid-traversal — only the top-level `url` passed
/// in here gets the resolve-and-check treatment.
private func childrenRecursive(of url: URL) -> [URL] {
guard let dir = self.resolvedDirectory(of: url) else { return [url] }
if url.isSymlink { self.results.insert(url) }
guard let fd = try? FileDescriptor.open(FilePath(dir.path), .readOnly, options: .directory) else {
return [dir]
}
defer { try? fd.close() }
var found: [URL] = [dir]
try? FileDescriptorOps.enumerate(fd) { relPath, _, _ in
found.append(dir.appendingPathComponent(relPath.string))
}
return found
}

/// Resolves `url` to the real directory whose contents should be listed in
/// its place. Non-symlinks resolve to themselves. A directory symlink
/// resolves to its target only if the fully-resolved target is still
/// within `self.input` (the match root); otherwise `nil`, so callers treat
/// it as a leaf rather than descending outside the context.
private func resolvedDirectory(of url: URL) -> URL? {
guard url.isSymlink else { return url }
let resolved = url.resolvingSymlinksInPath()
guard resolved.isDirectory, self.input.parentOf(resolved) else { return nil }
return resolved
}

func glob(_ input: String, _ pattern: String) throws -> Bool {
let regexPattern =
"^"
Expand All@@ -91,26 +146,6 @@ public class Globber {
}
}

extension URL {
var children: [URL] {

(try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil))
?? []
}

var childrenRecursive: [URL] {
var results: [URL] = []
if let enumerator = FileManager.default.enumerator(
at: self, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey])
{
while let child = enumerator.nextObject() as? URL {
results.append(child)
}
}
return [self] + results
}
}

extension [String] {
var tail: [String] {
if self.count <= 1 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Fix image env vars, build context checks, TCP/UDP port forward buffer, and validate plugin name by katiewasnothere · Pull Request #2027 · apple/container · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,6 +143,7 @@ let package = Package(
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationArchive", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "GRPCCore", package: "grpc-swift-2"),
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
Expand Down
85 changes: 73 additions & 12 deletions Sources/ContainerBuild/BuildFSSync.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,18 +22,49 @@ import CryptoKit
import Foundation
import GRPCCore

/// 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.
/// Dockerignore filtering is **not** applied here; the shim applies it after
/// unpacking the tar.
actor BuildFSSync: BuildPipelineHandler {
let contextDir: URL

init(_ contextDir: URL) throws {
let resolved = contextDir.resolvingSymlinksInPath()
guard FileManager.default.fileExists(atPath: contextDir.cleanPath) else {
throw Error.contextNotFound(contextDir.cleanPath)
}
guard try contextDir.isDir() else {
guard resolved.isDirectory else {
throw Error.contextIsNotDirectory(contextDir.cleanPath)
}

self.contextDir = contextDir
self.contextDir = resolved
}

nonisolated func accept(_ packet: ServerStream) throws -> Bool {
Expand DownExpand Up@@ -63,6 +94,11 @@ actor BuildFSSync: BuildPipelineHandler {
}
}

/// 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. Rejects any path whose symlink chain resolves
/// outside the context root.
func read(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let offset: UInt64 = packet.offset() ?? 0
let size: Int = packet.len() ?? 0
Expand All@@ -79,6 +115,10 @@ actor BuildFSSync: BuildPipelineHandler {
path = URL(filePath: self.contextDir.cleanPath)
path.append(components: packet.source.cleanPathComponent)
}
let resolved = path.resolvingSymlinksInPath()
guard self.contextDir.parentOf(resolved) else {
throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath)
}
let data = try {
if try path.isDir() {
return Data()
Expand All@@ -95,6 +135,12 @@ actor BuildFSSync: BuildPipelineHandler {
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. Must reject paths that escape the context root
/// via symlinks for the same reasons as ``read(_:_:_:)``.
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let path: URL
if packet.source.hasPrefix("/") {
Expand All@@ -105,6 +151,10 @@ actor BuildFSSync: BuildPipelineHandler {
.appendingPathComponent(packet.source)
.standardizedFileURL
}
let resolved = path.resolvingSymlinksInPath()
guard self.contextDir.parentOf(resolved) else {
throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath)
}
let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true)
var response = ClientStream()
response.buildID = buildID
Expand All@@ -127,6 +177,23 @@ actor BuildFSSync: BuildPipelineHandler {
}
}

/// 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).
/// 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,
Expand DownExpand Up@@ -334,16 +401,10 @@ actor BuildFSSync: BuildPipelineHandler {
let target: String

init(path: URL, contextDir: URL) throws {
if path.isSymlink {
let target: URL = path.resolvingSymlinksInPath()
if contextDir.parentOf(target) {
self.target = target.relativePathFrom(from: path)
} else {
self.target = target.cleanPath
}
} else {
self.target = ""
}
// 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()
Expand Down
7 changes: 7 additions & 0 deletions Sources/ContainerBuild/BuildImageResolver.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,13 @@ import GRPCCore
import Logging
import TerminalProgress

/// Handles the `resolver` stage of the build protocol.
///
/// Resolves image references on behalf of BuildKit: authenticates with
/// registries, pulls missing base-image manifests and layers, and stores
/// them in the local content store. BuildKit delegates these operations to
/// the host because registry credentials and network access live on the
/// macOS side, not inside the builder VM.
struct BuildImageResolver: BuildPipelineHandler {
let contentStore: ContentStore
let quiet: Bool
Expand Down
50 changes: 50 additions & 0 deletions Sources/ContainerBuild/BuildPipelineHandler.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,61 @@ import Foundation
import GRPCCore
import NIO

/// A handler for one stage of the build protocol.
///
/// The build pipeline multiplexes a single bidirectional gRPC stream between
/// the macOS host and the builder shim. Each packet carries a stage tag;
/// a handler claims packets for its stage via ``accept(_:)`` and processes
/// them via ``handle(_:_:)``.
protocol BuildPipelineHandler: Sendable {
func accept(_ packet: ServerStream) throws -> Bool
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws
}

/// Drives a build session by routing packets from the builder shim to the
/// appropriate handler.
///
/// ## Three-tier architecture
///
/// Builds involve three components with distinct responsibilities:
///
/// **macOS host (`BuildPipeline` / its handlers)**
/// Serves resources to the builder shim over a bidirectional gRPC stream.
/// Responsibilities include:
/// - Packing requested build-context files into a tar archive (``BuildFSSync``).
/// - Proxying image-layer blobs from the local content store (``BuildRemoteContentProxy``).
/// - Resolving and pulling base images (``BuildImageResolver``).
/// - Relaying builder stdout/stderr to the terminal (``BuildStdio``).
/// - Enforcing the context root boundary: directory traversal uses `openat(O_NOFOLLOW)`
/// at every descent step, and every individual file request resolves symlinks to their
/// canonical path before verifying containment within the context root.
///
/// **Builder shim (`container-builder-shim`)**
/// A Go process running inside a Linux VM that bridges the host gRPC stream
/// and BuildKit's `filesync` gRPC interface. Responsibilities include:
/// - Receiving the context tar from the host, unpacking it to a local cache,
/// and presenting the result to BuildKit via `DiffCopy`.
/// - Applying dockerignore exclusions (received from BuildKit as
/// `exclude-patterns` metadata) when walking the unpacked cache.
/// - Passing `followpaths` from BuildKit to the host so the host knows which
/// context paths to include in the tar.
///
/// **BuildKit**
/// Parses and executes the Dockerfile. Responsibilities include:
/// - Sending `Walk` requests with `followpaths` derived from each `COPY`/`ADD`
/// source and `exclude-patterns` derived from `.dockerignore`.
/// - Dereferencing symlinks, recursing into directories, and applying all
/// other COPY/ADD transfer semantics on the unpacked context the shim provides.
///
/// ## Packet flow
///
/// ```
/// BuildKit ──► shim DiffCopy ──► host Walk (tar of context files)
/// ◄── tar archive
/// ◄── PACKET_STAT per file (after shim unpacks + filters)
/// ──► PACKET_REQ for each regular file
/// ◄── PACKET_DATA (shim reads from local unpacked cache)
/// ```
public actor BuildPipeline {
let handlers: [BuildPipelineHandler]
public init(_ config: Builder.BuildConfig) async throws {
Expand Down
5 changes: 5 additions & 0 deletions Sources/ContainerBuild/BuildRemoteContentProxy.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,11 @@ import ContainerizationOCI
import Foundation
import GRPCCore

/// Handles the `content-store` stage of the build protocol.
///
/// Proxies image-layer blob requests from BuildKit to the host's local
/// containerd content store. BuildKit issues these requests when it needs
/// base-image layers that are not already present in the builder VM.
struct BuildRemoteContentProxy: BuildPipelineHandler {
let local: ContentStore

Expand Down
5 changes: 5 additions & 0 deletions Sources/ContainerBuild/BuildStdio.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,11 @@ import Foundation
import GRPCCore
import NIO

/// Handles the stdio stage of the build protocol.
///
/// Relays builder stdout/stderr from the shim to the client terminal.
/// Build output (layer download progress, `RUN` command output, etc.) flows
/// through this handler and is written directly to the configured file handle.
actor BuildStdio: BuildPipelineHandler {
public let quiet: Bool
public let handle: FileHandle
Expand Down
83 changes: 59 additions & 24 deletions Sources/ContainerBuild/Globber.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,9 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerizationOS
import Foundation
import SystemPackage

public class Globber {
let input: URL
Expand All@@ -33,7 +35,7 @@ public class Globber {
.replacingOccurrences(of: "[/]\\*{2,}([^/])", with: "/**/*$1", options: .regularExpression)
.replacingOccurrences(of: "^\\*{2,}([^/])", with: "**/*$1", options: .regularExpression)

for child in input.children {
for child in self.children(of: input) {
try self.match(input: child, components: adjustedPattern.split(separator: "/").map(String.init))
}
}
Expand All@@ -47,7 +49,7 @@ public class Globber {
guard dir.pathComponents.count > 1 else { break }
dir.deleteLastPathComponent()
}
return input.childrenRecursive.forEach { results.insert($0) }
return self.childrenRecursive(of: input).forEach { results.insert($0) }
}

let head = components.first ?? ""
Expand All@@ -59,7 +61,7 @@ public class Globber {
tail = tail.tail
}
try self.match(input: input, components: tail)
for child in input.children {
for child in self.children(of: input) {
try self.match(input: child, components: components)
}
return
Expand All@@ -68,13 +70,66 @@ public class Globber {
if try glob(input.lastPathComponent, head) {
try self.match(input: input, components: tail)

for child in input.children where try glob(child.lastPathComponent, tail.first ?? "") {
for child in self.children(of: input) where try glob(child.lastPathComponent, tail.first ?? "") {
try self.match(input: child, components: tail)
}
return
}
}

/// Returns the direct children of `url`, following `url` itself when it is
/// a directory symlink whose fully-resolved target stays within the match
/// root. A symlink that escapes the root is treated as having no children
/// (same as a regular file) so pattern components after it never match —
/// mirrors the containment check `BuildFSSync` applies before reading.
///
/// Children are named by their resolved (physical) path, not by `url`, so
/// that `walk(root:includePatterns:)`'s later filter — which is driven by
/// `Archiver.compress`'s own physical directory walk — reliably finds a
/// matching entry regardless of whether that walk itself follows `url`'s
/// symlink. `url` is separately inserted into `results` so the symlink
/// entry is still present in the tar for the builder to resolve the
/// original path against.
private func children(of url: URL) -> [URL] {
// TODO: modifying object state and returning results is odd, rework
guard let dir = self.resolvedDirectory(of: url) else { return [] }
if url.isSymlink { self.results.insert(url) }
return (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))
?? []
}

/// Recursive form of ``children(of:)``, used once a full pattern (or `**`)
/// has matched `url` and every descendant needs to be collected. Nested
/// directory symlinks are resolved and boundary-checked the same way, one
/// level at a time, via ``FileDescriptorOps/enumerate`` which never follows
/// symlinks it encounters mid-traversal — only the top-level `url` passed
/// in here gets the resolve-and-check treatment.
private func childrenRecursive(of url: URL) -> [URL] {
guard let dir = self.resolvedDirectory(of: url) else { return [url] }
if url.isSymlink { self.results.insert(url) }
guard let fd = try? FileDescriptor.open(FilePath(dir.path), .readOnly, options: .directory) else {
return [dir]
}
defer { try? fd.close() }
var found: [URL] = [dir]
try? FileDescriptorOps.enumerate(fd) { relPath, _, _ in
found.append(dir.appendingPathComponent(relPath.string))
}
return found
}

/// Resolves `url` to the real directory whose contents should be listed in
/// its place. Non-symlinks resolve to themselves. A directory symlink
/// resolves to its target only if the fully-resolved target is still
/// within `self.input` (the match root); otherwise `nil`, so callers treat
/// it as a leaf rather than descending outside the context.
private func resolvedDirectory(of url: URL) -> URL? {
guard url.isSymlink else { return url }
let resolved = url.resolvingSymlinksInPath()
guard resolved.isDirectory, self.input.parentOf(resolved) else { return nil }
return resolved
}

func glob(_ input: String, _ pattern: String) throws -> Bool {
let regexPattern =
"^"
Expand All@@ -91,26 +146,6 @@ public class Globber {
}
}

extension URL {
var children: [URL] {

(try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil))
?? []
}

var childrenRecursive: [URL] {
var results: [URL] = []
if let enumerator = FileManager.default.enumerator(
at: self, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey])
{
while let child = enumerator.nextObject() as? URL {
results.append(child)
}
}
return [self] + results
}
}

extension [String] {
var tail: [String] {
if self.count <= 1 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix image env vars, build context checks, TCP/UDP port forward buffer, and validate plugin name by katiewasnothere · Pull Request #2027 · apple/container · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,6 +143,7 @@ let package = Package(
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationArchive", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "GRPCCore", package: "grpc-swift-2"),
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
Expand Down
85 changes: 73 additions & 12 deletions Sources/ContainerBuild/BuildFSSync.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,18 +22,49 @@ import CryptoKit
import Foundation
import GRPCCore

/// 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.
/// Dockerignore filtering is **not** applied here; the shim applies it after
/// unpacking the tar.
actor BuildFSSync: BuildPipelineHandler {
let contextDir: URL

init(_ contextDir: URL) throws {
let resolved = contextDir.resolvingSymlinksInPath()
guard FileManager.default.fileExists(atPath: contextDir.cleanPath) else {
throw Error.contextNotFound(contextDir.cleanPath)
}
guard try contextDir.isDir() else {
guard resolved.isDirectory else {
throw Error.contextIsNotDirectory(contextDir.cleanPath)
}

self.contextDir = contextDir
self.contextDir = resolved
}

nonisolated func accept(_ packet: ServerStream) throws -> Bool {
Expand DownExpand Up@@ -63,6 +94,11 @@ actor BuildFSSync: BuildPipelineHandler {
}
}

/// 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. Rejects any path whose symlink chain resolves
/// outside the context root.
func read(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let offset: UInt64 = packet.offset() ?? 0
let size: Int = packet.len() ?? 0
Expand All@@ -79,6 +115,10 @@ actor BuildFSSync: BuildPipelineHandler {
path = URL(filePath: self.contextDir.cleanPath)
path.append(components: packet.source.cleanPathComponent)
}
let resolved = path.resolvingSymlinksInPath()
guard self.contextDir.parentOf(resolved) else {
throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath)
}
let data = try {
if try path.isDir() {
return Data()
Expand All@@ -95,6 +135,12 @@ actor BuildFSSync: BuildPipelineHandler {
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. Must reject paths that escape the context root
/// via symlinks for the same reasons as ``read(_:_:_:)``.
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let path: URL
if packet.source.hasPrefix("/") {
Expand All@@ -105,6 +151,10 @@ actor BuildFSSync: BuildPipelineHandler {
.appendingPathComponent(packet.source)
.standardizedFileURL
}
let resolved = path.resolvingSymlinksInPath()
guard self.contextDir.parentOf(resolved) else {
throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath)
}
let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true)
var response = ClientStream()
response.buildID = buildID
Expand All@@ -127,6 +177,23 @@ actor BuildFSSync: BuildPipelineHandler {
}
}

/// 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).
/// 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,
Expand DownExpand Up@@ -334,16 +401,10 @@ actor BuildFSSync: BuildPipelineHandler {
let target: String

init(path: URL, contextDir: URL) throws {
if path.isSymlink {
let target: URL = path.resolvingSymlinksInPath()
if contextDir.parentOf(target) {
self.target = target.relativePathFrom(from: path)
} else {
self.target = target.cleanPath
}
} else {
self.target = ""
}
// 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()
Expand Down
7 changes: 7 additions & 0 deletions Sources/ContainerBuild/BuildImageResolver.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,13 @@ import GRPCCore
import Logging
import TerminalProgress

/// Handles the `resolver` stage of the build protocol.
///
/// Resolves image references on behalf of BuildKit: authenticates with
/// registries, pulls missing base-image manifests and layers, and stores
/// them in the local content store. BuildKit delegates these operations to
/// the host because registry credentials and network access live on the
/// macOS side, not inside the builder VM.
struct BuildImageResolver: BuildPipelineHandler {
let contentStore: ContentStore
let quiet: Bool
Expand Down
50 changes: 50 additions & 0 deletions Sources/ContainerBuild/BuildPipelineHandler.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,61 @@ import Foundation
import GRPCCore
import NIO

/// A handler for one stage of the build protocol.
///
/// The build pipeline multiplexes a single bidirectional gRPC stream between
/// the macOS host and the builder shim. Each packet carries a stage tag;
/// a handler claims packets for its stage via ``accept(_:)`` and processes
/// them via ``handle(_:_:)``.
protocol BuildPipelineHandler: Sendable {
func accept(_ packet: ServerStream) throws -> Bool
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws
}

/// Drives a build session by routing packets from the builder shim to the
/// appropriate handler.
///
/// ## Three-tier architecture
///
/// Builds involve three components with distinct responsibilities:
///
/// **macOS host (`BuildPipeline` / its handlers)**
/// Serves resources to the builder shim over a bidirectional gRPC stream.
/// Responsibilities include:
/// - Packing requested build-context files into a tar archive (``BuildFSSync``).
/// - Proxying image-layer blobs from the local content store (``BuildRemoteContentProxy``).
/// - Resolving and pulling base images (``BuildImageResolver``).
/// - Relaying builder stdout/stderr to the terminal (``BuildStdio``).
/// - Enforcing the context root boundary: directory traversal uses `openat(O_NOFOLLOW)`
/// at every descent step, and every individual file request resolves symlinks to their
/// canonical path before verifying containment within the context root.
///
/// **Builder shim (`container-builder-shim`)**
/// A Go process running inside a Linux VM that bridges the host gRPC stream
/// and BuildKit's `filesync` gRPC interface. Responsibilities include:
/// - Receiving the context tar from the host, unpacking it to a local cache,
/// and presenting the result to BuildKit via `DiffCopy`.
/// - Applying dockerignore exclusions (received from BuildKit as
/// `exclude-patterns` metadata) when walking the unpacked cache.
/// - Passing `followpaths` from BuildKit to the host so the host knows which
/// context paths to include in the tar.
///
/// **BuildKit**
/// Parses and executes the Dockerfile. Responsibilities include:
/// - Sending `Walk` requests with `followpaths` derived from each `COPY`/`ADD`
/// source and `exclude-patterns` derived from `.dockerignore`.
/// - Dereferencing symlinks, recursing into directories, and applying all
/// other COPY/ADD transfer semantics on the unpacked context the shim provides.
///
/// ## Packet flow
///
/// ```
/// BuildKit ──► shim DiffCopy ──► host Walk (tar of context files)
/// ◄── tar archive
/// ◄── PACKET_STAT per file (after shim unpacks + filters)
/// ──► PACKET_REQ for each regular file
/// ◄── PACKET_DATA (shim reads from local unpacked cache)
/// ```
public actor BuildPipeline {
let handlers: [BuildPipelineHandler]
public init(_ config: Builder.BuildConfig) async throws {
Expand Down
5 changes: 5 additions & 0 deletions Sources/ContainerBuild/BuildRemoteContentProxy.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,11 @@ import ContainerizationOCI
import Foundation
import GRPCCore

/// Handles the `content-store` stage of the build protocol.
///
/// Proxies image-layer blob requests from BuildKit to the host's local
/// containerd content store. BuildKit issues these requests when it needs
/// base-image layers that are not already present in the builder VM.
struct BuildRemoteContentProxy: BuildPipelineHandler {
let local: ContentStore

Expand Down
5 changes: 5 additions & 0 deletions Sources/ContainerBuild/BuildStdio.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,11 @@ import Foundation
import GRPCCore
import NIO

/// Handles the stdio stage of the build protocol.
///
/// Relays builder stdout/stderr from the shim to the client terminal.
/// Build output (layer download progress, `RUN` command output, etc.) flows
/// through this handler and is written directly to the configured file handle.
actor BuildStdio: BuildPipelineHandler {
public let quiet: Bool
public let handle: FileHandle
Expand Down
83 changes: 59 additions & 24 deletions Sources/ContainerBuild/Globber.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,9 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerizationOS
import Foundation
import SystemPackage

public class Globber {
let input: URL
Expand All@@ -33,7 +35,7 @@ public class Globber {
.replacingOccurrences(of: "[/]\\*{2,}([^/])", with: "/**/*$1", options: .regularExpression)
.replacingOccurrences(of: "^\\*{2,}([^/])", with: "**/*$1", options: .regularExpression)

for child in input.children {
for child in self.children(of: input) {
try self.match(input: child, components: adjustedPattern.split(separator: "/").map(String.init))
}
}
Expand All@@ -47,7 +49,7 @@ public class Globber {
guard dir.pathComponents.count > 1 else { break }
dir.deleteLastPathComponent()
}
return input.childrenRecursive.forEach { results.insert($0) }
return self.childrenRecursive(of: input).forEach { results.insert($0) }
}

let head = components.first ?? ""
Expand All@@ -59,7 +61,7 @@ public class Globber {
tail = tail.tail
}
try self.match(input: input, components: tail)
for child in input.children {
for child in self.children(of: input) {
try self.match(input: child, components: components)
}
return
Expand All@@ -68,13 +70,66 @@ public class Globber {
if try glob(input.lastPathComponent, head) {
try self.match(input: input, components: tail)

for child in input.children where try glob(child.lastPathComponent, tail.first ?? "") {
for child in self.children(of: input) where try glob(child.lastPathComponent, tail.first ?? "") {
try self.match(input: child, components: tail)
}
return
}
}

/// Returns the direct children of `url`, following `url` itself when it is
/// a directory symlink whose fully-resolved target stays within the match
/// root. A symlink that escapes the root is treated as having no children
/// (same as a regular file) so pattern components after it never match —
/// mirrors the containment check `BuildFSSync` applies before reading.
///
/// Children are named by their resolved (physical) path, not by `url`, so
/// that `walk(root:includePatterns:)`'s later filter — which is driven by
/// `Archiver.compress`'s own physical directory walk — reliably finds a
/// matching entry regardless of whether that walk itself follows `url`'s
/// symlink. `url` is separately inserted into `results` so the symlink
/// entry is still present in the tar for the builder to resolve the
/// original path against.
private func children(of url: URL) -> [URL] {
// TODO: modifying object state and returning results is odd, rework
guard let dir = self.resolvedDirectory(of: url) else { return [] }
if url.isSymlink { self.results.insert(url) }
return (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))
?? []
}

/// Recursive form of ``children(of:)``, used once a full pattern (or `**`)
/// has matched `url` and every descendant needs to be collected. Nested
/// directory symlinks are resolved and boundary-checked the same way, one
/// level at a time, via ``FileDescriptorOps/enumerate`` which never follows
/// symlinks it encounters mid-traversal — only the top-level `url` passed
/// in here gets the resolve-and-check treatment.
private func childrenRecursive(of url: URL) -> [URL] {
guard let dir = self.resolvedDirectory(of: url) else { return [url] }
if url.isSymlink { self.results.insert(url) }
guard let fd = try? FileDescriptor.open(FilePath(dir.path), .readOnly, options: .directory) else {
return [dir]
}
defer { try? fd.close() }
var found: [URL] = [dir]
try? FileDescriptorOps.enumerate(fd) { relPath, _, _ in
found.append(dir.appendingPathComponent(relPath.string))
}
return found
}

/// Resolves `url` to the real directory whose contents should be listed in
/// its place. Non-symlinks resolve to themselves. A directory symlink
/// resolves to its target only if the fully-resolved target is still
/// within `self.input` (the match root); otherwise `nil`, so callers treat
/// it as a leaf rather than descending outside the context.
private func resolvedDirectory(of url: URL) -> URL? {
guard url.isSymlink else { return url }
let resolved = url.resolvingSymlinksInPath()
guard resolved.isDirectory, self.input.parentOf(resolved) else { return nil }
return resolved
}

func glob(_ input: String, _ pattern: String) throws -> Bool {
let regexPattern =
"^"
Expand All@@ -91,26 +146,6 @@ public class Globber {
}
}

extension URL {
var children: [URL] {

(try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil))
?? []
}

var childrenRecursive: [URL] {
var results: [URL] = []
if let enumerator = FileManager.default.enumerator(
at: self, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey])
{
while let child = enumerator.nextObject() as? URL {
results.append(child)
}
}
return [self] + results
}
}

extension [String] {
var tail: [String] {
if self.count <= 1 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix image env vars, build context checks, TCP/UDP port forward buffer, and validate plugin name by katiewasnothere · Pull Request #2027 · apple/container · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,6 +143,7 @@ let package = Package(
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationArchive", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "GRPCCore", package: "grpc-swift-2"),
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
Expand Down
85 changes: 73 additions & 12 deletions Sources/ContainerBuild/BuildFSSync.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,18 +22,49 @@ import CryptoKit
import Foundation
import GRPCCore

/// 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.
/// Dockerignore filtering is **not** applied here; the shim applies it after
/// unpacking the tar.
actor BuildFSSync: BuildPipelineHandler {
let contextDir: URL

init(_ contextDir: URL) throws {
let resolved = contextDir.resolvingSymlinksInPath()
guard FileManager.default.fileExists(atPath: contextDir.cleanPath) else {
throw Error.contextNotFound(contextDir.cleanPath)
}
guard try contextDir.isDir() else {
guard resolved.isDirectory else {
throw Error.contextIsNotDirectory(contextDir.cleanPath)
}

self.contextDir = contextDir
self.contextDir = resolved
}

nonisolated func accept(_ packet: ServerStream) throws -> Bool {
Expand DownExpand Up@@ -63,6 +94,11 @@ actor BuildFSSync: BuildPipelineHandler {
}
}

/// 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. Rejects any path whose symlink chain resolves
/// outside the context root.
func read(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let offset: UInt64 = packet.offset() ?? 0
let size: Int = packet.len() ?? 0
Expand All@@ -79,6 +115,10 @@ actor BuildFSSync: BuildPipelineHandler {
path = URL(filePath: self.contextDir.cleanPath)
path.append(components: packet.source.cleanPathComponent)
}
let resolved = path.resolvingSymlinksInPath()
guard self.contextDir.parentOf(resolved) else {
throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath)
}
let data = try {
if try path.isDir() {
return Data()
Expand All@@ -95,6 +135,12 @@ actor BuildFSSync: BuildPipelineHandler {
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. Must reject paths that escape the context root
/// via symlinks for the same reasons as ``read(_:_:_:)``.
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let path: URL
if packet.source.hasPrefix("/") {
Expand All@@ -105,6 +151,10 @@ actor BuildFSSync: BuildPipelineHandler {
.appendingPathComponent(packet.source)
.standardizedFileURL
}
let resolved = path.resolvingSymlinksInPath()
guard self.contextDir.parentOf(resolved) else {
throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath)
}
let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true)
var response = ClientStream()
response.buildID = buildID
Expand All@@ -127,6 +177,23 @@ actor BuildFSSync: BuildPipelineHandler {
}
}

/// 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).
/// 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,
Expand DownExpand Up@@ -334,16 +401,10 @@ actor BuildFSSync: BuildPipelineHandler {
let target: String

init(path: URL, contextDir: URL) throws {
if path.isSymlink {
let target: URL = path.resolvingSymlinksInPath()
if contextDir.parentOf(target) {
self.target = target.relativePathFrom(from: path)
} else {
self.target = target.cleanPath
}
} else {
self.target = ""
}
// 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()
Expand Down
7 changes: 7 additions & 0 deletions Sources/ContainerBuild/BuildImageResolver.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,13 @@ import GRPCCore
import Logging
import TerminalProgress

/// Handles the `resolver` stage of the build protocol.
///
/// Resolves image references on behalf of BuildKit: authenticates with
/// registries, pulls missing base-image manifests and layers, and stores
/// them in the local content store. BuildKit delegates these operations to
/// the host because registry credentials and network access live on the
/// macOS side, not inside the builder VM.
struct BuildImageResolver: BuildPipelineHandler {
let contentStore: ContentStore
let quiet: Bool
Expand Down
50 changes: 50 additions & 0 deletions Sources/ContainerBuild/BuildPipelineHandler.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,61 @@ import Foundation
import GRPCCore
import NIO

/// A handler for one stage of the build protocol.
///
/// The build pipeline multiplexes a single bidirectional gRPC stream between
/// the macOS host and the builder shim. Each packet carries a stage tag;
/// a handler claims packets for its stage via ``accept(_:)`` and processes
/// them via ``handle(_:_:)``.
protocol BuildPipelineHandler: Sendable {
func accept(_ packet: ServerStream) throws -> Bool
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws
}

/// Drives a build session by routing packets from the builder shim to the
/// appropriate handler.
///
/// ## Three-tier architecture
///
/// Builds involve three components with distinct responsibilities:
///
/// **macOS host (`BuildPipeline` / its handlers)**
/// Serves resources to the builder shim over a bidirectional gRPC stream.
/// Responsibilities include:
/// - Packing requested build-context files into a tar archive (``BuildFSSync``).
/// - Proxying image-layer blobs from the local content store (``BuildRemoteContentProxy``).
/// - Resolving and pulling base images (``BuildImageResolver``).
/// - Relaying builder stdout/stderr to the terminal (``BuildStdio``).
/// - Enforcing the context root boundary: directory traversal uses `openat(O_NOFOLLOW)`
/// at every descent step, and every individual file request resolves symlinks to their
/// canonical path before verifying containment within the context root.
///
/// **Builder shim (`container-builder-shim`)**
/// A Go process running inside a Linux VM that bridges the host gRPC stream
/// and BuildKit's `filesync` gRPC interface. Responsibilities include:
/// - Receiving the context tar from the host, unpacking it to a local cache,
/// and presenting the result to BuildKit via `DiffCopy`.
/// - Applying dockerignore exclusions (received from BuildKit as
/// `exclude-patterns` metadata) when walking the unpacked cache.
/// - Passing `followpaths` from BuildKit to the host so the host knows which
/// context paths to include in the tar.
///
/// **BuildKit**
/// Parses and executes the Dockerfile. Responsibilities include:
/// - Sending `Walk` requests with `followpaths` derived from each `COPY`/`ADD`
/// source and `exclude-patterns` derived from `.dockerignore`.
/// - Dereferencing symlinks, recursing into directories, and applying all
/// other COPY/ADD transfer semantics on the unpacked context the shim provides.
///
/// ## Packet flow
///
/// ```
/// BuildKit ──► shim DiffCopy ──► host Walk (tar of context files)
/// ◄── tar archive
/// ◄── PACKET_STAT per file (after shim unpacks + filters)
/// ──► PACKET_REQ for each regular file
/// ◄── PACKET_DATA (shim reads from local unpacked cache)
/// ```
public actor BuildPipeline {
let handlers: [BuildPipelineHandler]
public init(_ config: Builder.BuildConfig) async throws {
Expand Down
5 changes: 5 additions & 0 deletions Sources/ContainerBuild/BuildRemoteContentProxy.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,11 @@ import ContainerizationOCI
import Foundation
import GRPCCore

/// Handles the `content-store` stage of the build protocol.
///
/// Proxies image-layer blob requests from BuildKit to the host's local
/// containerd content store. BuildKit issues these requests when it needs
/// base-image layers that are not already present in the builder VM.
struct BuildRemoteContentProxy: BuildPipelineHandler {
let local: ContentStore

Expand Down
5 changes: 5 additions & 0 deletions Sources/ContainerBuild/BuildStdio.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,11 @@ import Foundation
import GRPCCore
import NIO

/// Handles the stdio stage of the build protocol.
///
/// Relays builder stdout/stderr from the shim to the client terminal.
/// Build output (layer download progress, `RUN` command output, etc.) flows
/// through this handler and is written directly to the configured file handle.
actor BuildStdio: BuildPipelineHandler {
public let quiet: Bool
public let handle: FileHandle
Expand Down
83 changes: 59 additions & 24 deletions Sources/ContainerBuild/Globber.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,9 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerizationOS
import Foundation
import SystemPackage

public class Globber {
let input: URL
Expand All@@ -33,7 +35,7 @@ public class Globber {
.replacingOccurrences(of: "[/]\\*{2,}([^/])", with: "/**/*$1", options: .regularExpression)
.replacingOccurrences(of: "^\\*{2,}([^/])", with: "**/*$1", options: .regularExpression)

for child in input.children {
for child in self.children(of: input) {
try self.match(input: child, components: adjustedPattern.split(separator: "/").map(String.init))
}
}
Expand All@@ -47,7 +49,7 @@ public class Globber {
guard dir.pathComponents.count > 1 else { break }
dir.deleteLastPathComponent()
}
return input.childrenRecursive.forEach { results.insert($0) }
return self.childrenRecursive(of: input).forEach { results.insert($0) }
}

let head = components.first ?? ""
Expand All@@ -59,7 +61,7 @@ public class Globber {
tail = tail.tail
}
try self.match(input: input, components: tail)
for child in input.children {
for child in self.children(of: input) {
try self.match(input: child, components: components)
}
return
Expand All@@ -68,13 +70,66 @@ public class Globber {
if try glob(input.lastPathComponent, head) {
try self.match(input: input, components: tail)

for child in input.children where try glob(child.lastPathComponent, tail.first ?? "") {
for child in self.children(of: input) where try glob(child.lastPathComponent, tail.first ?? "") {
try self.match(input: child, components: tail)
}
return
}
}

/// Returns the direct children of `url`, following `url` itself when it is
/// a directory symlink whose fully-resolved target stays within the match
/// root. A symlink that escapes the root is treated as having no children
/// (same as a regular file) so pattern components after it never match —
/// mirrors the containment check `BuildFSSync` applies before reading.
///
/// Children are named by their resolved (physical) path, not by `url`, so
/// that `walk(root:includePatterns:)`'s later filter — which is driven by
/// `Archiver.compress`'s own physical directory walk — reliably finds a
/// matching entry regardless of whether that walk itself follows `url`'s
/// symlink. `url` is separately inserted into `results` so the symlink
/// entry is still present in the tar for the builder to resolve the
/// original path against.
private func children(of url: URL) -> [URL] {
// TODO: modifying object state and returning results is odd, rework
guard let dir = self.resolvedDirectory(of: url) else { return [] }
if url.isSymlink { self.results.insert(url) }
return (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))
?? []
}

/// Recursive form of ``children(of:)``, used once a full pattern (or `**`)
/// has matched `url` and every descendant needs to be collected. Nested
/// directory symlinks are resolved and boundary-checked the same way, one
/// level at a time, via ``FileDescriptorOps/enumerate`` which never follows
/// symlinks it encounters mid-traversal — only the top-level `url` passed
/// in here gets the resolve-and-check treatment.
private func childrenRecursive(of url: URL) -> [URL] {
guard let dir = self.resolvedDirectory(of: url) else { return [url] }
if url.isSymlink { self.results.insert(url) }
guard let fd = try? FileDescriptor.open(FilePath(dir.path), .readOnly, options: .directory) else {
return [dir]
}
defer { try? fd.close() }
var found: [URL] = [dir]
try? FileDescriptorOps.enumerate(fd) { relPath, _, _ in
found.append(dir.appendingPathComponent(relPath.string))
}
return found
}

/// Resolves `url` to the real directory whose contents should be listed in
/// its place. Non-symlinks resolve to themselves. A directory symlink
/// resolves to its target only if the fully-resolved target is still
/// within `self.input` (the match root); otherwise `nil`, so callers treat
/// it as a leaf rather than descending outside the context.
private func resolvedDirectory(of url: URL) -> URL? {
guard url.isSymlink else { return url }
let resolved = url.resolvingSymlinksInPath()
guard resolved.isDirectory, self.input.parentOf(resolved) else { return nil }
return resolved
}

func glob(_ input: String, _ pattern: String) throws -> Bool {
let regexPattern =
"^"
Expand All@@ -91,26 +146,6 @@ public class Globber {
}
}

extension URL {
var children: [URL] {

(try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil))
?? []
}

var childrenRecursive: [URL] {
var results: [URL] = []
if let enumerator = FileManager.default.enumerator(
at: self, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey])
{
while let child = enumerator.nextObject() as? URL {
results.append(child)
}
}
return [self] + results
}
}

extension [String] {
var tail: [String] {
if self.count <= 1 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Fix image env vars, build context checks, TCP/UDP port forward buffer, and validate plugin name by katiewasnothere · Pull Request #2027 · apple/container · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,6 +143,7 @@ let package = Package(
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationArchive", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "GRPCCore", package: "grpc-swift-2"),
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
Expand Down
85 changes: 73 additions & 12 deletions Sources/ContainerBuild/BuildFSSync.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,18 +22,49 @@ import CryptoKit
import Foundation
import GRPCCore

/// 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.
/// Dockerignore filtering is **not** applied here; the shim applies it after
/// unpacking the tar.
actor BuildFSSync: BuildPipelineHandler {
let contextDir: URL

init(_ contextDir: URL) throws {
let resolved = contextDir.resolvingSymlinksInPath()
guard FileManager.default.fileExists(atPath: contextDir.cleanPath) else {
throw Error.contextNotFound(contextDir.cleanPath)
}
guard try contextDir.isDir() else {
guard resolved.isDirectory else {
throw Error.contextIsNotDirectory(contextDir.cleanPath)
}

self.contextDir = contextDir
self.contextDir = resolved
}

nonisolated func accept(_ packet: ServerStream) throws -> Bool {
Expand DownExpand Up@@ -63,6 +94,11 @@ actor BuildFSSync: BuildPipelineHandler {
}
}

/// 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. Rejects any path whose symlink chain resolves
/// outside the context root.
func read(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let offset: UInt64 = packet.offset() ?? 0
let size: Int = packet.len() ?? 0
Expand All@@ -79,6 +115,10 @@ actor BuildFSSync: BuildPipelineHandler {
path = URL(filePath: self.contextDir.cleanPath)
path.append(components: packet.source.cleanPathComponent)
}
let resolved = path.resolvingSymlinksInPath()
guard self.contextDir.parentOf(resolved) else {
throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath)
}
let data = try {
if try path.isDir() {
return Data()
Expand All@@ -95,6 +135,12 @@ actor BuildFSSync: BuildPipelineHandler {
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. Must reject paths that escape the context root
/// via symlinks for the same reasons as ``read(_:_:_:)``.
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let path: URL
if packet.source.hasPrefix("/") {
Expand All@@ -105,6 +151,10 @@ actor BuildFSSync: BuildPipelineHandler {
.appendingPathComponent(packet.source)
.standardizedFileURL
}
let resolved = path.resolvingSymlinksInPath()
guard self.contextDir.parentOf(resolved) else {
throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath)
}
let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true)
var response = ClientStream()
response.buildID = buildID
Expand All@@ -127,6 +177,23 @@ actor BuildFSSync: BuildPipelineHandler {
}
}

/// 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).
/// 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,
Expand DownExpand Up@@ -334,16 +401,10 @@ actor BuildFSSync: BuildPipelineHandler {
let target: String

init(path: URL, contextDir: URL) throws {
if path.isSymlink {
let target: URL = path.resolvingSymlinksInPath()
if contextDir.parentOf(target) {
self.target = target.relativePathFrom(from: path)
} else {
self.target = target.cleanPath
}
} else {
self.target = ""
}
// 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()
Expand Down
7 changes: 7 additions & 0 deletions Sources/ContainerBuild/BuildImageResolver.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,13 @@ import GRPCCore
import Logging
import TerminalProgress

/// Handles the `resolver` stage of the build protocol.
///
/// Resolves image references on behalf of BuildKit: authenticates with
/// registries, pulls missing base-image manifests and layers, and stores
/// them in the local content store. BuildKit delegates these operations to
/// the host because registry credentials and network access live on the
/// macOS side, not inside the builder VM.
struct BuildImageResolver: BuildPipelineHandler {
let contentStore: ContentStore
let quiet: Bool
Expand Down
50 changes: 50 additions & 0 deletions Sources/ContainerBuild/BuildPipelineHandler.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,61 @@ import Foundation
import GRPCCore
import NIO

/// A handler for one stage of the build protocol.
///
/// The build pipeline multiplexes a single bidirectional gRPC stream between
/// the macOS host and the builder shim. Each packet carries a stage tag;
/// a handler claims packets for its stage via ``accept(_:)`` and processes
/// them via ``handle(_:_:)``.
protocol BuildPipelineHandler: Sendable {
func accept(_ packet: ServerStream) throws -> Bool
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws
}

/// Drives a build session by routing packets from the builder shim to the
/// appropriate handler.
///
/// ## Three-tier architecture
///
/// Builds involve three components with distinct responsibilities:
///
/// **macOS host (`BuildPipeline` / its handlers)**
/// Serves resources to the builder shim over a bidirectional gRPC stream.
/// Responsibilities include:
/// - Packing requested build-context files into a tar archive (``BuildFSSync``).
/// - Proxying image-layer blobs from the local content store (``BuildRemoteContentProxy``).
/// - Resolving and pulling base images (``BuildImageResolver``).
/// - Relaying builder stdout/stderr to the terminal (``BuildStdio``).
/// - Enforcing the context root boundary: directory traversal uses `openat(O_NOFOLLOW)`
/// at every descent step, and every individual file request resolves symlinks to their
/// canonical path before verifying containment within the context root.
///
/// **Builder shim (`container-builder-shim`)**
/// A Go process running inside a Linux VM that bridges the host gRPC stream
/// and BuildKit's `filesync` gRPC interface. Responsibilities include:
/// - Receiving the context tar from the host, unpacking it to a local cache,
/// and presenting the result to BuildKit via `DiffCopy`.
/// - Applying dockerignore exclusions (received from BuildKit as
/// `exclude-patterns` metadata) when walking the unpacked cache.
/// - Passing `followpaths` from BuildKit to the host so the host knows which
/// context paths to include in the tar.
///
/// **BuildKit**
/// Parses and executes the Dockerfile. Responsibilities include:
/// - Sending `Walk` requests with `followpaths` derived from each `COPY`/`ADD`
/// source and `exclude-patterns` derived from `.dockerignore`.
/// - Dereferencing symlinks, recursing into directories, and applying all
/// other COPY/ADD transfer semantics on the unpacked context the shim provides.
///
/// ## Packet flow
///
/// ```
/// BuildKit ──► shim DiffCopy ──► host Walk (tar of context files)
/// ◄── tar archive
/// ◄── PACKET_STAT per file (after shim unpacks + filters)
/// ──► PACKET_REQ for each regular file
/// ◄── PACKET_DATA (shim reads from local unpacked cache)
/// ```
public actor BuildPipeline {
let handlers: [BuildPipelineHandler]
public init(_ config: Builder.BuildConfig) async throws {
Expand Down
5 changes: 5 additions & 0 deletions Sources/ContainerBuild/BuildRemoteContentProxy.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,11 @@ import ContainerizationOCI
import Foundation
import GRPCCore

/// Handles the `content-store` stage of the build protocol.
///
/// Proxies image-layer blob requests from BuildKit to the host's local
/// containerd content store. BuildKit issues these requests when it needs
/// base-image layers that are not already present in the builder VM.
struct BuildRemoteContentProxy: BuildPipelineHandler {
let local: ContentStore

Expand Down
5 changes: 5 additions & 0 deletions Sources/ContainerBuild/BuildStdio.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,11 @@ import Foundation
import GRPCCore
import NIO

/// Handles the stdio stage of the build protocol.
///
/// Relays builder stdout/stderr from the shim to the client terminal.
/// Build output (layer download progress, `RUN` command output, etc.) flows
/// through this handler and is written directly to the configured file handle.
actor BuildStdio: BuildPipelineHandler {
public let quiet: Bool
public let handle: FileHandle
Expand Down
83 changes: 59 additions & 24 deletions Sources/ContainerBuild/Globber.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,9 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerizationOS
import Foundation
import SystemPackage

public class Globber {
let input: URL
Expand All@@ -33,7 +35,7 @@ public class Globber {
.replacingOccurrences(of: "[/]\\*{2,}([^/])", with: "/**/*$1", options: .regularExpression)
.replacingOccurrences(of: "^\\*{2,}([^/])", with: "**/*$1", options: .regularExpression)

for child in input.children {
for child in self.children(of: input) {
try self.match(input: child, components: adjustedPattern.split(separator: "/").map(String.init))
}
}
Expand All@@ -47,7 +49,7 @@ public class Globber {
guard dir.pathComponents.count > 1 else { break }
dir.deleteLastPathComponent()
}
return input.childrenRecursive.forEach { results.insert($0) }
return self.childrenRecursive(of: input).forEach { results.insert($0) }
}

let head = components.first ?? ""
Expand All@@ -59,7 +61,7 @@ public class Globber {
tail = tail.tail
}
try self.match(input: input, components: tail)
for child in input.children {
for child in self.children(of: input) {
try self.match(input: child, components: components)
}
return
Expand All@@ -68,13 +70,66 @@ public class Globber {
if try glob(input.lastPathComponent, head) {
try self.match(input: input, components: tail)

for child in input.children where try glob(child.lastPathComponent, tail.first ?? "") {
for child in self.children(of: input) where try glob(child.lastPathComponent, tail.first ?? "") {
try self.match(input: child, components: tail)
}
return
}
}

/// Returns the direct children of `url`, following `url` itself when it is
/// a directory symlink whose fully-resolved target stays within the match
/// root. A symlink that escapes the root is treated as having no children
/// (same as a regular file) so pattern components after it never match —
/// mirrors the containment check `BuildFSSync` applies before reading.
///
/// Children are named by their resolved (physical) path, not by `url`, so
/// that `walk(root:includePatterns:)`'s later filter — which is driven by
/// `Archiver.compress`'s own physical directory walk — reliably finds a
/// matching entry regardless of whether that walk itself follows `url`'s
/// symlink. `url` is separately inserted into `results` so the symlink
/// entry is still present in the tar for the builder to resolve the
/// original path against.
private func children(of url: URL) -> [URL] {
// TODO: modifying object state and returning results is odd, rework
guard let dir = self.resolvedDirectory(of: url) else { return [] }
if url.isSymlink { self.results.insert(url) }
return (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))
?? []
}

/// Recursive form of ``children(of:)``, used once a full pattern (or `**`)
/// has matched `url` and every descendant needs to be collected. Nested
/// directory symlinks are resolved and boundary-checked the same way, one
/// level at a time, via ``FileDescriptorOps/enumerate`` which never follows
/// symlinks it encounters mid-traversal — only the top-level `url` passed
/// in here gets the resolve-and-check treatment.
private func childrenRecursive(of url: URL) -> [URL] {
guard let dir = self.resolvedDirectory(of: url) else { return [url] }
if url.isSymlink { self.results.insert(url) }
guard let fd = try? FileDescriptor.open(FilePath(dir.path), .readOnly, options: .directory) else {
return [dir]
}
defer { try? fd.close() }
var found: [URL] = [dir]
try? FileDescriptorOps.enumerate(fd) { relPath, _, _ in
found.append(dir.appendingPathComponent(relPath.string))
}
return found
}

/// Resolves `url` to the real directory whose contents should be listed in
/// its place. Non-symlinks resolve to themselves. A directory symlink
/// resolves to its target only if the fully-resolved target is still
/// within `self.input` (the match root); otherwise `nil`, so callers treat
/// it as a leaf rather than descending outside the context.
private func resolvedDirectory(of url: URL) -> URL? {
guard url.isSymlink else { return url }
let resolved = url.resolvingSymlinksInPath()
guard resolved.isDirectory, self.input.parentOf(resolved) else { return nil }
return resolved
}

func glob(_ input: String, _ pattern: String) throws -> Bool {
let regexPattern =
"^"
Expand All@@ -91,26 +146,6 @@ public class Globber {
}
}

extension URL {
var children: [URL] {

(try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil))
?? []
}

var childrenRecursive: [URL] {
var results: [URL] = []
if let enumerator = FileManager.default.enumerator(
at: self, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey])
{
while let child = enumerator.nextObject() as? URL {
results.append(child)
}
}
return [self] + results
}
}

extension [String] {
var tail: [String] {
if self.count <= 1 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix image env vars, build context checks, TCP/UDP port forward buffer, and validate plugin name by katiewasnothere · Pull Request #2027 · apple/container · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,6 +143,7 @@ let package = Package(
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationArchive", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "GRPCCore", package: "grpc-swift-2"),
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
Expand Down
85 changes: 73 additions & 12 deletions Sources/ContainerBuild/BuildFSSync.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,18 +22,49 @@ import CryptoKit
import Foundation
import GRPCCore

/// 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.
/// Dockerignore filtering is **not** applied here; the shim applies it after
/// unpacking the tar.
actor BuildFSSync: BuildPipelineHandler {
let contextDir: URL

init(_ contextDir: URL) throws {
let resolved = contextDir.resolvingSymlinksInPath()
guard FileManager.default.fileExists(atPath: contextDir.cleanPath) else {
throw Error.contextNotFound(contextDir.cleanPath)
}
guard try contextDir.isDir() else {
guard resolved.isDirectory else {
throw Error.contextIsNotDirectory(contextDir.cleanPath)
}

self.contextDir = contextDir
self.contextDir = resolved
}

nonisolated func accept(_ packet: ServerStream) throws -> Bool {
Expand DownExpand Up@@ -63,6 +94,11 @@ actor BuildFSSync: BuildPipelineHandler {
}
}

/// 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. Rejects any path whose symlink chain resolves
/// outside the context root.
func read(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let offset: UInt64 = packet.offset() ?? 0
let size: Int = packet.len() ?? 0
Expand All@@ -79,6 +115,10 @@ actor BuildFSSync: BuildPipelineHandler {
path = URL(filePath: self.contextDir.cleanPath)
path.append(components: packet.source.cleanPathComponent)
}
let resolved = path.resolvingSymlinksInPath()
guard self.contextDir.parentOf(resolved) else {
throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath)
}
let data = try {
if try path.isDir() {
return Data()
Expand All@@ -95,6 +135,12 @@ actor BuildFSSync: BuildPipelineHandler {
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. Must reject paths that escape the context root
/// via symlinks for the same reasons as ``read(_:_:_:)``.
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let path: URL
if packet.source.hasPrefix("/") {
Expand All@@ -105,6 +151,10 @@ actor BuildFSSync: BuildPipelineHandler {
.appendingPathComponent(packet.source)
.standardizedFileURL
}
let resolved = path.resolvingSymlinksInPath()
guard self.contextDir.parentOf(resolved) else {
throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath)
}
let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true)
var response = ClientStream()
response.buildID = buildID
Expand All@@ -127,6 +177,23 @@ actor BuildFSSync: BuildPipelineHandler {
}
}

/// 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).
/// 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,
Expand DownExpand Up@@ -334,16 +401,10 @@ actor BuildFSSync: BuildPipelineHandler {
let target: String

init(path: URL, contextDir: URL) throws {
if path.isSymlink {
let target: URL = path.resolvingSymlinksInPath()
if contextDir.parentOf(target) {
self.target = target.relativePathFrom(from: path)
} else {
self.target = target.cleanPath
}
} else {
self.target = ""
}
// 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()
Expand Down
7 changes: 7 additions & 0 deletions Sources/ContainerBuild/BuildImageResolver.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,13 @@ import GRPCCore
import Logging
import TerminalProgress

/// Handles the `resolver` stage of the build protocol.
///
/// Resolves image references on behalf of BuildKit: authenticates with
/// registries, pulls missing base-image manifests and layers, and stores
/// them in the local content store. BuildKit delegates these operations to
/// the host because registry credentials and network access live on the
/// macOS side, not inside the builder VM.
struct BuildImageResolver: BuildPipelineHandler {
let contentStore: ContentStore
let quiet: Bool
Expand Down
50 changes: 50 additions & 0 deletions Sources/ContainerBuild/BuildPipelineHandler.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,61 @@ import Foundation
import GRPCCore
import NIO

/// A handler for one stage of the build protocol.
///
/// The build pipeline multiplexes a single bidirectional gRPC stream between
/// the macOS host and the builder shim. Each packet carries a stage tag;
/// a handler claims packets for its stage via ``accept(_:)`` and processes
/// them via ``handle(_:_:)``.
protocol BuildPipelineHandler: Sendable {
func accept(_ packet: ServerStream) throws -> Bool
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws
}

/// Drives a build session by routing packets from the builder shim to the
/// appropriate handler.
///
/// ## Three-tier architecture
///
/// Builds involve three components with distinct responsibilities:
///
/// **macOS host (`BuildPipeline` / its handlers)**
/// Serves resources to the builder shim over a bidirectional gRPC stream.
/// Responsibilities include:
/// - Packing requested build-context files into a tar archive (``BuildFSSync``).
/// - Proxying image-layer blobs from the local content store (``BuildRemoteContentProxy``).
/// - Resolving and pulling base images (``BuildImageResolver``).
/// - Relaying builder stdout/stderr to the terminal (``BuildStdio``).
/// - Enforcing the context root boundary: directory traversal uses `openat(O_NOFOLLOW)`
/// at every descent step, and every individual file request resolves symlinks to their
/// canonical path before verifying containment within the context root.
///
/// **Builder shim (`container-builder-shim`)**
/// A Go process running inside a Linux VM that bridges the host gRPC stream
/// and BuildKit's `filesync` gRPC interface. Responsibilities include:
/// - Receiving the context tar from the host, unpacking it to a local cache,
/// and presenting the result to BuildKit via `DiffCopy`.
/// - Applying dockerignore exclusions (received from BuildKit as
/// `exclude-patterns` metadata) when walking the unpacked cache.
/// - Passing `followpaths` from BuildKit to the host so the host knows which
/// context paths to include in the tar.
///
/// **BuildKit**
/// Parses and executes the Dockerfile. Responsibilities include:
/// - Sending `Walk` requests with `followpaths` derived from each `COPY`/`ADD`
/// source and `exclude-patterns` derived from `.dockerignore`.
/// - Dereferencing symlinks, recursing into directories, and applying all
/// other COPY/ADD transfer semantics on the unpacked context the shim provides.
///
/// ## Packet flow
///
/// ```
/// BuildKit ──► shim DiffCopy ──► host Walk (tar of context files)
/// ◄── tar archive
/// ◄── PACKET_STAT per file (after shim unpacks + filters)
/// ──► PACKET_REQ for each regular file
/// ◄── PACKET_DATA (shim reads from local unpacked cache)
/// ```
public actor BuildPipeline {
let handlers: [BuildPipelineHandler]
public init(_ config: Builder.BuildConfig) async throws {
Expand Down
5 changes: 5 additions & 0 deletions Sources/ContainerBuild/BuildRemoteContentProxy.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,11 @@ import ContainerizationOCI
import Foundation
import GRPCCore

/// Handles the `content-store` stage of the build protocol.
///
/// Proxies image-layer blob requests from BuildKit to the host's local
/// containerd content store. BuildKit issues these requests when it needs
/// base-image layers that are not already present in the builder VM.
struct BuildRemoteContentProxy: BuildPipelineHandler {
let local: ContentStore

Expand Down
5 changes: 5 additions & 0 deletions Sources/ContainerBuild/BuildStdio.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,11 @@ import Foundation
import GRPCCore
import NIO

/// Handles the stdio stage of the build protocol.
///
/// Relays builder stdout/stderr from the shim to the client terminal.
/// Build output (layer download progress, `RUN` command output, etc.) flows
/// through this handler and is written directly to the configured file handle.
actor BuildStdio: BuildPipelineHandler {
public let quiet: Bool
public let handle: FileHandle
Expand Down
83 changes: 59 additions & 24 deletions Sources/ContainerBuild/Globber.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,9 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerizationOS
import Foundation
import SystemPackage

public class Globber {
let input: URL
Expand All@@ -33,7 +35,7 @@ public class Globber {
.replacingOccurrences(of: "[/]\\*{2,}([^/])", with: "/**/*$1", options: .regularExpression)
.replacingOccurrences(of: "^\\*{2,}([^/])", with: "**/*$1", options: .regularExpression)

for child in input.children {
for child in self.children(of: input) {
try self.match(input: child, components: adjustedPattern.split(separator: "/").map(String.init))
}
}
Expand All@@ -47,7 +49,7 @@ public class Globber {
guard dir.pathComponents.count > 1 else { break }
dir.deleteLastPathComponent()
}
return input.childrenRecursive.forEach { results.insert($0) }
return self.childrenRecursive(of: input).forEach { results.insert($0) }
}

let head = components.first ?? ""
Expand All@@ -59,7 +61,7 @@ public class Globber {
tail = tail.tail
}
try self.match(input: input, components: tail)
for child in input.children {
for child in self.children(of: input) {
try self.match(input: child, components: components)
}
return
Expand All@@ -68,13 +70,66 @@ public class Globber {
if try glob(input.lastPathComponent, head) {
try self.match(input: input, components: tail)

for child in input.children where try glob(child.lastPathComponent, tail.first ?? "") {
for child in self.children(of: input) where try glob(child.lastPathComponent, tail.first ?? "") {
try self.match(input: child, components: tail)
}
return
}
}

/// Returns the direct children of `url`, following `url` itself when it is
/// a directory symlink whose fully-resolved target stays within the match
/// root. A symlink that escapes the root is treated as having no children
/// (same as a regular file) so pattern components after it never match —
/// mirrors the containment check `BuildFSSync` applies before reading.
///
/// Children are named by their resolved (physical) path, not by `url`, so
/// that `walk(root:includePatterns:)`'s later filter — which is driven by
/// `Archiver.compress`'s own physical directory walk — reliably finds a
/// matching entry regardless of whether that walk itself follows `url`'s
/// symlink. `url` is separately inserted into `results` so the symlink
/// entry is still present in the tar for the builder to resolve the
/// original path against.
private func children(of url: URL) -> [URL] {
// TODO: modifying object state and returning results is odd, rework
guard let dir = self.resolvedDirectory(of: url) else { return [] }
if url.isSymlink { self.results.insert(url) }
return (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))
?? []
}

/// Recursive form of ``children(of:)``, used once a full pattern (or `**`)
/// has matched `url` and every descendant needs to be collected. Nested
/// directory symlinks are resolved and boundary-checked the same way, one
/// level at a time, via ``FileDescriptorOps/enumerate`` which never follows
/// symlinks it encounters mid-traversal — only the top-level `url` passed
/// in here gets the resolve-and-check treatment.
private func childrenRecursive(of url: URL) -> [URL] {
guard let dir = self.resolvedDirectory(of: url) else { return [url] }
if url.isSymlink { self.results.insert(url) }
guard let fd = try? FileDescriptor.open(FilePath(dir.path), .readOnly, options: .directory) else {
return [dir]
}
defer { try? fd.close() }
var found: [URL] = [dir]
try? FileDescriptorOps.enumerate(fd) { relPath, _, _ in
found.append(dir.appendingPathComponent(relPath.string))
}
return found
}

/// Resolves `url` to the real directory whose contents should be listed in
/// its place. Non-symlinks resolve to themselves. A directory symlink
/// resolves to its target only if the fully-resolved target is still
/// within `self.input` (the match root); otherwise `nil`, so callers treat
/// it as a leaf rather than descending outside the context.
private func resolvedDirectory(of url: URL) -> URL? {
guard url.isSymlink else { return url }
let resolved = url.resolvingSymlinksInPath()
guard resolved.isDirectory, self.input.parentOf(resolved) else { return nil }
return resolved
}

func glob(_ input: String, _ pattern: String) throws -> Bool {
let regexPattern =
"^"
Expand All@@ -91,26 +146,6 @@ public class Globber {
}
}

extension URL {
var children: [URL] {

(try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil))
?? []
}

var childrenRecursive: [URL] {
var results: [URL] = []
if let enumerator = FileManager.default.enumerator(
at: self, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey])
{
while let child = enumerator.nextObject() as? URL {
results.append(child)
}
}
return [self] + results
}
}

extension [String] {
var tail: [String] {
if self.count <= 1 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix image env vars, build context checks, TCP/UDP port forward buffer, and validate plugin name by katiewasnothere · Pull Request #2027 · apple/container · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,6 +143,7 @@ let package = Package(
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationArchive", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "GRPCCore", package: "grpc-swift-2"),
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
Expand Down
85 changes: 73 additions & 12 deletions Sources/ContainerBuild/BuildFSSync.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,18 +22,49 @@ import CryptoKit
import Foundation
import GRPCCore

/// 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.
/// Dockerignore filtering is **not** applied here; the shim applies it after
/// unpacking the tar.
actor BuildFSSync: BuildPipelineHandler {
let contextDir: URL

init(_ contextDir: URL) throws {
let resolved = contextDir.resolvingSymlinksInPath()
guard FileManager.default.fileExists(atPath: contextDir.cleanPath) else {
throw Error.contextNotFound(contextDir.cleanPath)
}
guard try contextDir.isDir() else {
guard resolved.isDirectory else {
throw Error.contextIsNotDirectory(contextDir.cleanPath)
}

self.contextDir = contextDir
self.contextDir = resolved
}

nonisolated func accept(_ packet: ServerStream) throws -> Bool {
Expand DownExpand Up@@ -63,6 +94,11 @@ actor BuildFSSync: BuildPipelineHandler {
}
}

/// 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. Rejects any path whose symlink chain resolves
/// outside the context root.
func read(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let offset: UInt64 = packet.offset() ?? 0
let size: Int = packet.len() ?? 0
Expand All@@ -79,6 +115,10 @@ actor BuildFSSync: BuildPipelineHandler {
path = URL(filePath: self.contextDir.cleanPath)
path.append(components: packet.source.cleanPathComponent)
}
let resolved = path.resolvingSymlinksInPath()
guard self.contextDir.parentOf(resolved) else {
throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath)
}
let data = try {
if try path.isDir() {
return Data()
Expand All@@ -95,6 +135,12 @@ actor BuildFSSync: BuildPipelineHandler {
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. Must reject paths that escape the context root
/// via symlinks for the same reasons as ``read(_:_:_:)``.
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let path: URL
if packet.source.hasPrefix("/") {
Expand All@@ -105,6 +151,10 @@ actor BuildFSSync: BuildPipelineHandler {
.appendingPathComponent(packet.source)
.standardizedFileURL
}
let resolved = path.resolvingSymlinksInPath()
guard self.contextDir.parentOf(resolved) else {
throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath)
}
let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true)
var response = ClientStream()
response.buildID = buildID
Expand All@@ -127,6 +177,23 @@ actor BuildFSSync: BuildPipelineHandler {
}
}

/// 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).
/// 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,
Expand DownExpand Up@@ -334,16 +401,10 @@ actor BuildFSSync: BuildPipelineHandler {
let target: String

init(path: URL, contextDir: URL) throws {
if path.isSymlink {
let target: URL = path.resolvingSymlinksInPath()
if contextDir.parentOf(target) {
self.target = target.relativePathFrom(from: path)
} else {
self.target = target.cleanPath
}
} else {
self.target = ""
}
// 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()
Expand Down
7 changes: 7 additions & 0 deletions Sources/ContainerBuild/BuildImageResolver.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,13 @@ import GRPCCore
import Logging
import TerminalProgress

/// Handles the `resolver` stage of the build protocol.
///
/// Resolves image references on behalf of BuildKit: authenticates with
/// registries, pulls missing base-image manifests and layers, and stores
/// them in the local content store. BuildKit delegates these operations to
/// the host because registry credentials and network access live on the
/// macOS side, not inside the builder VM.
struct BuildImageResolver: BuildPipelineHandler {
let contentStore: ContentStore
let quiet: Bool
Expand Down
50 changes: 50 additions & 0 deletions Sources/ContainerBuild/BuildPipelineHandler.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,61 @@ import Foundation
import GRPCCore
import NIO

/// A handler for one stage of the build protocol.
///
/// The build pipeline multiplexes a single bidirectional gRPC stream between
/// the macOS host and the builder shim. Each packet carries a stage tag;
/// a handler claims packets for its stage via ``accept(_:)`` and processes
/// them via ``handle(_:_:)``.
protocol BuildPipelineHandler: Sendable {
func accept(_ packet: ServerStream) throws -> Bool
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws
}

/// Drives a build session by routing packets from the builder shim to the
/// appropriate handler.
///
/// ## Three-tier architecture
///
/// Builds involve three components with distinct responsibilities:
///
/// **macOS host (`BuildPipeline` / its handlers)**
/// Serves resources to the builder shim over a bidirectional gRPC stream.
/// Responsibilities include:
/// - Packing requested build-context files into a tar archive (``BuildFSSync``).
/// - Proxying image-layer blobs from the local content store (``BuildRemoteContentProxy``).
/// - Resolving and pulling base images (``BuildImageResolver``).
/// - Relaying builder stdout/stderr to the terminal (``BuildStdio``).
/// - Enforcing the context root boundary: directory traversal uses `openat(O_NOFOLLOW)`
/// at every descent step, and every individual file request resolves symlinks to their
/// canonical path before verifying containment within the context root.
///
/// **Builder shim (`container-builder-shim`)**
/// A Go process running inside a Linux VM that bridges the host gRPC stream
/// and BuildKit's `filesync` gRPC interface. Responsibilities include:
/// - Receiving the context tar from the host, unpacking it to a local cache,
/// and presenting the result to BuildKit via `DiffCopy`.
/// - Applying dockerignore exclusions (received from BuildKit as
/// `exclude-patterns` metadata) when walking the unpacked cache.
/// - Passing `followpaths` from BuildKit to the host so the host knows which
/// context paths to include in the tar.
///
/// **BuildKit**
/// Parses and executes the Dockerfile. Responsibilities include:
/// - Sending `Walk` requests with `followpaths` derived from each `COPY`/`ADD`
/// source and `exclude-patterns` derived from `.dockerignore`.
/// - Dereferencing symlinks, recursing into directories, and applying all
/// other COPY/ADD transfer semantics on the unpacked context the shim provides.
///
/// ## Packet flow
///
/// ```
/// BuildKit ──► shim DiffCopy ──► host Walk (tar of context files)
/// ◄── tar archive
/// ◄── PACKET_STAT per file (after shim unpacks + filters)
/// ──► PACKET_REQ for each regular file
/// ◄── PACKET_DATA (shim reads from local unpacked cache)
/// ```
public actor BuildPipeline {
let handlers: [BuildPipelineHandler]
public init(_ config: Builder.BuildConfig) async throws {
Expand Down
5 changes: 5 additions & 0 deletions Sources/ContainerBuild/BuildRemoteContentProxy.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,11 @@ import ContainerizationOCI
import Foundation
import GRPCCore

/// Handles the `content-store` stage of the build protocol.
///
/// Proxies image-layer blob requests from BuildKit to the host's local
/// containerd content store. BuildKit issues these requests when it needs
/// base-image layers that are not already present in the builder VM.
struct BuildRemoteContentProxy: BuildPipelineHandler {
let local: ContentStore

Expand Down
5 changes: 5 additions & 0 deletions Sources/ContainerBuild/BuildStdio.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,11 @@ import Foundation
import GRPCCore
import NIO

/// Handles the stdio stage of the build protocol.
///
/// Relays builder stdout/stderr from the shim to the client terminal.
/// Build output (layer download progress, `RUN` command output, etc.) flows
/// through this handler and is written directly to the configured file handle.
actor BuildStdio: BuildPipelineHandler {
public let quiet: Bool
public let handle: FileHandle
Expand Down
83 changes: 59 additions & 24 deletions Sources/ContainerBuild/Globber.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,9 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerizationOS
import Foundation
import SystemPackage

public class Globber {
let input: URL
Expand All@@ -33,7 +35,7 @@ public class Globber {
.replacingOccurrences(of: "[/]\\*{2,}([^/])", with: "/**/*$1", options: .regularExpression)
.replacingOccurrences(of: "^\\*{2,}([^/])", with: "**/*$1", options: .regularExpression)

for child in input.children {
for child in self.children(of: input) {
try self.match(input: child, components: adjustedPattern.split(separator: "/").map(String.init))
}
}
Expand All@@ -47,7 +49,7 @@ public class Globber {
guard dir.pathComponents.count > 1 else { break }
dir.deleteLastPathComponent()
}
return input.childrenRecursive.forEach { results.insert($0) }
return self.childrenRecursive(of: input).forEach { results.insert($0) }
}

let head = components.first ?? ""
Expand All@@ -59,7 +61,7 @@ public class Globber {
tail = tail.tail
}
try self.match(input: input, components: tail)
for child in input.children {
for child in self.children(of: input) {
try self.match(input: child, components: components)
}
return
Expand All@@ -68,13 +70,66 @@ public class Globber {
if try glob(input.lastPathComponent, head) {
try self.match(input: input, components: tail)

for child in input.children where try glob(child.lastPathComponent, tail.first ?? "") {
for child in self.children(of: input) where try glob(child.lastPathComponent, tail.first ?? "") {
try self.match(input: child, components: tail)
}
return
}
}

/// Returns the direct children of `url`, following `url` itself when it is
/// a directory symlink whose fully-resolved target stays within the match
/// root. A symlink that escapes the root is treated as having no children
/// (same as a regular file) so pattern components after it never match —
/// mirrors the containment check `BuildFSSync` applies before reading.
///
/// Children are named by their resolved (physical) path, not by `url`, so
/// that `walk(root:includePatterns:)`'s later filter — which is driven by
/// `Archiver.compress`'s own physical directory walk — reliably finds a
/// matching entry regardless of whether that walk itself follows `url`'s
/// symlink. `url` is separately inserted into `results` so the symlink
/// entry is still present in the tar for the builder to resolve the
/// original path against.
private func children(of url: URL) -> [URL] {
// TODO: modifying object state and returning results is odd, rework
guard let dir = self.resolvedDirectory(of: url) else { return [] }
if url.isSymlink { self.results.insert(url) }
return (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))
?? []
}

/// Recursive form of ``children(of:)``, used once a full pattern (or `**`)
/// has matched `url` and every descendant needs to be collected. Nested
/// directory symlinks are resolved and boundary-checked the same way, one
/// level at a time, via ``FileDescriptorOps/enumerate`` which never follows
/// symlinks it encounters mid-traversal — only the top-level `url` passed
/// in here gets the resolve-and-check treatment.
private func childrenRecursive(of url: URL) -> [URL] {
guard let dir = self.resolvedDirectory(of: url) else { return [url] }
if url.isSymlink { self.results.insert(url) }
guard let fd = try? FileDescriptor.open(FilePath(dir.path), .readOnly, options: .directory) else {
return [dir]
}
defer { try? fd.close() }
var found: [URL] = [dir]
try? FileDescriptorOps.enumerate(fd) { relPath, _, _ in
found.append(dir.appendingPathComponent(relPath.string))
}
return found
}

/// Resolves `url` to the real directory whose contents should be listed in
/// its place. Non-symlinks resolve to themselves. A directory symlink
/// resolves to its target only if the fully-resolved target is still
/// within `self.input` (the match root); otherwise `nil`, so callers treat
/// it as a leaf rather than descending outside the context.
private func resolvedDirectory(of url: URL) -> URL? {
guard url.isSymlink else { return url }
let resolved = url.resolvingSymlinksInPath()
guard resolved.isDirectory, self.input.parentOf(resolved) else { return nil }
return resolved
}

func glob(_ input: String, _ pattern: String) throws -> Bool {
let regexPattern =
"^"
Expand All@@ -91,26 +146,6 @@ public class Globber {
}
}

extension URL {
var children: [URL] {

(try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil))
?? []
}

var childrenRecursive: [URL] {
var results: [URL] = []
if let enumerator = FileManager.default.enumerator(
at: self, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey])
{
while let child = enumerator.nextObject() as? URL {
results.append(child)
}
}
return [self] + results
}
}

extension [String] {
var tail: [String] {
if self.count <= 1 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Fix image env vars, build context checks, TCP/UDP port forward buffer, and validate plugin name by katiewasnothere · Pull Request #2027 · apple/container · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Package.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,6 +143,7 @@ let package = Package(
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationArchive", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "GRPCCore", package: "grpc-swift-2"),
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
Expand Down
85 changes: 73 additions & 12 deletions Sources/ContainerBuild/BuildFSSync.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,18 +22,49 @@ import CryptoKit
import Foundation
import GRPCCore

/// 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.
/// Dockerignore filtering is **not** applied here; the shim applies it after
/// unpacking the tar.
actor BuildFSSync: BuildPipelineHandler {
let contextDir: URL

init(_ contextDir: URL) throws {
let resolved = contextDir.resolvingSymlinksInPath()
guard FileManager.default.fileExists(atPath: contextDir.cleanPath) else {
throw Error.contextNotFound(contextDir.cleanPath)
}
guard try contextDir.isDir() else {
guard resolved.isDirectory else {
throw Error.contextIsNotDirectory(contextDir.cleanPath)
}

self.contextDir = contextDir
self.contextDir = resolved
}

nonisolated func accept(_ packet: ServerStream) throws -> Bool {
Expand DownExpand Up@@ -63,6 +94,11 @@ actor BuildFSSync: BuildPipelineHandler {
}
}

/// 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. Rejects any path whose symlink chain resolves
/// outside the context root.
func read(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let offset: UInt64 = packet.offset() ?? 0
let size: Int = packet.len() ?? 0
Expand All@@ -79,6 +115,10 @@ actor BuildFSSync: BuildPipelineHandler {
path = URL(filePath: self.contextDir.cleanPath)
path.append(components: packet.source.cleanPathComponent)
}
let resolved = path.resolvingSymlinksInPath()
guard self.contextDir.parentOf(resolved) else {
throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath)
}
let data = try {
if try path.isDir() {
return Data()
Expand All@@ -95,6 +135,12 @@ actor BuildFSSync: BuildPipelineHandler {
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. Must reject paths that escape the context root
/// via symlinks for the same reasons as ``read(_:_:_:)``.
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let path: URL
if packet.source.hasPrefix("/") {
Expand All@@ -105,6 +151,10 @@ actor BuildFSSync: BuildPipelineHandler {
.appendingPathComponent(packet.source)
.standardizedFileURL
}
let resolved = path.resolvingSymlinksInPath()
guard self.contextDir.parentOf(resolved) else {
throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath)
}
let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true)
var response = ClientStream()
response.buildID = buildID
Expand All@@ -127,6 +177,23 @@ actor BuildFSSync: BuildPipelineHandler {
}
}

/// 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).
/// 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,
Expand DownExpand Up@@ -334,16 +401,10 @@ actor BuildFSSync: BuildPipelineHandler {
let target: String

init(path: URL, contextDir: URL) throws {
if path.isSymlink {
let target: URL = path.resolvingSymlinksInPath()
if contextDir.parentOf(target) {
self.target = target.relativePathFrom(from: path)
} else {
self.target = target.cleanPath
}
} else {
self.target = ""
}
// 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()
Expand Down
7 changes: 7 additions & 0 deletions Sources/ContainerBuild/BuildImageResolver.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,13 @@ import GRPCCore
import Logging
import TerminalProgress

/// Handles the `resolver` stage of the build protocol.
///
/// Resolves image references on behalf of BuildKit: authenticates with
/// registries, pulls missing base-image manifests and layers, and stores
/// them in the local content store. BuildKit delegates these operations to
/// the host because registry credentials and network access live on the
/// macOS side, not inside the builder VM.
struct BuildImageResolver: BuildPipelineHandler {
let contentStore: ContentStore
let quiet: Bool
Expand Down
50 changes: 50 additions & 0 deletions Sources/ContainerBuild/BuildPipelineHandler.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,61 @@ import Foundation
import GRPCCore
import NIO

/// A handler for one stage of the build protocol.
///
/// The build pipeline multiplexes a single bidirectional gRPC stream between
/// the macOS host and the builder shim. Each packet carries a stage tag;
/// a handler claims packets for its stage via ``accept(_:)`` and processes
/// them via ``handle(_:_:)``.
protocol BuildPipelineHandler: Sendable {
func accept(_ packet: ServerStream) throws -> Bool
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws
}

/// Drives a build session by routing packets from the builder shim to the
/// appropriate handler.
///
/// ## Three-tier architecture
///
/// Builds involve three components with distinct responsibilities:
///
/// **macOS host (`BuildPipeline` / its handlers)**
/// Serves resources to the builder shim over a bidirectional gRPC stream.
/// Responsibilities include:
/// - Packing requested build-context files into a tar archive (``BuildFSSync``).
/// - Proxying image-layer blobs from the local content store (``BuildRemoteContentProxy``).
/// - Resolving and pulling base images (``BuildImageResolver``).
/// - Relaying builder stdout/stderr to the terminal (``BuildStdio``).
/// - Enforcing the context root boundary: directory traversal uses `openat(O_NOFOLLOW)`
/// at every descent step, and every individual file request resolves symlinks to their
/// canonical path before verifying containment within the context root.
///
/// **Builder shim (`container-builder-shim`)**
/// A Go process running inside a Linux VM that bridges the host gRPC stream
/// and BuildKit's `filesync` gRPC interface. Responsibilities include:
/// - Receiving the context tar from the host, unpacking it to a local cache,
/// and presenting the result to BuildKit via `DiffCopy`.
/// - Applying dockerignore exclusions (received from BuildKit as
/// `exclude-patterns` metadata) when walking the unpacked cache.
/// - Passing `followpaths` from BuildKit to the host so the host knows which
/// context paths to include in the tar.
///
/// **BuildKit**
/// Parses and executes the Dockerfile. Responsibilities include:
/// - Sending `Walk` requests with `followpaths` derived from each `COPY`/`ADD`
/// source and `exclude-patterns` derived from `.dockerignore`.
/// - Dereferencing symlinks, recursing into directories, and applying all
/// other COPY/ADD transfer semantics on the unpacked context the shim provides.
///
/// ## Packet flow
///
/// ```
/// BuildKit ──► shim DiffCopy ──► host Walk (tar of context files)
/// ◄── tar archive
/// ◄── PACKET_STAT per file (after shim unpacks + filters)
/// ──► PACKET_REQ for each regular file
/// ◄── PACKET_DATA (shim reads from local unpacked cache)
/// ```
public actor BuildPipeline {
let handlers: [BuildPipelineHandler]
public init(_ config: Builder.BuildConfig) async throws {
Expand Down
5 changes: 5 additions & 0 deletions Sources/ContainerBuild/BuildRemoteContentProxy.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,11 @@ import ContainerizationOCI
import Foundation
import GRPCCore

/// Handles the `content-store` stage of the build protocol.
///
/// Proxies image-layer blob requests from BuildKit to the host's local
/// containerd content store. BuildKit issues these requests when it needs
/// base-image layers that are not already present in the builder VM.
struct BuildRemoteContentProxy: BuildPipelineHandler {
let local: ContentStore

Expand Down
5 changes: 5 additions & 0 deletions Sources/ContainerBuild/BuildStdio.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,11 @@ import Foundation
import GRPCCore
import NIO

/// Handles the stdio stage of the build protocol.
///
/// Relays builder stdout/stderr from the shim to the client terminal.
/// Build output (layer download progress, `RUN` command output, etc.) flows
/// through this handler and is written directly to the configured file handle.
actor BuildStdio: BuildPipelineHandler {
public let quiet: Bool
public let handle: FileHandle
Expand Down
83 changes: 59 additions & 24 deletions Sources/ContainerBuild/Globber.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,9 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerizationOS
import Foundation
import SystemPackage

public class Globber {
let input: URL
Expand All@@ -33,7 +35,7 @@ public class Globber {
.replacingOccurrences(of: "[/]\\*{2,}([^/])", with: "/**/*$1", options: .regularExpression)
.replacingOccurrences(of: "^\\*{2,}([^/])", with: "**/*$1", options: .regularExpression)

for child in input.children {
for child in self.children(of: input) {
try self.match(input: child, components: adjustedPattern.split(separator: "/").map(String.init))
}
}
Expand All@@ -47,7 +49,7 @@ public class Globber {
guard dir.pathComponents.count > 1 else { break }
dir.deleteLastPathComponent()
}
return input.childrenRecursive.forEach { results.insert($0) }
return self.childrenRecursive(of: input).forEach { results.insert($0) }
}

let head = components.first ?? ""
Expand All@@ -59,7 +61,7 @@ public class Globber {
tail = tail.tail
}
try self.match(input: input, components: tail)
for child in input.children {
for child in self.children(of: input) {
try self.match(input: child, components: components)
}
return
Expand All@@ -68,13 +70,66 @@ public class Globber {
if try glob(input.lastPathComponent, head) {
try self.match(input: input, components: tail)

for child in input.children where try glob(child.lastPathComponent, tail.first ?? "") {
for child in self.children(of: input) where try glob(child.lastPathComponent, tail.first ?? "") {
try self.match(input: child, components: tail)
}
return
}
}

/// Returns the direct children of `url`, following `url` itself when it is
/// a directory symlink whose fully-resolved target stays within the match
/// root. A symlink that escapes the root is treated as having no children
/// (same as a regular file) so pattern components after it never match —
/// mirrors the containment check `BuildFSSync` applies before reading.
///
/// Children are named by their resolved (physical) path, not by `url`, so
/// that `walk(root:includePatterns:)`'s later filter — which is driven by
/// `Archiver.compress`'s own physical directory walk — reliably finds a
/// matching entry regardless of whether that walk itself follows `url`'s
/// symlink. `url` is separately inserted into `results` so the symlink
/// entry is still present in the tar for the builder to resolve the
/// original path against.
private func children(of url: URL) -> [URL] {
// TODO: modifying object state and returning results is odd, rework
guard let dir = self.resolvedDirectory(of: url) else { return [] }
if url.isSymlink { self.results.insert(url) }
return (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))
?? []
}

/// Recursive form of ``children(of:)``, used once a full pattern (or `**`)
/// has matched `url` and every descendant needs to be collected. Nested
/// directory symlinks are resolved and boundary-checked the same way, one
/// level at a time, via ``FileDescriptorOps/enumerate`` which never follows
/// symlinks it encounters mid-traversal — only the top-level `url` passed
/// in here gets the resolve-and-check treatment.
private func childrenRecursive(of url: URL) -> [URL] {
guard let dir = self.resolvedDirectory(of: url) else { return [url] }
if url.isSymlink { self.results.insert(url) }
guard let fd = try? FileDescriptor.open(FilePath(dir.path), .readOnly, options: .directory) else {
return [dir]
}
defer { try? fd.close() }
var found: [URL] = [dir]
try? FileDescriptorOps.enumerate(fd) { relPath, _, _ in
found.append(dir.appendingPathComponent(relPath.string))
}
return found
}

/// Resolves `url` to the real directory whose contents should be listed in
/// its place. Non-symlinks resolve to themselves. A directory symlink
/// resolves to its target only if the fully-resolved target is still
/// within `self.input` (the match root); otherwise `nil`, so callers treat
/// it as a leaf rather than descending outside the context.
private func resolvedDirectory(of url: URL) -> URL? {
guard url.isSymlink else { return url }
let resolved = url.resolvingSymlinksInPath()
guard resolved.isDirectory, self.input.parentOf(resolved) else { return nil }
return resolved
}

func glob(_ input: String, _ pattern: String) throws -> Bool {
let regexPattern =
"^"
Expand All@@ -91,26 +146,6 @@ public class Globber {
}
}

extension URL {
var children: [URL] {

(try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil))
?? []
}

var childrenRecursive: [URL] {
var results: [URL] = []
if let enumerator = FileManager.default.enumerator(
at: self, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey])
{
while let child = enumerator.nextObject() as? URL {
results.append(child)
}
}
return [self] + results
}
}

extension [String] {
var tail: [String] {
if self.count <= 1 {
Expand Down
Loading