From dc1e5975c34108270afac8d4caec11082059cad6 Mon Sep 17 00:00:00 2001 From: Rupin Ajay Date: Sat, 15 Aug 2026 09:46:02 +0530 Subject: [PATCH] Fix empty build context for symlinked context paths BuildFSSync initializes the context root by resolving symlinks to enforce containment boundaries. However, Archiver.compress enumerates physical paths using the unresolved prefix, causing relativeChildPath() to reject valid files. Canonicalize the URLs returned by the archive walk before checking them against the canonicalized context root, while preserving the existing containment security boundary. Fixes #2037 --- Sources/ContainerBuild/BuildFSSync.swift | 5 +- .../BuildFSSyncTests.swift | 54 +++++++++++++++++++ .../Build/TestCLIBuilder.swift | 29 ++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) 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") + } + } }