mirror of
https://github.com/apple/container.git
synced 2026-08-27 02:46:31 +00:00
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>
398 lines
18 KiB
Swift
398 lines
18 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
|
|
}
|
|
}
|
|
|
|
// 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 })")
|
|
}
|
|
}
|