mirror of
https://github.com/apple/container.git
synced 2026-09-27 17:36:08 -04:00
Fix image env vars, build context checks, TCP/UDP port forward buffer, and validate plugin name (#2027)
Signed-off-by: Kathryn Baldauf <k_baldauf@apple.com> Co-authored-by: John Logan <john_logan@apple.com> Co-authored-by: Raj Aryan Singh <rajaryan_singh@apple.com>
This commit is contained in:
co-authored by
John Logan
Raj Aryan Singh
parent
27e5043165
commit
48145ac7fb
@@ -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 {
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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("/") {
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
|
||||
public class Globber {
|
||||
let input: URL
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 ?? ""
|
||||
@@ -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
|
||||
@@ -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 =
|
||||
"^"
|
||||
@@ -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 {
|
||||
|
||||
@@ -54,6 +54,13 @@ extension URL {
|
||||
self.path.fs_cleaned
|
||||
}
|
||||
|
||||
/// Returns true if `url` is lexically a descendant of `self`.
|
||||
///
|
||||
/// This is a **string-prefix check** on the normalised path components; it
|
||||
/// does not call `realpath` or resolve symlinks. A URL that is lexically
|
||||
/// inside the context root but reachable through an intermediate symlink
|
||||
/// that points outside it will still pass this check. Callers that need
|
||||
/// physical containment must resolve symlinks before calling this method.
|
||||
func parentOf(_ url: URL) -> Bool {
|
||||
let parentPath = self.absoluteURL.cleanPath
|
||||
let childPath = url.absoluteURL.cleanPath
|
||||
@@ -80,18 +87,6 @@ extension URL {
|
||||
return selfParts.dropFirst(ctxParts.count).joined(separator: "/")
|
||||
}
|
||||
|
||||
func relativePathFrom(from base: URL) -> String {
|
||||
let destParts = cleanPath.fs_components
|
||||
let baseParts = base.cleanPath.fs_components
|
||||
|
||||
let common = zip(destParts, baseParts).prefix { $0 == $1 }.count
|
||||
guard common > 0 else { return cleanPath }
|
||||
|
||||
let ups = Array(repeating: "..", count: baseParts.count - common)
|
||||
let remainder = destParts.dropFirst(common)
|
||||
return (ups + remainder).joined(separator: "/")
|
||||
}
|
||||
|
||||
func zeroCopyReader(
|
||||
chunk: Int = 1024 * 1024,
|
||||
buffer: AsyncStream<Data>.Continuation.BufferingPolicy = .unbounded
|
||||
|
||||
Reference in New Issue
Block a user