Skip to main content

zlayer_builder/dockerfile/
parser.rs

1//! Dockerfile parser
2//!
3//! This module provides functionality to parse Dockerfiles into a structured representation
4//! using the `dockerfile-parser` crate as the parsing backend.
5
6use std::collections::HashMap;
7use std::path::Path;
8use std::str::FromStr;
9
10use dockerfile_parser::{Dockerfile as RawDockerfile, Instruction as RawInstruction};
11use serde::{Deserialize, Serialize};
12use zlayer_types::ImageReference;
13
14use crate::error::{BuildError, Result};
15
16use super::instruction::{
17    AddInstruction, ArgInstruction, CopyInstruction, EnvInstruction, ExposeInstruction,
18    ExposeProtocol, HealthcheckInstruction, Instruction, RunInstruction, ShellOrExec,
19};
20
21/// A Dockerfile `FROM` target.
22///
23/// `FROM` references can resolve to one of three things in a Dockerfile:
24/// an OCI image (the common case), a previous stage in a multi-stage
25/// build (e.g. `FROM builder AS final`), or the special `scratch`
26/// pseudo-image. This enum captures all three. For non-Dockerfile call
27/// sites (image registry lookups, toolchain detection, etc.) use
28/// [`zlayer_types::ImageReference`] directly — the bare OCI ref type
29/// without the Dockerfile-only variants.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub enum DockerfileFromTarget {
32    /// An OCI image reference (canonical OCI grammar).
33    Image(ImageReference),
34    /// A reference to another stage in this multi-stage build.
35    Stage(String),
36    /// The special `scratch` pseudo-image.
37    Scratch,
38}
39
40impl DockerfileFromTarget {
41    /// Parse a raw `FROM` target string.
42    ///
43    /// Recognizes `scratch` (case-insensitive), then attempts an OCI
44    /// reference parse via [`ImageReference::from_str`]. If parsing
45    /// succeeds, the result is an [`Self::Image`]; otherwise the
46    /// input is treated as a [`Self::Stage`] reference.
47    ///
48    /// Note that the OCI grammar accepts bare names like `alpine` as
49    /// valid image references, so disambiguation between an image
50    /// and a multi-stage stage reference must happen post-hoc at the
51    /// call site by consulting the set of known stage names.
52    #[must_use]
53    pub fn parse(s: &str) -> Self {
54        let s = s.trim();
55
56        if s.eq_ignore_ascii_case("scratch") {
57            return Self::Scratch;
58        }
59
60        match ImageReference::from_str(s) {
61            Ok(r) => Self::Image(r),
62            Err(_) => Self::Stage(s.to_string()),
63        }
64    }
65
66    /// Returns true if this is a stage reference.
67    #[must_use]
68    pub fn is_stage(&self) -> bool {
69        matches!(self, Self::Stage(_))
70    }
71
72    /// Returns true if this is the `scratch` pseudo-image.
73    #[must_use]
74    pub fn is_scratch(&self) -> bool {
75        matches!(self, Self::Scratch)
76    }
77}
78
79impl std::fmt::Display for DockerfileFromTarget {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            Self::Image(r) => write!(f, "{r}"),
83            Self::Stage(name) => f.write_str(name),
84            Self::Scratch => f.write_str("scratch"),
85        }
86    }
87}
88
89/// A single stage in a multi-stage Dockerfile
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Stage {
92    /// Stage index (0-based)
93    pub index: usize,
94
95    /// Optional stage name (from `AS name`)
96    pub name: Option<String>,
97
98    /// The base image for this stage
99    pub base_image: DockerfileFromTarget,
100
101    /// Optional platform specification (e.g., "linux/amd64")
102    pub platform: Option<String>,
103
104    /// Instructions in this stage (excluding the FROM)
105    pub instructions: Vec<Instruction>,
106}
107
108impl Stage {
109    /// Returns the stage identifier (name if present, otherwise index as string)
110    #[must_use]
111    pub fn identifier(&self) -> String {
112        self.name.clone().unwrap_or_else(|| self.index.to_string())
113    }
114
115    /// Returns true if this stage matches the given name or index
116    #[must_use]
117    pub fn matches(&self, name_or_index: &str) -> bool {
118        if let Some(ref name) = self.name {
119            if name == name_or_index {
120                return true;
121            }
122        }
123
124        if let Ok(idx) = name_or_index.parse::<usize>() {
125            return idx == self.index;
126        }
127
128        false
129    }
130}
131
132/// Expand Docker build-arg references in a `FROM` target string.
133///
134/// Supports the forms Docker accepts in `FROM` lines: `${VAR}`,
135/// `${VAR:-default}` (default when unset or empty), `${VAR:+alt}` (alt
136/// when set and non-empty), and bare `$VAR`. Unknown variables expand to
137/// the empty string, matching `docker build`.
138fn expand_from_args(input: &str, vars: &HashMap<String, String>) -> String {
139    let mut out = String::with_capacity(input.len());
140    let mut chars = input.char_indices().peekable();
141    while let Some((_, c)) = chars.next() {
142        if c != '$' {
143            out.push(c);
144            continue;
145        }
146        match chars.peek() {
147            Some(&(_, '{')) => {
148                chars.next(); // consume '{'
149                let mut body = String::new();
150                let mut closed = false;
151                for (_, bc) in chars.by_ref() {
152                    if bc == '}' {
153                        closed = true;
154                        break;
155                    }
156                    body.push(bc);
157                }
158                if !closed {
159                    // Unterminated `${...` — emit verbatim.
160                    out.push_str("${");
161                    out.push_str(&body);
162                    continue;
163                }
164                if let Some((name, default)) = body.split_once(":-") {
165                    match vars.get(name).filter(|v| !v.is_empty()) {
166                        Some(v) => out.push_str(v),
167                        None => out.push_str(default),
168                    }
169                } else if let Some((name, alt)) = body.split_once(":+") {
170                    if vars.get(name).is_some_and(|v| !v.is_empty()) {
171                        out.push_str(alt);
172                    }
173                } else {
174                    out.push_str(vars.get(&body).map_or("", String::as_str));
175                }
176            }
177            Some(&(_, next)) if next.is_ascii_alphabetic() || next == '_' => {
178                let mut name = String::new();
179                while let Some(&(_, nc)) = chars.peek() {
180                    if nc.is_ascii_alphanumeric() || nc == '_' {
181                        name.push(nc);
182                        chars.next();
183                    } else {
184                        break;
185                    }
186                }
187                out.push_str(vars.get(&name).map_or("", String::as_str));
188            }
189            // BuildKit escape: `$$` is a literal dollar sign.
190            Some(&(_, '$')) => {
191                chars.next();
192                out.push('$');
193            }
194            _ => out.push('$'),
195        }
196    }
197    out
198}
199
200/// Quote-tracking state while scanning an ARG/ENV instruction, carried
201/// across line continuations (quoted values may span lines).
202#[derive(Debug, Clone, Copy, Default)]
203struct QuoteState {
204    in_double: bool,
205    in_single: bool,
206}
207
208impl QuoteState {
209    fn in_quotes(self) -> bool {
210        self.in_double || self.in_single
211    }
212}
213
214/// Which multi-line construct the normalizer is currently inside.
215enum LineState {
216    /// Start of a fresh logical instruction.
217    Top,
218    /// Continuation of an ARG/ENV instruction (with carried quote state).
219    ArgEnv(QuoteState),
220    /// Continuation of any other instruction (e.g. RUN) — copied verbatim.
221    Other,
222}
223
224/// True if `body` (a line without its terminator) ends in a Dockerfile
225/// line continuation: a `\` followed only by spaces/tabs.
226fn ends_with_continuation(body: &str) -> bool {
227    body.trim_end_matches([' ', '\t']).ends_with('\\')
228}
229
230/// Rewrite unquoted-empty assignments (`KEY=` followed by whitespace or
231/// end-of-line) on one line of an ARG/ENV instruction to the quoted-empty
232/// spelling `KEY=""`, appending the result to `out`.
233///
234/// Returns `true` if the instruction continues on the next line (trailing
235/// `\` outside quotes, an escaped newline inside a quoted string, or a
236/// still-open quoted string).
237fn rewrite_empty_assignments_in_line(
238    body: &str,
239    quotes: &mut QuoteState,
240    out: &mut String,
241    changed: &mut bool,
242) -> bool {
243    let mut continues = false;
244    let mut prev_is_key_char = false;
245    let mut chars = body.char_indices().peekable();
246
247    while let Some((idx, c)) = chars.next() {
248        if quotes.in_quotes() {
249            match c {
250                '\\' => {
251                    out.push(c);
252                    if let Some((_, esc)) = chars.next() {
253                        out.push(esc);
254                    } else {
255                        // `\` at end of line inside a string: the grammar's
256                        // `escape` rule consumes the newline, so the string
257                        // (and the instruction) continues on the next line.
258                        continues = true;
259                    }
260                }
261                '"' if quotes.in_double => {
262                    quotes.in_double = false;
263                    out.push(c);
264                }
265                '\'' if quotes.in_single => {
266                    quotes.in_single = false;
267                    out.push(c);
268                }
269                _ => out.push(c),
270            }
271            prev_is_key_char = false;
272            continue;
273        }
274
275        match c {
276            '"' => {
277                quotes.in_double = true;
278                out.push(c);
279                prev_is_key_char = false;
280            }
281            '\'' => {
282                quotes.in_single = true;
283                out.push(c);
284                prev_is_key_char = false;
285            }
286            // Line continuation: `\` followed only by trailing whitespace.
287            '\\' if body[idx + 1..].chars().all(|w| w == ' ' || w == '\t') => {
288                continues = true;
289                out.push_str(&body[idx..]);
290                break;
291            }
292            '=' if prev_is_key_char => {
293                out.push(c);
294                // Empty value only when the `=` is followed by whitespace or
295                // end-of-line. `KEY=\` (line continuation directly after the
296                // `=`) is left alone: the value may continue on the next line.
297                if matches!(chars.peek(), None | Some(&(_, ' ' | '\t'))) {
298                    out.push_str("\"\"");
299                    *changed = true;
300                }
301                prev_is_key_char = false;
302            }
303            _ => {
304                out.push(c);
305                prev_is_key_char = c.is_ascii_alphanumeric() || c == '_';
306            }
307        }
308    }
309
310    // An unterminated quoted string spans the raw newline in the upstream
311    // grammar (`inner` matches ANY except `"`/`\`/two control chars), so the
312    // instruction continues either way.
313    continues || quotes.in_quotes()
314}
315
316/// Rewrite Docker-valid unquoted-empty assignments in ARG and ENV
317/// instructions to their quoted-empty spelling before upstream parsing.
318///
319/// The `dockerfile-parser` pest grammar rejects `ARG NAME=` / `ENV NAME=` /
320/// `ENV A= B=c`: its value rules (`arg_value` and `env_pair_value`, both
321/// `any_whitespace`) require at least one character after `=`, while real
322/// Docker (legacy builder and `BuildKit`) accepts an explicitly empty value —
323/// `cross` 0.2.5 generates `ARG CROSS_DEB_ARCH=`. The grammar DOES accept
324/// the quoted-empty form (`NAME=""`, parsed to an empty-string value), so
325/// this pass rewrites `KEY=` → `KEY=""` inside ARG/ENV instructions only:
326/// quoted regions are skipped, non-ARG/ENV instructions (and their
327/// continuation lines) are copied verbatim, and line structure is preserved
328/// so parse-error line numbers stay meaningful.
329fn normalize_empty_assignments(content: &str) -> std::borrow::Cow<'_, str> {
330    let mut out = String::with_capacity(content.len() + 8);
331    let mut changed = false;
332    let mut state = LineState::Top;
333
334    for raw_line in content.split_inclusive('\n') {
335        // Split the body from its terminator (`\n` or `\r\n`; the final line
336        // may have none).
337        let (body, terminator) = match raw_line.strip_suffix('\n') {
338            Some(rest) => match rest.strip_suffix('\r') {
339                Some(rest) => (rest, "\r\n"),
340                None => (rest, "\n"),
341            },
342            None => (raw_line, ""),
343        };
344
345        state = match state {
346            LineState::Top => {
347                let trimmed = body.trim_start();
348                let keyword_len = trimmed
349                    .chars()
350                    .take_while(char::is_ascii_alphabetic)
351                    .count();
352                let keyword = &trimmed[..keyword_len];
353                let after_keyword = trimmed[keyword_len..].chars().next();
354                let is_arg_env = (keyword.eq_ignore_ascii_case("arg")
355                    || keyword.eq_ignore_ascii_case("env")
356                    || keyword.eq_ignore_ascii_case("label"))
357                    && matches!(after_keyword, Some(' ' | '\t' | '\\'));
358
359                if is_arg_env {
360                    let prefix_len = body.len() - trimmed.len() + keyword_len;
361                    out.push_str(&body[..prefix_len]);
362                    let mut quotes = QuoteState::default();
363                    if rewrite_empty_assignments_in_line(
364                        &trimmed[keyword_len..],
365                        &mut quotes,
366                        &mut out,
367                        &mut changed,
368                    ) {
369                        LineState::ArgEnv(quotes)
370                    } else {
371                        LineState::Top
372                    }
373                } else {
374                    out.push_str(body);
375                    let is_blank_or_comment = trimmed.is_empty() || trimmed.starts_with('#');
376                    if !is_blank_or_comment && ends_with_continuation(body) {
377                        LineState::Other
378                    } else {
379                        LineState::Top
380                    }
381                }
382            }
383            LineState::ArgEnv(mut quotes) => {
384                let trimmed = body.trim_start();
385                if !quotes.in_quotes() && (trimmed.is_empty() || trimmed.starts_with('#')) {
386                    // Comment and empty lines may follow a line continuation
387                    // (grammar `arg_ws`); the instruction resumes after them.
388                    out.push_str(body);
389                    LineState::ArgEnv(quotes)
390                } else if rewrite_empty_assignments_in_line(
391                    body,
392                    &mut quotes,
393                    &mut out,
394                    &mut changed,
395                ) {
396                    LineState::ArgEnv(quotes)
397                } else {
398                    LineState::Top
399                }
400            }
401            LineState::Other => {
402                out.push_str(body);
403                let trimmed = body.trim_start();
404                let is_blank_or_comment = trimmed.is_empty() || trimmed.starts_with('#');
405                if is_blank_or_comment || ends_with_continuation(body) {
406                    LineState::Other
407                } else {
408                    LineState::Top
409                }
410            }
411        };
412
413        out.push_str(terminator);
414    }
415
416    if changed {
417        std::borrow::Cow::Owned(out)
418    } else {
419        std::borrow::Cow::Borrowed(content)
420    }
421}
422
423/// A parsed Dockerfile
424#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct Dockerfile {
426    /// Global ARG instructions that appear before the first FROM
427    pub global_args: Vec<ArgInstruction>,
428
429    /// Build stages
430    pub stages: Vec<Stage>,
431}
432
433impl Dockerfile {
434    /// Expand pre-FROM `ARG`s in every stage's `FROM` target.
435    ///
436    /// Docker semantics: only ARGs declared BEFORE the first `FROM`
437    /// participate in `FROM`-line expansion (`FROM ${BASE_IMAGE}` /
438    /// `FROM img:${TAG:-latest}`), with `--build-arg` values overriding
439    /// their defaults. The parser can't do this (it has no build args), so
440    /// targets containing `$` end up classified as [`DockerfileFromTarget::Stage`]
441    /// — and struct-consuming backends then fail with `Stage '${BASE_IMAGE}'
442    /// not found`. Call this with the effective build args before handing
443    /// the Dockerfile to a backend.
444    ///
445    /// Expanded targets are re-classified: `scratch`, a previously declared
446    /// stage name, an OCI image reference, or (still) a stage string. A
447    /// target that expands to an empty string is left untouched so the
448    /// eventual error names the unexpanded variable instead of a blank.
449    pub fn resolve_from_args(&mut self, build_args: &HashMap<String, String>) {
450        // Effective FROM-scope variables: declared global ARGs only,
451        // defaults overridden by matching build args (an undeclared build
452        // arg does NOT leak into FROM lines, per Docker).
453        let mut vars: HashMap<String, String> = HashMap::new();
454        for arg in &self.global_args {
455            let value = build_args
456                .get(&arg.name)
457                .cloned()
458                .or_else(|| arg.default.clone())
459                .unwrap_or_default();
460            vars.insert(arg.name.clone(), value);
461        }
462
463        let mut known_stage_names: std::collections::HashSet<String> =
464            std::collections::HashSet::new();
465        for stage in &mut self.stages {
466            if let DockerfileFromTarget::Stage(raw) = &stage.base_image {
467                if raw.contains('$') {
468                    let expanded = expand_from_args(raw, &vars);
469                    if !expanded.trim().is_empty() {
470                        let mut target = DockerfileFromTarget::parse(&expanded);
471                        // Same post-hoc stage promotion as `from_raw`: a bare
472                        // name that matches an earlier stage alias is a stage
473                        // reference even though it parses as an OCI ref.
474                        if matches!(target, DockerfileFromTarget::Image(_))
475                            && known_stage_names.contains(expanded.trim())
476                        {
477                            target = DockerfileFromTarget::Stage(expanded.trim().to_string());
478                        }
479                        stage.base_image = target;
480                    }
481                }
482            }
483            if let Some(name) = &stage.name {
484                known_stage_names.insert(name.clone());
485            }
486        }
487    }
488
489    /// Parse a Dockerfile from a string
490    ///
491    /// # Errors
492    ///
493    /// Returns an error if the Dockerfile content is malformed or contains invalid instructions.
494    pub fn parse(content: &str) -> Result<Self> {
495        // The upstream grammar rejects Docker-valid unquoted-empty
496        // assignments (`ARG NAME=` / `ENV NAME=`); rewrite them to the
497        // quoted-empty spelling it accepts. See `normalize_empty_assignments`.
498        let content = normalize_empty_assignments(content);
499        let raw = RawDockerfile::parse(&content).map_err(|e| BuildError::DockerfileParse {
500            message: e.to_string(),
501            line: 1,
502        })?;
503
504        Self::from_raw(raw)
505    }
506
507    /// Parse a Dockerfile from a file
508    ///
509    /// # Errors
510    ///
511    /// Returns an error if the file cannot be read or the Dockerfile is malformed.
512    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
513        let content =
514            std::fs::read_to_string(path.as_ref()).map_err(|e| BuildError::ContextRead {
515                path: path.as_ref().to_path_buf(),
516                source: e,
517            })?;
518
519        Self::parse(&content)
520    }
521
522    /// Convert from the raw dockerfile-parser types to our internal representation
523    fn from_raw(raw: RawDockerfile) -> Result<Self> {
524        let mut global_args = Vec::new();
525        let mut stages = Vec::new();
526        let mut current_stage: Option<Stage> = None;
527        let mut stage_index = 0;
528        // Track stage names declared so far so subsequent FROM lines can
529        // resolve `FROM <name>` to a stage reference even when the name
530        // is also a syntactically-valid OCI reference (e.g. `FROM builder`).
531        let mut known_stage_names: std::collections::HashSet<String> =
532            std::collections::HashSet::new();
533
534        for instruction in raw.instructions {
535            match &instruction {
536                RawInstruction::From(from) => {
537                    // Save previous stage if any
538                    if let Some(stage) = current_stage.take() {
539                        stages.push(stage);
540                    }
541
542                    // Parse base image
543                    let raw_from = from.image.content.trim().to_string();
544                    let mut base_image = DockerfileFromTarget::parse(&raw_from);
545
546                    // Post-hoc stage promotion: `DockerfileFromTarget::parse`
547                    // delegates to the OCI grammar, which accepts bare names
548                    // like `builder` as valid image refs. If the raw FROM
549                    // text matches a previously-declared stage name, swap
550                    // the parsed `Image` for a `Stage` reference.
551                    if matches!(base_image, DockerfileFromTarget::Image(_))
552                        && known_stage_names.contains(&raw_from)
553                    {
554                        base_image = DockerfileFromTarget::Stage(raw_from.clone());
555                    }
556
557                    // Get alias (stage name) - the field is `alias` not `image_alias`
558                    let name = from.alias.as_ref().map(|a| a.content.clone());
559
560                    if let Some(ref n) = name {
561                        known_stage_names.insert(n.clone());
562                    }
563
564                    // Get platform flag
565                    let platform = from
566                        .flags
567                        .iter()
568                        .find(|f| f.name.content.as_str() == "platform")
569                        .map(|f| f.value.to_string());
570
571                    current_stage = Some(Stage {
572                        index: stage_index,
573                        name,
574                        base_image,
575                        platform,
576                        instructions: Vec::new(),
577                    });
578
579                    stage_index += 1;
580                }
581
582                RawInstruction::Arg(arg) => {
583                    let arg_inst = ArgInstruction {
584                        name: arg.name.to_string(),
585                        default: arg.value.as_ref().map(std::string::ToString::to_string),
586                    };
587
588                    if current_stage.is_none() {
589                        global_args.push(arg_inst);
590                    } else if let Some(ref mut stage) = current_stage {
591                        stage.instructions.push(Instruction::Arg(arg_inst));
592                    }
593                }
594
595                _ => {
596                    if let Some(ref mut stage) = current_stage {
597                        if let Some(inst) = Self::convert_instruction(&instruction)? {
598                            stage.instructions.push(inst);
599                        }
600                    }
601                }
602            }
603        }
604
605        // Don't forget the last stage
606        if let Some(stage) = current_stage {
607            stages.push(stage);
608        }
609
610        // Resolve stage references in COPY --from
611        // (This is currently a no-op as stage references are already correct,
612        // but kept for future validation/resolution logic)
613        let _stage_names: HashMap<String, usize> = stages
614            .iter()
615            .filter_map(|s| s.name.as_ref().map(|n| (n.clone(), s.index)))
616            .collect();
617        let _num_stages = stages.len();
618
619        Ok(Self {
620            global_args,
621            stages,
622        })
623    }
624
625    /// Convert a raw instruction to our internal representation
626    #[allow(clippy::too_many_lines)]
627    fn convert_instruction(raw: &RawInstruction) -> Result<Option<Instruction>> {
628        let instruction = match raw {
629            RawInstruction::From(_) => {
630                return Ok(None);
631            }
632
633            RawInstruction::Run(run) => {
634                let command = match &run.expr {
635                    dockerfile_parser::ShellOrExecExpr::Shell(s) => {
636                        ShellOrExec::Shell(s.to_string())
637                    }
638                    dockerfile_parser::ShellOrExecExpr::Exec(args) => {
639                        ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
640                    }
641                };
642
643                Instruction::Run(RunInstruction {
644                    command,
645                    mounts: Vec::new(),
646                    network: None,
647                    security: None,
648                    env: HashMap::new(),
649                })
650            }
651
652            RawInstruction::Copy(copy) => {
653                let from = copy
654                    .flags
655                    .iter()
656                    .find(|f| f.name.content.as_str() == "from")
657                    .map(|f| f.value.to_string());
658
659                let chown = copy
660                    .flags
661                    .iter()
662                    .find(|f| f.name.content.as_str() == "chown")
663                    .map(|f| f.value.to_string());
664
665                let chmod = copy
666                    .flags
667                    .iter()
668                    .find(|f| f.name.content.as_str() == "chmod")
669                    .map(|f| f.value.to_string());
670
671                let link = copy.flags.iter().any(|f| f.name.content.as_str() == "link");
672
673                // The external parser separates sources and destination already.
674                let sources: Vec<String> = copy
675                    .sources
676                    .iter()
677                    .map(std::string::ToString::to_string)
678                    .collect();
679                let destination = copy.destination.to_string();
680
681                Instruction::Copy(CopyInstruction {
682                    sources,
683                    destination,
684                    from,
685                    chown,
686                    chmod,
687                    link,
688                    exclude: Vec::new(),
689                })
690            }
691
692            RawInstruction::Entrypoint(ep) => {
693                let command = match &ep.expr {
694                    dockerfile_parser::ShellOrExecExpr::Shell(s) => {
695                        ShellOrExec::Shell(s.to_string())
696                    }
697                    dockerfile_parser::ShellOrExecExpr::Exec(args) => {
698                        ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
699                    }
700                };
701                Instruction::Entrypoint(command)
702            }
703
704            RawInstruction::Cmd(cmd) => {
705                let command = match &cmd.expr {
706                    dockerfile_parser::ShellOrExecExpr::Shell(s) => {
707                        ShellOrExec::Shell(s.to_string())
708                    }
709                    dockerfile_parser::ShellOrExecExpr::Exec(args) => {
710                        ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
711                    }
712                };
713                Instruction::Cmd(command)
714            }
715
716            RawInstruction::Env(env) => {
717                let mut vars = HashMap::new();
718                for var in &env.vars {
719                    vars.insert(var.key.to_string(), var.value.to_string());
720                }
721                Instruction::Env(EnvInstruction { vars })
722            }
723
724            RawInstruction::Label(label) => {
725                let mut labels = HashMap::new();
726                for l in &label.labels {
727                    labels.insert(l.name.to_string(), l.value.to_string());
728                }
729                Instruction::Label(labels)
730            }
731
732            RawInstruction::Arg(arg) => Instruction::Arg(ArgInstruction {
733                name: arg.name.to_string(),
734                default: arg.value.as_ref().map(std::string::ToString::to_string),
735            }),
736
737            RawInstruction::Misc(misc) => {
738                let instruction_upper = misc.instruction.content.to_uppercase();
739                match instruction_upper.as_str() {
740                    "WORKDIR" => Instruction::Workdir(misc.arguments.to_string()),
741
742                    "USER" => Instruction::User(misc.arguments.to_string()),
743
744                    "VOLUME" => {
745                        let args = misc.arguments.to_string();
746                        let volumes = if args.trim().starts_with('[') {
747                            serde_json::from_str(&args).unwrap_or_else(|_| vec![args])
748                        } else {
749                            args.split_whitespace().map(String::from).collect()
750                        };
751                        Instruction::Volume(volumes)
752                    }
753
754                    "EXPOSE" => {
755                        let args = misc.arguments.to_string();
756                        let (port_str, protocol) = if let Some((p, proto)) = args.split_once('/') {
757                            let proto = match proto.to_lowercase().as_str() {
758                                "udp" => ExposeProtocol::Udp,
759                                _ => ExposeProtocol::Tcp,
760                            };
761                            (p, proto)
762                        } else {
763                            (args.as_str(), ExposeProtocol::Tcp)
764                        };
765
766                        let port: u16 = port_str.trim().parse().map_err(|_| {
767                            BuildError::InvalidInstruction {
768                                instruction: "EXPOSE".to_string(),
769                                reason: format!("Invalid port number: {port_str}"),
770                            }
771                        })?;
772
773                        Instruction::Expose(ExposeInstruction { port, protocol })
774                    }
775
776                    "SHELL" => {
777                        let args = misc.arguments.to_string();
778                        let shell: Vec<String> = serde_json::from_str(&args).map_err(|_| {
779                            BuildError::InvalidInstruction {
780                                instruction: "SHELL".to_string(),
781                                reason: "SHELL requires a JSON array".to_string(),
782                            }
783                        })?;
784                        Instruction::Shell(shell)
785                    }
786
787                    "STOPSIGNAL" => Instruction::Stopsignal(misc.arguments.to_string()),
788
789                    "HEALTHCHECK" => {
790                        let args = misc.arguments.to_string().trim().to_string();
791                        if args.eq_ignore_ascii_case("NONE") {
792                            Instruction::Healthcheck(HealthcheckInstruction::None)
793                        } else {
794                            let command = if let Some(stripped) = args.strip_prefix("CMD ") {
795                                ShellOrExec::Shell(stripped.to_string())
796                            } else {
797                                ShellOrExec::Shell(args)
798                            };
799                            Instruction::Healthcheck(HealthcheckInstruction::cmd(command))
800                        }
801                    }
802
803                    "ONBUILD" => {
804                        tracing::warn!("ONBUILD instruction parsing not fully implemented");
805                        return Ok(None);
806                    }
807
808                    "MAINTAINER" => {
809                        let mut labels = HashMap::new();
810                        labels.insert("maintainer".to_string(), misc.arguments.to_string());
811                        Instruction::Label(labels)
812                    }
813
814                    "ADD" => {
815                        let args = misc.arguments.to_string();
816                        let parts: Vec<String> =
817                            args.split_whitespace().map(String::from).collect();
818
819                        if parts.len() < 2 {
820                            return Err(BuildError::InvalidInstruction {
821                                instruction: "ADD".to_string(),
822                                reason: "ADD requires at least one source and a destination"
823                                    .to_string(),
824                            });
825                        }
826
827                        let (sources, dest) = parts.split_at(parts.len() - 1);
828                        let destination = dest.first().cloned().unwrap_or_default();
829
830                        Instruction::Add(AddInstruction {
831                            sources: sources.to_vec(),
832                            destination,
833                            chown: None,
834                            chmod: None,
835                            link: false,
836                            checksum: None,
837                            keep_git_dir: false,
838                        })
839                    }
840
841                    other => {
842                        tracing::warn!("Unknown Dockerfile instruction: {}", other);
843                        return Ok(None);
844                    }
845                }
846            }
847        };
848
849        Ok(Some(instruction))
850    }
851
852    /// Get a stage by name or index
853    #[must_use]
854    pub fn get_stage(&self, name_or_index: &str) -> Option<&Stage> {
855        self.stages.iter().find(|s| s.matches(name_or_index))
856    }
857
858    /// Get the final stage (last one in the Dockerfile)
859    #[must_use]
860    pub fn final_stage(&self) -> Option<&Stage> {
861        self.stages.last()
862    }
863
864    /// Get all stage names/identifiers
865    #[must_use]
866    pub fn stage_names(&self) -> Vec<String> {
867        self.stages.iter().map(Stage::identifier).collect()
868    }
869
870    /// Check if a stage exists
871    #[must_use]
872    pub fn has_stage(&self, name_or_index: &str) -> bool {
873        self.get_stage(name_or_index).is_some()
874    }
875
876    /// Returns the number of stages
877    #[must_use]
878    pub fn stage_count(&self) -> usize {
879        self.stages.len()
880    }
881}
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886
887    #[test]
888    fn test_parse_simple_dockerfile() {
889        let content = r#"
890FROM alpine:3.18
891RUN apk add --no-cache curl
892COPY . /app
893WORKDIR /app
894CMD ["./app"]
895"#;
896
897        let dockerfile = Dockerfile::parse(content).unwrap();
898        assert_eq!(dockerfile.stages.len(), 1);
899
900        let stage = &dockerfile.stages[0];
901        assert_eq!(stage.index, 0);
902        assert!(stage.name.is_none());
903        assert_eq!(stage.instructions.len(), 4);
904    }
905
906    #[test]
907    fn test_parse_multistage_dockerfile() {
908        let content = r#"
909FROM golang:1.21 AS builder
910WORKDIR /src
911COPY . .
912RUN go build -o /app
913
914FROM alpine:3.18
915COPY --from=builder /app /app
916CMD ["/app"]
917"#;
918
919        let dockerfile = Dockerfile::parse(content).unwrap();
920        assert_eq!(dockerfile.stages.len(), 2);
921
922        let builder = &dockerfile.stages[0];
923        assert_eq!(builder.name, Some("builder".to_string()));
924
925        let runtime = &dockerfile.stages[1];
926        assert!(runtime.name.is_none());
927
928        let copy = runtime
929            .instructions
930            .iter()
931            .find(|i| matches!(i, Instruction::Copy(_)));
932        assert!(copy.is_some());
933        if let Some(Instruction::Copy(c)) = copy {
934            assert_eq!(c.from, Some("builder".to_string()));
935        }
936    }
937
938    #[test]
939    fn test_parse_copy_from_external_image_reference() {
940        // `COPY --from=<external-image>` must capture the full registry-
941        // qualified reference in `CopyInstruction.from` so the buildah
942        // backend can pull and forward it to `buildah copy --from=...`.
943        let content = r"
944FROM alpine:3.18
945COPY --from=ghcr.io/astral-sh/uv:0.5.0 /uv /usr/local/bin/uv
946RUN /usr/local/bin/uv --version
947";
948
949        let dockerfile = Dockerfile::parse(content).unwrap();
950        assert_eq!(dockerfile.stages.len(), 1);
951
952        let copy = dockerfile.stages[0]
953            .instructions
954            .iter()
955            .find_map(|i| {
956                if let Instruction::Copy(c) = i {
957                    Some(c)
958                } else {
959                    None
960                }
961            })
962            .expect("COPY instruction present");
963
964        assert_eq!(
965            copy.from,
966            Some("ghcr.io/astral-sh/uv:0.5.0".to_string()),
967            "external image ref must be preserved verbatim in CopyInstruction.from",
968        );
969        assert_eq!(copy.sources, vec!["/uv".to_string()]);
970        assert_eq!(copy.destination, "/usr/local/bin/uv".to_string());
971
972        // The parser must NOT treat the external ref as a stage; only the
973        // top-level `FROM alpine:3.18` should appear in the stage list.
974        assert!(dockerfile.get_stage("ghcr.io/astral-sh/uv:0.5.0").is_none());
975    }
976
977    #[test]
978    fn expand_from_args_all_forms() {
979        let vars: HashMap<String, String> = [
980            ("BASE".to_string(), "ghcr.io/org/img".to_string()),
981            ("TAG".to_string(), "1.2".to_string()),
982            ("EMPTY".to_string(), String::new()),
983        ]
984        .into_iter()
985        .collect();
986
987        assert_eq!(
988            expand_from_args("${BASE}:${TAG}", &vars),
989            "ghcr.io/org/img:1.2"
990        );
991        assert_eq!(expand_from_args("$BASE", &vars), "ghcr.io/org/img");
992        assert_eq!(
993            expand_from_args("img:${MISSING:-latest}", &vars),
994            "img:latest"
995        );
996        assert_eq!(
997            expand_from_args("img:${EMPTY:-fallback}", &vars),
998            "img:fallback"
999        );
1000        assert_eq!(expand_from_args("img:${TAG:+pinned}", &vars), "img:pinned");
1001        assert_eq!(expand_from_args("img:${MISSING:+pinned}", &vars), "img:");
1002        assert_eq!(expand_from_args("${MISSING}", &vars), "");
1003        // BuildKit `$$` escape yields a literal dollar; a trailing `$`
1004        // passes through.
1005        assert_eq!(expand_from_args("a$$b", &vars), "a$b");
1006        assert_eq!(expand_from_args("price$", &vars), "price$");
1007    }
1008
1009    #[test]
1010    fn resolve_from_args_expands_from_lines() {
1011        let content = r"
1012ARG BASE_IMAGE=ghcr.io/org/alpine:latest
1013ARG BASE_TAG=latest
1014FROM ${BASE_IMAGE} AS builder
1015RUN echo hi
1016FROM ghcr.io/org/alpine:${BASE_TAG}
1017COPY --from=builder /x /x
1018";
1019        let mut dockerfile = Dockerfile::parse(content).unwrap();
1020        // Pre-resolution both FROM targets are (mis)classified as stages.
1021        assert!(dockerfile.stages[0].base_image.is_stage());
1022        assert!(dockerfile.stages[1].base_image.is_stage());
1023
1024        let build_args: HashMap<String, String> = [("BASE_TAG".to_string(), "3.20".to_string())]
1025            .into_iter()
1026            .collect();
1027        dockerfile.resolve_from_args(&build_args);
1028
1029        match &dockerfile.stages[0].base_image {
1030            DockerfileFromTarget::Image(r) => {
1031                assert_eq!(r.to_string(), "ghcr.io/org/alpine:latest");
1032            }
1033            other => panic!("stage 0 not resolved to an image: {other:?}"),
1034        }
1035        match &dockerfile.stages[1].base_image {
1036            DockerfileFromTarget::Image(r) => {
1037                // The build arg overrides the declared default.
1038                assert_eq!(r.to_string(), "ghcr.io/org/alpine:3.20");
1039            }
1040            other => panic!("stage 1 not resolved to an image: {other:?}"),
1041        }
1042    }
1043
1044    #[test]
1045    fn resolve_from_args_keeps_stage_references() {
1046        let content = r"
1047ARG BASE=ghcr.io/org/alpine:latest
1048FROM ${BASE} AS builder
1049RUN echo hi
1050FROM builder
1051RUN echo again
1052";
1053        let mut dockerfile = Dockerfile::parse(content).unwrap();
1054        dockerfile.resolve_from_args(&HashMap::new());
1055        assert!(matches!(
1056            &dockerfile.stages[0].base_image,
1057            DockerfileFromTarget::Image(_)
1058        ));
1059        // `FROM builder` must stay a stage reference.
1060        assert_eq!(
1061            dockerfile.stages[1].base_image,
1062            DockerfileFromTarget::Stage("builder".to_string())
1063        );
1064    }
1065
1066    #[test]
1067    fn test_parse_global_args() {
1068        let content = r#"
1069ARG BASE_IMAGE=alpine:3.18
1070FROM ${BASE_IMAGE}
1071RUN echo "hello"
1072"#;
1073
1074        let dockerfile = Dockerfile::parse(content).unwrap();
1075        assert_eq!(dockerfile.global_args.len(), 1);
1076        assert_eq!(dockerfile.global_args[0].name, "BASE_IMAGE");
1077        assert_eq!(
1078            dockerfile.global_args[0].default,
1079            Some("alpine:3.18".to_string())
1080        );
1081    }
1082
1083    #[test]
1084    fn test_get_stage_by_name() {
1085        let content = r#"
1086FROM alpine:3.18 AS base
1087RUN echo "base"
1088
1089FROM base AS builder
1090RUN echo "builder"
1091"#;
1092
1093        let dockerfile = Dockerfile::parse(content).unwrap();
1094
1095        let base = dockerfile.get_stage("base");
1096        assert!(base.is_some());
1097        assert_eq!(base.unwrap().index, 0);
1098
1099        let builder = dockerfile.get_stage("builder");
1100        assert!(builder.is_some());
1101        assert_eq!(builder.unwrap().index, 1);
1102
1103        let stage_0 = dockerfile.get_stage("0");
1104        assert!(stage_0.is_some());
1105        assert_eq!(stage_0.unwrap().name, Some("base".to_string()));
1106    }
1107
1108    #[test]
1109    fn test_final_stage() {
1110        let content = r#"
1111FROM alpine:3.18 AS builder
1112RUN echo "builder"
1113
1114FROM scratch
1115COPY --from=builder /app /app
1116"#;
1117
1118        let dockerfile = Dockerfile::parse(content).unwrap();
1119        let final_stage = dockerfile.final_stage().unwrap();
1120
1121        assert_eq!(final_stage.index, 1);
1122        assert!(matches!(
1123            final_stage.base_image,
1124            DockerfileFromTarget::Scratch
1125        ));
1126    }
1127
1128    #[test]
1129    fn test_parse_env_instruction() {
1130        let content = r"
1131FROM alpine
1132ENV FOO=bar BAZ=qux
1133";
1134
1135        let dockerfile = Dockerfile::parse(content).unwrap();
1136        let stage = &dockerfile.stages[0];
1137
1138        let env = stage
1139            .instructions
1140            .iter()
1141            .find(|i| matches!(i, Instruction::Env(_)));
1142        assert!(env.is_some());
1143
1144        if let Some(Instruction::Env(e)) = env {
1145            assert_eq!(e.vars.get("FOO"), Some(&"bar".to_string()));
1146            assert_eq!(e.vars.get("BAZ"), Some(&"qux".to_string()));
1147        }
1148    }
1149
1150    /// Extract the ARG instructions of a stage as (name, default) pairs.
1151    fn stage_args(stage: &Stage) -> Vec<(String, Option<String>)> {
1152        stage
1153            .instructions
1154            .iter()
1155            .filter_map(|i| {
1156                if let Instruction::Arg(a) = i {
1157                    Some((a.name.clone(), a.default.clone()))
1158                } else {
1159                    None
1160                }
1161            })
1162            .collect()
1163    }
1164
1165    #[test]
1166    fn arg_empty_unquoted_default_parses_as_defined_empty() {
1167        // `cross` 0.2.5 generates `ARG CROSS_DEB_ARCH=` — Docker-valid
1168        // (defined, empty default), distinct from `ARG NAME` (no default).
1169        let content = "FROM alpine:3.18\nARG CROSS_DEB_ARCH=\n";
1170        let dockerfile = Dockerfile::parse(content).unwrap();
1171        assert_eq!(
1172            stage_args(&dockerfile.stages[0]),
1173            vec![("CROSS_DEB_ARCH".to_string(), Some(String::new()))],
1174        );
1175    }
1176
1177    #[test]
1178    fn arg_empty_unquoted_default_parses_as_global_arg() {
1179        // Pre-FROM position: must land in `global_args` with a defined-empty
1180        // default so `resolve_from_args` sees "" rather than unset.
1181        let content = "ARG EMPTYDEFAULT=\nFROM alpine:3.18\n";
1182        let dockerfile = Dockerfile::parse(content).unwrap();
1183        assert_eq!(dockerfile.global_args.len(), 1);
1184        assert_eq!(dockerfile.global_args[0].name, "EMPTYDEFAULT");
1185        assert_eq!(dockerfile.global_args[0].default, Some(String::new()));
1186    }
1187
1188    #[test]
1189    fn label_empty_unquoted_value_parses() {
1190        // Same upstream-grammar defect class as ARG/ENV (`label_value` needs
1191        // ≥1 char after `=`); the normalizer rewrites `LABEL k=` → `LABEL k=""`.
1192        let content = "FROM alpine:3.18\nLABEL emptyone= other=\"x\"\n";
1193        let dockerfile = Dockerfile::parse(content).unwrap();
1194        assert_eq!(dockerfile.stages.len(), 1);
1195    }
1196
1197    #[test]
1198    fn arg_quoted_empty_default_parses_as_defined_empty() {
1199        let content = "FROM alpine:3.18\nARG NAME=\"\"\n";
1200        let dockerfile = Dockerfile::parse(content).unwrap();
1201        assert_eq!(
1202            stage_args(&dockerfile.stages[0]),
1203            vec![("NAME".to_string(), Some(String::new()))],
1204        );
1205    }
1206
1207    #[test]
1208    fn arg_no_default_and_plain_default_still_parse() {
1209        let content = "FROM alpine:3.18\nARG X\nARG Y=z\n";
1210        let dockerfile = Dockerfile::parse(content).unwrap();
1211        assert_eq!(
1212            stage_args(&dockerfile.stages[0]),
1213            vec![
1214                ("X".to_string(), None),
1215                ("Y".to_string(), Some("z".to_string())),
1216            ],
1217        );
1218    }
1219
1220    #[test]
1221    fn env_empty_values_parse() {
1222        // `ENV NAME=` and a trailing empty pair in a multi-pair ENV are both
1223        // Docker-valid (defined, empty).
1224        let content = "FROM alpine:3.18\nENV EMPTYONE=\nENV A= B=c\n";
1225        let dockerfile = Dockerfile::parse(content).unwrap();
1226        let stage = &dockerfile.stages[0];
1227
1228        let envs: Vec<&EnvInstruction> = stage
1229            .instructions
1230            .iter()
1231            .filter_map(|i| {
1232                if let Instruction::Env(e) = i {
1233                    Some(e)
1234                } else {
1235                    None
1236                }
1237            })
1238            .collect();
1239        assert_eq!(envs.len(), 2);
1240        assert_eq!(envs[0].vars.get("EMPTYONE"), Some(&String::new()));
1241        assert_eq!(envs[1].vars.get("A"), Some(&String::new()));
1242        assert_eq!(envs[1].vars.get("B"), Some(&"c".to_string()));
1243    }
1244
1245    #[test]
1246    fn env_quoted_value_containing_equals_is_untouched() {
1247        // The quoted `x= ` must not be rewritten by empty-assignment
1248        // normalization; only the trailing bare `B=` is.
1249        let content = "FROM alpine:3.18\nENV A=\"x= \" B=\n";
1250        let dockerfile = Dockerfile::parse(content).unwrap();
1251        let stage = &dockerfile.stages[0];
1252        if let Some(Instruction::Env(e)) = stage
1253            .instructions
1254            .iter()
1255            .find(|i| matches!(i, Instruction::Env(_)))
1256        {
1257            assert_eq!(e.vars.get("A"), Some(&"x= ".to_string()));
1258            assert_eq!(e.vars.get("B"), Some(&String::new()));
1259        } else {
1260            panic!("ENV instruction missing");
1261        }
1262    }
1263
1264    #[test]
1265    fn run_shell_content_with_trailing_equals_is_untouched() {
1266        // A `KEY=` token inside RUN shell content (including on a
1267        // continuation line) is shell text — normalization must not
1268        // rewrite it into `KEY=""`.
1269        let content = "FROM alpine:3.18\nRUN export FOO= && \\\n    BAR= true\n";
1270        let dockerfile = Dockerfile::parse(content).unwrap();
1271        let stage = &dockerfile.stages[0];
1272        if let Some(Instruction::Run(run)) = stage
1273            .instructions
1274            .iter()
1275            .find(|i| matches!(i, Instruction::Run(_)))
1276        {
1277            let ShellOrExec::Shell(cmd) = &run.command else {
1278                panic!("expected shell-form RUN");
1279            };
1280            assert!(cmd.contains("FOO="), "cmd: {cmd}");
1281            assert!(!cmd.contains("FOO=\"\""), "cmd: {cmd}");
1282            assert!(cmd.contains("BAR="), "cmd: {cmd}");
1283            assert!(!cmd.contains("BAR=\"\""), "cmd: {cmd}");
1284        } else {
1285            panic!("RUN instruction missing");
1286        }
1287    }
1288
1289    #[test]
1290    fn env_empty_value_on_continuation_line_parses() {
1291        let content = "FROM alpine:3.18\nENV A=1 \\\n    B=\n";
1292        let dockerfile = Dockerfile::parse(content).unwrap();
1293        let stage = &dockerfile.stages[0];
1294        if let Some(Instruction::Env(e)) = stage
1295            .instructions
1296            .iter()
1297            .find(|i| matches!(i, Instruction::Env(_)))
1298        {
1299            assert_eq!(e.vars.get("A"), Some(&"1".to_string()));
1300            assert_eq!(e.vars.get("B"), Some(&String::new()));
1301        } else {
1302            panic!("ENV instruction missing");
1303        }
1304    }
1305
1306    #[test]
1307    fn normalize_empty_assignments_rewrites_only_arg_env() {
1308        // ARG / ENV empty assignments get the quoted-empty spelling.
1309        assert_eq!(normalize_empty_assignments("ARG A=\n"), "ARG A=\"\"\n");
1310        assert_eq!(
1311            normalize_empty_assignments("ENV A= B=c\n"),
1312            "ENV A=\"\" B=c\n"
1313        );
1314        // Quoted regions are skipped; only the bare trailing `B=` rewritten.
1315        assert_eq!(
1316            normalize_empty_assignments("ENV A=\"x= \" B=\n"),
1317            "ENV A=\"x= \" B=\"\"\n"
1318        );
1319        // Continuation lines of an ENV instruction are rewritten too.
1320        assert_eq!(
1321            normalize_empty_assignments("ENV A=1 \\\n    B=\n"),
1322            "ENV A=1 \\\n    B=\"\"\n"
1323        );
1324        // `KEY=\` (continuation directly after `=`) is left for the parser —
1325        // the value may continue on the next line.
1326        assert_eq!(
1327            normalize_empty_assignments("ARG A=\\\nx\n"),
1328            "ARG A=\\\nx\n"
1329        );
1330        // Non-ARG/ENV instructions and their continuation lines are verbatim.
1331        assert_eq!(
1332            normalize_empty_assignments("RUN export FOO= && \\\n    BAR= true\n"),
1333            "RUN export FOO= && \\\n    BAR= true\n"
1334        );
1335        // A keyword prefix is not enough — `ENVIRONMENT=` is not ENV.
1336        assert_eq!(
1337            normalize_empty_assignments("ENVIRONMENT= foo\n"),
1338            "ENVIRONMENT= foo\n"
1339        );
1340        // Untouched content comes back borrowed (no reallocation).
1341        assert!(matches!(
1342            normalize_empty_assignments("FROM alpine\nRUN echo hi\n"),
1343            std::borrow::Cow::Borrowed(_)
1344        ));
1345        // CRLF and a missing final newline are preserved.
1346        assert_eq!(
1347            normalize_empty_assignments("ARG A=\r\nARG B="),
1348            "ARG A=\"\"\r\nARG B=\"\""
1349        );
1350    }
1351
1352    #[test]
1353    fn cross_generated_dockerfile_parses_end_to_end() {
1354        // Minimal mirror of the preamble `cross` 0.2.5 generates
1355        // (live failure: ZLayer build.yml run 19200 job 105756).
1356        let content = "FROM cross/x86_64-unknown-linux-musl:0.2.5\n\
1357                       ARG CROSS_DEB_ARCH=\n\
1358                       ARG CROSS_CMD\n\
1359                       RUN eval \"${CROSS_CMD}\"\n";
1360        let dockerfile = Dockerfile::parse(content).unwrap();
1361        assert_eq!(dockerfile.stages.len(), 1);
1362
1363        let stage = &dockerfile.stages[0];
1364        assert_eq!(
1365            stage_args(stage),
1366            vec![
1367                ("CROSS_DEB_ARCH".to_string(), Some(String::new())),
1368                ("CROSS_CMD".to_string(), None),
1369            ],
1370        );
1371        assert!(stage
1372            .instructions
1373            .iter()
1374            .any(|i| matches!(i, Instruction::Run(_))));
1375    }
1376}