//===----------------------------------------------------------------------===// // Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. // // 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 ContainerBuildIR import Foundation /// DockerfileParser parses a dockerfile to a BuildGraph. public struct DockerfileParser: BuildParser { public func parse(_ input: String) throws -> BuildGraph { var instructions = [any DockerInstruction]() let lines = input.components(separatedBy: .newlines) var lineIndex = 0 while lineIndex < lines.count { var line = lines[lineIndex].trimmingCharacters(in: .whitespacesAndNewlines) if line.isEmpty { lineIndex += 1 continue } while lineIndex < lines.count && line.hasSuffix("\\") { line = String(line.dropLast("\\".count)) let next = lineIndex + 1 if next < lines.count { let nextLine = String(lines[next].trimmingCharacters(in: .whitespacesAndNewlines)) line.append(nextLine) lineIndex += 1 } } var tokenizer = DockerfileTokenizer(line) let tokens = try tokenizer.getTokens() try instructions.append(tokensToDockerInstruction(tokens: tokens)) lineIndex += 1 } let visitor = DockerInstructionVisitor() return try visitor.buildGraph(from: instructions) } private func tokensToDockerInstruction(tokens: [Token]) throws -> any DockerInstruction { guard case .stringLiteral(let value) = tokens.first else { throw ParseError.missingInstruction } let instruction = DockerInstructionName(rawValue: value.lowercased()) switch instruction { case .FROM: return try tokensToFromInstruction(tokens: tokens) case .RUN: return try tokensToRunInstruction(tokens: tokens) case .COPY: return try tokensToCopyInstruction(tokens: tokens) case .CMD: return try tokensToCMDInstruction(tokens: tokens) case .LABEL: return try tokensToLabelInstruction(tokens: tokens) case .EXPOSE: return try tokensToExposeInstruction(tokens: tokens) case .ARG: return try tokensToArgInstruction(tokens: tokens) default: throw ParseError.invalidInstruction(value) } } private struct InstructionOpt { let key: String let value: String } private func parseInstructionOpts(start: Int, tokens: [Token]) throws -> (Int, [InstructionOpt]) { var done = false var opts: [InstructionOpt] = [] var index = start while index < tokens.endIndex, !done { guard case .stringLiteral(let raw) = tokens[index] else { done = true break } guard raw.hasPrefix("--") else { done = true break } let components = raw.split(separator: "=", maxSplits: 1) if components.count == 2 { opts.append(InstructionOpt(key: String(components[0]), value: String(components[1]))) } else { // instruction options should ALWAYS have a value, so if we're at // the end or the next value is a string list, that's invalid input index += 1 guard index < tokens.endIndex else { throw ParseError.invalidOption(raw) } guard case .stringLiteral(let value) = tokens[index] else { throw ParseError.invalidOption(raw) } opts.append(InstructionOpt(key: raw, value: value)) } index += 1 } return (index, opts) } func tokensToFromInstruction(tokens: [Token]) throws -> FromInstruction { var index = tokens.startIndex + 1 // skip the instruction var stageName: String? var platform: String? var imageName: String? // Step 1: Parse instruction options let (newIndex, instructionOpts) = try parseInstructionOpts(start: index, tokens: tokens) index = newIndex for option in instructionOpts { guard FromOptions(rawValue: String(option.key)) == .platform else { throw ParseError.unexpectedValue } if platform != nil { throw ParseError.duplicateOptionSet(FromOptions.platform.rawValue) } platform = option.value } // Step 2: Parse image name if index < tokens.endIndex { guard case .stringLiteral(let value) = tokens[index] else { throw ParseError.unexpectedValue } imageName = value index += 1 } // Step 3 (optional): Parse stage name if index < tokens.endIndex { guard case .stringLiteral(let value) = tokens[index], DockerKeyword(rawValue: value.lowercased()) == .AS else { throw ParseError.unexpectedValue } index += 1 guard index < tokens.endIndex, case .stringLiteral(let name) = tokens[index] else { throw ParseError.invalidSyntax } stageName = name index += 1 } guard let imageName = imageName else { throw ParseError.invalidSyntax } // check for extra tokens if index < tokens.endIndex { throw ParseError.unexpectedValue } return try FromInstruction(image: imageName, platform: platform, stageName: stageName) } func tokensToRunInstruction(tokens: [Token]) throws -> RunInstruction { var index = tokens.startIndex + 1 // skip the instruction var rawMounts = [String]() var network: String? = nil // Step 1: Parse instruction options let (lastOption, instructionOpts) = try parseInstructionOpts(start: index, tokens: tokens) index = lastOption for option in instructionOpts { guard let runOpt = RunOptions(rawValue: option.key) else { throw ParseError.unexpectedValue } switch runOpt { case .mount: rawMounts.append(option.value) case .network: network = option.value } } // Step 2: parse run command let (newIndex, cmd) = getCommand(start: index, tokens: tokens) index = newIndex // check for extra tokens if index < tokens.endIndex { throw ParseError.unexpectedValue } return try RunInstruction(command: cmd, rawMounts: rawMounts, network: network) } private func getCommand(start: Int, tokens: [Token]) -> (index: Int, cmd: Command) { var command = [String]() var shell = true var index = start while index < tokens.endIndex { if case .stringList(let value) = tokens[index], command.isEmpty { // when using the exec form, there should only be a single list for the command // if there's other content in the command already, the input was invalid command = value shell = false index += 1 break } else if case .stringLiteral(let value) = tokens[index] { command.append(value) } else { break } index += 1 } let cmd = shell ? Command.shell(command.joined(separator: " ")) : Command.exec(command) return (index, cmd) } func tokensToCopyInstruction(tokens: [Token]) throws -> CopyInstruction { var index = tokens.startIndex + 1 // skip the instruction var from: String? = nil var chmod: String? = nil var chown: String? = nil var link: String? = nil // Step 1: Parse instruction options let (newIndex, instructionOpts) = try parseInstructionOpts(start: index, tokens: tokens) index = newIndex for option in instructionOpts { guard let copyOpt = CopyOptions(rawValue: option.key) else { throw ParseError.unexpectedValue } switch copyOpt { case .from: if from != nil { throw ParseError.duplicateOptionSet(CopyOptions.from.rawValue) } from = option.value case .chown: if chown != nil { throw ParseError.duplicateOptionSet(CopyOptions.chown.rawValue) } chown = option.value case .chmod: if chmod != nil { throw ParseError.duplicateOptionSet(CopyOptions.chmod.rawValue) } chmod = option.value case .link: if link != nil { throw ParseError.duplicateOptionSet(CopyOptions.link.rawValue) } link = option.value } } // Step 2: Get all source paths and destination path var sources: [String] = [] var destination: String? while index < tokens.endIndex { guard case .stringLiteral(let value) = tokens[index] else { break } if index + 1 == tokens.endIndex { // this is the last path provided, it must be the destination destination = value } else { sources.append(value) } index += 1 } // check for extra tokens if index < tokens.endIndex { throw ParseError.unexpectedValue } return try CopyInstruction(sources: sources, destination: destination, from: from, ownership: chown, permissions: chmod) } func tokensToCMDInstruction(tokens: [Token]) throws -> CMDInstruction { var index = tokens.startIndex + 1 // skip the instruction // get the command let (newIndex, cmd) = getCommand(start: index, tokens: tokens) index = newIndex // check for extra tokens if index < tokens.endIndex { throw ParseError.unexpectedValue } return CMDInstruction(command: cmd) } func tokensToLabelInstruction(tokens: [Token]) throws -> LabelInstruction { var index = tokens.startIndex + 1 // skip the instruction var labels: [String: String] = [:] while index < tokens.endIndex { guard case .stringLiteral(let option) = tokens[index] else { break } let components = option.split(separator: "=", maxSplits: 1) guard components.count == 2 else { throw ParseError.unexpectedValue } let key = String(components[0]) guard labels[key] == nil else { throw ParseError.duplicateOptionSet(key) } labels[key] = String(components[1]) index += 1 } // check for extra tokens if index < tokens.endIndex { throw ParseError.unexpectedValue } return LabelInstruction(labels: labels) } func tokensToExposeInstruction(tokens: [Token]) throws -> ExposeInstruction { var index = tokens.startIndex + 1 // skip the instruction var rawPorts: [String] = [] while index < tokens.endIndex { guard case .stringLiteral(let port) = tokens[index] else { throw ParseError.unexpectedValue } rawPorts.append(port) index += 1 } guard !rawPorts.isEmpty else { throw ParseError.missingRequiredField("port") } return ExposeInstruction(rawPorts) } func tokensToArgInstruction(tokens: [Token]) throws -> ArgInstruction { var index = tokens.startIndex + 1 // skip the instruction guard index < tokens.endIndex else { throw ParseError.missingRequiredField("name") } // Collect all remaining tokens. var argTokens: [String] = [] while index < tokens.endIndex { guard case .stringLiteral(let tokenValue) = tokens[index] else { throw ParseError.unexpectedValue } argTokens.append(tokenValue) index += 1 } let argSpec = argTokens.joined(separator: " ") let argDefinitions = try parseMultipleArgs(from: argSpec) return try ArgInstruction(args: argDefinitions) } private func parseMultipleArgs(from argSpec: String) throws -> [ArgDefinition] { var definitions: [ArgDefinition] = [] var remaining = argSpec while !remaining.isEmpty { let (name, defaultValue, rest) = try parseNextArg(from: remaining) let definition = ArgDefinition(name: name, defaultValue: defaultValue) definitions.append(definition) remaining = rest } guard !definitions.isEmpty else { throw ParseError.missingRequiredField("name") } return definitions } private func parseNextArg(from input: String) throws -> (name: String, defaultValue: String?, remaining: String) { let trimmed = input.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { throw ParseError.missingRequiredField("name") } // Find the name. guard let nameEnd = trimmed.firstIndex(where: { $0.isWhitespace || $0 == "=" }) else { return (name: trimmed, defaultValue: nil, remaining: "") } let name = String(trimmed[.. (value: String, remaining: String) { if input.hasPrefix("\"") { return try parseQuotedValue(from: input, quote: "\"") } else if input.hasPrefix("'") { return try parseQuotedValue(from: input, quote: "'") } else { return parseUnquotedValue(from: input) } } private func parseUnquotedValue(from input: String) -> (value: String, remaining: String) { guard let spaceIndex = input.firstIndex(where: { $0.isWhitespace }) else { return (value: input, remaining: "") } let value = String(input[.. (value: String, remaining: String) { var pos = input.index(after: input.startIndex) var value = "" var escaped = false while pos < input.endIndex { let char = input[pos] if !escaped && char == quote { pos = input.index(after: pos) let remaining = String(input[pos...]) return (value: value, remaining: remaining) } if escaped { switch char { case "\"", "'", "\\": value.append(char) default: value.append("\\") value.append(char) } escaped = false } else if char == "\\" { escaped = true } else { value.append(char) } pos = input.index(after: pos) } throw ParseError.invalidSyntax } }