Fix Globber dropping directory contents under a symlinked ancestor (#2252)

This commit is contained in:
jwhur authored and GitHub committed 2026-09-21 14:01:26 -07:00
1 parent 4e7dce66ad
commit 09d8e6aeea
2 files changed
+35 -9

No files matched your search

+7 -9
View File
@@ -83,19 +83,17 @@ public class Globber {
/// (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.
/// Children are named by appending each entry's basename onto `dir`
/// rather than using `contentsOfDirectory(at:)`'s own URLs, which silently
/// re-resolve `dir` (e.g. `/tmp` → `/private/tmp`) even when `dir` itself
/// isn't a symlink, breaking the lexical `parentOf` containment checks
/// downstream.
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))
?? []
let names = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? []
return names.map { dir.appendingPathComponent($0) }
}
/// Recursive form of ``children(of:)``, used once a full pattern (or `**`)
@@ -256,6 +256,34 @@ import Testing
#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,