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:
Kathryn Baldauf
2026-07-28 10:13:10 -07:00
committed by GitHub
co-authored by John Logan Raj Aryan Singh
parent 27e5043165
commit 48145ac7fb
19 changed files with 882 additions and 81 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"originHash" : "8277777f28690a67b811704b389423d888c06888b2f727cda0d627dab2202c99",
"originHash" : "17a1adf6ab79fe79a1a88954948cebaf96738c8c0b96c822dec9fba1b820453d",
"pins" : [
{
"identity" : "async-http-client",
+1
View File
@@ -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"),
+73 -12
View File
@@ -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
+5
View File
@@ -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
+59 -24
View File
@@ -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 {
+7 -12
View File
@@ -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
+15 -2
View File
@@ -14,8 +14,10 @@
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import Foundation
import Logging
import SystemPackage
/// Describes the configuration and binary file locations for a plugin.
public protocol PluginFactory: Sendable {
@@ -25,6 +27,15 @@ public protocol PluginFactory: Sendable {
func create(parentURL: URL, name: String) throws -> Plugin?
}
/// Requires `name` to be a single regular path component that round-trips
/// exactly, so it can't escape the plugin directory via `/`, `..`, or a NUL
/// that `FilePath.Component` would silently truncate.
private func validatePluginName(_ name: String) throws {
guard let component = FilePath.Component(name), component.kind == .regular, component.string == name else {
throw ContainerizationError(.invalidArgument, message: "invalid plugin name \(name)")
}
}
/// Default layout which uses a Unix-like structure.
public struct DefaultPluginFactory: PluginFactory {
// Order matters: earlier entries take priority during config file discovery.
@@ -78,7 +89,8 @@ public struct DefaultPluginFactory: PluginFactory {
}
public func create(parentURL: URL, name: String) throws -> Plugin? {
try create(installURL: parentURL.appendingPathComponent(name))
try validatePluginName(name)
return try create(installURL: parentURL.appendingPathComponent(name))
}
}
@@ -130,6 +142,7 @@ public struct AppBundlePluginFactory: PluginFactory {
}
public func create(parentURL: URL, name: String) throws -> Plugin? {
try create(installURL: parentURL.appendingPathComponent("\(name)\(Self.appSuffix)"))
try validatePluginName(name)
return try create(installURL: parentURL.appendingPathComponent("\(name)\(Self.appSuffix)"))
}
}
@@ -125,7 +125,9 @@ public struct Parser {
public static func allEnv(imageEnvs: [String], envFiles: [String], envs: [String]) throws -> [String] {
var combined: [String] = []
combined.append(contentsOf: Parser.env(envList: imageEnvs))
// Image config is untrusted. Bare env var names here must not be expanded from the host
// process's environment.
combined.append(contentsOf: imageEnvs.filter { $0.contains("=") })
for envFile in envFiles {
let content = try Parser.envFile(path: envFile)
combined.append(contentsOf: content)
+10 -26
View File
@@ -19,13 +19,13 @@ import NIOCore
import NIOPosix
final class ConnectHandler {
private var pendingBytes: [NIOAny]
private let serverAddress: SocketAddress
private let connectTimeout: TimeAmount
private var log: Logger? = nil
init(serverAddress: SocketAddress, log: Logger?) {
self.pendingBytes = []
init(serverAddress: SocketAddress, connectTimeout: TimeAmount, log: Logger?) {
self.serverAddress = serverAddress
self.connectTimeout = connectTimeout
self.log = log
}
}
@@ -34,10 +34,6 @@ extension ConnectHandler: ChannelInboundHandler {
typealias InboundIn = ByteBuffer
typealias OutboundOut = ByteBuffer
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
self.pendingBytes.append(data)
}
func handlerAdded(context: ChannelHandlerContext) {
// Add logger metadata.
self.log?[metadataKey: "proxy"] = "\(context.channel.localAddress?.description ?? "none")"
@@ -51,31 +47,14 @@ extension ConnectHandler: ChannelInboundHandler {
}
}
extension ConnectHandler: RemovableChannelHandler {
func removeHandler(context: ChannelHandlerContext, removalToken: ChannelHandlerContext.RemovalToken) {
var didRead = false
// We are being removed, and need to deliver any pending bytes we may have if we're upgrading.
while self.pendingBytes.count > 0 {
let data = self.pendingBytes.removeFirst()
context.fireChannelRead(data)
didRead = true
}
if didRead {
context.fireChannelReadComplete()
}
self.log?.trace("backend - removing connect handler from pipeline")
context.leavePipeline(removalToken: removalToken)
}
}
extension ConnectHandler: RemovableChannelHandler {}
extension ConnectHandler {
private func connectToServer(context: ChannelHandlerContext) {
self.log?.trace("backend - connecting")
ClientBootstrap(group: context.eventLoop)
.connectTimeout(self.connectTimeout)
.connect(to: serverAddress)
.assumeIsolatedUnsafeUnchecked()
.whenComplete { result in
@@ -105,6 +84,11 @@ extension ConnectHandler {
try context.channel.pipeline.syncOperations.addHandler(localGlue)
try peerChannel.pipeline.syncOperations.addHandler(peerGlue)
context.pipeline.syncOperations.removeHandler(self, promise: nil)
// Reads were paused on the frontend channel while we waited for the backend to
// connect. Resume both sides now that GlueHandler owns steady-state flow control.
try context.channel.syncOptions?.setOption(ChannelOptions.autoRead, value: true)
try peerChannel.syncOptions?.setOption(ChannelOptions.autoRead, value: true)
} catch {
// Close connected peer channel before closing our channel.
peerChannel.close(mode: .all, promise: nil)
+8 -1
View File
@@ -26,17 +26,21 @@ public struct TCPForwarder: SocketForwarder {
private let eventLoopGroup: any EventLoopGroup
private let connectTimeout: TimeAmount
private let log: Logger?
public init(
proxyAddress: SocketAddress,
serverAddress: SocketAddress,
eventLoopGroup: any EventLoopGroup,
connectTimeout: TimeAmount = .seconds(10),
log: Logger? = nil
) throws {
self.proxyAddress = proxyAddress
self.serverAddress = serverAddress
self.eventLoopGroup = eventLoopGroup
self.connectTimeout = connectTimeout
self.log = log
}
@@ -46,10 +50,13 @@ public struct TCPForwarder: SocketForwarder {
let bootstrap = ServerBootstrap(group: self.eventLoopGroup)
.serverChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1)
.childChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1)
// Reads are paused until the backend connects; the client's bytes are held in the
// kernel receive buffer instead of an app-level buffer while we wait.
.childChannelOption(ChannelOptions.autoRead, value: false)
.childChannelInitializer { channel in
channel.eventLoop.makeCompletedFuture {
try channel.pipeline.syncOperations.addHandler(
ConnectHandler(serverAddress: self.serverAddress, log: log)
ConnectHandler(serverAddress: self.serverAddress, connectTimeout: self.connectTimeout, log: log)
)
}
}
+9 -1
View File
@@ -26,6 +26,12 @@ private final class UDPProxyBackend: ChannelInboundHandler {
typealias InboundIn = AddressedEnvelope<ByteBuffer>
typealias OutboundOut = AddressedEnvelope<ByteBuffer>
// Datagrams sent by the client before the outbound backend channel finishes binding are
// queued here. The window is normally tiny (a local bind, not a network round-trip), so this
// cap is a safety valve, not the primary defense. UDP has no delivery guarantee, so dropping
// the newest datagram once full is an acceptable, protocol-consistent fallback.
private static let maxQueuedPayloads = 8
private struct State {
var queuedPayloads: Deque<ByteBuffer>
var channel: (any Channel)?
@@ -72,10 +78,12 @@ private final class UDPProxyBackend: ChannelInboundHandler {
self.log?.trace("backend - writing datagram to server")
let outbound: UDPProxyBackend.OutboundOut = OutboundOut(remoteAddress: self.serverAddress, data: data)
channel.writeAndFlush(outbound, promise: nil)
} else {
} else if state.queuedPayloads.count < Self.maxQueuedPayloads {
// channel is initializing, queue
self.log?.trace("backend - queuing datagram")
state.queuedPayloads.append(data)
} else {
self.log?.trace("backend - queue full, dropping datagram")
}
}
@@ -633,6 +633,18 @@ struct ParserTest {
#expect(Set(result) == Set(["FOO=fromuser", "BAR=fromimage", "BAZ=fromfile"]))
}
@Test
func testAllEnvRejectsBareNameFromImage() throws {
// Image config is untrusted: a bare name (no "=") must be dropped rather
// than expanded from the host process's environment.
let result = try Parser.allEnv(
imageEnvs: ["PATH", "FOO=fromimage"],
envFiles: [],
envs: []
)
#expect(Set(result) == Set(["FOO=fromimage"]))
}
private func tmpFileWithContent(_ content: String) throws -> URL {
let tempDir = FileManager.default.temporaryDirectory
let tempFile = tempDir.appendingPathComponent("envfile-test-\(UUID().uuidString)")
@@ -0,0 +1,397 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationOS
import Foundation
import SystemPackage
import Testing
@testable import ContainerBuild
// Tests for BuildFSSync the handler that serves build-context files to the
// builder over the FSSync protocol. The suite creates real files on disk and
// calls the actor methods directly, bypassing the Walk/cache layer in the shim
// so that the macOS-side boundary enforcement is exercised in isolation.
@Suite class BuildFSSyncTests {
let fm = FileManager.default
let base: URL
let contextDir: URL
let outsideDir: URL
init() throws {
base = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
contextDir = base.appendingPathComponent("context")
outsideDir = base.appendingPathComponent("outside")
try fm.createDirectory(at: URL(fileURLWithPath: contextDir.path(percentEncoded: false)), withIntermediateDirectories: true)
try fm.createDirectory(at: URL(fileURLWithPath: outsideDir.path(percentEncoded: false)), withIntermediateDirectories: true)
}
deinit {
try? fm.removeItem(at: base)
}
// MARK: - Helpers
private func write(_ content: String, to url: URL) throws {
try content.data(using: .utf8)!.write(to: url)
}
/// Returns a minimal BuildTransfer packet for the given context-relative source path.
private func readPacket(source: String) -> BuildTransfer {
var p = BuildTransfer()
p.id = UUID().uuidString
p.source = source
return p
}
// MARK: - read(): symlink boundary enforcement
//
// The tests below call read() directly and expect it to throw when the
// requested path resolves outside the context directory.
//
// Final-component symlink tests (testReadRejectsAbsoluteSymlinkOutsideContext,
// testReadRejectsRelativeSymlinkOutsideContext): the source path is itself a
// symlink that resolves outside the context.
//
// Intermediate-component symlink test (testReadRejectsIntermediateDirectorySymlinkOutsideContext):
// the source path looks like a normal relative path ("subdir/secret.txt"), but
// an intermediate directory component is a symlink that escapes the context.
// read() unconditionally resolves the full path and checks parentOf, so this
// case is caught as well.
@Test func testReadRejectsAbsoluteSymlinkOutsideContext() async throws {
let secretFile = outsideDir.appendingPathComponent("secret.txt")
try write("supersecret", to: secretFile)
// Symlink inside context absolute path outside context.
try fm.createSymbolicLink(
atPath: contextDir.appendingPathComponent("leak").path(percentEncoded: false),
withDestinationPath: secretFile.path(percentEncoded: false)
)
let fssync = try BuildFSSync(contextDir)
var continuation: AsyncStream<ClientStream>.Continuation!
_ = AsyncStream<ClientStream> { continuation = $0 }
defer { continuation.finish() }
do {
try await fssync.read(continuation, readPacket(source: "leak"), "build-0")
Issue.record("read() should throw BuildFSSync.Error for a symlink that resolves outside the context")
} catch is BuildFSSync.Error {
// expected
}
}
@Test func testReadRejectsRelativeSymlinkOutsideContext() async throws {
let secretFile = outsideDir.appendingPathComponent("secret.txt")
try write("supersecret", to: secretFile)
// Symlink inside context relative path that traverses above the context root.
// contextDir = base/context, outsideDir = base/outside, so the relative
// path from base/context/leak to base/outside/secret.txt is ../outside/secret.txt.
try fm.createSymbolicLink(
atPath: contextDir.appendingPathComponent("leak").path(percentEncoded: false),
withDestinationPath: "../outside/secret.txt"
)
let fssync = try BuildFSSync(contextDir)
var continuation: AsyncStream<ClientStream>.Continuation!
_ = AsyncStream<ClientStream> { continuation = $0 }
defer { continuation.finish() }
do {
try await fssync.read(continuation, readPacket(source: "leak"), "build-0")
Issue.record("read() should throw BuildFSSync.Error for a relative symlink that resolves outside the context")
} catch is BuildFSSync.Error {
// expected
}
}
@Test func testReadRejectsIntermediateDirectorySymlinkOutsideContext() async throws {
let secretFile = outsideDir.appendingPathComponent("secret.txt")
try write("supersecret", to: secretFile)
// Directory symlink inside context directory outside context.
// The requested source "subdir/secret.txt" has a plain file as its final
// component, but read() resolves the full path unconditionally, so the
// intermediate symlink escape is caught by the parentOf check.
try fm.createSymbolicLink(
atPath: contextDir.appendingPathComponent("subdir").path(percentEncoded: false),
withDestinationPath: outsideDir.path(percentEncoded: false)
)
let fssync = try BuildFSSync(contextDir)
var continuation: AsyncStream<ClientStream>.Continuation!
_ = AsyncStream<ClientStream> { continuation = $0 }
defer { continuation.finish() }
do {
try await fssync.read(continuation, readPacket(source: "subdir/secret.txt"), "build-0")
Issue.record("read() should throw BuildFSSync.Error when an intermediate path component is a symlink outside the context")
} catch is BuildFSSync.Error {
// expected
}
}
// MARK: - info(): symlink boundary enforcement
//
// info() is the metadata-only half of the shim's FS.Open() fallback path.
// It must enforce the same context boundary as read(). All three tests
// below should pass: the fix unconditionally resolves the full path and
// checks parentOf before serving any metadata.
@Test func testInfoRejectsAbsoluteSymlinkOutsideContext() async throws {
let secretFile = outsideDir.appendingPathComponent("secret.txt")
try write("supersecret", to: secretFile)
try fm.createSymbolicLink(
atPath: contextDir.appendingPathComponent("leak").path(percentEncoded: false),
withDestinationPath: secretFile.path(percentEncoded: false)
)
let fssync = try BuildFSSync(contextDir)
var continuation: AsyncStream<ClientStream>.Continuation!
_ = AsyncStream<ClientStream> { continuation = $0 }
defer { continuation.finish() }
do {
try await fssync.info(continuation, readPacket(source: "leak"), "build-0")
Issue.record("info() should throw BuildFSSync.Error for a symlink that resolves outside the context")
} catch is BuildFSSync.Error {
// expected
}
}
@Test func testInfoRejectsRelativeSymlinkOutsideContext() async throws {
let secretFile = outsideDir.appendingPathComponent("secret.txt")
try write("supersecret", to: secretFile)
try fm.createSymbolicLink(
atPath: contextDir.appendingPathComponent("leak").path(percentEncoded: false),
withDestinationPath: "../outside/secret.txt"
)
let fssync = try BuildFSSync(contextDir)
var continuation: AsyncStream<ClientStream>.Continuation!
_ = AsyncStream<ClientStream> { continuation = $0 }
defer { continuation.finish() }
do {
try await fssync.info(continuation, readPacket(source: "leak"), "build-0")
Issue.record("info() should throw BuildFSSync.Error for a relative symlink that resolves outside the context")
} catch is BuildFSSync.Error {
// expected
}
}
@Test func testInfoRejectsIntermediateDirectorySymlinkOutsideContext() async throws {
let secretFile = outsideDir.appendingPathComponent("secret.txt")
try write("supersecret", to: secretFile)
try fm.createSymbolicLink(
atPath: contextDir.appendingPathComponent("subdir").path(percentEncoded: false),
withDestinationPath: outsideDir.path(percentEncoded: false)
)
let fssync = try BuildFSSync(contextDir)
var continuation: AsyncStream<ClientStream>.Continuation!
_ = AsyncStream<ClientStream> { continuation = $0 }
defer { continuation.finish() }
do {
try await fssync.info(continuation, readPacket(source: "subdir/secret.txt"), "build-0")
Issue.record("info() should throw BuildFSSync.Error when an intermediate path component is a symlink outside the context")
} catch is BuildFSSync.Error {
// expected
}
}
// MARK: - walk(): directory symlink boundary enforcement
//
// walk() is the primary data path every build goes through it, and its
// results are what gets packed into the tar sent to the builder. A
// directory symlink inside the context must not let anything physically
// outside the context root end up in those results. The test below
// asserts that directly.
@Test func testWalkDoesNotFollowDirectorySymlinkOutsideContext() async throws {
let secretFile = outsideDir.appendingPathComponent("secret.txt")
try write("supersecret", to: secretFile)
// Directory symlink inside context directory outside context.
try fm.createSymbolicLink(
atPath: contextDir.appendingPathComponent("subdir").path(percentEncoded: false),
withDestinationPath: outsideDir.path(percentEncoded: false)
)
let fssync = try BuildFSSync(contextDir)
// Request the symlinked directory. Globber.childrenRecursive follows it
// and currently returns the external file as if it were in the context.
let urls = try await fssync.walk(root: contextDir, includePatterns: ["subdir"])
// The directory symlink itself is expected in results (Rule 2: it appears
// as a symlink entry in the tar). What must not appear are non-symlink
// entries regular files or directories that physically reside outside
// the context root.
let leaked = urls.filter { url in
guard !url.isSymlink else { return false }
let resolved = url.resolvingSymlinksInPath()
return !self.contextDir.parentOf(resolved)
&& resolved.cleanPath != self.contextDir.cleanPath
}
#expect(leaked.isEmpty, "walk() returned non-symlink URLs that physically resolve outside the context: \(leaked)")
}
// MARK: - walk(): JSON/FileInfo metadata paths
//
// walk() has two response formats: a tar stream (the primary data path,
// handled above) and a JSON array of FileInfo, used for metadata-only
// walks. FileInfo.target reports the same literal, unresolved on-disk
// symlink destination for every symlink in-context or not that tar
// mode already exposes via Archiver's use of destinationOfSymbolicLink.
// It must never be an absolute or canonicalized path derived from
// resolvingSymlinksInPath(), which would disclose a host-resolved path
// for a symlink that escapes the context.
/// Returns a minimal BuildTransfer packet requesting a JSON-mode walk.
private func walkJSONPacket(followPaths: [String]) -> BuildTransfer {
var p = BuildTransfer()
p.id = UUID().uuidString
p.source = "."
p.metadata = [
"followpaths": followPaths.joined(separator: ","),
"mode": "json",
]
return p
}
/// Drives the actor's real walk(_:_:_:) method in JSON mode and decodes
/// the resulting FileInfo array.
private func walkJSON(_ fssync: BuildFSSync, followPaths: [String] = ["*"]) async throws -> [BuildFSSync.FileInfo] {
var continuation: AsyncStream<ClientStream>.Continuation!
let stream = AsyncStream<ClientStream> { continuation = $0 }
try await fssync.walk(continuation, walkJSONPacket(followPaths: followPaths), "build-0")
continuation.finish()
var fileInfos: [BuildFSSync.FileInfo] = []
for await resp in stream {
let data = resp.buildTransfer.data
if !data.isEmpty {
fileInfos += try JSONDecoder().decode([BuildFSSync.FileInfo].self, from: data)
}
}
return fileInfos
}
@Test func testWalkJSONReportsEmptyTargetForRegularFile() async throws {
try write("hello", to: contextDir.appendingPathComponent("plain.txt"))
let fssync = try BuildFSSync(contextDir)
let infos = try await walkJSON(fssync)
let plain = infos.first { $0.name == "plain.txt" }
#expect(plain != nil, "the regular file should appear in walk() results")
#expect(plain?.target == "", "a regular file should report an empty target")
}
@Test func testWalkJSONReportsLiteralTargetForInContextSymlink() async throws {
try write("hello", to: contextDir.appendingPathComponent("real.txt"))
try fm.createSymbolicLink(
atPath: contextDir.appendingPathComponent("alias").path(percentEncoded: false),
withDestinationPath: "real.txt"
)
let fssync = try BuildFSSync(contextDir)
let infos = try await walkJSON(fssync)
let alias = infos.first { $0.name == "alias" }
#expect(alias != nil, "the in-context symlink should appear in walk() results")
#expect(alias?.target == "real.txt", "in-context symlink target should be the literal on-disk value, got \(alias?.target ?? "nil")")
}
@Test func testWalkJSONReportsLiteralTargetForAbsoluteSymlinkOutsideContext() async throws {
let secretFile = outsideDir.appendingPathComponent("secret.txt")
try write("supersecret", to: secretFile)
let literalDestination = secretFile.path(percentEncoded: false)
try fm.createSymbolicLink(
atPath: contextDir.appendingPathComponent("leak").path(percentEncoded: false),
withDestinationPath: literalDestination
)
let fssync = try BuildFSSync(contextDir)
let infos = try await walkJSON(fssync)
let leak = infos.first { $0.name == "leak" }
#expect(leak != nil, "the escaping symlink should still appear in walk() results")
#expect(
leak?.target == literalDestination,
"target should be the literal symlink destination, not a resolved/canonicalized path: \(leak?.target ?? "nil")")
}
@Test func testWalkJSONReportsLiteralTargetForRelativeSymlinkOutsideContext() async throws {
let secretFile = outsideDir.appendingPathComponent("secret.txt")
try write("supersecret", to: secretFile)
// contextDir = base/context, outsideDir = base/outside, so the
// relative destination from context/leak to outside/secret.txt is
// ../outside/secret.txt. Resolving this always yields an absolute
// path, so a literal-vs-resolved mismatch here is deterministic and
// does not depend on any symlink quirks in the host's temp dir.
let literalDestination = "../outside/secret.txt"
try fm.createSymbolicLink(
atPath: contextDir.appendingPathComponent("leak").path(percentEncoded: false),
withDestinationPath: literalDestination
)
let fssync = try BuildFSSync(contextDir)
let infos = try await walkJSON(fssync)
let leak = infos.first { $0.name == "leak" }
#expect(leak != nil, "the escaping symlink should still appear in walk() results")
#expect(
leak?.target == literalDestination,
"target should be the literal (relative) symlink destination, never a resolved absolute host path: \(leak?.target ?? "nil")"
)
#expect(leak?.target.hasPrefix("/") == false, "target must not be an absolute host path for an out-of-context symlink")
}
@Test func testWalkJSONReportsLiteralTargetForDirectorySymlinkOutsideContext() async throws {
let secretFile = outsideDir.appendingPathComponent("secret.txt")
try write("supersecret", to: secretFile)
let literalDestination = outsideDir.path(percentEncoded: false)
try fm.createSymbolicLink(
atPath: contextDir.appendingPathComponent("subdir").path(percentEncoded: false),
withDestinationPath: literalDestination
)
let fssync = try BuildFSSync(contextDir)
let infos = try await walkJSON(fssync, followPaths: ["subdir"])
let leak = infos.first { $0.name == "subdir" }
#expect(leak != nil, "the escaping directory symlink should still appear in walk() results")
#expect(
leak?.target == literalDestination,
"target should be the literal symlink destination, not the resolved external directory path: \(leak?.target ?? "nil")")
// Nothing from inside the external directory should have leaked in as
// its own entry the fixed Globber must not have descended into it.
let secretLeak = infos.first { $0.name.hasSuffix("secret.txt") }
#expect(secretLeak == nil, "no entry for the external file should appear in walk() results: \(infos.map { $0.name })")
}
}
+115 -1
View File
@@ -14,6 +14,7 @@
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationOS
import Foundation
import Testing
@@ -179,7 +180,8 @@ let testCases = [
#expect(throws: Never.self) {
try globber.match(test.pattern)
let found: Bool = !globber.results.isEmpty
#expect(found == test.expectSuccess, "expected match to be \(test.expectSuccess), instead got \(found) \(tempDir.childrenRecursive)")
let onDisk = FileManager.default.enumerator(at: tempDir, includingPropertiesForKeys: nil)?.allObjects ?? []
#expect(found == test.expectSuccess, "expected match to be \(test.expectSuccess), instead got \(found) \(onDisk)")
}
}
@@ -202,4 +204,116 @@ let testCases = [
#expect(globber.results.isEmpty, "expected to find no matches, instead found \(globber.results)")
}
}
// MARK: - Directory symlink traversal
//
// Globber must be able to descend through a directory symlink whose fully
// resolved target is still inside the match root (the same containment
// check BuildFSSync.read()/info() apply before serving content), while
// continuing to treat a symlink that escapes the root as a leaf with no
// children. See the discussion on
// https://github.com/apple/container-ghsa-2v2q-4q35-h585/pull/2.
@Test("Match descends through an in-context directory symlink")
func testMatchFollowsInContextDirectorySymlink() throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let real = tempDir.appendingPathComponent("real")
let link = tempDir.appendingPathComponent("link")
let file = real.appendingPathComponent("file.txt")
try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true)
try "hello".write(to: file, atomically: true, encoding: .utf8)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real)
defer { try? FileManager.default.removeItem(at: tempDir) }
let globber = Globber(tempDir)
try globber.match("link/file.txt")
// Compare by name rather than exact path: on macOS /var is itself a
// symlink to /private/var, and FileManager.contentsOfDirectory vs.
// URL.resolvingSymlinksInPath() normalize that inconsistently, so a
// temp-dir-rooted URL built by hand won't reliably string-match a URL
// Globber discovered through the real filesystem APIs.
#expect(
globber.results.contains { $0.isSymlink && $0.lastPathComponent == "link" },
"expected the symlink itself to be preserved in results, got \(globber.results)"
)
#expect(
globber.results.contains { $0.lastPathComponent == "file.txt" },
"expected the resolved physical file to be present in results, got \(globber.results)"
)
}
@Test("Recursive glob descends through an in-context directory symlink")
func testMatchRecursiveGlobFollowsInContextDirectorySymlink() throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let real = tempDir.appendingPathComponent("real")
let link = tempDir.appendingPathComponent("link")
let file = real.appendingPathComponent("file.txt")
try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true)
try "hello".write(to: file, atomically: true, encoding: .utf8)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real)
defer { try? FileManager.default.removeItem(at: tempDir) }
let globber = Globber(tempDir)
try globber.match("link/**")
#expect(globber.results.contains { $0.isSymlink && $0.lastPathComponent == "link" })
#expect(globber.results.contains { $0.lastPathComponent == "real" })
#expect(globber.results.contains { $0.lastPathComponent == "file.txt" })
}
@Test("Match does not descend through a directory symlink that escapes the root")
func testMatchDoesNotDescendThroughSymlinkEscapingRoot() throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let outsideDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let link = tempDir.appendingPathComponent("link")
let secret = outsideDir.appendingPathComponent("secret.txt")
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: outsideDir, withIntermediateDirectories: true)
try "supersecret".write(to: secret, atomically: true, encoding: .utf8)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: outsideDir)
defer {
try? FileManager.default.removeItem(at: tempDir)
try? FileManager.default.removeItem(at: outsideDir)
}
let globber = Globber(tempDir)
try globber.match("link/secret.txt")
#expect(globber.results.isEmpty, "expected no match through a symlink escaping the root, got \(globber.results)")
}
@Test("Recursive glob through a symlink escaping the root never leaks out-of-root files")
func testMatchRecursiveGlobDoesNotLeakOutsideRoot() throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let outsideDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let link = tempDir.appendingPathComponent("link")
let secret = outsideDir.appendingPathComponent("secret.txt")
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: outsideDir, withIntermediateDirectories: true)
try "supersecret".write(to: secret, atomically: true, encoding: .utf8)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: outsideDir)
defer {
try? FileManager.default.removeItem(at: tempDir)
try? FileManager.default.removeItem(at: outsideDir)
}
let globber = Globber(tempDir)
try globber.match("link/**")
let leaked = globber.results.filter { url in
guard !url.isSymlink else { return false }
let resolved = url.resolvingSymlinksInPath()
return !tempDir.parentOf(resolved) && resolved.cleanPath != tempDir.cleanPath
}
#expect(leaked.isEmpty, "match() produced non-symlink results that physically resolve outside the root: \(leaked)")
}
}
@@ -14,6 +14,7 @@
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import Foundation
import Logging
import Testing
@@ -328,4 +329,78 @@ struct PluginFactoryTest {
#expect(plugin.name == name)
#expect(plugin.config.abstract == "TOML service")
}
@Test
func testDefaultFactoryRejectsTraversalName() async throws {
let fm = FileManager.default
let tempURL = try fm.url(
for: .itemReplacementDirectory,
in: .userDomainMask,
appropriateFor: .temporaryDirectory,
create: true
)
defer { try? FileManager.default.removeItem(at: tempURL) }
// A complete, loadable plugin layout planted outside the plugin parent directory.
let outsideURL = tempURL.appending(path: "outside")
let binDirURL = outsideURL.appending(path: "bin")
try fm.createDirectory(at: binDirURL, withIntermediateDirectories: true)
try "abstract = \"payload\"\nauthor = \"Apple\""
.write(to: outsideURL.appending(path: "config.toml"), atomically: true, encoding: .utf8)
try "".write(to: binDirURL.appending(path: "outside"), atomically: true, encoding: .utf8)
let pluginParent = tempURL.appending(path: "plugins")
try fm.createDirectory(at: pluginParent, withIntermediateDirectories: true)
let factory = DefaultPluginFactory(logger: Logger(label: "test"))
#expect(throws: ContainerizationError.self) {
try factory.create(parentURL: pluginParent, name: "../outside")
}
}
@Test
func testDefaultFactoryRejectsParentDirectoryName() async throws {
let factory = DefaultPluginFactory(logger: Logger(label: "test"))
#expect(throws: ContainerizationError.self) {
try factory.create(parentURL: URL(fileURLWithPath: "/tmp"), name: "..")
}
}
@Test
func testDefaultFactoryRejectsNullByteTraversalName() async throws {
let factory = DefaultPluginFactory(logger: Logger(label: "test"))
#expect(throws: ContainerizationError.self) {
try factory.create(parentURL: URL(fileURLWithPath: "/tmp"), name: "evil\u{0}/../../../../etc")
}
}
@Test
func testAppBundleFactoryRejectsTraversalName() async throws {
let fm = FileManager.default
let tempURL = try fm.url(
for: .itemReplacementDirectory,
in: .userDomainMask,
appropriateFor: .temporaryDirectory,
create: true
)
defer { try? FileManager.default.removeItem(at: tempURL) }
// A complete, loadable app-bundle plugin layout planted outside the plugin parent directory.
let outsideURL = tempURL.appending(path: "outside.app")
let resourcesURL = outsideURL.appending(path: "Contents").appending(path: "Resources")
try fm.createDirectory(at: resourcesURL, withIntermediateDirectories: true)
try "abstract = \"payload\"\nauthor = \"Apple\""
.write(to: resourcesURL.appending(path: "config.toml"), atomically: true, encoding: .utf8)
let macosURL = outsideURL.appending(path: "Contents").appending(path: "MacOS")
try fm.createDirectory(at: macosURL, withIntermediateDirectories: true)
try "".write(to: macosURL.appending(path: "outside"), atomically: true, encoding: .utf8)
let pluginParent = tempURL.appending(path: "plugins")
try fm.createDirectory(at: pluginParent, withIntermediateDirectories: true)
let factory = AppBundlePluginFactory(logger: Logger(label: "test"))
#expect(throws: ContainerizationError.self) {
try factory.create(parentURL: pluginParent, name: "../outside")
}
}
}
@@ -1000,4 +1000,34 @@ struct TestCLIBuilder {
#expect(result.status != 0, "build should fail when source image does not exist")
}
}
/// Regression test: the context *root* itself (not an entry inside it) is a
/// symlink to a sibling directory, e.g. `context -> real-context`. The build
/// must resolve the symlink and use the real directory's contents.
@Test func testBuildContextRootSymlink() async throws {
try await ContainerFixture.with { f in
let dir = try f.createTempDir()
let dockerfile = """
FROM ghcr.io/linuxcontainers/alpine:3.20
COPY hello.txt hello.txt
RUN cat hello.txt
"""
try f.createContext(
dir: dir,
dockerfile: dockerfile,
context: [
.file("hello.txt", content: .data(Data("hello from real-context".utf8)))
])
let contextPath = dir.appending("context")
let realContextPath = dir.appending("real-context")
try FileManager.default.moveItem(atPath: contextPath.string, toPath: realContextPath.string)
try FileManager.default.createSymbolicLink(
atPath: contextPath.string, withDestinationPath: "real-context")
let image = "registry.local/build-context-root-symlink:\(UUID().uuidString)"
try f.build(tag: image, contextDir: dir)
try f.assertImageBuilt(image)
}
}
}