use std::collections::HashMap;
use std::path::Path;
use std::str::FromStr;
use dockerfile_parser::{Dockerfile as RawDockerfile, Instruction as RawInstruction};
use serde::{Deserialize, Serialize};
use zlayer_types::ImageReference;
use crate::error::{BuildError, Result};
use super::instruction::{
AddInstruction, ArgInstruction, CopyInstruction, EnvInstruction, ExposeInstruction,
ExposeProtocol, HealthcheckInstruction, Instruction, RunInstruction, ShellOrExec,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DockerfileFromTarget {
Image(ImageReference),
Stage(String),
Scratch,
}
impl DockerfileFromTarget {
#[must_use]
pub fn parse(s: &str) -> Self {
let s = s.trim();
if s.eq_ignore_ascii_case("scratch") {
return Self::Scratch;
}
match ImageReference::from_str(s) {
Ok(r) => Self::Image(r),
Err(_) => Self::Stage(s.to_string()),
}
}
#[must_use]
pub fn is_stage(&self) -> bool {
matches!(self, Self::Stage(_))
}
#[must_use]
pub fn is_scratch(&self) -> bool {
matches!(self, Self::Scratch)
}
}
impl std::fmt::Display for DockerfileFromTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Image(r) => write!(f, "{r}"),
Self::Stage(name) => f.write_str(name),
Self::Scratch => f.write_str("scratch"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Stage {
pub index: usize,
pub name: Option<String>,
pub base_image: DockerfileFromTarget,
pub platform: Option<String>,
pub instructions: Vec<Instruction>,
}
impl Stage {
#[must_use]
pub fn identifier(&self) -> String {
self.name.clone().unwrap_or_else(|| self.index.to_string())
}
#[must_use]
pub fn matches(&self, name_or_index: &str) -> bool {
if let Some(ref name) = self.name {
if name == name_or_index {
return true;
}
}
if let Ok(idx) = name_or_index.parse::<usize>() {
return idx == self.index;
}
false
}
}
fn expand_from_args(input: &str, vars: &HashMap<String, String>) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.char_indices().peekable();
while let Some((_, c)) = chars.next() {
if c != '$' {
out.push(c);
continue;
}
match chars.peek() {
Some(&(_, '{')) => {
chars.next(); let mut body = String::new();
let mut closed = false;
for (_, bc) in chars.by_ref() {
if bc == '}' {
closed = true;
break;
}
body.push(bc);
}
if !closed {
out.push_str("${");
out.push_str(&body);
continue;
}
if let Some((name, default)) = body.split_once(":-") {
match vars.get(name).filter(|v| !v.is_empty()) {
Some(v) => out.push_str(v),
None => out.push_str(default),
}
} else if let Some((name, alt)) = body.split_once(":+") {
if vars.get(name).is_some_and(|v| !v.is_empty()) {
out.push_str(alt);
}
} else {
out.push_str(vars.get(&body).map_or("", String::as_str));
}
}
Some(&(_, next)) if next.is_ascii_alphabetic() || next == '_' => {
let mut name = String::new();
while let Some(&(_, nc)) = chars.peek() {
if nc.is_ascii_alphanumeric() || nc == '_' {
name.push(nc);
chars.next();
} else {
break;
}
}
out.push_str(vars.get(&name).map_or("", String::as_str));
}
Some(&(_, '$')) => {
chars.next();
out.push('$');
}
_ => out.push('$'),
}
}
out
}
#[derive(Debug, Clone, Copy, Default)]
struct QuoteState {
in_double: bool,
in_single: bool,
}
impl QuoteState {
fn in_quotes(self) -> bool {
self.in_double || self.in_single
}
}
enum LineState {
Top,
ArgEnv(QuoteState),
Other,
}
fn ends_with_continuation(body: &str) -> bool {
body.trim_end_matches([' ', '\t']).ends_with('\\')
}
fn rewrite_empty_assignments_in_line(
body: &str,
quotes: &mut QuoteState,
out: &mut String,
changed: &mut bool,
) -> bool {
let mut continues = false;
let mut prev_is_key_char = false;
let mut chars = body.char_indices().peekable();
while let Some((idx, c)) = chars.next() {
if quotes.in_quotes() {
match c {
'\\' => {
out.push(c);
if let Some((_, esc)) = chars.next() {
out.push(esc);
} else {
continues = true;
}
}
'"' if quotes.in_double => {
quotes.in_double = false;
out.push(c);
}
'\'' if quotes.in_single => {
quotes.in_single = false;
out.push(c);
}
_ => out.push(c),
}
prev_is_key_char = false;
continue;
}
match c {
'"' => {
quotes.in_double = true;
out.push(c);
prev_is_key_char = false;
}
'\'' => {
quotes.in_single = true;
out.push(c);
prev_is_key_char = false;
}
'\\' if body[idx + 1..].chars().all(|w| w == ' ' || w == '\t') => {
continues = true;
out.push_str(&body[idx..]);
break;
}
'=' if prev_is_key_char => {
out.push(c);
if matches!(chars.peek(), None | Some(&(_, ' ' | '\t'))) {
out.push_str("\"\"");
*changed = true;
}
prev_is_key_char = false;
}
_ => {
out.push(c);
prev_is_key_char = c.is_ascii_alphanumeric() || c == '_';
}
}
}
continues || quotes.in_quotes()
}
fn normalize_empty_assignments(content: &str) -> std::borrow::Cow<'_, str> {
let mut out = String::with_capacity(content.len() + 8);
let mut changed = false;
let mut state = LineState::Top;
for raw_line in content.split_inclusive('\n') {
let (body, terminator) = match raw_line.strip_suffix('\n') {
Some(rest) => match rest.strip_suffix('\r') {
Some(rest) => (rest, "\r\n"),
None => (rest, "\n"),
},
None => (raw_line, ""),
};
state = match state {
LineState::Top => {
let trimmed = body.trim_start();
let keyword_len = trimmed
.chars()
.take_while(char::is_ascii_alphabetic)
.count();
let keyword = &trimmed[..keyword_len];
let after_keyword = trimmed[keyword_len..].chars().next();
let is_arg_env = (keyword.eq_ignore_ascii_case("arg")
|| keyword.eq_ignore_ascii_case("env")
|| keyword.eq_ignore_ascii_case("label"))
&& matches!(after_keyword, Some(' ' | '\t' | '\\'));
if is_arg_env {
let prefix_len = body.len() - trimmed.len() + keyword_len;
out.push_str(&body[..prefix_len]);
let mut quotes = QuoteState::default();
if rewrite_empty_assignments_in_line(
&trimmed[keyword_len..],
&mut quotes,
&mut out,
&mut changed,
) {
LineState::ArgEnv(quotes)
} else {
LineState::Top
}
} else {
out.push_str(body);
let is_blank_or_comment = trimmed.is_empty() || trimmed.starts_with('#');
if !is_blank_or_comment && ends_with_continuation(body) {
LineState::Other
} else {
LineState::Top
}
}
}
LineState::ArgEnv(mut quotes) => {
let trimmed = body.trim_start();
if !quotes.in_quotes() && (trimmed.is_empty() || trimmed.starts_with('#')) {
out.push_str(body);
LineState::ArgEnv(quotes)
} else if rewrite_empty_assignments_in_line(
body,
&mut quotes,
&mut out,
&mut changed,
) {
LineState::ArgEnv(quotes)
} else {
LineState::Top
}
}
LineState::Other => {
out.push_str(body);
let trimmed = body.trim_start();
let is_blank_or_comment = trimmed.is_empty() || trimmed.starts_with('#');
if is_blank_or_comment || ends_with_continuation(body) {
LineState::Other
} else {
LineState::Top
}
}
};
out.push_str(terminator);
}
if changed {
std::borrow::Cow::Owned(out)
} else {
std::borrow::Cow::Borrowed(content)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Dockerfile {
pub global_args: Vec<ArgInstruction>,
pub stages: Vec<Stage>,
}
impl Dockerfile {
pub fn resolve_from_args(&mut self, build_args: &HashMap<String, String>) {
let mut vars: HashMap<String, String> = HashMap::new();
for arg in &self.global_args {
let value = build_args
.get(&arg.name)
.cloned()
.or_else(|| arg.default.clone())
.unwrap_or_default();
vars.insert(arg.name.clone(), value);
}
let mut known_stage_names: std::collections::HashSet<String> =
std::collections::HashSet::new();
for stage in &mut self.stages {
if let DockerfileFromTarget::Stage(raw) = &stage.base_image {
if raw.contains('$') {
let expanded = expand_from_args(raw, &vars);
if !expanded.trim().is_empty() {
let mut target = DockerfileFromTarget::parse(&expanded);
if matches!(target, DockerfileFromTarget::Image(_))
&& known_stage_names.contains(expanded.trim())
{
target = DockerfileFromTarget::Stage(expanded.trim().to_string());
}
stage.base_image = target;
}
}
}
if let Some(name) = &stage.name {
known_stage_names.insert(name.clone());
}
}
}
pub fn parse(content: &str) -> Result<Self> {
let content = normalize_empty_assignments(content);
let raw = RawDockerfile::parse(&content).map_err(|e| BuildError::DockerfileParse {
message: e.to_string(),
line: 1,
})?;
Self::from_raw(raw)
}
pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
let content =
std::fs::read_to_string(path.as_ref()).map_err(|e| BuildError::ContextRead {
path: path.as_ref().to_path_buf(),
source: e,
})?;
Self::parse(&content)
}
fn from_raw(raw: RawDockerfile) -> Result<Self> {
let mut global_args = Vec::new();
let mut stages = Vec::new();
let mut current_stage: Option<Stage> = None;
let mut stage_index = 0;
let mut known_stage_names: std::collections::HashSet<String> =
std::collections::HashSet::new();
for instruction in raw.instructions {
match &instruction {
RawInstruction::From(from) => {
if let Some(stage) = current_stage.take() {
stages.push(stage);
}
let raw_from = from.image.content.trim().to_string();
let mut base_image = DockerfileFromTarget::parse(&raw_from);
if matches!(base_image, DockerfileFromTarget::Image(_))
&& known_stage_names.contains(&raw_from)
{
base_image = DockerfileFromTarget::Stage(raw_from.clone());
}
let name = from.alias.as_ref().map(|a| a.content.clone());
if let Some(ref n) = name {
known_stage_names.insert(n.clone());
}
let platform = from
.flags
.iter()
.find(|f| f.name.content.as_str() == "platform")
.map(|f| f.value.to_string());
current_stage = Some(Stage {
index: stage_index,
name,
base_image,
platform,
instructions: Vec::new(),
});
stage_index += 1;
}
RawInstruction::Arg(arg) => {
let arg_inst = ArgInstruction {
name: arg.name.to_string(),
default: arg.value.as_ref().map(std::string::ToString::to_string),
};
if current_stage.is_none() {
global_args.push(arg_inst);
} else if let Some(ref mut stage) = current_stage {
stage.instructions.push(Instruction::Arg(arg_inst));
}
}
_ => {
if let Some(ref mut stage) = current_stage {
if let Some(inst) = Self::convert_instruction(&instruction)? {
stage.instructions.push(inst);
}
}
}
}
}
if let Some(stage) = current_stage {
stages.push(stage);
}
let _stage_names: HashMap<String, usize> = stages
.iter()
.filter_map(|s| s.name.as_ref().map(|n| (n.clone(), s.index)))
.collect();
let _num_stages = stages.len();
Ok(Self {
global_args,
stages,
})
}
#[allow(clippy::too_many_lines)]
fn convert_instruction(raw: &RawInstruction) -> Result<Option<Instruction>> {
let instruction = match raw {
RawInstruction::From(_) => {
return Ok(None);
}
RawInstruction::Run(run) => {
let command = match &run.expr {
dockerfile_parser::ShellOrExecExpr::Shell(s) => {
ShellOrExec::Shell(s.to_string())
}
dockerfile_parser::ShellOrExecExpr::Exec(args) => {
ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
}
};
Instruction::Run(RunInstruction {
command,
mounts: Vec::new(),
network: None,
security: None,
env: HashMap::new(),
})
}
RawInstruction::Copy(copy) => {
let from = copy
.flags
.iter()
.find(|f| f.name.content.as_str() == "from")
.map(|f| f.value.to_string());
let chown = copy
.flags
.iter()
.find(|f| f.name.content.as_str() == "chown")
.map(|f| f.value.to_string());
let chmod = copy
.flags
.iter()
.find(|f| f.name.content.as_str() == "chmod")
.map(|f| f.value.to_string());
let link = copy.flags.iter().any(|f| f.name.content.as_str() == "link");
let sources: Vec<String> = copy
.sources
.iter()
.map(std::string::ToString::to_string)
.collect();
let destination = copy.destination.to_string();
Instruction::Copy(CopyInstruction {
sources,
destination,
from,
chown,
chmod,
link,
exclude: Vec::new(),
})
}
RawInstruction::Entrypoint(ep) => {
let command = match &ep.expr {
dockerfile_parser::ShellOrExecExpr::Shell(s) => {
ShellOrExec::Shell(s.to_string())
}
dockerfile_parser::ShellOrExecExpr::Exec(args) => {
ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
}
};
Instruction::Entrypoint(command)
}
RawInstruction::Cmd(cmd) => {
let command = match &cmd.expr {
dockerfile_parser::ShellOrExecExpr::Shell(s) => {
ShellOrExec::Shell(s.to_string())
}
dockerfile_parser::ShellOrExecExpr::Exec(args) => {
ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
}
};
Instruction::Cmd(command)
}
RawInstruction::Env(env) => {
let mut vars = HashMap::new();
for var in &env.vars {
vars.insert(var.key.to_string(), var.value.to_string());
}
Instruction::Env(EnvInstruction { vars })
}
RawInstruction::Label(label) => {
let mut labels = HashMap::new();
for l in &label.labels {
labels.insert(l.name.to_string(), l.value.to_string());
}
Instruction::Label(labels)
}
RawInstruction::Arg(arg) => Instruction::Arg(ArgInstruction {
name: arg.name.to_string(),
default: arg.value.as_ref().map(std::string::ToString::to_string),
}),
RawInstruction::Misc(misc) => {
let instruction_upper = misc.instruction.content.to_uppercase();
match instruction_upper.as_str() {
"WORKDIR" => Instruction::Workdir(misc.arguments.to_string()),
"USER" => Instruction::User(misc.arguments.to_string()),
"VOLUME" => {
let args = misc.arguments.to_string();
let volumes = if args.trim().starts_with('[') {
serde_json::from_str(&args).unwrap_or_else(|_| vec![args])
} else {
args.split_whitespace().map(String::from).collect()
};
Instruction::Volume(volumes)
}
"EXPOSE" => {
let args = misc.arguments.to_string();
let (port_str, protocol) = if let Some((p, proto)) = args.split_once('/') {
let proto = match proto.to_lowercase().as_str() {
"udp" => ExposeProtocol::Udp,
_ => ExposeProtocol::Tcp,
};
(p, proto)
} else {
(args.as_str(), ExposeProtocol::Tcp)
};
let port: u16 = port_str.trim().parse().map_err(|_| {
BuildError::InvalidInstruction {
instruction: "EXPOSE".to_string(),
reason: format!("Invalid port number: {port_str}"),
}
})?;
Instruction::Expose(ExposeInstruction { port, protocol })
}
"SHELL" => {
let args = misc.arguments.to_string();
let shell: Vec<String> = serde_json::from_str(&args).map_err(|_| {
BuildError::InvalidInstruction {
instruction: "SHELL".to_string(),
reason: "SHELL requires a JSON array".to_string(),
}
})?;
Instruction::Shell(shell)
}
"STOPSIGNAL" => Instruction::Stopsignal(misc.arguments.to_string()),
"HEALTHCHECK" => {
let args = misc.arguments.to_string().trim().to_string();
if args.eq_ignore_ascii_case("NONE") {
Instruction::Healthcheck(HealthcheckInstruction::None)
} else {
let command = if let Some(stripped) = args.strip_prefix("CMD ") {
ShellOrExec::Shell(stripped.to_string())
} else {
ShellOrExec::Shell(args)
};
Instruction::Healthcheck(HealthcheckInstruction::cmd(command))
}
}
"ONBUILD" => {
tracing::warn!("ONBUILD instruction parsing not fully implemented");
return Ok(None);
}
"MAINTAINER" => {
let mut labels = HashMap::new();
labels.insert("maintainer".to_string(), misc.arguments.to_string());
Instruction::Label(labels)
}
"ADD" => {
let args = misc.arguments.to_string();
let parts: Vec<String> =
args.split_whitespace().map(String::from).collect();
if parts.len() < 2 {
return Err(BuildError::InvalidInstruction {
instruction: "ADD".to_string(),
reason: "ADD requires at least one source and a destination"
.to_string(),
});
}
let (sources, dest) = parts.split_at(parts.len() - 1);
let destination = dest.first().cloned().unwrap_or_default();
Instruction::Add(AddInstruction {
sources: sources.to_vec(),
destination,
chown: None,
chmod: None,
link: false,
checksum: None,
keep_git_dir: false,
})
}
other => {
tracing::warn!("Unknown Dockerfile instruction: {}", other);
return Ok(None);
}
}
}
};
Ok(Some(instruction))
}
#[must_use]
pub fn get_stage(&self, name_or_index: &str) -> Option<&Stage> {
self.stages.iter().find(|s| s.matches(name_or_index))
}
#[must_use]
pub fn final_stage(&self) -> Option<&Stage> {
self.stages.last()
}
#[must_use]
pub fn stage_names(&self) -> Vec<String> {
self.stages.iter().map(Stage::identifier).collect()
}
#[must_use]
pub fn has_stage(&self, name_or_index: &str) -> bool {
self.get_stage(name_or_index).is_some()
}
#[must_use]
pub fn stage_count(&self) -> usize {
self.stages.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_dockerfile() {
let content = r#"
FROM alpine:3.18
RUN apk add --no-cache curl
COPY . /app
WORKDIR /app
CMD ["./app"]
"#;
let dockerfile = Dockerfile::parse(content).unwrap();
assert_eq!(dockerfile.stages.len(), 1);
let stage = &dockerfile.stages[0];
assert_eq!(stage.index, 0);
assert!(stage.name.is_none());
assert_eq!(stage.instructions.len(), 4);
}
#[test]
fn test_parse_multistage_dockerfile() {
let content = r#"
FROM golang:1.21 AS builder
WORKDIR /src
COPY . .
RUN go build -o /app
FROM alpine:3.18
COPY --from=builder /app /app
CMD ["/app"]
"#;
let dockerfile = Dockerfile::parse(content).unwrap();
assert_eq!(dockerfile.stages.len(), 2);
let builder = &dockerfile.stages[0];
assert_eq!(builder.name, Some("builder".to_string()));
let runtime = &dockerfile.stages[1];
assert!(runtime.name.is_none());
let copy = runtime
.instructions
.iter()
.find(|i| matches!(i, Instruction::Copy(_)));
assert!(copy.is_some());
if let Some(Instruction::Copy(c)) = copy {
assert_eq!(c.from, Some("builder".to_string()));
}
}
#[test]
fn test_parse_copy_from_external_image_reference() {
let content = r"
FROM alpine:3.18
COPY --from=ghcr.io/astral-sh/uv:0.5.0 /uv /usr/local/bin/uv
RUN /usr/local/bin/uv --version
";
let dockerfile = Dockerfile::parse(content).unwrap();
assert_eq!(dockerfile.stages.len(), 1);
let copy = dockerfile.stages[0]
.instructions
.iter()
.find_map(|i| {
if let Instruction::Copy(c) = i {
Some(c)
} else {
None
}
})
.expect("COPY instruction present");
assert_eq!(
copy.from,
Some("ghcr.io/astral-sh/uv:0.5.0".to_string()),
"external image ref must be preserved verbatim in CopyInstruction.from",
);
assert_eq!(copy.sources, vec!["/uv".to_string()]);
assert_eq!(copy.destination, "/usr/local/bin/uv".to_string());
assert!(dockerfile.get_stage("ghcr.io/astral-sh/uv:0.5.0").is_none());
}
#[test]
fn expand_from_args_all_forms() {
let vars: HashMap<String, String> = [
("BASE".to_string(), "ghcr.io/org/img".to_string()),
("TAG".to_string(), "1.2".to_string()),
("EMPTY".to_string(), String::new()),
]
.into_iter()
.collect();
assert_eq!(
expand_from_args("${BASE}:${TAG}", &vars),
"ghcr.io/org/img:1.2"
);
assert_eq!(expand_from_args("$BASE", &vars), "ghcr.io/org/img");
assert_eq!(
expand_from_args("img:${MISSING:-latest}", &vars),
"img:latest"
);
assert_eq!(
expand_from_args("img:${EMPTY:-fallback}", &vars),
"img:fallback"
);
assert_eq!(expand_from_args("img:${TAG:+pinned}", &vars), "img:pinned");
assert_eq!(expand_from_args("img:${MISSING:+pinned}", &vars), "img:");
assert_eq!(expand_from_args("${MISSING}", &vars), "");
assert_eq!(expand_from_args("a$$b", &vars), "a$b");
assert_eq!(expand_from_args("price$", &vars), "price$");
}
#[test]
fn resolve_from_args_expands_from_lines() {
let content = r"
ARG BASE_IMAGE=ghcr.io/org/alpine:latest
ARG BASE_TAG=latest
FROM ${BASE_IMAGE} AS builder
RUN echo hi
FROM ghcr.io/org/alpine:${BASE_TAG}
COPY --from=builder /x /x
";
let mut dockerfile = Dockerfile::parse(content).unwrap();
assert!(dockerfile.stages[0].base_image.is_stage());
assert!(dockerfile.stages[1].base_image.is_stage());
let build_args: HashMap<String, String> = [("BASE_TAG".to_string(), "3.20".to_string())]
.into_iter()
.collect();
dockerfile.resolve_from_args(&build_args);
match &dockerfile.stages[0].base_image {
DockerfileFromTarget::Image(r) => {
assert_eq!(r.to_string(), "ghcr.io/org/alpine:latest");
}
other => panic!("stage 0 not resolved to an image: {other:?}"),
}
match &dockerfile.stages[1].base_image {
DockerfileFromTarget::Image(r) => {
assert_eq!(r.to_string(), "ghcr.io/org/alpine:3.20");
}
other => panic!("stage 1 not resolved to an image: {other:?}"),
}
}
#[test]
fn resolve_from_args_keeps_stage_references() {
let content = r"
ARG BASE=ghcr.io/org/alpine:latest
FROM ${BASE} AS builder
RUN echo hi
FROM builder
RUN echo again
";
let mut dockerfile = Dockerfile::parse(content).unwrap();
dockerfile.resolve_from_args(&HashMap::new());
assert!(matches!(
&dockerfile.stages[0].base_image,
DockerfileFromTarget::Image(_)
));
assert_eq!(
dockerfile.stages[1].base_image,
DockerfileFromTarget::Stage("builder".to_string())
);
}
#[test]
fn test_parse_global_args() {
let content = r#"
ARG BASE_IMAGE=alpine:3.18
FROM ${BASE_IMAGE}
RUN echo "hello"
"#;
let dockerfile = Dockerfile::parse(content).unwrap();
assert_eq!(dockerfile.global_args.len(), 1);
assert_eq!(dockerfile.global_args[0].name, "BASE_IMAGE");
assert_eq!(
dockerfile.global_args[0].default,
Some("alpine:3.18".to_string())
);
}
#[test]
fn test_get_stage_by_name() {
let content = r#"
FROM alpine:3.18 AS base
RUN echo "base"
FROM base AS builder
RUN echo "builder"
"#;
let dockerfile = Dockerfile::parse(content).unwrap();
let base = dockerfile.get_stage("base");
assert!(base.is_some());
assert_eq!(base.unwrap().index, 0);
let builder = dockerfile.get_stage("builder");
assert!(builder.is_some());
assert_eq!(builder.unwrap().index, 1);
let stage_0 = dockerfile.get_stage("0");
assert!(stage_0.is_some());
assert_eq!(stage_0.unwrap().name, Some("base".to_string()));
}
#[test]
fn test_final_stage() {
let content = r#"
FROM alpine:3.18 AS builder
RUN echo "builder"
FROM scratch
COPY --from=builder /app /app
"#;
let dockerfile = Dockerfile::parse(content).unwrap();
let final_stage = dockerfile.final_stage().unwrap();
assert_eq!(final_stage.index, 1);
assert!(matches!(
final_stage.base_image,
DockerfileFromTarget::Scratch
));
}
#[test]
fn test_parse_env_instruction() {
let content = r"
FROM alpine
ENV FOO=bar BAZ=qux
";
let dockerfile = Dockerfile::parse(content).unwrap();
let stage = &dockerfile.stages[0];
let env = stage
.instructions
.iter()
.find(|i| matches!(i, Instruction::Env(_)));
assert!(env.is_some());
if let Some(Instruction::Env(e)) = env {
assert_eq!(e.vars.get("FOO"), Some(&"bar".to_string()));
assert_eq!(e.vars.get("BAZ"), Some(&"qux".to_string()));
}
}
fn stage_args(stage: &Stage) -> Vec<(String, Option<String>)> {
stage
.instructions
.iter()
.filter_map(|i| {
if let Instruction::Arg(a) = i {
Some((a.name.clone(), a.default.clone()))
} else {
None
}
})
.collect()
}
#[test]
fn arg_empty_unquoted_default_parses_as_defined_empty() {
let content = "FROM alpine:3.18\nARG CROSS_DEB_ARCH=\n";
let dockerfile = Dockerfile::parse(content).unwrap();
assert_eq!(
stage_args(&dockerfile.stages[0]),
vec![("CROSS_DEB_ARCH".to_string(), Some(String::new()))],
);
}
#[test]
fn arg_empty_unquoted_default_parses_as_global_arg() {
let content = "ARG EMPTYDEFAULT=\nFROM alpine:3.18\n";
let dockerfile = Dockerfile::parse(content).unwrap();
assert_eq!(dockerfile.global_args.len(), 1);
assert_eq!(dockerfile.global_args[0].name, "EMPTYDEFAULT");
assert_eq!(dockerfile.global_args[0].default, Some(String::new()));
}
#[test]
fn label_empty_unquoted_value_parses() {
let content = "FROM alpine:3.18\nLABEL emptyone= other=\"x\"\n";
let dockerfile = Dockerfile::parse(content).unwrap();
assert_eq!(dockerfile.stages.len(), 1);
}
#[test]
fn arg_quoted_empty_default_parses_as_defined_empty() {
let content = "FROM alpine:3.18\nARG NAME=\"\"\n";
let dockerfile = Dockerfile::parse(content).unwrap();
assert_eq!(
stage_args(&dockerfile.stages[0]),
vec![("NAME".to_string(), Some(String::new()))],
);
}
#[test]
fn arg_no_default_and_plain_default_still_parse() {
let content = "FROM alpine:3.18\nARG X\nARG Y=z\n";
let dockerfile = Dockerfile::parse(content).unwrap();
assert_eq!(
stage_args(&dockerfile.stages[0]),
vec![
("X".to_string(), None),
("Y".to_string(), Some("z".to_string())),
],
);
}
#[test]
fn env_empty_values_parse() {
let content = "FROM alpine:3.18\nENV EMPTYONE=\nENV A= B=c\n";
let dockerfile = Dockerfile::parse(content).unwrap();
let stage = &dockerfile.stages[0];
let envs: Vec<&EnvInstruction> = stage
.instructions
.iter()
.filter_map(|i| {
if let Instruction::Env(e) = i {
Some(e)
} else {
None
}
})
.collect();
assert_eq!(envs.len(), 2);
assert_eq!(envs[0].vars.get("EMPTYONE"), Some(&String::new()));
assert_eq!(envs[1].vars.get("A"), Some(&String::new()));
assert_eq!(envs[1].vars.get("B"), Some(&"c".to_string()));
}
#[test]
fn env_quoted_value_containing_equals_is_untouched() {
let content = "FROM alpine:3.18\nENV A=\"x= \" B=\n";
let dockerfile = Dockerfile::parse(content).unwrap();
let stage = &dockerfile.stages[0];
if let Some(Instruction::Env(e)) = stage
.instructions
.iter()
.find(|i| matches!(i, Instruction::Env(_)))
{
assert_eq!(e.vars.get("A"), Some(&"x= ".to_string()));
assert_eq!(e.vars.get("B"), Some(&String::new()));
} else {
panic!("ENV instruction missing");
}
}
#[test]
fn run_shell_content_with_trailing_equals_is_untouched() {
let content = "FROM alpine:3.18\nRUN export FOO= && \\\n BAR= true\n";
let dockerfile = Dockerfile::parse(content).unwrap();
let stage = &dockerfile.stages[0];
if let Some(Instruction::Run(run)) = stage
.instructions
.iter()
.find(|i| matches!(i, Instruction::Run(_)))
{
let ShellOrExec::Shell(cmd) = &run.command else {
panic!("expected shell-form RUN");
};
assert!(cmd.contains("FOO="), "cmd: {cmd}");
assert!(!cmd.contains("FOO=\"\""), "cmd: {cmd}");
assert!(cmd.contains("BAR="), "cmd: {cmd}");
assert!(!cmd.contains("BAR=\"\""), "cmd: {cmd}");
} else {
panic!("RUN instruction missing");
}
}
#[test]
fn env_empty_value_on_continuation_line_parses() {
let content = "FROM alpine:3.18\nENV A=1 \\\n B=\n";
let dockerfile = Dockerfile::parse(content).unwrap();
let stage = &dockerfile.stages[0];
if let Some(Instruction::Env(e)) = stage
.instructions
.iter()
.find(|i| matches!(i, Instruction::Env(_)))
{
assert_eq!(e.vars.get("A"), Some(&"1".to_string()));
assert_eq!(e.vars.get("B"), Some(&String::new()));
} else {
panic!("ENV instruction missing");
}
}
#[test]
fn normalize_empty_assignments_rewrites_only_arg_env() {
assert_eq!(normalize_empty_assignments("ARG A=\n"), "ARG A=\"\"\n");
assert_eq!(
normalize_empty_assignments("ENV A= B=c\n"),
"ENV A=\"\" B=c\n"
);
assert_eq!(
normalize_empty_assignments("ENV A=\"x= \" B=\n"),
"ENV A=\"x= \" B=\"\"\n"
);
assert_eq!(
normalize_empty_assignments("ENV A=1 \\\n B=\n"),
"ENV A=1 \\\n B=\"\"\n"
);
assert_eq!(
normalize_empty_assignments("ARG A=\\\nx\n"),
"ARG A=\\\nx\n"
);
assert_eq!(
normalize_empty_assignments("RUN export FOO= && \\\n BAR= true\n"),
"RUN export FOO= && \\\n BAR= true\n"
);
assert_eq!(
normalize_empty_assignments("ENVIRONMENT= foo\n"),
"ENVIRONMENT= foo\n"
);
assert!(matches!(
normalize_empty_assignments("FROM alpine\nRUN echo hi\n"),
std::borrow::Cow::Borrowed(_)
));
assert_eq!(
normalize_empty_assignments("ARG A=\r\nARG B="),
"ARG A=\"\"\r\nARG B=\"\""
);
}
#[test]
fn cross_generated_dockerfile_parses_end_to_end() {
let content = "FROM cross/x86_64-unknown-linux-musl:0.2.5\n\
ARG CROSS_DEB_ARCH=\n\
ARG CROSS_CMD\n\
RUN eval \"${CROSS_CMD}\"\n";
let dockerfile = Dockerfile::parse(content).unwrap();
assert_eq!(dockerfile.stages.len(), 1);
let stage = &dockerfile.stages[0];
assert_eq!(
stage_args(stage),
vec![
("CROSS_DEB_ARCH".to_string(), Some(String::new())),
("CROSS_CMD".to_string(), None),
],
);
assert!(stage
.instructions
.iter()
.any(|i| matches!(i, Instruction::Run(_))));
}
}