Skip to main content

sui_spec/
dockerfile.rs

1//! Typed border for a REGULAR Dockerfile (BuildKit-syntax, not a Nix
2//! derivation) digested into a content-addressed derivation graph.
3//!
4//! Sui-spec proves it can consume plain Dockerfiles without
5//! rewriting them: a hand-rolled instruction-line parser scoped to
6//! exactly the instruction set below produces a typed
7//! [`DockerfileGraph`] whose node hashes are computed the same way
8//! [`crate::hash`] and [`crate::derivation`] compute nix store-path
9//! hashes -- a function of `(instruction-kind, prior-layer-digest,
10//! resolved-build-arg-values)`.  Two runs over identical input
11//! produce hash-identical graphs; changing one instruction's content
12//! invalidates only that node and its downstream dependents.
13//!
14//! ## Scope (deliberately narrow)
15//!
16//! Supported instructions: `FROM`, `RUN` (incl. `--mount=type=bind`),
17//! `COPY` (incl. `--from=<stage>`), `ARG` (incl. default values),
18//! `ENV`, `WORKDIR`, `CMD`, `ENTRYPOINT`.  `ARG TARGETARCH` is
19//! resolved from the environment's build-arg map like any other
20//! `ARG`.  Multi-stage `FROM ... AS <alias>` aliasing beyond the
21//! `--from=` reference on `COPY`, full `BuildKit` heredoc syntax, and
22//! ARG default-value precedence beyond "declared default, overridden
23//! by environment" are explicitly OUT OF SCOPE for this phase (see
24//! the module-level doc on the missing pieces, or the phase report).
25//!
26//! ## Authoring surface
27//!
28//! ```lisp
29//! (defdockerfile-graph
30//!   :name        "simple-three-instruction"
31//!   :description "..."
32//!   :source-text "FROM debian:bookworm-slim\nRUN ...\nCMD [...]\n")
33//! ```
34
35use std::collections::BTreeMap;
36
37use serde::{Deserialize, Serialize};
38use tatara_lisp::DeriveTataraDomain;
39
40use crate::SpecError;
41
42// ── Typed border — authored canonical instances ─────────────────────
43
44/// One canonical `(defdockerfile-graph ...)` instance.  `source_text`
45/// is the raw Dockerfile text the interpreter parses; canonical
46/// instances exist so the graph-build algorithm has a fixed corpus
47/// to prove determinism + cache-leverage against.
48#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
49#[tatara(keyword = "defdockerfile-graph")]
50pub struct DockerfileGraphSpec {
51    pub name: String,
52    pub description: String,
53    #[serde(rename = "sourceText")]
54    pub source_text: String,
55}
56
57// ── Typed border — parsed instructions ──────────────────────────────
58
59/// One parsed Dockerfile instruction.  Bad states (a `COPY` with no
60/// source, an `ARG` reference that never resolves) are rejected at
61/// parse time via [`SpecError::Interp`] — never silently accepted.
62#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
63pub enum DockerfileInstruction {
64    From {
65        image: String,
66        stage_alias: Option<String>,
67    },
68    Run {
69        command: String,
70        mount_bind_source: Option<String>,
71        mount_bind_target: Option<String>,
72    },
73    Copy {
74        sources: Vec<String>,
75        destination: String,
76        from_stage: Option<String>,
77    },
78    Arg {
79        name: String,
80        default_value: Option<String>,
81    },
82    Env {
83        name: String,
84        value: String,
85    },
86    Workdir {
87        path: String,
88    },
89    Cmd {
90        argv: Vec<String>,
91    },
92    Entrypoint {
93        argv: Vec<String>,
94    },
95}
96
97/// Which instruction kind a node represents — the coarse key used in
98/// the node's content hash alongside its resolved argument bytes.
99#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
100pub enum InstructionKind {
101    From,
102    Run,
103    Copy,
104    Arg,
105    Env,
106    Workdir,
107    Cmd,
108    Entrypoint,
109}
110
111impl DockerfileInstruction {
112    fn kind(&self) -> InstructionKind {
113        match self {
114            Self::From { .. } => InstructionKind::From,
115            Self::Run { .. } => InstructionKind::Run,
116            Self::Copy { .. } => InstructionKind::Copy,
117            Self::Arg { .. } => InstructionKind::Arg,
118            Self::Env { .. } => InstructionKind::Env,
119            Self::Workdir { .. } => InstructionKind::Workdir,
120            Self::Cmd { .. } => InstructionKind::Cmd,
121            Self::Entrypoint { .. } => InstructionKind::Entrypoint,
122        }
123    }
124
125    /// Canonical byte representation of this instruction's own
126    /// content (excluding any prior-layer digest) — the input to the
127    /// node's content hash.  A typed `Display`-style renderer, never
128    /// `format!()` of ad-hoc pieces glued at the hash call site.
129    fn content_bytes(&self) -> Vec<u8> {
130        let mut buf = Vec::new();
131        match self {
132            Self::From { image, stage_alias } => {
133                buf.extend_from_slice(image.as_bytes());
134                buf.push(0);
135                if let Some(alias) = stage_alias {
136                    buf.extend_from_slice(alias.as_bytes());
137                }
138            }
139            Self::Run { command, mount_bind_source, mount_bind_target } => {
140                buf.extend_from_slice(command.as_bytes());
141                buf.push(0);
142                if let Some(s) = mount_bind_source {
143                    buf.extend_from_slice(s.as_bytes());
144                }
145                buf.push(0);
146                if let Some(t) = mount_bind_target {
147                    buf.extend_from_slice(t.as_bytes());
148                }
149            }
150            Self::Copy { sources, destination, from_stage } => {
151                for s in sources {
152                    buf.extend_from_slice(s.as_bytes());
153                    buf.push(0);
154                }
155                buf.extend_from_slice(destination.as_bytes());
156                buf.push(0);
157                if let Some(stage) = from_stage {
158                    buf.extend_from_slice(stage.as_bytes());
159                }
160            }
161            Self::Arg { name, default_value } => {
162                buf.extend_from_slice(name.as_bytes());
163                buf.push(0);
164                if let Some(v) = default_value {
165                    buf.extend_from_slice(v.as_bytes());
166                }
167            }
168            Self::Env { name, value } => {
169                buf.extend_from_slice(name.as_bytes());
170                buf.push(0);
171                buf.extend_from_slice(value.as_bytes());
172            }
173            Self::Workdir { path } => buf.extend_from_slice(path.as_bytes()),
174            Self::Cmd { argv } | Self::Entrypoint { argv } => {
175                for a in argv {
176                    buf.extend_from_slice(a.as_bytes());
177                    buf.push(0);
178                }
179            }
180        }
181        buf
182    }
183}
184
185/// One node in the content-addressed build graph.  `content_hash` is
186/// BLAKE3 over `(kind, prior_layer_digest, resolved_build_arg_values,
187/// instruction_content_bytes)` — reusing [`crate::hash`]'s BLAKE3
188/// convention (the same primitive `lockfile_graph`/`ast_graph` use
189/// for their content-addressed forms).
190#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
191pub struct DockerfileNode {
192    pub kind: InstructionKind,
193    pub instruction: DockerfileInstruction,
194    #[serde(rename = "contentHash")]
195    pub content_hash: String,
196    #[serde(rename = "parentHash")]
197    pub parent_hash: Option<String>,
198}
199
200/// The full parsed + hashed graph — a linear chain (Dockerfile
201/// instructions execute strictly top-to-bottom; no branching), so
202/// "graph" here is a hash-linked list, the same shape a derivation's
203/// input closure takes at each single-output layer.
204#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
205pub struct DockerfileGraph {
206    pub nodes: Vec<DockerfileNode>,
207}
208
209impl DockerfileGraph {
210    /// Content hash of the final node — the graph's overall identity,
211    /// analogous to a derivation's output path hash.
212    #[must_use]
213    pub fn root_hash(&self) -> Option<&str> {
214        self.nodes.last().map(|n| n.content_hash.as_str())
215    }
216}
217
218// ── Environment seam ─────────────────────────────────────────────────
219
220/// Abstract IO environment for the dockerfile interpreter.  The only
221/// side effect this domain performs is reading Dockerfile text and
222/// resolving build-arg values from environment — both behind this
223/// trait so tests use a mock and production wraps real
224/// filesystem/env access.
225pub trait DockerfileEnvironment {
226    /// Read the Dockerfile at `path`.  In the canonical-instance path
227    /// the "path" is just the spec's `name` and the text comes from
228    /// the authored spec itself; a real consumer wraps
229    /// `std::fs::read_to_string`.
230    ///
231    /// # Errors
232    ///
233    /// Implementations return their own error which the interpreter
234    /// converts to `SpecError::Interp { phase: "read-dockerfile" }`.
235    fn read_dockerfile(&self, path: &str) -> Result<String, String>;
236
237    /// Resolve a build-arg's value from the environment (e.g.
238    /// `--build-arg TARGETARCH=amd64`).  Returns `None` on miss —
239    /// the interpreter falls back to the `ARG`'s declared default,
240    /// or leaves it unresolved if neither exists.
241    fn resolve_build_arg(&self, name: &str) -> Option<String>;
242}
243
244/// In-memory mock environment for tests.  Pre-load Dockerfile text by
245/// path + build-arg values by name.
246#[derive(Default)]
247pub struct MockDockerfileEnvironment {
248    pub dockerfiles: BTreeMap<String, String>,
249    pub build_args: BTreeMap<String, String>,
250}
251
252impl MockDockerfileEnvironment {
253    #[must_use]
254    pub fn with_dockerfile(mut self, path: &str, text: &str) -> Self {
255        self.dockerfiles.insert(path.to_string(), text.to_string());
256        self
257    }
258
259    #[must_use]
260    pub fn with_build_arg(mut self, name: &str, value: &str) -> Self {
261        self.build_args.insert(name.to_string(), value.to_string());
262        self
263    }
264}
265
266impl DockerfileEnvironment for MockDockerfileEnvironment {
267    fn read_dockerfile(&self, path: &str) -> Result<String, String> {
268        self.dockerfiles
269            .get(path)
270            .cloned()
271            .ok_or_else(|| format!("no canned Dockerfile at `{path}`"))
272    }
273
274    fn resolve_build_arg(&self, name: &str) -> Option<String> {
275        self.build_args.get(name).cloned()
276    }
277}
278
279// ── Interpreter ───────────────────────────────────────────────────────
280
281/// Inputs to a dockerfile-graph run.  `path` is passed to
282/// [`DockerfileEnvironment::read_dockerfile`]; for the canonical-
283/// instance tests it is conventionally the spec's `name`.
284pub struct DockerfileArgs {
285    pub path: String,
286}
287
288/// Parse + content-address a Dockerfile into its typed
289/// [`DockerfileGraph`].
290///
291/// # Errors
292///
293/// - `SpecError::Interp { phase: "read-dockerfile" }` if the
294///   environment couldn't read the file.
295/// - `SpecError::Interp { phase: "parse-line" }` for a line this
296///   scoped parser can't classify, or a malformed instruction (e.g.
297///   `COPY` with no destination).
298/// - `SpecError::Interp { phase: "unresolved-arg-reference" }` if a
299///   `RUN`/`ENV`/`CMD`/... body references `$SOME_ARG` and neither
300///   the environment nor the `ARG`'s own default resolves it.
301pub fn apply<E: DockerfileEnvironment>(
302    args: &DockerfileArgs,
303    env: &E,
304) -> Result<DockerfileGraph, SpecError> {
305    let text = env.read_dockerfile(&args.path).map_err(|e| SpecError::Interp {
306        phase: "read-dockerfile".into(),
307        message: format!("reading `{}`: {e}", args.path),
308    })?;
309
310    let mut resolved_args: BTreeMap<String, String> = BTreeMap::new();
311    let mut nodes = Vec::new();
312    let mut parent_hash: Option<String> = None;
313
314    for (line_no, raw_line) in join_continuations(&text).into_iter().enumerate() {
315        let line = raw_line.trim();
316        if line.is_empty() || line.starts_with('#') {
317            continue;
318        }
319
320        let instruction = parse_instruction(line, line_no + 1, &resolved_args, env)?;
321
322        if let DockerfileInstruction::Arg { name, default_value } = &instruction {
323            let value = env
324                .resolve_build_arg(name)
325                .or_else(|| default_value.clone());
326            if let Some(v) = value {
327                resolved_args.insert(name.clone(), v);
328            }
329        }
330
331        let node = hash_node(instruction, parent_hash.as_deref(), &resolved_args);
332        parent_hash = Some(node.content_hash.clone());
333        nodes.push(node);
334    }
335
336    Ok(DockerfileGraph { nodes })
337}
338
339/// `BuildKit` allows a `RUN` (or any instruction) to continue onto the
340/// next physical line with a trailing `\`.  Join those before the
341/// per-line parser runs so `--mount=...` flags that wrap don't split.
342fn join_continuations(text: &str) -> Vec<String> {
343    let mut out = Vec::new();
344    let mut current = String::new();
345    for raw in text.lines() {
346        let trimmed_end = raw.trim_end();
347        if let Some(stripped) = trimmed_end.strip_suffix('\\') {
348            current.push_str(stripped);
349            current.push(' ');
350        } else {
351            current.push_str(trimmed_end);
352            out.push(std::mem::take(&mut current));
353        }
354    }
355    if !current.trim().is_empty() {
356        out.push(current);
357    }
358    out
359}
360
361fn hash_node(
362    instruction: DockerfileInstruction,
363    parent_hash: Option<&str>,
364    resolved_args: &BTreeMap<String, String>,
365) -> DockerfileNode {
366    let kind = instruction.kind();
367    let mut hasher = blake3::Hasher::new();
368    hasher.update(format!("{kind:?}").as_bytes());
369    hasher.update(b"\0");
370    if let Some(parent) = parent_hash {
371        hasher.update(parent.as_bytes());
372    }
373    hasher.update(b"\0");
374    for (k, v) in resolved_args {
375        hasher.update(k.as_bytes());
376        hasher.update(b"=");
377        hasher.update(v.as_bytes());
378        hasher.update(b"\0");
379    }
380    hasher.update(b"\0");
381    hasher.update(&instruction.content_bytes());
382    let content_hash = hasher.finalize().to_hex().to_string();
383
384    DockerfileNode {
385        kind,
386        instruction,
387        content_hash,
388        parent_hash: parent_hash.map(ToString::to_string),
389    }
390}
391
392/// Substitute `$NAME` / `${NAME}` references in `text` against
393/// `resolved_args`.  A reference to a name that resolves to nothing
394/// (never declared via `ARG`, or declared with no default and never
395/// supplied) is a typed error — never silently passed through.
396fn substitute_arg_refs(
397    text: &str,
398    resolved_args: &BTreeMap<String, String>,
399) -> Result<String, SpecError> {
400    let mut out = String::with_capacity(text.len());
401    let bytes = text.as_bytes();
402    let mut i = 0;
403    while i < bytes.len() {
404        if bytes[i] == b'$' {
405            let (name, consumed) = if bytes.get(i + 1) == Some(&b'{') {
406                let end = text[i + 2..]
407                    .find('}')
408                    .map_or(text.len(), |p| i + 2 + p);
409                (text[i + 2..end].to_string(), end + 1 - i)
410            } else {
411                let start = i + 1;
412                let end = text[start..]
413                    .find(|c: char| !(c.is_alphanumeric() || c == '_'))
414                    .map_or(text.len(), |p| start + p);
415                (text[start..end].to_string(), end - i)
416            };
417            if name.is_empty() {
418                out.push('$');
419                i += 1;
420                continue;
421            }
422            match resolved_args.get(&name) {
423                Some(v) => out.push_str(v),
424                None => {
425                    return Err(SpecError::Interp {
426                        phase: "unresolved-arg-reference".into(),
427                        message: format!(
428                            "`${name}` referenced but never resolved by ARG default or build-arg",
429                        ),
430                    });
431                }
432            }
433            i += consumed;
434        } else {
435            out.push(bytes[i] as char);
436            i += 1;
437        }
438    }
439    Ok(out)
440}
441
442fn parse_instruction<E: DockerfileEnvironment>(
443    line: &str,
444    line_no: usize,
445    resolved_args: &BTreeMap<String, String>,
446    _env: &E,
447) -> Result<DockerfileInstruction, SpecError> {
448    let (keyword, rest) = split_keyword(line).ok_or_else(|| SpecError::Interp {
449        phase: "parse-line".into(),
450        message: format!("line {line_no}: no instruction keyword found in `{line}`"),
451    })?;
452
453    match keyword.to_ascii_uppercase().as_str() {
454        "FROM" => parse_from(rest, line_no),
455        "RUN" => parse_run(rest, line_no, resolved_args),
456        "COPY" => parse_copy(rest, line_no),
457        "ARG" => parse_arg(rest, line_no),
458        "ENV" => parse_env(rest, line_no, resolved_args),
459        "WORKDIR" => Ok(DockerfileInstruction::Workdir { path: rest.trim().to_string() }),
460        "CMD" => Ok(DockerfileInstruction::Cmd { argv: parse_argv(rest) }),
461        "ENTRYPOINT" => Ok(DockerfileInstruction::Entrypoint { argv: parse_argv(rest) }),
462        other => Err(SpecError::Interp {
463            phase: "parse-line".into(),
464            message: format!(
465                "line {line_no}: unsupported instruction `{other}` — this scoped \
466                 parser handles FROM/RUN/COPY/ARG/ENV/WORKDIR/CMD/ENTRYPOINT only",
467            ),
468        }),
469    }
470}
471
472fn split_keyword(line: &str) -> Option<(&str, &str)> {
473    let idx = line.find(char::is_whitespace)?;
474    Some((&line[..idx], line[idx..].trim_start()))
475}
476
477fn parse_from(rest: &str, line_no: usize) -> Result<DockerfileInstruction, SpecError> {
478    let mut parts = rest.split_whitespace();
479    let image = parts.next().ok_or_else(|| SpecError::Interp {
480        phase: "parse-line".into(),
481        message: format!("line {line_no}: FROM with no image"),
482    })?;
483    let stage_alias = match parts.next() {
484        Some(as_kw) if as_kw.eq_ignore_ascii_case("as") => {
485            parts.next().map(ToString::to_string)
486        }
487        _ => None,
488    };
489    Ok(DockerfileInstruction::From { image: image.to_string(), stage_alias })
490}
491
492fn parse_run(
493    rest: &str,
494    line_no: usize,
495    resolved_args: &BTreeMap<String, String>,
496) -> Result<DockerfileInstruction, SpecError> {
497    let (flags, command) = split_flags(rest);
498    let mut mount_bind_source = None;
499    let mut mount_bind_target = None;
500    for flag in &flags {
501        if let Some(mount_spec) = flag.strip_prefix("--mount=") {
502            for kv in mount_spec.split(',') {
503                if let Some(v) = kv.strip_prefix("source=") {
504                    mount_bind_source = Some(v.to_string());
505                } else if let Some(v) = kv.strip_prefix("target=") {
506                    mount_bind_target = Some(v.to_string());
507                }
508            }
509        }
510    }
511    if command.trim().is_empty() {
512        return Err(SpecError::Interp {
513            phase: "parse-line".into(),
514            message: format!("line {line_no}: RUN with no command"),
515        });
516    }
517    let command = substitute_arg_refs(command.trim(), resolved_args)?;
518    Ok(DockerfileInstruction::Run { command, mount_bind_source, mount_bind_target })
519}
520
521fn parse_copy(rest: &str, line_no: usize) -> Result<DockerfileInstruction, SpecError> {
522    let (flags, body) = split_flags(rest);
523    let mut from_stage = None;
524    for flag in &flags {
525        if let Some(v) = flag.strip_prefix("--from=") {
526            from_stage = Some(v.to_string());
527        }
528    }
529    let tokens: Vec<&str> = body.split_whitespace().collect();
530    if tokens.len() < 2 {
531        return Err(SpecError::Interp {
532            phase: "parse-line".into(),
533            message: format!(
534                "line {line_no}: COPY requires at least one source and a destination, got `{rest}`",
535            ),
536        });
537    }
538    let (destination, sources) = tokens.split_last().expect("checked len >= 2 above");
539    Ok(DockerfileInstruction::Copy {
540        sources: sources.iter().map(ToString::to_string).collect(),
541        destination: (*destination).to_string(),
542        from_stage,
543    })
544}
545
546fn parse_arg(rest: &str, line_no: usize) -> Result<DockerfileInstruction, SpecError> {
547    let rest = rest.trim();
548    if rest.is_empty() {
549        return Err(SpecError::Interp {
550            phase: "parse-line".into(),
551            message: format!("line {line_no}: ARG with no name"),
552        });
553    }
554    match rest.split_once('=') {
555        Some((name, value)) => Ok(DockerfileInstruction::Arg {
556            name: name.trim().to_string(),
557            default_value: Some(value.trim().trim_matches('"').to_string()),
558        }),
559        None => Ok(DockerfileInstruction::Arg { name: rest.to_string(), default_value: None }),
560    }
561}
562
563fn parse_env(
564    rest: &str,
565    line_no: usize,
566    resolved_args: &BTreeMap<String, String>,
567) -> Result<DockerfileInstruction, SpecError> {
568    let rest = rest.trim();
569    let (name, value) = rest.split_once('=').ok_or_else(|| SpecError::Interp {
570        phase: "parse-line".into(),
571        message: format!("line {line_no}: ENV requires `NAME=value`, got `{rest}`"),
572    })?;
573    let value = substitute_arg_refs(value.trim().trim_matches('"'), resolved_args)?;
574    Ok(DockerfileInstruction::Env { name: name.trim().to_string(), value })
575}
576
577/// Parse a `CMD`/`ENTRYPOINT` body — either JSON-array exec form
578/// (`["a", "b"]`) or a bare shell-form string treated as a single
579/// argv element.
580fn parse_argv(rest: &str) -> Vec<String> {
581    let rest = rest.trim();
582    if let Some(inner) = rest.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
583        inner
584            .split(',')
585            .map(|s| s.trim().trim_matches('"').to_string())
586            .filter(|s| !s.is_empty())
587            .collect()
588    } else {
589        vec![rest.to_string()]
590    }
591}
592
593/// Split leading `--flag` / `--flag=value` tokens from the remainder
594/// of an instruction body (used by `RUN --mount=...` and
595/// `COPY --from=...`).
596fn split_flags(rest: &str) -> (Vec<String>, &str) {
597    let mut flags = Vec::new();
598    let mut remainder = rest.trim_start();
599    while let Some(stripped) = remainder.strip_prefix("--") {
600        let end = stripped.find(char::is_whitespace).unwrap_or(stripped.len());
601        let mut flag = String::with_capacity(2 + end);
602        flag.push_str("--");
603        flag.push_str(&stripped[..end]);
604        flags.push(flag);
605        remainder = remainder[2 + end..].trim_start();
606    }
607    (flags, remainder)
608}
609
610// ── Canonical spec ────────────────────────────────────────────────────
611
612pub const CANONICAL_DOCKERFILE_LISP: &str = include_str!("../specs/dockerfile.lisp");
613
614/// Compile every authored `(defdockerfile-graph ...)` instance.
615///
616/// # Errors
617///
618/// Returns an error if the Lisp source fails to parse.
619pub fn load_canonical() -> Result<Vec<DockerfileGraphSpec>, SpecError> {
620    crate::loader::load_all::<DockerfileGraphSpec>(CANONICAL_DOCKERFILE_LISP)
621}
622
623/// Return the canonical instance whose `name` matches.
624///
625/// # Errors
626///
627/// Returns an error if the spec fails to parse or `name` is missing.
628pub fn load_named(name: &str) -> Result<DockerfileGraphSpec, SpecError> {
629    load_canonical()?
630        .into_iter()
631        .find(|d| d.name == name)
632        .ok_or_else(|| SpecError::Load(format!(
633            "no (defdockerfile-graph) with :name {name:?}",
634        )))
635}
636
637/// Convenience wrapper: run [`apply`] against a canonical instance's
638/// own `source_text`, via a [`MockDockerfileEnvironment`] pre-loaded
639/// with that text under its own `name` as the path.
640///
641/// # Errors
642///
643/// Propagates any error from [`apply`] or [`load_named`].
644pub fn apply_canonical(name: &str, build_args: &[(&str, &str)]) -> Result<DockerfileGraph, SpecError> {
645    let spec = load_named(name)?;
646    let mut env = MockDockerfileEnvironment::default().with_dockerfile(name, &spec.source_text);
647    for (k, v) in build_args {
648        env = env.with_build_arg(k, v);
649    }
650    apply(&DockerfileArgs { path: name.to_string() }, &env)
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    #[test]
658    fn canonical_dockerfile_specs_parse() {
659        let specs = load_canonical().expect("canonical dockerfile specs must compile");
660        assert_eq!(specs.len(), 2);
661    }
662
663    #[test]
664    fn simple_three_instruction_shape() {
665        let graph = apply_canonical("simple-three-instruction", &[]).unwrap();
666        let kinds: Vec<InstructionKind> = graph.nodes.iter().map(|n| n.kind).collect();
667        assert_eq!(
668            kinds,
669            vec![InstructionKind::From, InstructionKind::Run, InstructionKind::Cmd],
670        );
671    }
672
673    #[test]
674    fn nonroot_gateway_shape() {
675        let graph = apply_canonical("nonroot-gateway", &[("TARGETARCH", "amd64")]).unwrap();
676        // FROM, ARG TARGETARCH, ARG FIPS, RUN(apt), RUN(--mount FIPS),
677        // ENV, WORKDIR, COPY, RUN(adduser), ENTRYPOINT, CMD = 11 nodes.
678        assert_eq!(graph.nodes.len(), 11);
679
680        let run_with_mount = graph
681            .nodes
682            .iter()
683            .find(|n| matches!(&n.instruction, DockerfileInstruction::Run { mount_bind_source: Some(_), .. }))
684            .expect("expected a RUN with --mount=type=bind");
685        match &run_with_mount.instruction {
686            DockerfileInstruction::Run { mount_bind_source, mount_bind_target, command } => {
687                assert_eq!(mount_bind_source.as_deref(), Some("."));
688                assert_eq!(mount_bind_target.as_deref(), Some("/build"));
689                assert!(command.contains("amd64"), "TARGETARCH should have been substituted: {command}");
690            }
691            _ => unreachable!(),
692        }
693    }
694
695    #[test]
696    fn identical_inputs_produce_hash_identical_graphs() {
697        let g1 = apply_canonical("nonroot-gateway", &[("TARGETARCH", "arm64")]).unwrap();
698        let g2 = apply_canonical("nonroot-gateway", &[("TARGETARCH", "arm64")]).unwrap();
699        assert_eq!(g1, g2);
700        assert_eq!(g1.root_hash(), g2.root_hash());
701        for (a, b) in g1.nodes.iter().zip(g2.nodes.iter()) {
702            assert_eq!(a.content_hash, b.content_hash);
703        }
704    }
705
706    #[test]
707    fn different_build_arg_changes_downstream_hashes_only() {
708        let amd = apply_canonical("nonroot-gateway", &[("TARGETARCH", "amd64")]).unwrap();
709        let arm = apply_canonical("nonroot-gateway", &[("TARGETARCH", "arm64")]).unwrap();
710
711        // Nodes strictly before the ARG TARGETARCH resolution (index 0:
712        // FROM) share content that doesn't depend on the arg value, but
713        // this implementation folds resolved_args into every node's
714        // hash (parent-chain style), so upstream-of-the-ARG nodes differ
715        // only from the point the ARG itself is declared onward. Prove
716        // the concrete cache-leverage property instead: the FROM node
717        // (the true root, before any ARG) is hash-identical across runs.
718        assert_eq!(amd.nodes[0].content_hash, arm.nodes[0].content_hash);
719
720        // From the RUN with the substituted TARGETARCH onward, hashes
721        // diverge.
722        let diverge_from = amd
723            .nodes
724            .iter()
725            .zip(arm.nodes.iter())
726            .position(|(a, b)| a.content_hash != b.content_hash)
727            .expect("amd64 vs arm64 runs must diverge somewhere");
728        assert!(diverge_from > 0, "must not diverge at the FROM root");
729
730        // Every node from that point on differs (parent-hash chaining
731        // means downstream divergence propagates monotonically).
732        for (a, b) in amd.nodes[diverge_from..].iter().zip(arm.nodes[diverge_from..].iter()) {
733            assert_ne!(a.content_hash, b.content_hash);
734        }
735    }
736
737    #[test]
738    fn changing_one_instruction_only_invalidates_downstream() {
739        let mut env = MockDockerfileEnvironment::default().with_dockerfile(
740            "a",
741            "FROM debian:bookworm-slim\nRUN echo one\nRUN echo two\nCMD [\"/bin/true\"]\n",
742        );
743        let g1 = apply(&DockerfileArgs { path: "a".into() }, &env).unwrap();
744
745        env = MockDockerfileEnvironment::default().with_dockerfile(
746            "a",
747            "FROM debian:bookworm-slim\nRUN echo one\nRUN echo CHANGED\nCMD [\"/bin/true\"]\n",
748        );
749        let g2 = apply(&DockerfileArgs { path: "a".into() }, &env).unwrap();
750
751        // FROM and the first RUN are untouched upstream siblings.
752        assert_eq!(g1.nodes[0].content_hash, g2.nodes[0].content_hash);
753        assert_eq!(g1.nodes[1].content_hash, g2.nodes[1].content_hash);
754        // The changed RUN and everything after it (CMD, whose parent
755        // hash chains through it) differ.
756        assert_ne!(g1.nodes[2].content_hash, g2.nodes[2].content_hash);
757        assert_ne!(g1.nodes[3].content_hash, g2.nodes[3].content_hash);
758    }
759
760    #[test]
761    fn copy_with_no_source_is_a_typed_error() {
762        let env = MockDockerfileEnvironment::default()
763            .with_dockerfile("bad", "FROM x\nCOPY onlydestination\n");
764        let err = apply(&DockerfileArgs { path: "bad".into() }, &env).unwrap_err();
765        match err {
766            SpecError::Interp { phase, .. } => assert_eq!(phase, "parse-line"),
767            _ => panic!("expected parse-line error"),
768        }
769    }
770
771    #[test]
772    fn unresolvable_arg_reference_is_a_typed_error() {
773        let env = MockDockerfileEnvironment::default()
774            .with_dockerfile("bad", "FROM x\nRUN echo $NEVER_DECLARED\n");
775        let err = apply(&DockerfileArgs { path: "bad".into() }, &env).unwrap_err();
776        match err {
777            SpecError::Interp { phase, message } => {
778                assert_eq!(phase, "unresolved-arg-reference");
779                assert!(message.contains("NEVER_DECLARED"));
780            }
781            _ => panic!("expected unresolved-arg-reference error"),
782        }
783    }
784
785    #[test]
786    fn missing_dockerfile_is_a_typed_error() {
787        let env = MockDockerfileEnvironment::default();
788        let err = apply(&DockerfileArgs { path: "missing".into() }, &env).unwrap_err();
789        match err {
790            SpecError::Interp { phase, .. } => assert_eq!(phase, "read-dockerfile"),
791            _ => panic!("expected read-dockerfile error"),
792        }
793    }
794
795    #[test]
796    fn unsupported_instruction_is_a_typed_error() {
797        let env = MockDockerfileEnvironment::default()
798            .with_dockerfile("bad", "FROM x\nHEALTHCHECK CMD curl -f http://localhost/ || exit 1\n");
799        let err = apply(&DockerfileArgs { path: "bad".into() }, &env).unwrap_err();
800        match err {
801            SpecError::Interp { phase, message } => {
802                assert_eq!(phase, "parse-line");
803                assert!(message.contains("HEALTHCHECK"));
804            }
805            _ => panic!("expected parse-line error"),
806        }
807    }
808
809    #[test]
810    fn line_continuation_is_joined() {
811        let env = MockDockerfileEnvironment::default().with_dockerfile(
812            "cont",
813            "FROM x\nRUN apt-get update && \\\n    apt-get install -y curl\n",
814        );
815        let graph = apply(&DockerfileArgs { path: "cont".into() }, &env).unwrap();
816        assert_eq!(graph.nodes.len(), 2);
817        match &graph.nodes[1].instruction {
818            DockerfileInstruction::Run { command, .. } => {
819                assert!(command.contains("apt-get install"));
820            }
821            _ => panic!("expected RUN"),
822        }
823    }
824}