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

592 lines
27 KiB
Swift

//===----------------------------------------------------------------------===//
// 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
}
}
// Symlink following is limited to relative targets beneath the context
// root. A link with an absolute target is rejected even when that target is
// inside the context, because the descriptor walk cannot follow an absolute
// path without leaving the pinned directory.
@Test func testReadRejectsAbsoluteSymlinkInsideContext() async throws {
let realFile = contextDir.appendingPathComponent("real.txt")
try write("hello", to: realFile)
try fm.createSymbolicLink(
atPath: contextDir.appendingPathComponent("alias").path(percentEncoded: false),
withDestinationPath: realFile.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: "alias"), "build-0")
Issue.record("read() should throw BuildFSSync.Error for a symlink with an absolute target, even inside the context")
} catch is BuildFSSync.Error {
// expected
}
}
@Test func testInfoRejectsAbsoluteSymlinkInsideContext() async throws {
let realFile = contextDir.appendingPathComponent("real.txt")
try write("hello", to: realFile)
try fm.createSymbolicLink(
atPath: contextDir.appendingPathComponent("alias").path(percentEncoded: false),
withDestinationPath: realFile.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: "alias"), "build-0")
Issue.record("info() should throw BuildFSSync.Error for a symlink with an absolute target, even inside the context")
} catch is BuildFSSync.Error {
// expected
}
}
// MARK: - read(): served content
private func readResponse(_ fssync: BuildFSSync, source: String) async throws -> BuildTransfer {
var continuation: AsyncStream<ClientStream>.Continuation!
let stream = AsyncStream<ClientStream> { continuation = $0 }
defer { continuation.finish() }
try await fssync.read(continuation, readPacket(source: source), "build-0")
continuation.finish()
for await packet in stream { return packet.buildTransfer }
throw BuildFSSync.Error.buildTransferMissing
}
@Test func testReadServesRegularFileAndRelativeInContextSymlink() async throws {
try write("hello", to: contextDir.appendingPathComponent("plain.txt"))
try fm.createSymbolicLink(
atPath: contextDir.appendingPathComponent("alias").path(percentEncoded: false),
withDestinationPath: "plain.txt")
let fssync = try BuildFSSync(contextDir)
for source in ["plain.txt", "alias", "./plain.txt", contextDir.appendingPathComponent("plain.txt").path(percentEncoded: false)] {
let transfer = try await readResponse(fssync, source: source)
#expect(String(data: transfer.data, encoding: .utf8) == "hello", "source: \(source)")
}
}
@Test func testReadOfDirectoryReturnsNoData() async throws {
try fm.createDirectory(at: contextDir.appendingPathComponent("sub"), withIntermediateDirectories: true)
let transfer = try await readResponse(try BuildFSSync(contextDir), source: "sub")
#expect(transfer.data.isEmpty)
#expect(transfer.isDirectory)
}
/// The scenario from GHSA-fqxg-rrf3-358v: a link is swapped between an
/// in-context and an out-of-context target while reads are in flight. Every
/// read must either return the in-context content or fail; the secret must
/// never be served.
@Test func testReadNeverServesSwappedSymlinkTarget() async throws {
try write("safe", to: contextDir.appendingPathComponent("safe.txt"))
try write("supersecret", to: outsideDir.appendingPathComponent("secret.txt"))
let bait = contextDir.appendingPathComponent("bait").path(percentEncoded: false)
let tmp = contextDir.appendingPathComponent("bait.tmp").path(percentEncoded: false)
let targets = ["safe.txt", outsideDir.appendingPathComponent("secret.txt").path(percentEncoded: false)]
try fm.createSymbolicLink(atPath: bait, withDestinationPath: targets[0])
let stop = ManagedAtomicFlag()
let swapper = Thread {
var i = 0
while !stop.isSet {
unlink(tmp)
symlink(targets[i % 2], tmp)
rename(tmp, bait)
i += 1
}
}
swapper.start()
defer { stop.set() }
let fssync = try BuildFSSync(contextDir)
for _ in 0..<2000 {
if let transfer = try? await readResponse(fssync, source: "bait") {
#expect(String(data: transfer.data, encoding: .utf8) != "supersecret")
}
}
}
// 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(): plain subdirectory contents under a symlinked ancestor
//
// Globber.children(of:) used to list directories via
// contentsOfDirectory(at:), which silently resolves /tmp to /private/tmp
// even for a non-symlink child. That made walk()'s parentOf() check
// reject every entry found by descending into a plain subdirectory, so
// "COPY foodir /dest" tarred an empty directory while
// "COPY foodir/payload /dest/payload" worked fine.
@Test func testWalkUnderSymlinkedAncestor() async throws {
// Root the context directly under /tmp (a symlink to /private/tmp on
// macOS) to reproduce the bug precisely.
let tmpBase = URL(fileURLWithPath: "/tmp").appendingPathComponent(UUID().uuidString)
let ctx = tmpBase.appendingPathComponent("context")
try fm.createDirectory(at: ctx, withIntermediateDirectories: true)
defer { try? fm.removeItem(at: tmpBase) }
let subdir = ctx.appendingPathComponent("foodir")
try fm.createDirectory(at: subdir, withIntermediateDirectories: true)
try write("payload", to: subdir.appendingPathComponent("payload.txt"))
let fssync = try BuildFSSync(ctx)
let infos = try await walkJSON(fssync, followPaths: ["foodir"])
let payload = infos.first { $0.name == "foodir/payload.txt" }
#expect(payload != nil, "expected foodir/payload.txt to be included when COPYing a directory glob, got: \(infos.map { $0.name })")
}
// 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 })")
}
// MARK: - walk(): empty or omitted followpaths requests entire context
//
// BuildKit omits followpaths when a build step needs the whole context
// (for example, RUN --mount=type=tmpfs). An empty or omitted followpaths
// must result in the entire context being served rather than an empty
// result.
@Test func testWalkJSONEmptyFollowPathsIncludesEntireContext() async throws {
try write("hello", to: contextDir.appendingPathComponent("a.txt"))
try write("world", to: contextDir.appendingPathComponent("b.txt"))
let fssync = try BuildFSSync(contextDir)
let infos = try await walkJSON(fssync, followPaths: [])
#expect(infos.contains { $0.name == "a.txt" }, "a.txt should be included when followpaths is empty")
#expect(infos.contains { $0.name == "b.txt" }, "b.txt should be included when followpaths is empty")
}
@Test func testWalkJSONMissingFollowPathsMetadataIncludesEntireContext() async throws {
try write("hello", to: contextDir.appendingPathComponent("a.txt"))
try write("world", to: contextDir.appendingPathComponent("b.txt"))
let fssync = try BuildFSSync(contextDir)
var p = BuildTransfer()
p.id = UUID().uuidString
p.source = "."
p.metadata = [
"mode": "json"
]
var continuation: AsyncStream<ClientStream>.Continuation!
let stream = AsyncStream<ClientStream> { continuation = $0 }
try await fssync.walk(continuation, p, "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)
}
}
#expect(fileInfos.contains { $0.name == "a.txt" }, "a.txt should be included when followpaths metadata is omitted")
#expect(fileInfos.contains { $0.name == "b.txt" }, "b.txt should be included when followpaths metadata is omitted")
}
}
private final class ManagedAtomicFlag: @unchecked Sendable {
private let lock = NSLock()
private var value = false
var isSet: Bool { lock.withLock { value } }
func set() { lock.withLock { value = true } }
}