mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05: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
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "8277777f28690a67b811704b389423d888c06888b2f727cda0d627dab2202c99",
|
"originHash" : "17a1adf6ab79fe79a1a88954948cebaf96738c8c0b96c822dec9fba1b820453d",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "async-http-client",
|
"identity" : "async-http-client",
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ let package = Package(
|
|||||||
.product(name: "Containerization", package: "containerization"),
|
.product(name: "Containerization", package: "containerization"),
|
||||||
.product(name: "ContainerizationArchive", package: "containerization"),
|
.product(name: "ContainerizationArchive", package: "containerization"),
|
||||||
.product(name: "ContainerizationOCI", package: "containerization"),
|
.product(name: "ContainerizationOCI", package: "containerization"),
|
||||||
|
.product(name: "ContainerizationOS", package: "containerization"),
|
||||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
.product(name: "GRPCCore", package: "grpc-swift-2"),
|
.product(name: "GRPCCore", package: "grpc-swift-2"),
|
||||||
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
|
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
|
||||||
|
|||||||
@@ -22,18 +22,49 @@ import CryptoKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import GRPCCore
|
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 {
|
actor BuildFSSync: BuildPipelineHandler {
|
||||||
let contextDir: URL
|
let contextDir: URL
|
||||||
|
|
||||||
init(_ contextDir: URL) throws {
|
init(_ contextDir: URL) throws {
|
||||||
|
let resolved = contextDir.resolvingSymlinksInPath()
|
||||||
guard FileManager.default.fileExists(atPath: contextDir.cleanPath) else {
|
guard FileManager.default.fileExists(atPath: contextDir.cleanPath) else {
|
||||||
throw Error.contextNotFound(contextDir.cleanPath)
|
throw Error.contextNotFound(contextDir.cleanPath)
|
||||||
}
|
}
|
||||||
guard try contextDir.isDir() else {
|
guard resolved.isDirectory else {
|
||||||
throw Error.contextIsNotDirectory(contextDir.cleanPath)
|
throw Error.contextIsNotDirectory(contextDir.cleanPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.contextDir = contextDir
|
self.contextDir = resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func accept(_ packet: ServerStream) throws -> Bool {
|
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 {
|
func read(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
|
||||||
let offset: UInt64 = packet.offset() ?? 0
|
let offset: UInt64 = packet.offset() ?? 0
|
||||||
let size: Int = packet.len() ?? 0
|
let size: Int = packet.len() ?? 0
|
||||||
@@ -79,6 +115,10 @@ actor BuildFSSync: BuildPipelineHandler {
|
|||||||
path = URL(filePath: self.contextDir.cleanPath)
|
path = URL(filePath: self.contextDir.cleanPath)
|
||||||
path.append(components: packet.source.cleanPathComponent)
|
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 {
|
let data = try {
|
||||||
if try path.isDir() {
|
if try path.isDir() {
|
||||||
return Data()
|
return Data()
|
||||||
@@ -95,6 +135,12 @@ actor BuildFSSync: BuildPipelineHandler {
|
|||||||
sender.yield(response)
|
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 {
|
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
|
||||||
let path: URL
|
let path: URL
|
||||||
if packet.source.hasPrefix("/") {
|
if packet.source.hasPrefix("/") {
|
||||||
@@ -105,6 +151,10 @@ actor BuildFSSync: BuildPipelineHandler {
|
|||||||
.appendingPathComponent(packet.source)
|
.appendingPathComponent(packet.source)
|
||||||
.standardizedFileURL
|
.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)
|
let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true)
|
||||||
var response = ClientStream()
|
var response = ClientStream()
|
||||||
response.buildID = buildID
|
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(
|
func walk(
|
||||||
_ sender: AsyncStream<ClientStream>.Continuation,
|
_ sender: AsyncStream<ClientStream>.Continuation,
|
||||||
_ packet: BuildTransfer,
|
_ packet: BuildTransfer,
|
||||||
@@ -334,16 +401,10 @@ actor BuildFSSync: BuildPipelineHandler {
|
|||||||
let target: String
|
let target: String
|
||||||
|
|
||||||
init(path: URL, contextDir: URL) throws {
|
init(path: URL, contextDir: URL) throws {
|
||||||
if path.isSymlink {
|
// Always report the literal, unresolved on-disk symlink target —
|
||||||
let target: URL = path.resolvingSymlinksInPath()
|
// the same value tar mode provides via Archiver's use of
|
||||||
if contextDir.parentOf(target) {
|
// destinationOfSymbolicLink — rather than a host-resolved path.
|
||||||
self.target = target.relativePathFrom(from: path)
|
self.target = path.isSymlink ? try FileManager.default.destinationOfSymbolicLink(atPath: path.cleanPath) : ""
|
||||||
} else {
|
|
||||||
self.target = target.cleanPath
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.target = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
self.name = try path.relativeChildPath(to: contextDir)
|
self.name = try path.relativeChildPath(to: contextDir)
|
||||||
self.modTime = try path.modTime()
|
self.modTime = try path.modTime()
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ import GRPCCore
|
|||||||
import Logging
|
import Logging
|
||||||
import TerminalProgress
|
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 {
|
struct BuildImageResolver: BuildPipelineHandler {
|
||||||
let contentStore: ContentStore
|
let contentStore: ContentStore
|
||||||
let quiet: Bool
|
let quiet: Bool
|
||||||
|
|||||||
@@ -18,11 +18,61 @@ import Foundation
|
|||||||
import GRPCCore
|
import GRPCCore
|
||||||
import NIO
|
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 {
|
protocol BuildPipelineHandler: Sendable {
|
||||||
func accept(_ packet: ServerStream) throws -> Bool
|
func accept(_ packet: ServerStream) throws -> Bool
|
||||||
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws
|
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 {
|
public actor BuildPipeline {
|
||||||
let handlers: [BuildPipelineHandler]
|
let handlers: [BuildPipelineHandler]
|
||||||
public init(_ config: Builder.BuildConfig) async throws {
|
public init(_ config: Builder.BuildConfig) async throws {
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import ContainerizationOCI
|
|||||||
import Foundation
|
import Foundation
|
||||||
import GRPCCore
|
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 {
|
struct BuildRemoteContentProxy: BuildPipelineHandler {
|
||||||
let local: ContentStore
|
let local: ContentStore
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import Foundation
|
|||||||
import GRPCCore
|
import GRPCCore
|
||||||
import NIO
|
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 {
|
actor BuildStdio: BuildPipelineHandler {
|
||||||
public let quiet: Bool
|
public let quiet: Bool
|
||||||
public let handle: FileHandle
|
public let handle: FileHandle
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//===----------------------------------------------------------------------===//
|
//===----------------------------------------------------------------------===//
|
||||||
|
|
||||||
|
import ContainerizationOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SystemPackage
|
||||||
|
|
||||||
public class Globber {
|
public class Globber {
|
||||||
let input: URL
|
let input: URL
|
||||||
@@ -33,7 +35,7 @@ public class Globber {
|
|||||||
.replacingOccurrences(of: "[/]\\*{2,}([^/])", with: "/**/*$1", options: .regularExpression)
|
.replacingOccurrences(of: "[/]\\*{2,}([^/])", with: "/**/*$1", options: .regularExpression)
|
||||||
.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))
|
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 }
|
guard dir.pathComponents.count > 1 else { break }
|
||||||
dir.deleteLastPathComponent()
|
dir.deleteLastPathComponent()
|
||||||
}
|
}
|
||||||
return input.childrenRecursive.forEach { results.insert($0) }
|
return self.childrenRecursive(of: input).forEach { results.insert($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
let head = components.first ?? ""
|
let head = components.first ?? ""
|
||||||
@@ -59,7 +61,7 @@ public class Globber {
|
|||||||
tail = tail.tail
|
tail = tail.tail
|
||||||
}
|
}
|
||||||
try self.match(input: input, components: 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)
|
try self.match(input: child, components: components)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -68,13 +70,66 @@ public class Globber {
|
|||||||
if try glob(input.lastPathComponent, head) {
|
if try glob(input.lastPathComponent, head) {
|
||||||
try self.match(input: input, components: tail)
|
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)
|
try self.match(input: child, components: tail)
|
||||||
}
|
}
|
||||||
return
|
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 {
|
func glob(_ input: String, _ pattern: String) throws -> Bool {
|
||||||
let regexPattern =
|
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] {
|
extension [String] {
|
||||||
var tail: [String] {
|
var tail: [String] {
|
||||||
if self.count <= 1 {
|
if self.count <= 1 {
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ extension URL {
|
|||||||
self.path.fs_cleaned
|
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 {
|
func parentOf(_ url: URL) -> Bool {
|
||||||
let parentPath = self.absoluteURL.cleanPath
|
let parentPath = self.absoluteURL.cleanPath
|
||||||
let childPath = url.absoluteURL.cleanPath
|
let childPath = url.absoluteURL.cleanPath
|
||||||
@@ -80,18 +87,6 @@ extension URL {
|
|||||||
return selfParts.dropFirst(ctxParts.count).joined(separator: "/")
|
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(
|
func zeroCopyReader(
|
||||||
chunk: Int = 1024 * 1024,
|
chunk: Int = 1024 * 1024,
|
||||||
buffer: AsyncStream<Data>.Continuation.BufferingPolicy = .unbounded
|
buffer: AsyncStream<Data>.Continuation.BufferingPolicy = .unbounded
|
||||||
|
|||||||
@@ -14,8 +14,10 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//===----------------------------------------------------------------------===//
|
//===----------------------------------------------------------------------===//
|
||||||
|
|
||||||
|
import ContainerizationError
|
||||||
import Foundation
|
import Foundation
|
||||||
import Logging
|
import Logging
|
||||||
|
import SystemPackage
|
||||||
|
|
||||||
/// Describes the configuration and binary file locations for a plugin.
|
/// Describes the configuration and binary file locations for a plugin.
|
||||||
public protocol PluginFactory: Sendable {
|
public protocol PluginFactory: Sendable {
|
||||||
@@ -25,6 +27,15 @@ public protocol PluginFactory: Sendable {
|
|||||||
func create(parentURL: URL, name: String) throws -> Plugin?
|
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.
|
/// Default layout which uses a Unix-like structure.
|
||||||
public struct DefaultPluginFactory: PluginFactory {
|
public struct DefaultPluginFactory: PluginFactory {
|
||||||
// Order matters: earlier entries take priority during config file discovery.
|
// 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? {
|
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? {
|
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] {
|
public static func allEnv(imageEnvs: [String], envFiles: [String], envs: [String]) throws -> [String] {
|
||||||
var combined: [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 {
|
for envFile in envFiles {
|
||||||
let content = try Parser.envFile(path: envFile)
|
let content = try Parser.envFile(path: envFile)
|
||||||
combined.append(contentsOf: content)
|
combined.append(contentsOf: content)
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ import NIOCore
|
|||||||
import NIOPosix
|
import NIOPosix
|
||||||
|
|
||||||
final class ConnectHandler {
|
final class ConnectHandler {
|
||||||
private var pendingBytes: [NIOAny]
|
|
||||||
private let serverAddress: SocketAddress
|
private let serverAddress: SocketAddress
|
||||||
|
private let connectTimeout: TimeAmount
|
||||||
private var log: Logger? = nil
|
private var log: Logger? = nil
|
||||||
|
|
||||||
init(serverAddress: SocketAddress, log: Logger?) {
|
init(serverAddress: SocketAddress, connectTimeout: TimeAmount, log: Logger?) {
|
||||||
self.pendingBytes = []
|
|
||||||
self.serverAddress = serverAddress
|
self.serverAddress = serverAddress
|
||||||
|
self.connectTimeout = connectTimeout
|
||||||
self.log = log
|
self.log = log
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,10 +34,6 @@ extension ConnectHandler: ChannelInboundHandler {
|
|||||||
typealias InboundIn = ByteBuffer
|
typealias InboundIn = ByteBuffer
|
||||||
typealias OutboundOut = ByteBuffer
|
typealias OutboundOut = ByteBuffer
|
||||||
|
|
||||||
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
|
||||||
self.pendingBytes.append(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handlerAdded(context: ChannelHandlerContext) {
|
func handlerAdded(context: ChannelHandlerContext) {
|
||||||
// Add logger metadata.
|
// Add logger metadata.
|
||||||
self.log?[metadataKey: "proxy"] = "\(context.channel.localAddress?.description ?? "none")"
|
self.log?[metadataKey: "proxy"] = "\(context.channel.localAddress?.description ?? "none")"
|
||||||
@@ -51,31 +47,14 @@ extension ConnectHandler: ChannelInboundHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConnectHandler: RemovableChannelHandler {
|
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 {
|
extension ConnectHandler {
|
||||||
private func connectToServer(context: ChannelHandlerContext) {
|
private func connectToServer(context: ChannelHandlerContext) {
|
||||||
self.log?.trace("backend - connecting")
|
self.log?.trace("backend - connecting")
|
||||||
|
|
||||||
ClientBootstrap(group: context.eventLoop)
|
ClientBootstrap(group: context.eventLoop)
|
||||||
|
.connectTimeout(self.connectTimeout)
|
||||||
.connect(to: serverAddress)
|
.connect(to: serverAddress)
|
||||||
.assumeIsolatedUnsafeUnchecked()
|
.assumeIsolatedUnsafeUnchecked()
|
||||||
.whenComplete { result in
|
.whenComplete { result in
|
||||||
@@ -105,6 +84,11 @@ extension ConnectHandler {
|
|||||||
try context.channel.pipeline.syncOperations.addHandler(localGlue)
|
try context.channel.pipeline.syncOperations.addHandler(localGlue)
|
||||||
try peerChannel.pipeline.syncOperations.addHandler(peerGlue)
|
try peerChannel.pipeline.syncOperations.addHandler(peerGlue)
|
||||||
context.pipeline.syncOperations.removeHandler(self, promise: nil)
|
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 {
|
} catch {
|
||||||
// Close connected peer channel before closing our channel.
|
// Close connected peer channel before closing our channel.
|
||||||
peerChannel.close(mode: .all, promise: nil)
|
peerChannel.close(mode: .all, promise: nil)
|
||||||
|
|||||||
@@ -26,17 +26,21 @@ public struct TCPForwarder: SocketForwarder {
|
|||||||
|
|
||||||
private let eventLoopGroup: any EventLoopGroup
|
private let eventLoopGroup: any EventLoopGroup
|
||||||
|
|
||||||
|
private let connectTimeout: TimeAmount
|
||||||
|
|
||||||
private let log: Logger?
|
private let log: Logger?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
proxyAddress: SocketAddress,
|
proxyAddress: SocketAddress,
|
||||||
serverAddress: SocketAddress,
|
serverAddress: SocketAddress,
|
||||||
eventLoopGroup: any EventLoopGroup,
|
eventLoopGroup: any EventLoopGroup,
|
||||||
|
connectTimeout: TimeAmount = .seconds(10),
|
||||||
log: Logger? = nil
|
log: Logger? = nil
|
||||||
) throws {
|
) throws {
|
||||||
self.proxyAddress = proxyAddress
|
self.proxyAddress = proxyAddress
|
||||||
self.serverAddress = serverAddress
|
self.serverAddress = serverAddress
|
||||||
self.eventLoopGroup = eventLoopGroup
|
self.eventLoopGroup = eventLoopGroup
|
||||||
|
self.connectTimeout = connectTimeout
|
||||||
self.log = log
|
self.log = log
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,10 +50,13 @@ public struct TCPForwarder: SocketForwarder {
|
|||||||
let bootstrap = ServerBootstrap(group: self.eventLoopGroup)
|
let bootstrap = ServerBootstrap(group: self.eventLoopGroup)
|
||||||
.serverChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1)
|
.serverChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1)
|
||||||
.childChannelOption(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
|
.childChannelInitializer { channel in
|
||||||
channel.eventLoop.makeCompletedFuture {
|
channel.eventLoop.makeCompletedFuture {
|
||||||
try channel.pipeline.syncOperations.addHandler(
|
try channel.pipeline.syncOperations.addHandler(
|
||||||
ConnectHandler(serverAddress: self.serverAddress, log: log)
|
ConnectHandler(serverAddress: self.serverAddress, connectTimeout: self.connectTimeout, log: log)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ private final class UDPProxyBackend: ChannelInboundHandler {
|
|||||||
typealias InboundIn = AddressedEnvelope<ByteBuffer>
|
typealias InboundIn = AddressedEnvelope<ByteBuffer>
|
||||||
typealias OutboundOut = 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 {
|
private struct State {
|
||||||
var queuedPayloads: Deque<ByteBuffer>
|
var queuedPayloads: Deque<ByteBuffer>
|
||||||
var channel: (any Channel)?
|
var channel: (any Channel)?
|
||||||
@@ -72,10 +78,12 @@ private final class UDPProxyBackend: ChannelInboundHandler {
|
|||||||
self.log?.trace("backend - writing datagram to server")
|
self.log?.trace("backend - writing datagram to server")
|
||||||
let outbound: UDPProxyBackend.OutboundOut = OutboundOut(remoteAddress: self.serverAddress, data: data)
|
let outbound: UDPProxyBackend.OutboundOut = OutboundOut(remoteAddress: self.serverAddress, data: data)
|
||||||
channel.writeAndFlush(outbound, promise: nil)
|
channel.writeAndFlush(outbound, promise: nil)
|
||||||
} else {
|
} else if state.queuedPayloads.count < Self.maxQueuedPayloads {
|
||||||
// channel is initializing, queue
|
// channel is initializing, queue
|
||||||
self.log?.trace("backend - queuing datagram")
|
self.log?.trace("backend - queuing datagram")
|
||||||
state.queuedPayloads.append(data)
|
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"]))
|
#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 {
|
private func tmpFileWithContent(_ content: String) throws -> URL {
|
||||||
let tempDir = FileManager.default.temporaryDirectory
|
let tempDir = FileManager.default.temporaryDirectory
|
||||||
let tempFile = tempDir.appendingPathComponent("envfile-test-\(UUID().uuidString)")
|
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 })")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//===----------------------------------------------------------------------===//
|
//===----------------------------------------------------------------------===//
|
||||||
|
|
||||||
|
import ContainerizationOS
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@@ -179,7 +180,8 @@ let testCases = [
|
|||||||
#expect(throws: Never.self) {
|
#expect(throws: Never.self) {
|
||||||
try globber.match(test.pattern)
|
try globber.match(test.pattern)
|
||||||
let found: Bool = !globber.results.isEmpty
|
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)")
|
#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.
|
// limitations under the License.
|
||||||
//===----------------------------------------------------------------------===//
|
//===----------------------------------------------------------------------===//
|
||||||
|
|
||||||
|
import ContainerizationError
|
||||||
import Foundation
|
import Foundation
|
||||||
import Logging
|
import Logging
|
||||||
import Testing
|
import Testing
|
||||||
@@ -328,4 +329,78 @@ struct PluginFactoryTest {
|
|||||||
#expect(plugin.name == name)
|
#expect(plugin.name == name)
|
||||||
#expect(plugin.config.abstract == "TOML service")
|
#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")
|
#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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user