Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
65 changes: 65 additions & 0 deletions Sources/ContainerBuild/BuildFSSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ actor BuildFSSync: BuildPipelineHandler {
var entries: [String: Set<DirEntry>] = [:]
let followPaths: [String] = packet.followPaths() ?? []

// Parse .dockerignore if present
let ignorePatterns = try parseDockerignore()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolve the path to the ignore file here and load the file into Data, and then create an IgnoreSpec(data)


let followPathsWalked = try walk(root: self.contextDir, includePatterns: followPaths)
for url in followPathsWalked {
guard self.contextDir.absoluteURL.cleanPath != url.absoluteURL.cleanPath else {
Expand All @@ -146,6 +149,12 @@ actor BuildFSSync: BuildPipelineHandler {
}

let relPath = try url.relativeChildPath(to: contextDir)

// Check if the file should be ignored
if try shouldIgnore(relPath, patterns: ignorePatterns, isDirectory: url.hasDirectoryPath) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can become ignoreSpec.shouldIgnore(relPath:isDirectory:)?

continue
}

let parentPath = try url.deletingLastPathComponent().relativeChildPath(to: contextDir)
let entry = DirEntry(url: url, isDirectory: url.hasDirectoryPath, relativePath: relPath)
entries[parentPath, default: []].insert(entry)
Expand Down Expand Up @@ -278,6 +287,62 @@ actor BuildFSSync: BuildPipelineHandler {
return Array(globber.results)
}

/// Parse .dockerignore file and return list of ignore patterns
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extract these two methods into IgnoreSpec.swift.

private func parseDockerignore() throws -> [String] {
let dockerignorePath = contextDir.appendingPathComponent(".dockerignore")

guard FileManager.default.fileExists(atPath: dockerignorePath.path) else {
return []
}

let contents = try String(contentsOf: dockerignorePath, encoding: .utf8)

return
contents
.split(separator: "\n")
.map { line in line.trimmingCharacters(in: .whitespaces) }
.filter { line in !line.isEmpty && !line.hasPrefix("#") }
.map { String($0) }
}

/// Check if a file path should be ignored based on .dockerignore patterns
private func shouldIgnore(_ path: String, patterns: [String], isDirectory: Bool) throws -> Bool {
guard !patterns.isEmpty else {
return false
}

let globber = Globber(URL(fileURLWithPath: "/"))

for pattern in patterns {
// Try to match the pattern against the path
let pathToMatch = isDirectory ? path + "/" : path

let matchesWithSlash = try globber.glob(pathToMatch, pattern)
if matchesWithSlash {
return true
}

// Also try without the trailing slash for directories
if isDirectory {
let matchesWithoutSlash = try globber.glob(path, pattern)
if matchesWithoutSlash {
return true
}
}

// Check if pattern matches with ** prefix for nested paths
let shouldAddPrefix = !pattern.hasPrefix("**/") && !pattern.hasPrefix("/")
if shouldAddPrefix {
let matchesWithPrefix = try globber.glob(pathToMatch, "**/" + pattern)
if matchesWithPrefix {
return true
}
}
}

return false
}

private func processDirectory(
_ currentDir: String,
inputEntries: [String: Set<DirEntry>],
Expand Down
209 changes: 209 additions & 0 deletions Tests/ContainerBuildTests/DockerignoreTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//

import Foundation
import Testing

@testable import ContainerBuild

enum TestError: Error {
case missingBuildTransfer
}

@Suite class DockerignoreTests {
private var baseTempURL: URL
private let fileManager = FileManager.default

init() throws {
self.baseTempURL = URL.temporaryDirectory
.appendingPathComponent("DockerignoreTests-\(UUID().uuidString)")
try fileManager.createDirectory(at: baseTempURL, withIntermediateDirectories: true, attributes: nil)
}

deinit {
try? fileManager.removeItem(at: baseTempURL)
}

private func createFile(at url: URL, content: String = "") throws {
try fileManager.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true,
attributes: nil
)
let created = fileManager.createFile(
atPath: url.path,
contents: content.data(using: .utf8),
attributes: nil
)
try #require(created)
}

@Test func testDockerignoreExcludesMatchingFiles() async throws {
// Setup: Create a build context with files and .dockerignore
let contextDir = baseTempURL.appendingPathComponent("build-context")
try fileManager.createDirectory(at: contextDir, withIntermediateDirectories: true, attributes: nil)

// Create Dockerfile
let dockerfilePath = contextDir.appendingPathComponent("Dockerfile")
try createFile(at: dockerfilePath, content: "FROM scratch\n")

// Create hello.txt (should be included)
let helloPath = contextDir.appendingPathComponent("hello.txt")
try createFile(at: helloPath, content: "Hello, World!\n")

// Create to-be-ignored.txt (should be excluded)
let ignoredPath = contextDir.appendingPathComponent("to-be-ignored.txt")
try createFile(at: ignoredPath, content: "This should be ignored\n")

// Create .dockerignore
let dockerignorePath = contextDir.appendingPathComponent(".dockerignore")
try createFile(at: dockerignorePath, content: "to-be-ignored.txt\n")

// Execute: Create BuildFSSync and call walk with all files pattern
let fsSync = try BuildFSSync(contextDir)

// Create a BuildTransfer packet to simulate the walk request
let buildTransfer: BuildTransfer = {
var transfer = BuildTransfer()
transfer.id = "test-walk"
transfer.source = "."
transfer.metadata = [
"stage": "fssync",
"method": "Walk",
"followpaths": "**", // Include all files
"mode": "json",
]
return transfer
}()

// Capture the response
var responses: [ClientStream] = []
let stream = AsyncStream<ClientStream> { continuation in
Task {
do {
try await fsSync.walk(continuation, buildTransfer, "test-build")
} catch {
// Propagate error
throw error
}
continuation.finish()
}
}

for await response in stream {
responses.append(response)
}

// Parse the JSON response
try #require(responses.count == 1, "Expected exactly one response")
let response = responses[0]

// Check if the response has a buildTransfer packet type
guard case .buildTransfer(let responseTransfer) = response.packetType else {
throw TestError.missingBuildTransfer
}

try #require(responseTransfer.complete, "Response should be complete")

let fileInfos = try JSONDecoder().decode([BuildFSSync.FileInfo].self, from: responseTransfer.data)

// Extract file names
let fileNames = Set(fileInfos.map { $0.name })

// Assert: Verify hello.txt is present and to-be-ignored.txt is NOT present
#expect(fileNames.contains("hello.txt"), "hello.txt should be included in the build context")
#expect(!fileNames.contains("to-be-ignored.txt"), "to-be-ignored.txt should be excluded by .dockerignore")
#expect(fileNames.contains("Dockerfile"), "Dockerfile should be included")

// The .dockerignore file itself should also be included (Docker behavior)
#expect(fileNames.contains(".dockerignore"), ".dockerignore should be included")
}

@Test func testDockerignoreExcludesDirectories() async throws {
// Setup: Create a build context with directories and .dockerignore
let contextDir = baseTempURL.appendingPathComponent("build-context-dirs")
try fileManager.createDirectory(at: contextDir, withIntermediateDirectories: true, attributes: nil)

// Create Dockerfile
let dockerfilePath = contextDir.appendingPathComponent("Dockerfile")
try createFile(at: dockerfilePath, content: "FROM scratch\n")

// Create src/app.txt (should be included)
let srcPath = contextDir.appendingPathComponent("src")
try fileManager.createDirectory(at: srcPath, withIntermediateDirectories: true, attributes: nil)
let appPath = srcPath.appendingPathComponent("app.txt")
try createFile(at: appPath, content: "app code\n")

// Create node_modules/package.txt (should be excluded)
let nodeModulesPath = contextDir.appendingPathComponent("node_modules")
try fileManager.createDirectory(at: nodeModulesPath, withIntermediateDirectories: true, attributes: nil)
let packagePath = nodeModulesPath.appendingPathComponent("package.txt")
try createFile(at: packagePath, content: "dependency\n")

// Create .dockerignore
let dockerignorePath = contextDir.appendingPathComponent(".dockerignore")
try createFile(at: dockerignorePath, content: "node_modules\n")

// Execute: Create BuildFSSync and call walk
let fsSync = try BuildFSSync(contextDir)

let buildTransfer: BuildTransfer = {
var transfer = BuildTransfer()
transfer.id = "test-walk-dirs"
transfer.source = "."
transfer.metadata = [
"stage": "fssync",
"method": "Walk",
"followpaths": "**",
"mode": "json",
]
return transfer
}()

var responses: [ClientStream] = []
let stream = AsyncStream<ClientStream> { continuation in
Task {
do {
try await fsSync.walk(continuation, buildTransfer, "test-build")
} catch {
throw error
}
continuation.finish()
}
}

for await response in stream {
responses.append(response)
}

// Parse the JSON response
try #require(responses.count == 1)
let response = responses[0]

guard case .buildTransfer(let responseTransfer) = response.packetType else {
throw TestError.missingBuildTransfer
}

let fileInfos = try JSONDecoder().decode([BuildFSSync.FileInfo].self, from: responseTransfer.data)
let fileNames = Set(fileInfos.map { $0.name })

// Assert: Verify src is included, node_modules is excluded
#expect(fileNames.contains("src"), "src directory should be included")
#expect(fileNames.contains("src/app.txt"), "src/app.txt should be included")
#expect(!fileNames.contains("node_modules"), "node_modules directory should be excluded")
#expect(!fileNames.contains("node_modules/package.txt"), "node_modules/package.txt should be excluded")
}
}