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`, `VOLUME` (plain or
19//! JSON-array form).  `ARG TARGETARCH` is
20//! resolved from the environment's build-arg map like any other
21//! `ARG`.  Multi-stage `FROM ... AS <alias>` aliasing beyond the
22//! `--from=` reference on `COPY`, full `BuildKit` heredoc syntax, and
23//! ARG default-value precedence beyond "declared default, overridden
24//! by environment" are explicitly OUT OF SCOPE for this phase (see
25//! the module-level doc on the missing pieces, or the phase report).
26//!
27//! ## Authoring surface
28//!
29//! ```lisp
30//! (defdockerfile-graph
31//!   :name        "simple-three-instruction"
32//!   :description "..."
33//!   :source-text "FROM debian:bookworm-slim\nRUN ...\nCMD [...]\n")
34//! ```
35
36use std::collections::{BTreeMap, BTreeSet};
37
38use serde::{Deserialize, Serialize};
39use tatara_lisp::DeriveTataraDomain;
40
41use crate::SpecError;
42
43// ── Typed border — authored canonical instances ─────────────────────
44
45/// One canonical `(defdockerfile-graph ...)` instance.  `source_text`
46/// is the raw Dockerfile text the interpreter parses; canonical
47/// instances exist so the graph-build algorithm has a fixed corpus
48/// to prove determinism + cache-leverage against.
49#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
50#[tatara(keyword = "defdockerfile-graph")]
51pub struct DockerfileGraphSpec {
52    pub name: String,
53    pub description: String,
54    #[serde(rename = "sourceText")]
55    pub source_text: String,
56}
57
58// ── Typed border — parsed instructions ──────────────────────────────
59
60/// One parsed Dockerfile instruction.  Bad states (a `COPY` with no
61/// source, an `ARG` reference that never resolves) are rejected at
62/// parse time via [`SpecError::Interp`] — never silently accepted.
63#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
64pub enum DockerfileInstruction {
65    From {
66        image: String,
67        stage_alias: Option<String>,
68    },
69    Run {
70        command: String,
71        mount_bind_source: Option<String>,
72        mount_bind_target: Option<String>,
73    },
74    Copy {
75        sources: Vec<String>,
76        destination: String,
77        from_stage: Option<String>,
78    },
79    Arg {
80        name: String,
81        default_value: Option<String>,
82    },
83    Env {
84        name: String,
85        value: String,
86    },
87    Workdir {
88        path: String,
89    },
90    Cmd {
91        argv: Vec<String>,
92    },
93    Entrypoint {
94        argv: Vec<String>,
95    },
96    Volume {
97        paths: Vec<String>,
98    },
99}
100
101/// Which instruction kind a node represents — the coarse key used in
102/// the node's content hash alongside its resolved argument bytes.
103#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
104pub enum InstructionKind {
105    From,
106    Run,
107    Copy,
108    Arg,
109    Env,
110    Workdir,
111    Cmd,
112    Entrypoint,
113    Volume,
114}
115
116impl DockerfileInstruction {
117    fn kind(&self) -> InstructionKind {
118        match self {
119            Self::From { .. } => InstructionKind::From,
120            Self::Run { .. } => InstructionKind::Run,
121            Self::Copy { .. } => InstructionKind::Copy,
122            Self::Arg { .. } => InstructionKind::Arg,
123            Self::Env { .. } => InstructionKind::Env,
124            Self::Workdir { .. } => InstructionKind::Workdir,
125            Self::Cmd { .. } => InstructionKind::Cmd,
126            Self::Entrypoint { .. } => InstructionKind::Entrypoint,
127            Self::Volume { .. } => InstructionKind::Volume,
128        }
129    }
130
131    /// Canonical byte representation of this instruction's own
132    /// content (excluding any prior-layer digest) — the input to the
133    /// node's content hash.  A typed `Display`-style renderer, never
134    /// `format!()` of ad-hoc pieces glued at the hash call site.
135    fn content_bytes(&self) -> Vec<u8> {
136        let mut buf = Vec::new();
137        match self {
138            Self::From { image, stage_alias } => {
139                buf.extend_from_slice(image.as_bytes());
140                buf.push(0);
141                if let Some(alias) = stage_alias {
142                    buf.extend_from_slice(alias.as_bytes());
143                }
144            }
145            Self::Run { command, mount_bind_source, mount_bind_target } => {
146                buf.extend_from_slice(command.as_bytes());
147                buf.push(0);
148                if let Some(s) = mount_bind_source {
149                    buf.extend_from_slice(s.as_bytes());
150                }
151                buf.push(0);
152                if let Some(t) = mount_bind_target {
153                    buf.extend_from_slice(t.as_bytes());
154                }
155            }
156            Self::Copy { sources, destination, from_stage } => {
157                for s in sources {
158                    buf.extend_from_slice(s.as_bytes());
159                    buf.push(0);
160                }
161                buf.extend_from_slice(destination.as_bytes());
162                buf.push(0);
163                if let Some(stage) = from_stage {
164                    buf.extend_from_slice(stage.as_bytes());
165                }
166            }
167            Self::Arg { name, default_value } => {
168                buf.extend_from_slice(name.as_bytes());
169                buf.push(0);
170                if let Some(v) = default_value {
171                    buf.extend_from_slice(v.as_bytes());
172                }
173            }
174            Self::Env { name, value } => {
175                buf.extend_from_slice(name.as_bytes());
176                buf.push(0);
177                buf.extend_from_slice(value.as_bytes());
178            }
179            Self::Workdir { path } => buf.extend_from_slice(path.as_bytes()),
180            Self::Cmd { argv } | Self::Entrypoint { argv } => {
181                for a in argv {
182                    buf.extend_from_slice(a.as_bytes());
183                    buf.push(0);
184                }
185            }
186            Self::Volume { paths } => {
187                for p in paths {
188                    buf.extend_from_slice(p.as_bytes());
189                    buf.push(0);
190                }
191            }
192        }
193        buf
194    }
195}
196
197/// One node in the content-addressed build graph.  `content_hash` is
198/// BLAKE3 over `(kind, prior_layer_digest, resolved_build_arg_values,
199/// instruction_content_bytes)` — reusing [`crate::hash`]'s BLAKE3
200/// convention (the same primitive `lockfile_graph`/`ast_graph` use
201/// for their content-addressed forms).
202#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
203pub struct DockerfileNode {
204    pub kind: InstructionKind,
205    pub instruction: DockerfileInstruction,
206    #[serde(rename = "contentHash")]
207    pub content_hash: String,
208    #[serde(rename = "parentHash")]
209    pub parent_hash: Option<String>,
210}
211
212/// The full parsed + hashed graph — a linear chain (Dockerfile
213/// instructions execute strictly top-to-bottom; no branching), so
214/// "graph" here is a hash-linked list, the same shape a derivation's
215/// input closure takes at each single-output layer.
216#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
217pub struct DockerfileGraph {
218    pub nodes: Vec<DockerfileNode>,
219}
220
221impl DockerfileGraph {
222    /// Content hash of the final node — the graph's overall identity,
223    /// analogous to a derivation's output path hash.
224    #[must_use]
225    pub fn root_hash(&self) -> Option<&str> {
226        self.nodes.last().map(|n| n.content_hash.as_str())
227    }
228}
229
230// ── Environment seam ─────────────────────────────────────────────────
231
232/// Abstract IO environment for the dockerfile interpreter.  The only
233/// side effect this domain performs is reading Dockerfile text and
234/// resolving build-arg values from environment — both behind this
235/// trait so tests use a mock and production wraps real
236/// filesystem/env access.
237pub trait DockerfileEnvironment {
238    /// Read the Dockerfile at `path`.  In the canonical-instance path
239    /// the "path" is just the spec's `name` and the text comes from
240    /// the authored spec itself; a real consumer wraps
241    /// `std::fs::read_to_string`.
242    ///
243    /// # Errors
244    ///
245    /// Implementations return their own error which the interpreter
246    /// converts to `SpecError::Interp { phase: "read-dockerfile" }`.
247    fn read_dockerfile(&self, path: &str) -> Result<String, String>;
248
249    /// Resolve a build-arg's value from the environment (e.g.
250    /// `--build-arg TARGETARCH=amd64`).  Returns `None` on miss —
251    /// the interpreter falls back to the `ARG`'s declared default,
252    /// or leaves it unresolved if neither exists.
253    fn resolve_build_arg(&self, name: &str) -> Option<String>;
254}
255
256/// In-memory mock environment for tests.  Pre-load Dockerfile text by
257/// path + build-arg values by name.
258#[derive(Default)]
259pub struct MockDockerfileEnvironment {
260    pub dockerfiles: BTreeMap<String, String>,
261    pub build_args: BTreeMap<String, String>,
262}
263
264impl MockDockerfileEnvironment {
265    #[must_use]
266    pub fn with_dockerfile(mut self, path: &str, text: &str) -> Self {
267        self.dockerfiles.insert(path.to_string(), text.to_string());
268        self
269    }
270
271    #[must_use]
272    pub fn with_build_arg(mut self, name: &str, value: &str) -> Self {
273        self.build_args.insert(name.to_string(), value.to_string());
274        self
275    }
276}
277
278impl DockerfileEnvironment for MockDockerfileEnvironment {
279    fn read_dockerfile(&self, path: &str) -> Result<String, String> {
280        self.dockerfiles
281            .get(path)
282            .cloned()
283            .ok_or_else(|| format!("no canned Dockerfile at `{path}`"))
284    }
285
286    fn resolve_build_arg(&self, name: &str) -> Option<String> {
287        self.build_args.get(name).cloned()
288    }
289}
290
291// ── Interpreter ───────────────────────────────────────────────────────
292
293/// Inputs to a dockerfile-graph run.  `path` is passed to
294/// [`DockerfileEnvironment::read_dockerfile`]; for the canonical-
295/// instance tests it is conventionally the spec's `name`.
296pub struct DockerfileArgs {
297    pub path: String,
298}
299
300/// Parse + content-address a Dockerfile into its typed
301/// [`DockerfileGraph`].
302///
303/// # Errors
304///
305/// - `SpecError::Interp { phase: "read-dockerfile" }` if the
306///   environment couldn't read the file.
307/// - `SpecError::Interp { phase: "parse-line" }` for a line this
308///   scoped parser can't classify, or a malformed instruction (e.g.
309///   `COPY` with no destination).
310/// - `SpecError::Interp { phase: "unresolved-arg-reference" }` if a
311///   `RUN`/`ENV`/`CMD`/... body references `$SOME_ARG` and neither
312///   the environment nor the `ARG`'s own default resolves it.
313pub fn apply<E: DockerfileEnvironment>(
314    args: &DockerfileArgs,
315    env: &E,
316) -> Result<DockerfileGraph, SpecError> {
317    let text = env.read_dockerfile(&args.path).map_err(|e| SpecError::Interp {
318        phase: "read-dockerfile".into(),
319        message: format!("reading `{}`: {e}", args.path),
320    })?;
321
322    let mut resolved_args: BTreeMap<String, String> = BTreeMap::new();
323    let mut declared_envs: BTreeSet<String> = BTreeSet::new();
324    let mut nodes = Vec::new();
325    let mut parent_hash: Option<String> = None;
326
327    for (line_no, raw_line) in join_continuations(&text).into_iter().enumerate() {
328        let line = raw_line.trim();
329        if line.is_empty() || line.starts_with('#') {
330            continue;
331        }
332
333        let instruction = parse_instruction(line, line_no + 1, &resolved_args, &declared_envs, env)?;
334
335        if let DockerfileInstruction::Arg { name, default_value } = &instruction {
336            let value = env
337                .resolve_build_arg(name)
338                .or_else(|| default_value.clone());
339            if let Some(v) = value {
340                resolved_args.insert(name.clone(), v);
341            }
342        }
343
344        if let DockerfileInstruction::Env { name, .. } = &instruction {
345            declared_envs.insert(name.clone());
346        }
347
348        let node = hash_node(instruction, parent_hash.as_deref(), &resolved_args);
349        parent_hash = Some(node.content_hash.clone());
350        nodes.push(node);
351    }
352
353    Ok(DockerfileGraph { nodes })
354}
355
356/// `BuildKit` allows a `RUN` (or any instruction) to continue onto the
357/// next physical line with a trailing `\`.  Join those before the
358/// per-line parser runs so `--mount=...` flags that wrap don't split.
359fn join_continuations(text: &str) -> Vec<String> {
360    let mut out = Vec::new();
361    let mut current = String::new();
362    for raw in text.lines() {
363        let trimmed_end = raw.trim_end();
364        if let Some(stripped) = trimmed_end.strip_suffix('\\') {
365            current.push_str(stripped);
366            current.push(' ');
367        } else {
368            current.push_str(trimmed_end);
369            out.push(std::mem::take(&mut current));
370        }
371    }
372    if !current.trim().is_empty() {
373        out.push(current);
374    }
375    out
376}
377
378fn hash_node(
379    instruction: DockerfileInstruction,
380    parent_hash: Option<&str>,
381    resolved_args: &BTreeMap<String, String>,
382) -> DockerfileNode {
383    let kind = instruction.kind();
384    let mut hasher = blake3::Hasher::new();
385    hasher.update(format!("{kind:?}").as_bytes());
386    hasher.update(b"\0");
387    if let Some(parent) = parent_hash {
388        hasher.update(parent.as_bytes());
389    }
390    hasher.update(b"\0");
391    for (k, v) in resolved_args {
392        hasher.update(k.as_bytes());
393        hasher.update(b"=");
394        hasher.update(v.as_bytes());
395        hasher.update(b"\0");
396    }
397    hasher.update(b"\0");
398    hasher.update(&instruction.content_bytes());
399    let content_hash = hasher.finalize().to_hex().to_string();
400
401    DockerfileNode {
402        kind,
403        instruction,
404        content_hash,
405        parent_hash: parent_hash.map(ToString::to_string),
406    }
407}
408
409/// Standard environment variables present in virtually every base
410/// image (inherited from the image's own `ENV`/shell setup) — a
411/// reference to one of these is never a build-`ARG` and always
412/// resolves without error.
413const WELL_KNOWN_ENV_VARS: &[&str] = &["PATH", "HOME", "TERM"];
414
415/// Which class a `$NAME` reference in an instruction's value belongs
416/// to. `Arg` references must resolve via a declared `ARG`'s default or
417/// a passed build-arg — unresolved is a typed error. `Env` and
418/// `WellKnownEnv` references are standard `ENV`-expansion (a prior
419/// `ENV NAME=value` in the same file, or a name every base image
420/// already carries) and always resolve — their literal `$NAME` text is
421/// passed through unchanged, since sui-spec has no base-image runtime
422/// to read the actual value from.
423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
424enum VariableKind {
425    Arg,
426    Env,
427    WellKnownEnv,
428}
429
430impl VariableKind {
431    fn classify(name: &str, resolved_args: &BTreeMap<String, String>, declared_envs: &BTreeSet<String>) -> Self {
432        if resolved_args.contains_key(name) {
433            Self::Arg
434        } else if declared_envs.contains(name) {
435            Self::Env
436        } else if WELL_KNOWN_ENV_VARS.contains(&name) {
437            Self::WellKnownEnv
438        } else {
439            Self::Arg
440        }
441    }
442}
443
444/// Substitute `$NAME` / `${NAME}` references in `text`. An `ARG`-class
445/// reference (declared via `ARG`, not a prior `ENV` or well-known env
446/// var) that resolves to nothing (never declared, or declared with no
447/// default and never supplied) is a typed error — never silently
448/// passed through. An `ENV`-class reference (a name declared by a
449/// prior `ENV` in the same file, or a well-known env var like `PATH`)
450/// is left as literal `$NAME` text — standard `ENV`-expansion sui-spec
451/// has no base-image runtime to resolve, but which is never a build-arg
452/// gap either.
453fn substitute_arg_refs(
454    text: &str,
455    resolved_args: &BTreeMap<String, String>,
456    declared_envs: &BTreeSet<String>,
457) -> Result<String, SpecError> {
458    let mut out = String::with_capacity(text.len());
459    let bytes = text.as_bytes();
460    let mut i = 0;
461    while i < bytes.len() {
462        if bytes[i] == b'$' {
463            let (name, consumed) = if bytes.get(i + 1) == Some(&b'{') {
464                let end = text[i + 2..]
465                    .find('}')
466                    .map_or(text.len(), |p| i + 2 + p);
467                (text[i + 2..end].to_string(), end + 1 - i)
468            } else {
469                let start = i + 1;
470                let end = text[start..]
471                    .find(|c: char| !(c.is_alphanumeric() || c == '_'))
472                    .map_or(text.len(), |p| start + p);
473                (text[start..end].to_string(), end - i)
474            };
475            if name.is_empty() {
476                out.push('$');
477                i += 1;
478                continue;
479            }
480            match VariableKind::classify(&name, resolved_args, declared_envs) {
481                VariableKind::Env | VariableKind::WellKnownEnv => {
482                    out.push_str(&text[i..i + consumed]);
483                }
484                VariableKind::Arg => match resolved_args.get(&name) {
485                    Some(v) => out.push_str(v),
486                    None => {
487                        return Err(SpecError::Interp {
488                            phase: "unresolved-arg-reference".into(),
489                            message: format!(
490                                "`${name}` referenced but never resolved by ARG default or build-arg",
491                            ),
492                        });
493                    }
494                },
495            }
496            i += consumed;
497        } else {
498            out.push(bytes[i] as char);
499            i += 1;
500        }
501    }
502    Ok(out)
503}
504
505fn parse_instruction<E: DockerfileEnvironment>(
506    line: &str,
507    line_no: usize,
508    resolved_args: &BTreeMap<String, String>,
509    declared_envs: &BTreeSet<String>,
510    _env: &E,
511) -> Result<DockerfileInstruction, SpecError> {
512    let (keyword, rest) = split_keyword(line).ok_or_else(|| SpecError::Interp {
513        phase: "parse-line".into(),
514        message: format!("line {line_no}: no instruction keyword found in `{line}`"),
515    })?;
516
517    match keyword.to_ascii_uppercase().as_str() {
518        "FROM" => parse_from(rest, line_no),
519        "RUN" => parse_run(rest, line_no, resolved_args, declared_envs),
520        "COPY" => parse_copy(rest, line_no),
521        "ARG" => parse_arg(rest, line_no),
522        "ENV" => parse_env(rest, line_no, resolved_args, declared_envs),
523        "WORKDIR" => Ok(DockerfileInstruction::Workdir { path: rest.trim().to_string() }),
524        "CMD" => Ok(DockerfileInstruction::Cmd { argv: parse_argv(rest) }),
525        "ENTRYPOINT" => Ok(DockerfileInstruction::Entrypoint { argv: parse_argv(rest) }),
526        "VOLUME" => parse_volume(rest, line_no),
527        other => Err(SpecError::Interp {
528            phase: "parse-line".into(),
529            message: format!(
530                "line {line_no}: unsupported instruction `{other}` — this scoped \
531                 parser handles FROM/RUN/COPY/ARG/ENV/WORKDIR/CMD/ENTRYPOINT/VOLUME only",
532            ),
533        }),
534    }
535}
536
537fn split_keyword(line: &str) -> Option<(&str, &str)> {
538    let idx = line.find(char::is_whitespace)?;
539    Some((&line[..idx], line[idx..].trim_start()))
540}
541
542fn parse_from(rest: &str, line_no: usize) -> Result<DockerfileInstruction, SpecError> {
543    let mut parts = rest.split_whitespace();
544    let image = parts.next().ok_or_else(|| SpecError::Interp {
545        phase: "parse-line".into(),
546        message: format!("line {line_no}: FROM with no image"),
547    })?;
548    let stage_alias = match parts.next() {
549        Some(as_kw) if as_kw.eq_ignore_ascii_case("as") => {
550            parts.next().map(ToString::to_string)
551        }
552        _ => None,
553    };
554    Ok(DockerfileInstruction::From { image: image.to_string(), stage_alias })
555}
556
557fn parse_run(
558    rest: &str,
559    line_no: usize,
560    resolved_args: &BTreeMap<String, String>,
561    declared_envs: &BTreeSet<String>,
562) -> Result<DockerfileInstruction, SpecError> {
563    let (flags, command) = split_flags(rest);
564    let mut mount_bind_source = None;
565    let mut mount_bind_target = None;
566    for flag in &flags {
567        if let Some(mount_spec) = flag.strip_prefix("--mount=") {
568            for kv in mount_spec.split(',') {
569                if let Some(v) = kv.strip_prefix("source=") {
570                    mount_bind_source = Some(v.to_string());
571                } else if let Some(v) = kv.strip_prefix("target=") {
572                    mount_bind_target = Some(v.to_string());
573                }
574            }
575        }
576    }
577    if command.trim().is_empty() {
578        return Err(SpecError::Interp {
579            phase: "parse-line".into(),
580            message: format!("line {line_no}: RUN with no command"),
581        });
582    }
583    let command = substitute_arg_refs(command.trim(), resolved_args, declared_envs)?;
584    Ok(DockerfileInstruction::Run { command, mount_bind_source, mount_bind_target })
585}
586
587fn parse_copy(rest: &str, line_no: usize) -> Result<DockerfileInstruction, SpecError> {
588    let (flags, body) = split_flags(rest);
589    let mut from_stage = None;
590    for flag in &flags {
591        if let Some(v) = flag.strip_prefix("--from=") {
592            from_stage = Some(v.to_string());
593        }
594    }
595    let tokens: Vec<&str> = body.split_whitespace().collect();
596    if tokens.len() < 2 {
597        return Err(SpecError::Interp {
598            phase: "parse-line".into(),
599            message: format!(
600                "line {line_no}: COPY requires at least one source and a destination, got `{rest}`",
601            ),
602        });
603    }
604    let (destination, sources) = tokens.split_last().expect("checked len >= 2 above");
605    Ok(DockerfileInstruction::Copy {
606        sources: sources.iter().map(ToString::to_string).collect(),
607        destination: (*destination).to_string(),
608        from_stage,
609    })
610}
611
612fn parse_arg(rest: &str, line_no: usize) -> Result<DockerfileInstruction, SpecError> {
613    let rest = rest.trim();
614    if rest.is_empty() {
615        return Err(SpecError::Interp {
616            phase: "parse-line".into(),
617            message: format!("line {line_no}: ARG with no name"),
618        });
619    }
620    match rest.split_once('=') {
621        Some((name, value)) => Ok(DockerfileInstruction::Arg {
622            name: name.trim().to_string(),
623            default_value: Some(value.trim().trim_matches('"').to_string()),
624        }),
625        None => Ok(DockerfileInstruction::Arg { name: rest.to_string(), default_value: None }),
626    }
627}
628
629fn parse_env(
630    rest: &str,
631    line_no: usize,
632    resolved_args: &BTreeMap<String, String>,
633    declared_envs: &BTreeSet<String>,
634) -> Result<DockerfileInstruction, SpecError> {
635    let rest = rest.trim();
636    let (name, value) = rest.split_once('=').ok_or_else(|| SpecError::Interp {
637        phase: "parse-line".into(),
638        message: format!("line {line_no}: ENV requires `NAME=value`, got `{rest}`"),
639    })?;
640    let value = substitute_arg_refs(value.trim().trim_matches('"'), resolved_args, declared_envs)?;
641    Ok(DockerfileInstruction::Env { name: name.trim().to_string(), value })
642}
643
644/// Parse a `CMD`/`ENTRYPOINT` body — either JSON-array exec form
645/// (`["a", "b"]`) or a bare shell-form string treated as a single
646/// argv element.
647fn parse_argv(rest: &str) -> Vec<String> {
648    let rest = rest.trim();
649    if let Some(inner) = rest.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
650        inner
651            .split(',')
652            .map(|s| s.trim().trim_matches('"').to_string())
653            .filter(|s| !s.is_empty())
654            .collect()
655    } else {
656        vec![rest.to_string()]
657    }
658}
659
660/// Parse a `VOLUME` body — either JSON-array form (`["/a", "/b"]`, same
661/// shape as [`parse_argv`]) or plain whitespace-separated paths
662/// (`VOLUME /a /b`). At least one path is required; a bare `VOLUME`
663/// with nothing after it is a typed error rather than a silently
664/// empty declaration.
665fn parse_volume(rest: &str, line_no: usize) -> Result<DockerfileInstruction, SpecError> {
666    let trimmed = rest.trim();
667    if trimmed.is_empty() {
668        return Err(SpecError::Interp {
669            phase: "parse-line".into(),
670            message: format!("line {line_no}: VOLUME with no paths"),
671        });
672    }
673    let paths = if trimmed.starts_with('[') {
674        parse_argv(trimmed)
675    } else {
676        trimmed.split_whitespace().map(ToString::to_string).collect()
677    };
678    if paths.is_empty() {
679        return Err(SpecError::Interp {
680            phase: "parse-line".into(),
681            message: format!("line {line_no}: VOLUME with no paths"),
682        });
683    }
684    Ok(DockerfileInstruction::Volume { paths })
685}
686
687/// Split leading `--flag` / `--flag=value` tokens from the remainder
688/// of an instruction body (used by `RUN --mount=...` and
689/// `COPY --from=...`).
690fn split_flags(rest: &str) -> (Vec<String>, &str) {
691    let mut flags = Vec::new();
692    let mut remainder = rest.trim_start();
693    while let Some(stripped) = remainder.strip_prefix("--") {
694        let end = stripped.find(char::is_whitespace).unwrap_or(stripped.len());
695        let mut flag = String::with_capacity(2 + end);
696        flag.push_str("--");
697        flag.push_str(&stripped[..end]);
698        flags.push(flag);
699        remainder = remainder[2 + end..].trim_start();
700    }
701    (flags, remainder)
702}
703
704// ── Canonical spec ────────────────────────────────────────────────────
705
706pub const CANONICAL_DOCKERFILE_LISP: &str = include_str!("../specs/dockerfile.lisp");
707
708/// Compile every authored `(defdockerfile-graph ...)` instance.
709///
710/// # Errors
711///
712/// Returns an error if the Lisp source fails to parse.
713pub fn load_canonical() -> Result<Vec<DockerfileGraphSpec>, SpecError> {
714    crate::loader::load_all::<DockerfileGraphSpec>(CANONICAL_DOCKERFILE_LISP)
715}
716
717/// Return the canonical instance whose `name` matches.
718///
719/// # Errors
720///
721/// Returns an error if the spec fails to parse or `name` is missing.
722pub fn load_named(name: &str) -> Result<DockerfileGraphSpec, SpecError> {
723    load_canonical()?
724        .into_iter()
725        .find(|d| d.name == name)
726        .ok_or_else(|| SpecError::Load(format!(
727            "no (defdockerfile-graph) with :name {name:?}",
728        )))
729}
730
731/// Convenience wrapper: run [`apply`] against a canonical instance's
732/// own `source_text`, via a [`MockDockerfileEnvironment`] pre-loaded
733/// with that text under its own `name` as the path.
734///
735/// # Errors
736///
737/// Propagates any error from [`apply`] or [`load_named`].
738pub fn apply_canonical(name: &str, build_args: &[(&str, &str)]) -> Result<DockerfileGraph, SpecError> {
739    let spec = load_named(name)?;
740    let mut env = MockDockerfileEnvironment::default().with_dockerfile(name, &spec.source_text);
741    for (k, v) in build_args {
742        env = env.with_build_arg(k, v);
743    }
744    apply(&DockerfileArgs { path: name.to_string() }, &env)
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    #[test]
752    fn canonical_dockerfile_specs_parse() {
753        let specs = load_canonical().expect("canonical dockerfile specs must compile");
754        assert_eq!(specs.len(), 2);
755    }
756
757    #[test]
758    fn simple_three_instruction_shape() {
759        let graph = apply_canonical("simple-three-instruction", &[]).unwrap();
760        let kinds: Vec<InstructionKind> = graph.nodes.iter().map(|n| n.kind).collect();
761        assert_eq!(
762            kinds,
763            vec![InstructionKind::From, InstructionKind::Run, InstructionKind::Cmd],
764        );
765    }
766
767    #[test]
768    fn nonroot_gateway_shape() {
769        let graph = apply_canonical("nonroot-gateway", &[("TARGETARCH", "amd64")]).unwrap();
770        // FROM, ARG TARGETARCH, ARG FIPS, RUN(apt), RUN(--mount FIPS),
771        // ENV, WORKDIR, COPY, RUN(adduser), ENTRYPOINT, CMD = 11 nodes.
772        assert_eq!(graph.nodes.len(), 11);
773
774        let run_with_mount = graph
775            .nodes
776            .iter()
777            .find(|n| matches!(&n.instruction, DockerfileInstruction::Run { mount_bind_source: Some(_), .. }))
778            .expect("expected a RUN with --mount=type=bind");
779        match &run_with_mount.instruction {
780            DockerfileInstruction::Run { mount_bind_source, mount_bind_target, command } => {
781                assert_eq!(mount_bind_source.as_deref(), Some("."));
782                assert_eq!(mount_bind_target.as_deref(), Some("/build"));
783                assert!(command.contains("amd64"), "TARGETARCH should have been substituted: {command}");
784            }
785            _ => unreachable!(),
786        }
787    }
788
789    #[test]
790    fn identical_inputs_produce_hash_identical_graphs() {
791        let g1 = apply_canonical("nonroot-gateway", &[("TARGETARCH", "arm64")]).unwrap();
792        let g2 = apply_canonical("nonroot-gateway", &[("TARGETARCH", "arm64")]).unwrap();
793        assert_eq!(g1, g2);
794        assert_eq!(g1.root_hash(), g2.root_hash());
795        for (a, b) in g1.nodes.iter().zip(g2.nodes.iter()) {
796            assert_eq!(a.content_hash, b.content_hash);
797        }
798    }
799
800    #[test]
801    fn different_build_arg_changes_downstream_hashes_only() {
802        let amd = apply_canonical("nonroot-gateway", &[("TARGETARCH", "amd64")]).unwrap();
803        let arm = apply_canonical("nonroot-gateway", &[("TARGETARCH", "arm64")]).unwrap();
804
805        // Nodes strictly before the ARG TARGETARCH resolution (index 0:
806        // FROM) share content that doesn't depend on the arg value, but
807        // this implementation folds resolved_args into every node's
808        // hash (parent-chain style), so upstream-of-the-ARG nodes differ
809        // only from the point the ARG itself is declared onward. Prove
810        // the concrete cache-leverage property instead: the FROM node
811        // (the true root, before any ARG) is hash-identical across runs.
812        assert_eq!(amd.nodes[0].content_hash, arm.nodes[0].content_hash);
813
814        // From the RUN with the substituted TARGETARCH onward, hashes
815        // diverge.
816        let diverge_from = amd
817            .nodes
818            .iter()
819            .zip(arm.nodes.iter())
820            .position(|(a, b)| a.content_hash != b.content_hash)
821            .expect("amd64 vs arm64 runs must diverge somewhere");
822        assert!(diverge_from > 0, "must not diverge at the FROM root");
823
824        // Every node from that point on differs (parent-hash chaining
825        // means downstream divergence propagates monotonically).
826        for (a, b) in amd.nodes[diverge_from..].iter().zip(arm.nodes[diverge_from..].iter()) {
827            assert_ne!(a.content_hash, b.content_hash);
828        }
829    }
830
831    #[test]
832    fn changing_one_instruction_only_invalidates_downstream() {
833        let mut env = MockDockerfileEnvironment::default().with_dockerfile(
834            "a",
835            "FROM debian:bookworm-slim\nRUN echo one\nRUN echo two\nCMD [\"/bin/true\"]\n",
836        );
837        let g1 = apply(&DockerfileArgs { path: "a".into() }, &env).unwrap();
838
839        env = MockDockerfileEnvironment::default().with_dockerfile(
840            "a",
841            "FROM debian:bookworm-slim\nRUN echo one\nRUN echo CHANGED\nCMD [\"/bin/true\"]\n",
842        );
843        let g2 = apply(&DockerfileArgs { path: "a".into() }, &env).unwrap();
844
845        // FROM and the first RUN are untouched upstream siblings.
846        assert_eq!(g1.nodes[0].content_hash, g2.nodes[0].content_hash);
847        assert_eq!(g1.nodes[1].content_hash, g2.nodes[1].content_hash);
848        // The changed RUN and everything after it (CMD, whose parent
849        // hash chains through it) differ.
850        assert_ne!(g1.nodes[2].content_hash, g2.nodes[2].content_hash);
851        assert_ne!(g1.nodes[3].content_hash, g2.nodes[3].content_hash);
852    }
853
854    #[test]
855    fn copy_with_no_source_is_a_typed_error() {
856        let env = MockDockerfileEnvironment::default()
857            .with_dockerfile("bad", "FROM x\nCOPY onlydestination\n");
858        let err = apply(&DockerfileArgs { path: "bad".into() }, &env).unwrap_err();
859        match err {
860            SpecError::Interp { phase, .. } => assert_eq!(phase, "parse-line"),
861            _ => panic!("expected parse-line error"),
862        }
863    }
864
865    #[test]
866    fn unresolvable_arg_reference_is_a_typed_error() {
867        let env = MockDockerfileEnvironment::default()
868            .with_dockerfile("bad", "FROM x\nRUN echo $NEVER_DECLARED\n");
869        let err = apply(&DockerfileArgs { path: "bad".into() }, &env).unwrap_err();
870        match err {
871            SpecError::Interp { phase, message } => {
872                assert_eq!(phase, "unresolved-arg-reference");
873                assert!(message.contains("NEVER_DECLARED"));
874            }
875            _ => panic!("expected unresolved-arg-reference error"),
876        }
877    }
878
879    #[test]
880    fn missing_dockerfile_is_a_typed_error() {
881        let env = MockDockerfileEnvironment::default();
882        let err = apply(&DockerfileArgs { path: "missing".into() }, &env).unwrap_err();
883        match err {
884            SpecError::Interp { phase, .. } => assert_eq!(phase, "read-dockerfile"),
885            _ => panic!("expected read-dockerfile error"),
886        }
887    }
888
889    #[test]
890    fn unsupported_instruction_is_a_typed_error() {
891        let env = MockDockerfileEnvironment::default()
892            .with_dockerfile("bad", "FROM x\nHEALTHCHECK CMD curl -f http://localhost/ || exit 1\n");
893        let err = apply(&DockerfileArgs { path: "bad".into() }, &env).unwrap_err();
894        match err {
895            SpecError::Interp { phase, message } => {
896                assert_eq!(phase, "parse-line");
897                assert!(message.contains("HEALTHCHECK"));
898            }
899            _ => panic!("expected parse-line error"),
900        }
901    }
902
903    #[test]
904    fn line_continuation_is_joined() {
905        let env = MockDockerfileEnvironment::default().with_dockerfile(
906            "cont",
907            "FROM x\nRUN apt-get update && \\\n    apt-get install -y curl\n",
908        );
909        let graph = apply(&DockerfileArgs { path: "cont".into() }, &env).unwrap();
910        assert_eq!(graph.nodes.len(), 2);
911        match &graph.nodes[1].instruction {
912            DockerfileInstruction::Run { command, .. } => {
913                assert!(command.contains("apt-get install"));
914            }
915            _ => panic!("expected RUN"),
916        }
917    }
918
919    #[test]
920    fn volume_plain_form_is_parsed() {
921        let env = MockDockerfileEnvironment::default()
922            .with_dockerfile("v", "FROM x\nVOLUME /data\n");
923        let graph = apply(&DockerfileArgs { path: "v".into() }, &env).unwrap();
924        assert_eq!(graph.nodes.len(), 2);
925        match &graph.nodes[1].instruction {
926            DockerfileInstruction::Volume { paths } => {
927                assert_eq!(paths, &vec!["/data".to_string()]);
928            }
929            _ => panic!("expected VOLUME"),
930        }
931        assert_eq!(graph.nodes[1].kind, InstructionKind::Volume);
932    }
933
934    #[test]
935    fn volume_json_array_form_is_parsed() {
936        let env = MockDockerfileEnvironment::default()
937            .with_dockerfile("v", "FROM x\nVOLUME [\"/data\", \"/logs\"]\n");
938        let graph = apply(&DockerfileArgs { path: "v".into() }, &env).unwrap();
939        match &graph.nodes[1].instruction {
940            DockerfileInstruction::Volume { paths } => {
941                assert_eq!(paths, &vec!["/data".to_string(), "/logs".to_string()]);
942            }
943            _ => panic!("expected VOLUME"),
944        }
945    }
946
947    #[test]
948    fn volume_produces_a_graph_node() {
949        let env = MockDockerfileEnvironment::default()
950            .with_dockerfile("v", "FROM x\nVOLUME /data /logs\n");
951        let graph = apply(&DockerfileArgs { path: "v".into() }, &env).unwrap();
952        assert_eq!(graph.nodes.len(), 2);
953        assert_eq!(graph.nodes[1].kind, InstructionKind::Volume);
954        assert!(graph.nodes[1].parent_hash.is_some());
955    }
956
957    #[test]
958    fn volume_hashing_is_deterministic() {
959        let env = MockDockerfileEnvironment::default()
960            .with_dockerfile("v", "FROM x\nVOLUME /data /logs\n");
961        let g1 = apply(&DockerfileArgs { path: "v".into() }, &env).unwrap();
962        let g2 = apply(&DockerfileArgs { path: "v".into() }, &env).unwrap();
963        assert_eq!(g1, g2);
964        assert_eq!(g1.nodes[1].content_hash, g2.nodes[1].content_hash);
965    }
966
967    #[test]
968    fn env_path_prepend_self_reference_resolves() {
969        let env = MockDockerfileEnvironment::default().with_dockerfile(
970            "p",
971            "FROM ubuntu:24.04\nENV VIRTUAL_ENV=/opt/venv\nENV PATH=\"/opt/venv/bin:/akeyless/bin:/usr/local/ssl/bin:${PATH}\"\n",
972        );
973        let graph = apply(&DockerfileArgs { path: "p".into() }, &env).unwrap();
974        assert_eq!(graph.nodes.len(), 3);
975        match &graph.nodes[2].instruction {
976            DockerfileInstruction::Env { name, value } => {
977                assert_eq!(name, "PATH");
978                assert!(value.contains("${PATH}"), "PATH self-reference should be left literal: {value}");
979            }
980            _ => panic!("expected ENV"),
981        }
982    }
983
984    #[test]
985    fn env_referencing_prior_env_resolves() {
986        let env = MockDockerfileEnvironment::default().with_dockerfile(
987            "p",
988            "FROM x\nENV FOO=bar\nENV BAZ=\"$FOO/baz\"\n",
989        );
990        let graph = apply(&DockerfileArgs { path: "p".into() }, &env).unwrap();
991        match &graph.nodes[2].instruction {
992            DockerfileInstruction::Env { name, value } => {
993                assert_eq!(name, "BAZ");
994                assert!(value.contains("$FOO"), "prior-ENV reference should be left literal: {value}");
995            }
996            _ => panic!("expected ENV"),
997        }
998    }
999
1000    #[test]
1001    fn env_well_known_var_does_not_trigger_arg_driven_hash_variance() {
1002        let env = MockDockerfileEnvironment::default().with_dockerfile(
1003            "p",
1004            "FROM x\nENV PATH=\"/custom/bin:$PATH\"\n",
1005        );
1006        let g1 = apply(&DockerfileArgs { path: "p".into() }, &env).unwrap();
1007        let g2 = apply(&DockerfileArgs { path: "p".into() }, &env).unwrap();
1008        assert_eq!(g1.nodes[1].content_hash, g2.nodes[1].content_hash);
1009    }
1010
1011    #[test]
1012    fn empty_volume_is_a_typed_error() {
1013        let env = MockDockerfileEnvironment::default()
1014            .with_dockerfile("bad", "FROM x\nVOLUME\n");
1015        let err = apply(&DockerfileArgs { path: "bad".into() }, &env).unwrap_err();
1016        match err {
1017            SpecError::Interp { phase, message } => {
1018                assert_eq!(phase, "parse-line");
1019                assert!(message.contains("VOLUME"));
1020            }
1021            _ => panic!("expected parse-line error"),
1022        }
1023    }
1024
1025    /// Regression fixture: the real `VOLUME` lines from akeyless's base
1026    /// image Dockerfile (akeylesslabs/akeyless-main-repo,
1027    /// `supa-charge-proof` branch,
1028    /// `tools/deployment/docker/base/Dockerfile`, fetched 2026-07-08 via
1029    /// the GitHub contents API) — this exact declarative shape (two
1030    /// separate plain-form `VOLUME` lines with a trailing slash) is what
1031    /// surfaced the gap during the supa-charge-akeyless-ci Phase 5 proof
1032    /// run.
1033    #[test]
1034    fn real_akeyless_base_dockerfile_volume_lines_regression() {
1035        let env = MockDockerfileEnvironment::default().with_dockerfile(
1036            "akeyless-base",
1037            "FROM ubuntu:24.04\n\
1038             VOLUME /akeyless_shared_vol/\n\
1039             # External shared volume to save log files\n\
1040             VOLUME /var/log/akeyless/\n",
1041        );
1042        let graph = apply(&DockerfileArgs { path: "akeyless-base".into() }, &env).unwrap();
1043        assert_eq!(graph.nodes.len(), 3);
1044        match &graph.nodes[1].instruction {
1045            DockerfileInstruction::Volume { paths } => {
1046                assert_eq!(paths, &vec!["/akeyless_shared_vol/".to_string()]);
1047            }
1048            _ => panic!("expected VOLUME"),
1049        }
1050        match &graph.nodes[2].instruction {
1051            DockerfileInstruction::Volume { paths } => {
1052                assert_eq!(paths, &vec!["/var/log/akeyless/".to_string()]);
1053            }
1054            _ => panic!("expected VOLUME"),
1055        }
1056    }
1057
1058    /// Regression fixture: the real `ENV PATH` prepend line from
1059    /// akeyless's base image Dockerfile (akeylesslabs/akeyless-main-repo,
1060    /// `supa-charge-proof` branch,
1061    /// `tools/deployment/docker/base/Dockerfile`, fetched 2026-07-08 via
1062    /// the GitHub contents API) — `$PATH` here refers to the environment's
1063    /// own inherited PATH, not a build ARG; this exact shape surfaced the
1064    /// ARG-vs-ENV gap during the supa-charge-akeyless-ci Phase 5 proof run.
1065    #[test]
1066    fn real_akeyless_base_dockerfile_env_path_prepend_regression() {
1067        let env = MockDockerfileEnvironment::default().with_dockerfile(
1068            "akeyless-base",
1069            "FROM ubuntu:24.04\n\
1070             RUN mkdir /akeyless/bin\n\
1071             ENV VIRTUAL_ENV=/opt/venv\n\
1072             ENV PATH=\"/opt/venv/bin:/akeyless/bin:/usr/local/ssl/bin:${PATH}\"\n",
1073        );
1074        let graph = apply(&DockerfileArgs { path: "akeyless-base".into() }, &env).unwrap();
1075        assert_eq!(graph.nodes.len(), 4);
1076        match &graph.nodes[3].instruction {
1077            DockerfileInstruction::Env { name, value } => {
1078                assert_eq!(name, "PATH");
1079                assert_eq!(value, "/opt/venv/bin:/akeyless/bin:/usr/local/ssl/bin:${PATH}");
1080            }
1081            _ => panic!("expected ENV"),
1082        }
1083    }
1084}