diff --git a/Sources/ContainerBuild/BuildFSSync.swift b/Sources/ContainerBuild/BuildFSSync.swift index 5d228ae9..fcb30160 100644 --- a/Sources/ContainerBuild/BuildFSSync.swift +++ b/Sources/ContainerBuild/BuildFSSync.swift @@ -233,6 +233,9 @@ actor BuildFSSync: BuildPipelineHandler { /// This is the primary data path for build-context transfer. BuildKit sends /// a `Walk` request whose `followpaths` field names the context paths needed /// for the current build step (e.g. the source of a `COPY` instruction). + /// If `followpaths` is omitted or empty (meaning the build step requires the + /// entire context, such as a step with a tmpfs mount), all files in the + /// context root are included. /// The host resolves those globs, builds an entry set, and passes it to /// `Archiver.compress` to produce the tar. /// @@ -253,7 +256,7 @@ actor BuildFSSync: BuildPipelineHandler { let wantsTar = packet.mode() == "tar" var entries: [String: Set] = [:] - let followPaths: [String] = packet.followPaths() ?? [] + let followPaths: [String] = packet.followPaths().flatMap { $0.isEmpty ? nil : $0 } ?? ["."] let followPathsWalked = try walk(root: self.contextDir, includePatterns: followPaths) for url in followPathsWalked { diff --git a/Tests/ContainerBuildTests/BuildFSSyncTests.swift b/Tests/ContainerBuildTests/BuildFSSyncTests.swift index 61e5069f..738ddba5 100644 --- a/Tests/ContainerBuildTests/BuildFSSyncTests.swift +++ b/Tests/ContainerBuildTests/BuildFSSyncTests.swift @@ -534,6 +534,53 @@ import Testing 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 })") } + + // MARK: - walk(): empty or omitted followpaths requests entire context + // + // BuildKit omits followpaths when a build step needs the whole context + // (for example, RUN --mount=type=tmpfs). An empty or omitted followpaths + // must result in the entire context being served rather than an empty + // result. + + @Test func testWalkJSONEmptyFollowPathsIncludesEntireContext() async throws { + try write("hello", to: contextDir.appendingPathComponent("a.txt")) + try write("world", to: contextDir.appendingPathComponent("b.txt")) + + let fssync = try BuildFSSync(contextDir) + let infos = try await walkJSON(fssync, followPaths: []) + + #expect(infos.contains { $0.name == "a.txt" }, "a.txt should be included when followpaths is empty") + #expect(infos.contains { $0.name == "b.txt" }, "b.txt should be included when followpaths is empty") + } + + @Test func testWalkJSONMissingFollowPathsMetadataIncludesEntireContext() async throws { + try write("hello", to: contextDir.appendingPathComponent("a.txt")) + try write("world", to: contextDir.appendingPathComponent("b.txt")) + + let fssync = try BuildFSSync(contextDir) + var p = BuildTransfer() + p.id = UUID().uuidString + p.source = "." + p.metadata = [ + "mode": "json" + ] + + var continuation: AsyncStream.Continuation! + let stream = AsyncStream { continuation = $0 } + try await fssync.walk(continuation, p, "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) + } + } + + #expect(fileInfos.contains { $0.name == "a.txt" }, "a.txt should be included when followpaths metadata is omitted") + #expect(fileInfos.contains { $0.name == "b.txt" }, "b.txt should be included when followpaths metadata is omitted") + } } private final class ManagedAtomicFlag: @unchecked Sendable { diff --git a/Tests/IntegrationTests/Build/TestCLIBuilder.swift b/Tests/IntegrationTests/Build/TestCLIBuilder.swift index 95d4f74f..9b181491 100644 --- a/Tests/IntegrationTests/Build/TestCLIBuilder.swift +++ b/Tests/IntegrationTests/Build/TestCLIBuilder.swift @@ -496,6 +496,26 @@ struct TestCLIBuilder { } } + @Test func testRunWithTmpfsMount() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY a.txt b.txt /out/ + RUN --mount=type=tmpfs,dst=/scratch ls -l /out + """, + context: [ + .file("a.txt", content: .data("one\n".data(using: .utf8)!)), + .file("b.txt", content: .data("two\n".data(using: .utf8)!)), + ]) + let image = "registry.local/tmpfs-mount-test:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + // MARK: - .dockerignore tests @Test func testBuildDockerIgnore() async throws {