mirror of
https://github.com/apple/container.git
synced 2026-10-23 14:31:06 +00:00
- `BuildFSSync.read()` and `info()` resolved a requested path, checked that the result was inside the build context, and then opened the original path. A symlink replaced between the check and the open was followed to its new target, which could be outside the context. -This change opens each requested file by walking its path one component at a time from a directory descriptor pinned on the context root, using `FileDescriptorOps` from `containerization`. Symlinks are followed only when the target is relative and stays beneath the context root. The metadata and the content returned to the builder both come from the same open descriptor. - Behavior change: a symlink with an absolute target is now rejected by `read()` and `info()`, even when the target is inside the context. Relative symlinks that stay inside the context still work. `walk()`-based transfers are unchanged. - Dependency: Requires `containerization` 0.50.0 for its `FileDescriptorOps` API enhancements. This change updates the library from 0.52.0 to 0.52.1.
545 lines
25 KiB
Swift
545 lines
25 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 })")
|
|
}
|
|
}
|
|
|
|
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 } }
|
|
}
|