Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Sources/ContainerBuild/BuildFSSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
54 changes: 54 additions & 0 deletions Tests/ContainerBuildTests/BuildFSSyncTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClientStream>.Continuation!
let stream = AsyncStream<ClientStream> { 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)")
}
}
29 changes: 29 additions & 0 deletions Tests/IntegrationTests/Build/TestCLIBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}