Skip to content

Commit 09d8e6a

Browse files
authored
Fix Globber dropping directory contents under a symlinked ancestor (#2252)
1 parent 4e7dce6 commit 09d8e6a

2 files changed

Lines changed: 35 additions & 9 deletions

File tree

‎Sources/ContainerBuild/Globber.swift‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,19 +83,17 @@ public class Globber {
8383
/// (same as a regular file) so pattern components after it never match —
8484
/// mirrors the containment check `BuildFSSync` applies before reading.
8585
///
86-
/// Children are named by their resolved (physical) path, not by `url`, so
87-
/// that `walk(root:includePatterns:)`'s later filter — which is driven by
88-
/// `Archiver.compress`'s own physical directory walk — reliably finds a
89-
/// matching entry regardless of whether that walk itself follows `url`'s
90-
/// symlink. `url` is separately inserted into `results` so the symlink
91-
/// entry is still present in the tar for the builder to resolve the
92-
/// original path against.
86+
/// Children are named by appending each entry's basename onto `dir`
87+
/// rather than using `contentsOfDirectory(at:)`'s own URLs, which silently
88+
/// re-resolve `dir` (e.g. `/tmp` → `/private/tmp`) even when `dir` itself
89+
/// isn't a symlink, breaking the lexical `parentOf` containment checks
90+
/// downstream.
9391
private func children(of url: URL) -> [URL] {
9492
// TODO: modifying object state and returning results is odd, rework
9593
guard let dir = self.resolvedDirectory(of: url) else { return [] }
9694
if url.isSymlink { self.results.insert(url) }
97-
return (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))
98-
?? []
95+
let names = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? []
96+
return names.map { dir.appendingPathComponent($0) }
9997
}
10098

10199
/// Recursive form of ``children(of:)``, used once a full pattern (or `**`)

‎Tests/ContainerBuildTests/BuildFSSyncTests.swift‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,34 @@ import Testing
256256
#expect(leaked.isEmpty, "walk() returned non-symlink URLs that physically resolve outside the context: \(leaked)")
257257
}
258258

259+
// MARK: - walk(): plain subdirectory contents under a symlinked ancestor
260+
//
261+
// Globber.children(of:) used to list directories via
262+
// contentsOfDirectory(at:), which silently resolves /tmp to /private/tmp
263+
// even for a non-symlink child. That made walk()'s parentOf() check
264+
// reject every entry found by descending into a plain subdirectory, so
265+
// "COPY foodir /dest" tarred an empty directory while
266+
// "COPY foodir/payload /dest/payload" worked fine.
267+
268+
@Test func testWalkUnderSymlinkedAncestor() async throws {
269+
// Root the context directly under /tmp (a symlink to /private/tmp on
270+
// macOS) to reproduce the bug precisely.
271+
let tmpBase = URL(fileURLWithPath: "/tmp").appendingPathComponent(UUID().uuidString)
272+
let ctx = tmpBase.appendingPathComponent("context")
273+
try fm.createDirectory(at: ctx, withIntermediateDirectories: true)
274+
defer { try? fm.removeItem(at: tmpBase) }
275+
276+
let subdir = ctx.appendingPathComponent("foodir")
277+
try fm.createDirectory(at: subdir, withIntermediateDirectories: true)
278+
try write("payload", to: subdir.appendingPathComponent("payload.txt"))
279+
280+
let fssync = try BuildFSSync(ctx)
281+
let infos = try await walkJSON(fssync, followPaths: ["foodir"])
282+
283+
let payload = infos.first { $0.name == "foodir/payload.txt" }
284+
#expect(payload != nil, "expected foodir/payload.txt to be included when COPYing a directory glob, got: \(infos.map { $0.name })")
285+
}
286+
259287
// MARK: - walk(): JSON/FileInfo metadata paths
260288
//
261289
// walk() has two response formats: a tar stream (the primary data path,

0 commit comments

Comments
 (0)