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/// A parsed Dockerfile
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct Dockerfile {
203    /// Global ARG instructions that appear before the first FROM
204    pub global_args: Vec<ArgInstruction>,
205
206    /// Build stages
207    pub stages: Vec<Stage>,
208}
209
210impl Dockerfile {
211    /// Expand pre-FROM `ARG`s in every stage's `FROM` target.
212    ///
213    /// Docker semantics: only ARGs declared BEFORE the first `FROM`
214    /// participate in `FROM`-line expansion (`FROM ${BASE_IMAGE}` /
215    /// `FROM img:${TAG:-latest}`), with `--build-arg` values overriding
216    /// their defaults. The parser can't do this (it has no build args), so
217    /// targets containing `$` end up classified as [`DockerfileFromTarget::Stage`]
218    /// — and struct-consuming backends then fail with `Stage '${BASE_IMAGE}'
219    /// not found`. Call this with the effective build args before handing
220    /// the Dockerfile to a backend.
221    ///
222    /// Expanded targets are re-classified: `scratch`, a previously declared
223    /// stage name, an OCI image reference, or (still) a stage string. A
224    /// target that expands to an empty string is left untouched so the
225    /// eventual error names the unexpanded variable instead of a blank.
226    pub fn resolve_from_args(&mut self, build_args: &HashMap<String, String>) {
227        // Effective FROM-scope variables: declared global ARGs only,
228        // defaults overridden by matching build args (an undeclared build
229        // arg does NOT leak into FROM lines, per Docker).
230        let mut vars: HashMap<String, String> = HashMap::new();
231        for arg in &self.global_args {
232            let value = build_args
233                .get(&arg.name)
234                .cloned()
235                .or_else(|| arg.default.clone())
236                .unwrap_or_default();
237            vars.insert(arg.name.clone(), value);
238        }
239
240        let mut known_stage_names: std::collections::HashSet<String> =
241            std::collections::HashSet::new();
242        for stage in &mut self.stages {
243            if let DockerfileFromTarget::Stage(raw) = &stage.base_image {
244                if raw.contains('$') {
245                    let expanded = expand_from_args(raw, &vars);
246                    if !expanded.trim().is_empty() {
247                        let mut target = DockerfileFromTarget::parse(&expanded);
248                        // Same post-hoc stage promotion as `from_raw`: a bare
249                        // name that matches an earlier stage alias is a stage
250                        // reference even though it parses as an OCI ref.
251                        if matches!(target, DockerfileFromTarget::Image(_))
252                            && known_stage_names.contains(expanded.trim())
253                        {
254                            target = DockerfileFromTarget::Stage(expanded.trim().to_string());
255                        }
256                        stage.base_image = target;
257                    }
258                }
259            }
260            if let Some(name) = &stage.name {
261                known_stage_names.insert(name.clone());
262            }
263        }
264    }
265
266    /// Parse a Dockerfile from a string
267    ///
268    /// # Errors
269    ///
270    /// Returns an error if the Dockerfile content is malformed or contains invalid instructions.
271    pub fn parse(content: &str) -> Result<Self> {
272        let raw = RawDockerfile::parse(content).map_err(|e| BuildError::DockerfileParse {
273            message: e.to_string(),
274            line: 1,
275        })?;
276
277        Self::from_raw(raw)
278    }
279
280    /// Parse a Dockerfile from a file
281    ///
282    /// # Errors
283    ///
284    /// Returns an error if the file cannot be read or the Dockerfile is malformed.
285    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
286        let content =
287            std::fs::read_to_string(path.as_ref()).map_err(|e| BuildError::ContextRead {
288                path: path.as_ref().to_path_buf(),
289                source: e,
290            })?;
291
292        Self::parse(&content)
293    }
294
295    /// Convert from the raw dockerfile-parser types to our internal representation
296    fn from_raw(raw: RawDockerfile) -> Result<Self> {
297        let mut global_args = Vec::new();
298        let mut stages = Vec::new();
299        let mut current_stage: Option<Stage> = None;
300        let mut stage_index = 0;
301        // Track stage names declared so far so subsequent FROM lines can
302        // resolve `FROM <name>` to a stage reference even when the name
303        // is also a syntactically-valid OCI reference (e.g. `FROM builder`).
304        let mut known_stage_names: std::collections::HashSet<String> =
305            std::collections::HashSet::new();
306
307        for instruction in raw.instructions {
308            match &instruction {
309                RawInstruction::From(from) => {
310                    // Save previous stage if any
311                    if let Some(stage) = current_stage.take() {
312                        stages.push(stage);
313                    }
314
315                    // Parse base image
316                    let raw_from = from.image.content.trim().to_string();
317                    let mut base_image = DockerfileFromTarget::parse(&raw_from);
318
319                    // Post-hoc stage promotion: `DockerfileFromTarget::parse`
320                    // delegates to the OCI grammar, which accepts bare names
321                    // like `builder` as valid image refs. If the raw FROM
322                    // text matches a previously-declared stage name, swap
323                    // the parsed `Image` for a `Stage` reference.
324                    if matches!(base_image, DockerfileFromTarget::Image(_))
325                        && known_stage_names.contains(&raw_from)
326                    {
327                        base_image = DockerfileFromTarget::Stage(raw_from.clone());
328                    }
329
330                    // Get alias (stage name) - the field is `alias` not `image_alias`
331                    let name = from.alias.as_ref().map(|a| a.content.clone());
332
333                    if let Some(ref n) = name {
334                        known_stage_names.insert(n.clone());
335                    }
336
337                    // Get platform flag
338                    let platform = from
339                        .flags
340                        .iter()
341                        .find(|f| f.name.content.as_str() == "platform")
342                        .map(|f| f.value.to_string());
343
344                    current_stage = Some(Stage {
345                        index: stage_index,
346                        name,
347                        base_image,
348                        platform,
349                        instructions: Vec::new(),
350                    });
351
352                    stage_index += 1;
353                }
354
355                RawInstruction::Arg(arg) => {
356                    let arg_inst = ArgInstruction {
357                        name: arg.name.to_string(),
358                        default: arg.value.as_ref().map(std::string::ToString::to_string),
359                    };
360
361                    if current_stage.is_none() {
362                        global_args.push(arg_inst);
363                    } else if let Some(ref mut stage) = current_stage {
364                        stage.instructions.push(Instruction::Arg(arg_inst));
365                    }
366                }
367
368                _ => {
369                    if let Some(ref mut stage) = current_stage {
370                        if let Some(inst) = Self::convert_instruction(&instruction)? {
371                            stage.instructions.push(inst);
372                        }
373                    }
374                }
375            }
376        }
377
378        // Don't forget the last stage
379        if let Some(stage) = current_stage {
380            stages.push(stage);
381        }
382
383        // Resolve stage references in COPY --from
384        // (This is currently a no-op as stage references are already correct,
385        // but kept for future validation/resolution logic)
386        let _stage_names: HashMap<String, usize> = stages
387            .iter()
388            .filter_map(|s| s.name.as_ref().map(|n| (n.clone(), s.index)))
389            .collect();
390        let _num_stages = stages.len();
391
392        Ok(Self {
393            global_args,
394            stages,
395        })
396    }
397
398    /// Convert a raw instruction to our internal representation
399    #[allow(clippy::too_many_lines)]
400    fn convert_instruction(raw: &RawInstruction) -> Result<Option<Instruction>> {
401        let instruction = match raw {
402            RawInstruction::From(_) => {
403                return Ok(None);
404            }
405
406            RawInstruction::Run(run) => {
407                let command = match &run.expr {
408                    dockerfile_parser::ShellOrExecExpr::Shell(s) => {
409                        ShellOrExec::Shell(s.to_string())
410                    }
411                    dockerfile_parser::ShellOrExecExpr::Exec(args) => {
412                        ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
413                    }
414                };
415
416                Instruction::Run(RunInstruction {
417                    command,
418                    mounts: Vec::new(),
419                    network: None,
420                    security: None,
421                    env: HashMap::new(),
422                })
423            }
424
425            RawInstruction::Copy(copy) => {
426                let from = copy
427                    .flags
428                    .iter()
429                    .find(|f| f.name.content.as_str() == "from")
430                    .map(|f| f.value.to_string());
431
432                let chown = copy
433                    .flags
434                    .iter()
435                    .find(|f| f.name.content.as_str() == "chown")
436                    .map(|f| f.value.to_string());
437
438                let chmod = copy
439                    .flags
440                    .iter()
441                    .find(|f| f.name.content.as_str() == "chmod")
442                    .map(|f| f.value.to_string());
443
444                let link = copy.flags.iter().any(|f| f.name.content.as_str() == "link");
445
446                // The external parser separates sources and destination already.
447                let sources: Vec<String> = copy
448                    .sources
449                    .iter()
450                    .map(std::string::ToString::to_string)
451                    .collect();
452                let destination = copy.destination.to_string();
453
454                Instruction::Copy(CopyInstruction {
455                    sources,
456                    destination,
457                    from,
458                    chown,
459                    chmod,
460                    link,
461                    exclude: Vec::new(),
462                })
463            }
464
465            RawInstruction::Entrypoint(ep) => {
466                let command = match &ep.expr {
467                    dockerfile_parser::ShellOrExecExpr::Shell(s) => {
468                        ShellOrExec::Shell(s.to_string())
469                    }
470                    dockerfile_parser::ShellOrExecExpr::Exec(args) => {
471                        ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
472                    }
473                };
474                Instruction::Entrypoint(command)
475            }
476
477            RawInstruction::Cmd(cmd) => {
478                let command = match &cmd.expr {
479                    dockerfile_parser::ShellOrExecExpr::Shell(s) => {
480                        ShellOrExec::Shell(s.to_string())
481                    }
482                    dockerfile_parser::ShellOrExecExpr::Exec(args) => {
483                        ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
484                    }
485                };
486                Instruction::Cmd(command)
487            }
488
489            RawInstruction::Env(env) => {
490                let mut vars = HashMap::new();
491                for var in &env.vars {
492                    vars.insert(var.key.to_string(), var.value.to_string());
493                }
494                Instruction::Env(EnvInstruction { vars })
495            }
496
497            RawInstruction::Label(label) => {
498                let mut labels = HashMap::new();
499                for l in &label.labels {
500                    labels.insert(l.name.to_string(), l.value.to_string());
501                }
502                Instruction::Label(labels)
503            }
504
505            RawInstruction::Arg(arg) => Instruction::Arg(ArgInstruction {
506                name: arg.name.to_string(),
507                default: arg.value.as_ref().map(std::string::ToString::to_string),
508            }),
509
510            RawInstruction::Misc(misc) => {
511                let instruction_upper = misc.instruction.content.to_uppercase();
512                match instruction_upper.as_str() {
513                    "WORKDIR" => Instruction::Workdir(misc.arguments.to_string()),
514
515                    "USER" => Instruction::User(misc.arguments.to_string()),
516
517                    "VOLUME" => {
518                        let args = misc.arguments.to_string();
519                        let volumes = if args.trim().starts_with('[') {
520                            serde_json::from_str(&args).unwrap_or_else(|_| vec![args])
521                        } else {
522                            args.split_whitespace().map(String::from).collect()
523                        };
524                        Instruction::Volume(volumes)
525                    }
526
527                    "EXPOSE" => {
528                        let args = misc.arguments.to_string();
529                        let (port_str, protocol) = if let Some((p, proto)) = args.split_once('/') {
530                            let proto = match proto.to_lowercase().as_str() {
531                                "udp" => ExposeProtocol::Udp,
532                                _ => ExposeProtocol::Tcp,
533                            };
534                            (p, proto)
535                        } else {
536                            (args.as_str(), ExposeProtocol::Tcp)
537                        };
538
539                        let port: u16 = port_str.trim().parse().map_err(|_| {
540                            BuildError::InvalidInstruction {
541                                instruction: "EXPOSE".to_string(),
542                                reason: format!("Invalid port number: {port_str}"),
543                            }
544                        })?;
545
546                        Instruction::Expose(ExposeInstruction { port, protocol })
547                    }
548
549                    "SHELL" => {
550                        let args = misc.arguments.to_string();
551                        let shell: Vec<String> = serde_json::from_str(&args).map_err(|_| {
552                            BuildError::InvalidInstruction {
553                                instruction: "SHELL".to_string(),
554                                reason: "SHELL requires a JSON array".to_string(),
555                            }
556                        })?;
557                        Instruction::Shell(shell)
558                    }
559
560                    "STOPSIGNAL" => Instruction::Stopsignal(misc.arguments.to_string()),
561
562                    "HEALTHCHECK" => {
563                        let args = misc.arguments.to_string().trim().to_string();
564                        if args.eq_ignore_ascii_case("NONE") {
565                            Instruction::Healthcheck(HealthcheckInstruction::None)
566                        } else {
567                            let command = if let Some(stripped) = args.strip_prefix("CMD ") {
568                                ShellOrExec::Shell(stripped.to_string())
569                            } else {
570                                ShellOrExec::Shell(args)
571                            };
572                            Instruction::Healthcheck(HealthcheckInstruction::cmd(command))
573                        }
574                    }
575
576                    "ONBUILD" => {
577                        tracing::warn!("ONBUILD instruction parsing not fully implemented");
578                        return Ok(None);
579                    }
580
581                    "MAINTAINER" => {
582                        let mut labels = HashMap::new();
583                        labels.insert("maintainer".to_string(), misc.arguments.to_string());
584                        Instruction::Label(labels)
585                    }
586
587                    "ADD" => {
588                        let args = misc.arguments.to_string();
589                        let parts: Vec<String> =
590                            args.split_whitespace().map(String::from).collect();
591
592                        if parts.len() < 2 {
593                            return Err(BuildError::InvalidInstruction {
594                                instruction: "ADD".to_string(),
595                                reason: "ADD requires at least one source and a destination"
596                                    .to_string(),
597                            });
598                        }
599
600                        let (sources, dest) = parts.split_at(parts.len() - 1);
601                        let destination = dest.first().cloned().unwrap_or_default();
602
603                        Instruction::Add(AddInstruction {
604                            sources: sources.to_vec(),
605                            destination,
606                            chown: None,
607                            chmod: None,
608                            link: false,
609                            checksum: None,
610                            keep_git_dir: false,
611                        })
612                    }
613
614                    other => {
615                        tracing::warn!("Unknown Dockerfile instruction: {}", other);
616                        return Ok(None);
617                    }
618                }
619            }
620        };
621
622        Ok(Some(instruction))
623    }
624
625    /// Get a stage by name or index
626    #[must_use]
627    pub fn get_stage(&self, name_or_index: &str) -> Option<&Stage> {
628        self.stages.iter().find(|s| s.matches(name_or_index))
629    }
630
631    /// Get the final stage (last one in the Dockerfile)
632    #[must_use]
633    pub fn final_stage(&self) -> Option<&Stage> {
634        self.stages.last()
635    }
636
637    /// Get all stage names/identifiers
638    #[must_use]
639    pub fn stage_names(&self) -> Vec<String> {
640        self.stages.iter().map(Stage::identifier).collect()
641    }
642
643    /// Check if a stage exists
644    #[must_use]
645    pub fn has_stage(&self, name_or_index: &str) -> bool {
646        self.get_stage(name_or_index).is_some()
647    }
648
649    /// Returns the number of stages
650    #[must_use]
651    pub fn stage_count(&self) -> usize {
652        self.stages.len()
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    #[test]
661    fn test_parse_simple_dockerfile() {
662        let content = r#"
663FROM alpine:3.18
664RUN apk add --no-cache curl
665COPY . /app
666WORKDIR /app
667CMD ["./app"]
668"#;
669
670        let dockerfile = Dockerfile::parse(content).unwrap();
671        assert_eq!(dockerfile.stages.len(), 1);
672
673        let stage = &dockerfile.stages[0];
674        assert_eq!(stage.index, 0);
675        assert!(stage.name.is_none());
676        assert_eq!(stage.instructions.len(), 4);
677    }
678
679    #[test]
680    fn test_parse_multistage_dockerfile() {
681        let content = r#"
682FROM golang:1.21 AS builder
683WORKDIR /src
684COPY . .
685RUN go build -o /app
686
687FROM alpine:3.18
688COPY --from=builder /app /app
689CMD ["/app"]
690"#;
691
692        let dockerfile = Dockerfile::parse(content).unwrap();
693        assert_eq!(dockerfile.stages.len(), 2);
694
695        let builder = &dockerfile.stages[0];
696        assert_eq!(builder.name, Some("builder".to_string()));
697
698        let runtime = &dockerfile.stages[1];
699        assert!(runtime.name.is_none());
700
701        let copy = runtime
702            .instructions
703            .iter()
704            .find(|i| matches!(i, Instruction::Copy(_)));
705        assert!(copy.is_some());
706        if let Some(Instruction::Copy(c)) = copy {
707            assert_eq!(c.from, Some("builder".to_string()));
708        }
709    }
710
711    #[test]
712    fn test_parse_copy_from_external_image_reference() {
713        // `COPY --from=<external-image>` must capture the full registry-
714        // qualified reference in `CopyInstruction.from` so the buildah
715        // backend can pull and forward it to `buildah copy --from=...`.
716        let content = r"
717FROM alpine:3.18
718COPY --from=ghcr.io/astral-sh/uv:0.5.0 /uv /usr/local/bin/uv
719RUN /usr/local/bin/uv --version
720";
721
722        let dockerfile = Dockerfile::parse(content).unwrap();
723        assert_eq!(dockerfile.stages.len(), 1);
724
725        let copy = dockerfile.stages[0]
726            .instructions
727            .iter()
728            .find_map(|i| {
729                if let Instruction::Copy(c) = i {
730                    Some(c)
731                } else {
732                    None
733                }
734            })
735            .expect("COPY instruction present");
736
737        assert_eq!(
738            copy.from,
739            Some("ghcr.io/astral-sh/uv:0.5.0".to_string()),
740            "external image ref must be preserved verbatim in CopyInstruction.from",
741        );
742        assert_eq!(copy.sources, vec!["/uv".to_string()]);
743        assert_eq!(copy.destination, "/usr/local/bin/uv".to_string());
744
745        // The parser must NOT treat the external ref as a stage; only the
746        // top-level `FROM alpine:3.18` should appear in the stage list.
747        assert!(dockerfile.get_stage("ghcr.io/astral-sh/uv:0.5.0").is_none());
748    }
749
750    #[test]
751    fn expand_from_args_all_forms() {
752        let vars: HashMap<String, String> = [
753            ("BASE".to_string(), "ghcr.io/org/img".to_string()),
754            ("TAG".to_string(), "1.2".to_string()),
755            ("EMPTY".to_string(), String::new()),
756        ]
757        .into_iter()
758        .collect();
759
760        assert_eq!(
761            expand_from_args("${BASE}:${TAG}", &vars),
762            "ghcr.io/org/img:1.2"
763        );
764        assert_eq!(expand_from_args("$BASE", &vars), "ghcr.io/org/img");
765        assert_eq!(
766            expand_from_args("img:${MISSING:-latest}", &vars),
767            "img:latest"
768        );
769        assert_eq!(
770            expand_from_args("img:${EMPTY:-fallback}", &vars),
771            "img:fallback"
772        );
773        assert_eq!(expand_from_args("img:${TAG:+pinned}", &vars), "img:pinned");
774        assert_eq!(expand_from_args("img:${MISSING:+pinned}", &vars), "img:");
775        assert_eq!(expand_from_args("${MISSING}", &vars), "");
776        // BuildKit `$$` escape yields a literal dollar; a trailing `$`
777        // passes through.
778        assert_eq!(expand_from_args("a$$b", &vars), "a$b");
779        assert_eq!(expand_from_args("price$", &vars), "price$");
780    }
781
782    #[test]
783    fn resolve_from_args_expands_from_lines() {
784        let content = r"
785ARG BASE_IMAGE=ghcr.io/org/alpine:latest
786ARG BASE_TAG=latest
787FROM ${BASE_IMAGE} AS builder
788RUN echo hi
789FROM ghcr.io/org/alpine:${BASE_TAG}
790COPY --from=builder /x /x
791";
792        let mut dockerfile = Dockerfile::parse(content).unwrap();
793        // Pre-resolution both FROM targets are (mis)classified as stages.
794        assert!(dockerfile.stages[0].base_image.is_stage());
795        assert!(dockerfile.stages[1].base_image.is_stage());
796
797        let build_args: HashMap<String, String> = [("BASE_TAG".to_string(), "3.20".to_string())]
798            .into_iter()
799            .collect();
800        dockerfile.resolve_from_args(&build_args);
801
802        match &dockerfile.stages[0].base_image {
803            DockerfileFromTarget::Image(r) => {
804                assert_eq!(r.to_string(), "ghcr.io/org/alpine:latest");
805            }
806            other => panic!("stage 0 not resolved to an image: {other:?}"),
807        }
808        match &dockerfile.stages[1].base_image {
809            DockerfileFromTarget::Image(r) => {
810                // The build arg overrides the declared default.
811                assert_eq!(r.to_string(), "ghcr.io/org/alpine:3.20");
812            }
813            other => panic!("stage 1 not resolved to an image: {other:?}"),
814        }
815    }
816
817    #[test]
818    fn resolve_from_args_keeps_stage_references() {
819        let content = r"
820ARG BASE=ghcr.io/org/alpine:latest
821FROM ${BASE} AS builder
822RUN echo hi
823FROM builder
824RUN echo again
825";
826        let mut dockerfile = Dockerfile::parse(content).unwrap();
827        dockerfile.resolve_from_args(&HashMap::new());
828        assert!(matches!(
829            &dockerfile.stages[0].base_image,
830            DockerfileFromTarget::Image(_)
831        ));
832        // `FROM builder` must stay a stage reference.
833        assert_eq!(
834            dockerfile.stages[1].base_image,
835            DockerfileFromTarget::Stage("builder".to_string())
836        );
837    }
838
839    #[test]
840    fn test_parse_global_args() {
841        let content = r#"
842ARG BASE_IMAGE=alpine:3.18
843FROM ${BASE_IMAGE}
844RUN echo "hello"
845"#;
846
847        let dockerfile = Dockerfile::parse(content).unwrap();
848        assert_eq!(dockerfile.global_args.len(), 1);
849        assert_eq!(dockerfile.global_args[0].name, "BASE_IMAGE");
850        assert_eq!(
851            dockerfile.global_args[0].default,
852            Some("alpine:3.18".to_string())
853        );
854    }
855
856    #[test]
857    fn test_get_stage_by_name() {
858        let content = r#"
859FROM alpine:3.18 AS base
860RUN echo "base"
861
862FROM base AS builder
863RUN echo "builder"
864"#;
865
866        let dockerfile = Dockerfile::parse(content).unwrap();
867
868        let base = dockerfile.get_stage("base");
869        assert!(base.is_some());
870        assert_eq!(base.unwrap().index, 0);
871
872        let builder = dockerfile.get_stage("builder");
873        assert!(builder.is_some());
874        assert_eq!(builder.unwrap().index, 1);
875
876        let stage_0 = dockerfile.get_stage("0");
877        assert!(stage_0.is_some());
878        assert_eq!(stage_0.unwrap().name, Some("base".to_string()));
879    }
880
881    #[test]
882    fn test_final_stage() {
883        let content = r#"
884FROM alpine:3.18 AS builder
885RUN echo "builder"
886
887FROM scratch
888COPY --from=builder /app /app
889"#;
890
891        let dockerfile = Dockerfile::parse(content).unwrap();
892        let final_stage = dockerfile.final_stage().unwrap();
893
894        assert_eq!(final_stage.index, 1);
895        assert!(matches!(
896            final_stage.base_image,
897            DockerfileFromTarget::Scratch
898        ));
899    }
900
901    #[test]
902    fn test_parse_env_instruction() {
903        let content = r"
904FROM alpine
905ENV FOO=bar BAZ=qux
906";
907
908        let dockerfile = Dockerfile::parse(content).unwrap();
909        let stage = &dockerfile.stages[0];
910
911        let env = stage
912            .instructions
913            .iter()
914            .find(|i| matches!(i, Instruction::Env(_)));
915        assert!(env.is_some());
916
917        if let Some(Instruction::Env(e)) = env {
918            assert_eq!(e.vars.get("FOO"), Some(&"bar".to_string()));
919            assert_eq!(e.vars.get("BAZ"), Some(&"qux".to_string()));
920        }
921    }
922}