diff --git a/Sources/ContainerBuild/BuildFSSync.swift b/Sources/ContainerBuild/BuildFSSync.swift index c4cfb352..c7cfb745 100644 --- a/Sources/ContainerBuild/BuildFSSync.swift +++ b/Sources/ContainerBuild/BuildFSSync.swift @@ -136,6 +136,18 @@ actor BuildFSSync: BuildPipelineHandler { var entries: [String: Set] = [:] let followPaths: [String] = packet.followPaths() ?? [] + // Load .dockerignore if present + let dockerignorePath = contextDir.appendingPathComponent(".dockerignore") + let ignoreSpec: IgnoreSpec? = { + guard FileManager.default.fileExists(atPath: dockerignorePath.path) else { + return nil + } + guard let data = try? Data(contentsOf: dockerignorePath) else { + return nil + } + return IgnoreSpec(data) + }() + let followPathsWalked = try walk(root: self.contextDir, includePatterns: followPaths) for url in followPathsWalked { guard self.contextDir.absoluteURL.cleanPath != url.absoluteURL.cleanPath else { @@ -146,6 +158,12 @@ actor BuildFSSync: BuildPipelineHandler { } let relPath = try url.relativeChildPath(to: contextDir) + + // Check if the file should be ignored + if let ignoreSpec = ignoreSpec, try ignoreSpec.shouldIgnore(relPath: relPath, isDirectory: url.hasDirectoryPath) { + continue + } + let parentPath = try url.deletingLastPathComponent().relativeChildPath(to: contextDir) let entry = DirEntry(url: url, isDirectory: url.hasDirectoryPath, relativePath: relPath) entries[parentPath, default: []].insert(entry) diff --git a/Sources/ContainerBuild/IgnoreSpec.swift b/Sources/ContainerBuild/IgnoreSpec.swift new file mode 100644 index 00000000..b1d925d8 --- /dev/null +++ b/Sources/ContainerBuild/IgnoreSpec.swift @@ -0,0 +1,199 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// A type that handles .dockerignore pattern matching +struct IgnoreSpec { + private let patterns: [String] + + /// Initialize an IgnoreSpec from .dockerignore file data + /// - Parameter data: The contents of a .dockerignore file + init(_ data: Data) { + guard let contents = String(data: data, encoding: .utf8) else { + self.patterns = [] + return + } + + self.patterns = + 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 + /// - Parameters: + /// - relPath: The relative path to check + /// - isDirectory: Whether the path is a directory + /// - Returns: true if the path should be ignored, false otherwise + func shouldIgnore(relPath: String, isDirectory: Bool) throws -> Bool { + guard !patterns.isEmpty else { + return false + } + + var shouldIgnore = false + + // Process patterns in order - later patterns override earlier ones + for pattern in patterns { + // Check if this is a negation pattern + let isNegation = pattern.hasPrefix("!") + let actualPattern = isNegation ? String(pattern.dropFirst()) : pattern + + if try matchesPattern(path: relPath, pattern: actualPattern, isDirectory: isDirectory) { + // If it's a negation pattern, DON'T ignore (include the file) + // Otherwise, DO ignore + shouldIgnore = !isNegation + } + } + + return shouldIgnore + } + + /// Match a path against a dockerignore pattern + /// - Parameters: + /// - path: The path to match + /// - pattern: The dockerignore pattern + /// - isDirectory: Whether the path is a directory + /// - Returns: true if the path matches the pattern + private func matchesPattern(path: String, pattern: String, isDirectory: Bool) throws -> Bool { + var pattern = pattern + + // Handle trailing slash on pattern (means directories only) + let dirOnly = pattern.hasSuffix("/") + if dirOnly { + pattern = String(pattern.dropLast()) + } + + // Handle root-only patterns (starting with /) + let rootOnly = pattern.hasPrefix("/") + if rootOnly { + pattern = String(pattern.dropFirst()) + } + + // Convert pattern to regex, handling ** specially + let regex = try patternToRegex(pattern: pattern, rootOnly: rootOnly) + + // Try matching the path (and with trailing slash for directories) + if path.range(of: regex, options: .regularExpression) != nil { + // If dirOnly is set, ensure the matched path is actually a directory + if dirOnly && !isDirectory { + return false + } + return true + } + + if isDirectory { + let pathWithSlash = path + "/" + if pathWithSlash.range(of: regex, options: .regularExpression) != nil { + return true + } + } + + // Also check if the path is inside a matched directory + // e.g., pattern "foo" should match "foo/bar.txt" + // This is done by checking if any parent directory matches + let pathComponents = path.split(separator: "/") + for i in 0.. String { + var result = "" + + // If not root-only, pattern can match at any depth + if !rootOnly && !pattern.hasPrefix("**/") { + // Pattern matches at any depth (like **/pattern) + result = "(?:^|.*/)" + } else { + result = "^" + } + + // Process the pattern + var i = pattern.startIndex + while i < pattern.endIndex { + let char = pattern[i] + + // Handle ** + if char == "*" && pattern.index(after: i) < pattern.endIndex && pattern[pattern.index(after: i)] == "*" { + // Check if it's **/ or just ** + let afterStar = pattern.index(i, offsetBy: 2) + if afterStar < pattern.endIndex && pattern[afterStar] == "/" { + // **/ matches zero or more path segments + result += "(?:.*/)?" + i = pattern.index(after: afterStar) + continue + } else if afterStar == pattern.endIndex { + // ** at end matches anything + result += ".*" + i = afterStar + continue + } + } + + // Handle single * + if char == "*" { + result += "[^/]*" + i = pattern.index(after: i) + continue + } + + // Handle ? + if char == "?" { + result += "[^/]" + i = pattern.index(after: i) + continue + } + + // Handle character classes [...] + if char == "[" { + var j = pattern.index(after: i) + while j < pattern.endIndex && pattern[j] != "]" { + j = pattern.index(after: j) + } + if j < pattern.endIndex { + let charClass = String(pattern[i...j]) + result += charClass + i = pattern.index(after: j) + continue + } + } + + // Escape special regex characters + let specialChars = "\\^$.+(){}|" + if specialChars.contains(char) { + result += "\\" + } + result += String(char) + i = pattern.index(after: i) + } + + result += "$" + return result + } +} diff --git a/Tests/ContainerBuildTests/IgnoreSpecTests.swift b/Tests/ContainerBuildTests/IgnoreSpecTests.swift new file mode 100644 index 00000000..767cf81f --- /dev/null +++ b/Tests/ContainerBuildTests/IgnoreSpecTests.swift @@ -0,0 +1,363 @@ +//===----------------------------------------------------------------------===// +// 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 IgnoreSpecTests { + private var baseTempURL: URL + private let fileManager = FileManager.default + + init() throws { + self.baseTempURL = URL.temporaryDirectory + .appendingPathComponent("IgnoreSpecTests-\(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) + } + + // MARK: - Unit tests for IgnoreSpec + + @Test func testIgnoreSpecParsesPatterns() throws { + let content = """ + # This is a comment + *.log + + node_modules + # Another comment + temp/ + """ + let data = content.data(using: .utf8)! + let spec = IgnoreSpec(data) + + // Test that it correctly ignores files matching patterns + #expect(try spec.shouldIgnore(relPath: "debug.log", isDirectory: false)) + #expect(try spec.shouldIgnore(relPath: "node_modules", isDirectory: true)) + #expect(try spec.shouldIgnore(relPath: "temp", isDirectory: true)) + #expect(try !spec.shouldIgnore(relPath: "src/app.swift", isDirectory: false)) + } + + @Test func testIgnoreSpecHandlesEmptyData() throws { + let data = Data() + let spec = IgnoreSpec(data) + + // Empty spec should not ignore anything + #expect(try !spec.shouldIgnore(relPath: "anyfile.txt", isDirectory: false)) + } + + @Test func testIgnoreSpecHandlesNestedPaths() throws { + let content = "*.log" + let data = content.data(using: .utf8)! + let spec = IgnoreSpec(data) + + // Should match .log files in nested directories + #expect(try spec.shouldIgnore(relPath: "logs/debug.log", isDirectory: false)) + #expect(try spec.shouldIgnore(relPath: "app/logs/error.log", isDirectory: false)) + #expect(try !spec.shouldIgnore(relPath: "logs/debug.txt", isDirectory: false)) + } + + @Test func testIgnoreSpecHandlesDirectories() throws { + let content = "node_modules" + let data = content.data(using: .utf8)! + let spec = IgnoreSpec(data) + + // Should match directories + #expect(try spec.shouldIgnore(relPath: "node_modules", isDirectory: true)) + #expect(try spec.shouldIgnore(relPath: "src/node_modules", isDirectory: true)) + } + + @Test func testIgnoreSpecHandlesDoubleStarPatterns() throws { + let content = """ + **/*.log + **/temp + src/**/*.tmp + """ + let data = content.data(using: .utf8)! + let spec = IgnoreSpec(data) + + // **/*.log should match .log files at any depth + #expect(try spec.shouldIgnore(relPath: "debug.log", isDirectory: false)) + #expect(try spec.shouldIgnore(relPath: "logs/debug.log", isDirectory: false)) + #expect(try spec.shouldIgnore(relPath: "app/logs/error.log", isDirectory: false)) + #expect(try !spec.shouldIgnore(relPath: "debug.txt", isDirectory: false)) + + // **/temp should match temp directory at any depth + #expect(try spec.shouldIgnore(relPath: "temp", isDirectory: true)) + #expect(try spec.shouldIgnore(relPath: "build/temp", isDirectory: true)) + #expect(try spec.shouldIgnore(relPath: "app/cache/temp", isDirectory: true)) + + // src/**/*.tmp should match .tmp files only under src/ + #expect(try spec.shouldIgnore(relPath: "src/file.tmp", isDirectory: false)) + #expect(try spec.shouldIgnore(relPath: "src/nested/file.tmp", isDirectory: false)) + #expect(try !spec.shouldIgnore(relPath: "file.tmp", isDirectory: false)) + #expect(try !spec.shouldIgnore(relPath: "other/file.tmp", isDirectory: false)) + } + + @Test func testIgnoreSpecHandlesRootPatterns() throws { + let content = """ + /root-only.txt + /build + """ + let data = content.data(using: .utf8)! + let spec = IgnoreSpec(data) + + // /root-only.txt should only match at root + #expect(try spec.shouldIgnore(relPath: "root-only.txt", isDirectory: false)) + #expect(try !spec.shouldIgnore(relPath: "src/root-only.txt", isDirectory: false)) + + // /build should only match at root + #expect(try spec.shouldIgnore(relPath: "build", isDirectory: true)) + #expect(try !spec.shouldIgnore(relPath: "src/build", isDirectory: true)) + } + + @Test func testIgnoreSpecHandlesNegationPatterns() throws { + let content = """ + *.log + !important.log + """ + let data = content.data(using: .utf8)! + let spec = IgnoreSpec(data) + + // *.log should match all .log files + #expect(try spec.shouldIgnore(relPath: "debug.log", isDirectory: false)) + #expect(try spec.shouldIgnore(relPath: "error.log", isDirectory: false)) + + // But !important.log should negate and include important.log + #expect(try !spec.shouldIgnore(relPath: "important.log", isDirectory: false)) + } + + @Test func testIgnoreSpecHandlesNegationWithDirectories() throws { + let content = """ + foo + !foo/bar.txt + """ + let data = content.data(using: .utf8)! + let spec = IgnoreSpec(data) + + // foo should match the directory and its contents + #expect(try spec.shouldIgnore(relPath: "foo", isDirectory: true)) + #expect(try spec.shouldIgnore(relPath: "foo/other.txt", isDirectory: false)) + + // But !foo/bar.txt should negate and include that specific file + #expect(try !spec.shouldIgnore(relPath: "foo/bar.txt", isDirectory: false)) + } + + @Test func testIgnoreSpecHandlesComplexNegation() throws { + let content = """ + # Ignore all markdown files + **/*.md + # Except README files + !README.md + !**/README.md + # But ignore README in temp directories + temp/**/README.md + """ + let data = content.data(using: .utf8)! + let spec = IgnoreSpec(data) + + // *.md should match all markdown files + #expect(try spec.shouldIgnore(relPath: "notes.md", isDirectory: false)) + #expect(try spec.shouldIgnore(relPath: "docs/guide.md", isDirectory: false)) + + // But !README.md should include README files + #expect(try !spec.shouldIgnore(relPath: "README.md", isDirectory: false)) + #expect(try !spec.shouldIgnore(relPath: "docs/README.md", isDirectory: false)) + + // Except temp/**/README.md should exclude README in temp dirs + #expect(try spec.shouldIgnore(relPath: "temp/README.md", isDirectory: false)) + #expect(try spec.shouldIgnore(relPath: "temp/cache/README.md", isDirectory: false)) + } + + // MARK: - Integration tests with BuildFSSync + + @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 { 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 { 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") + } +}