diff --git a/Sources/ContainerBuild/Globber.swift b/Sources/ContainerBuild/Globber.swift index 9b3011b3..96e7095a 100644 --- a/Sources/ContainerBuild/Globber.swift +++ b/Sources/ContainerBuild/Globber.swift @@ -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 `**`) diff --git a/Tests/ContainerBuildTests/BuildFSSyncTests.swift b/Tests/ContainerBuildTests/BuildFSSyncTests.swift index 5d6d607d..e48195ff 100644 --- a/Tests/ContainerBuildTests/BuildFSSyncTests.swift +++ b/Tests/ContainerBuildTests/BuildFSSyncTests.swift @@ -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,