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
18 changes: 18 additions & 0 deletions Sources/ContainerBuild/BuildFSSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,18 @@ actor BuildFSSync: BuildPipelineHandler {
var entries: [String: Set<DirEntry>] = [:]
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 {
Expand All @@ -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)
Expand Down
199 changes: 199 additions & 0 deletions Sources/ContainerBuild/IgnoreSpec.swift
Original file line number Diff line number Diff line change
@@ -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..<pathComponents.count {
let parentPath = pathComponents[0...i].joined(separator: "/")
if parentPath.range(of: regex, options: .regularExpression) != nil {
return true
}
}

return false
}

/// Convert a dockerignore pattern to a regex pattern
/// - Parameters:
/// - pattern: The dockerignore pattern
/// - rootOnly: Whether the pattern should only match at root
/// - Returns: A regex pattern string
private func patternToRegex(pattern: String, rootOnly: Bool) throws -> 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
}
}
Loading