diff --git a/Sources/ContainerBuild/BuildFSSync.swift b/Sources/ContainerBuild/BuildFSSync.swift index c5a5288f2..e97724e5d 100644 --- a/Sources/ContainerBuild/BuildFSSync.swift +++ b/Sources/ContainerBuild/BuildFSSync.swift @@ -272,11 +272,12 @@ actor BuildFSSync: BuildPipelineHandler { destination: tarURL, writerConfiguration: writerCfg ) { url in - guard let rel = try? url.relativeChildPath(to: contextDir) else { + let resolvedURL = url.resolvingSymlinksInPath() + guard let rel = try? resolvedURL.relativeChildPath(to: self.contextDir) else { return nil } - guard let parent = try? url.deletingLastPathComponent().relativeChildPath(to: self.contextDir) else { + guard let parent = try? resolvedURL.deletingLastPathComponent().relativeChildPath(to: self.contextDir) else { return nil } diff --git a/Tests/ContainerBuildTests/BuildFSSyncTests.swift b/Tests/ContainerBuildTests/BuildFSSyncTests.swift index 5d6d607d2..4fbecc5f9 100644 --- a/Tests/ContainerBuildTests/BuildFSSyncTests.swift +++ b/Tests/ContainerBuildTests/BuildFSSyncTests.swift @@ -394,4 +394,58 @@ 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(): tar mode symlinked contextDir prefix regression test (#2037) + + @Test func testWalkTarIncludesFilesWhenContextDirUsesSymlinkedPrefix() async throws { + // Create context directory using a symlinked path prefix (e.g. /tmp on macOS -> /private/tmp) + let symlinkBase = URL(fileURLWithPath: "/tmp/" + UUID().uuidString) + try fm.createDirectory(at: symlinkBase, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: symlinkBase) } + + let sampleFile = symlinkBase.appendingPathComponent("staged-run.sh") + try write("#!/bin/sh\n", to: sampleFile) + + let fssync = try BuildFSSync(symlinkBase) + + var continuation: AsyncStream.Continuation! + let stream = AsyncStream { continuation = $0 } + + var packet = BuildTransfer() + packet.id = UUID().uuidString + packet.source = "." + packet.metadata = [ + "followpaths": "staged-run.sh", + "mode": "tar", + ] + + try await fssync.walk(continuation, packet, "build-repro") + continuation.finish() + + var receivedData = Data() + for await resp in stream { + if !resp.buildTransfer.data.isEmpty { + receivedData.append(resp.buildTransfer.data) + } + } + + #expect(!receivedData.isEmpty, "tar archive data should not be empty for context under symlinked prefix") + + // Unpack tar to temporary directory and verify staged-run.sh is explicitly present inside the archive + let unpackDir = URL.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let archiveFile = URL.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".tar") + try receivedData.write(to: archiveFile) + defer { + try? fm.removeItem(at: unpackDir) + try? fm.removeItem(at: archiveFile) + } + + let archiveReader = try ArchiveReader(url: archiveFile) + var entryPaths = [String]() + for entry in try archiveReader.readEntries() { + entryPaths.append(entry.path) + } + + #expect(entryPaths.contains("staged-run.sh"), "staged-run.sh must be explicitly present in tar archive entries: \(entryPaths)") + } } diff --git a/Tests/IntegrationTests/Build/TestCLIBuilder.swift b/Tests/IntegrationTests/Build/TestCLIBuilder.swift index 95d4f74f8..7c5a65860 100644 --- a/Tests/IntegrationTests/Build/TestCLIBuilder.swift +++ b/Tests/IntegrationTests/Build/TestCLIBuilder.swift @@ -1030,4 +1030,33 @@ struct TestCLIBuilder { try f.assertImageBuilt(image) } } + + /// Regression test for issue #2037: `container build` with single file COPY and no `.dockerignore`. + /// Verifies context files under symlinked parent paths (like /tmp on macOS) are transferred properly. + @Test func testBuildContextSingleFileCOPY() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY staged-run.sh /run.sh + RUN cat /run.sh + """ + try f.createContext( + dir: dir, + dockerfile: dockerfile, + context: [ + .file("staged-run.sh", content: .data(Data("#!/bin/sh\necho hello\n".utf8))) + ]) + + let image = "registry.local/build-context-single-file:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + + let containerName = "test-repro-2037-\(UUID().uuidString)" + try f.run(["run", "-d", "--name", containerName, image, "sleep", "60"]).check() + defer { try? f.run(["rm", "-f", containerName]) } + + try f.assertContainerHasFile(containerName, at: "/run.sh", "/run.sh should exist in image") + } + } }