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;
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 nodes = Vec::new();
324    let mut parent_hash: Option<String> = None;
325
326    for (line_no, raw_line) in join_continuations(&text).into_iter().enumerate() {
327        let line = raw_line.trim();
328        if line.is_empty() || line.starts_with('#') {
329            continue;
330        }
331
332        let instruction = parse_instruction(line, line_no + 1, &resolved_args, env)?;
333
334        if let DockerfileInstruction::Arg { name, default_value } = &instruction {
335            let value = env
336                .resolve_build_arg(name)
337                .or_else(|| default_value.clone());
338            if let Some(v) = value {
339                resolved_args.insert(name.clone(), v);
340            }
341        }
342
343        let node = hash_node(instruction, parent_hash.as_deref(), &resolved_args);
344        parent_hash = Some(node.content_hash.clone());
345        nodes.push(node);
346    }
347
348    Ok(DockerfileGraph { nodes })
349}
350
351/// `BuildKit` allows a `RUN` (or any instruction) to continue onto the
352/// next physical line with a trailing `\`.  Join those before the
353/// per-line parser runs so `--mount=...` flags that wrap don't split.
354fn join_continuations(text: &str) -> Vec<String> {
355    let mut out = Vec::new();
356    let mut current = String::new();
357    for raw in text.lines() {
358        let trimmed_end = raw.trim_end();
359        if let Some(stripped) = trimmed_end.strip_suffix('\\') {
360            current.push_str(stripped);
361            current.push(' ');
362        } else {
363            current.push_str(trimmed_end);
364            out.push(std::mem::take(&mut current));
365        }
366    }
367    if !current.trim().is_empty() {
368        out.push(current);
369    }
370    out
371}
372
373fn hash_node(
374    instruction: DockerfileInstruction,
375    parent_hash: Option<&str>,
376    resolved_args: &BTreeMap<String, String>,
377) -> DockerfileNode {
378    let kind = instruction.kind();
379    let mut hasher = blake3::Hasher::new();
380    hasher.update(format!("{kind:?}").as_bytes());
381    hasher.update(b"\0");
382    if let Some(parent) = parent_hash {
383        hasher.update(parent.as_bytes());
384    }
385    hasher.update(b"\0");
386    for (k, v) in resolved_args {
387        hasher.update(k.as_bytes());
388        hasher.update(b"=");
389        hasher.update(v.as_bytes());
390        hasher.update(b"\0");
391    }
392    hasher.update(b"\0");
393    hasher.update(&instruction.content_bytes());
394    let content_hash = hasher.finalize().to_hex().to_string();
395
396    DockerfileNode {
397        kind,
398        instruction,
399        content_hash,
400        parent_hash: parent_hash.map(ToString::to_string),
401    }
402}
403
404/// Substitute `$NAME` / `${NAME}` references in `text` against
405/// `resolved_args`.  A reference to a name that resolves to nothing
406/// (never declared via `ARG`, or declared with no default and never
407/// supplied) is a typed error — never silently passed through.
408fn substitute_arg_refs(
409    text: &str,
410    resolved_args: &BTreeMap<String, String>,
411) -> Result<String, SpecError> {
412    let mut out = String::with_capacity(text.len());
413    let bytes = text.as_bytes();
414    let mut i = 0;
415    while i < bytes.len() {
416        if bytes[i] == b'$' {
417            let (name, consumed) = if bytes.get(i + 1) == Some(&b'{') {
418                let end = text[i + 2..]
419                    .find('}')
420                    .map_or(text.len(), |p| i + 2 + p);
421                (text[i + 2..end].to_string(), end + 1 - i)
422            } else {
423                let start = i + 1;
424                let end = text[start..]
425                    .find(|c: char| !(c.is_alphanumeric() || c == '_'))
426                    .map_or(text.len(), |p| start + p);
427                (text[start..end].to_string(), end - i)
428            };
429            if name.is_empty() {
430                out.push('$');
431                i += 1;
432                continue;
433            }
434            match resolved_args.get(&name) {
435                Some(v) => out.push_str(v),
436                None => {
437                    return Err(SpecError::Interp {
438                        phase: "unresolved-arg-reference".into(),
439                        message: format!(
440                            "`${name}` referenced but never resolved by ARG default or build-arg",
441                        ),
442                    });
443                }
444            }
445            i += consumed;
446        } else {
447            out.push(bytes[i] as char);
448            i += 1;
449        }
450    }
451    Ok(out)
452}
453
454fn parse_instruction<E: DockerfileEnvironment>(
455    line: &str,
456    line_no: usize,
457    resolved_args: &BTreeMap<String, String>,
458    _env: &E,
459) -> Result<DockerfileInstruction, SpecError> {
460    let (keyword, rest) = split_keyword(line).ok_or_else(|| SpecError::Interp {
461        phase: "parse-line".into(),
462        message: format!("line {line_no}: no instruction keyword found in `{line}`"),
463    })?;
464
465    match keyword.to_ascii_uppercase().as_str() {
466        "FROM" => parse_from(rest, line_no),
467        "RUN" => parse_run(rest, line_no, resolved_args),
468        "COPY" => parse_copy(rest, line_no),
469        "ARG" => parse_arg(rest, line_no),
470        "ENV" => parse_env(rest, line_no, resolved_args),
471        "WORKDIR" => Ok(DockerfileInstruction::Workdir { path: rest.trim().to_string() }),
472        "CMD" => Ok(DockerfileInstruction::Cmd { argv: parse_argv(rest) }),
473        "ENTRYPOINT" => Ok(DockerfileInstruction::Entrypoint { argv: parse_argv(rest) }),
474        "VOLUME" => parse_volume(rest, line_no),
475        other => Err(SpecError::Interp {
476            phase: "parse-line".into(),
477            message: format!(
478                "line {line_no}: unsupported instruction `{other}` — this scoped \
479                 parser handles FROM/RUN/COPY/ARG/ENV/WORKDIR/CMD/ENTRYPOINT/VOLUME only",
480            ),
481        }),
482    }
483}
484
485fn split_keyword(line: &str) -> Option<(&str, &str)> {
486    let idx = line.find(char::is_whitespace)?;
487    Some((&line[..idx], line[idx..].trim_start()))
488}
489
490fn parse_from(rest: &str, line_no: usize) -> Result<DockerfileInstruction, SpecError> {
491    let mut parts = rest.split_whitespace();
492    let image = parts.next().ok_or_else(|| SpecError::Interp {
493        phase: "parse-line".into(),
494        message: format!("line {line_no}: FROM with no image"),
495    })?;
496    let stage_alias = match parts.next() {
497        Some(as_kw) if as_kw.eq_ignore_ascii_case("as") => {
498            parts.next().map(ToString::to_string)
499        }
500        _ => None,
501    };
502    Ok(DockerfileInstruction::From { image: image.to_string(), stage_alias })
503}
504
505fn parse_run(
506    rest: &str,
507    line_no: usize,
508    resolved_args: &BTreeMap<String, String>,
509) -> Result<DockerfileInstruction, SpecError> {
510    let (flags, command) = split_flags(rest);
511    let mut mount_bind_source = None;
512    let mut mount_bind_target = None;
513    for flag in &flags {
514        if let Some(mount_spec) = flag.strip_prefix("--mount=") {
515            for kv in mount_spec.split(',') {
516                if let Some(v) = kv.strip_prefix("source=") {
517                    mount_bind_source = Some(v.to_string());
518                } else if let Some(v) = kv.strip_prefix("target=") {
519                    mount_bind_target = Some(v.to_string());
520                }
521            }
522        }
523    }
524    if command.trim().is_empty() {
525        return Err(SpecError::Interp {
526            phase: "parse-line".into(),
527            message: format!("line {line_no}: RUN with no command"),
528        });
529    }
530    let command = substitute_arg_refs(command.trim(), resolved_args)?;
531    Ok(DockerfileInstruction::Run { command, mount_bind_source, mount_bind_target })
532}
533
534fn parse_copy(rest: &str, line_no: usize) -> Result<DockerfileInstruction, SpecError> {
535    let (flags, body) = split_flags(rest);
536    let mut from_stage = None;
537    for flag in &flags {
538        if let Some(v) = flag.strip_prefix("--from=") {
539            from_stage = Some(v.to_string());
540        }
541    }
542    let tokens: Vec<&str> = body.split_whitespace().collect();
543    if tokens.len() < 2 {
544        return Err(SpecError::Interp {
545            phase: "parse-line".into(),
546            message: format!(
547                "line {line_no}: COPY requires at least one source and a destination, got `{rest}`",
548            ),
549        });
550    }
551    let (destination, sources) = tokens.split_last().expect("checked len >= 2 above");
552    Ok(DockerfileInstruction::Copy {
553        sources: sources.iter().map(ToString::to_string).collect(),
554        destination: (*destination).to_string(),
555        from_stage,
556    })
557}
558
559fn parse_arg(rest: &str, line_no: usize) -> Result<DockerfileInstruction, SpecError> {
560    let rest = rest.trim();
561    if rest.is_empty() {
562        return Err(SpecError::Interp {
563            phase: "parse-line".into(),
564            message: format!("line {line_no}: ARG with no name"),
565        });
566    }
567    match rest.split_once('=') {
568        Some((name, value)) => Ok(DockerfileInstruction::Arg {
569            name: name.trim().to_string(),
570            default_value: Some(value.trim().trim_matches('"').to_string()),
571        }),
572        None => Ok(DockerfileInstruction::Arg { name: rest.to_string(), default_value: None }),
573    }
574}
575
576fn parse_env(
577    rest: &str,
578    line_no: usize,
579    resolved_args: &BTreeMap<String, String>,
580) -> Result<DockerfileInstruction, SpecError> {
581    let rest = rest.trim();
582    let (name, value) = rest.split_once('=').ok_or_else(|| SpecError::Interp {
583        phase: "parse-line".into(),
584        message: format!("line {line_no}: ENV requires `NAME=value`, got `{rest}`"),
585    })?;
586    let value = substitute_arg_refs(value.trim().trim_matches('"'), resolved_args)?;
587    Ok(DockerfileInstruction::Env { name: name.trim().to_string(), value })
588}
589
590/// Parse a `CMD`/`ENTRYPOINT` body — either JSON-array exec form
591/// (`["a", "b"]`) or a bare shell-form string treated as a single
592/// argv element.
593fn parse_argv(rest: &str) -> Vec<String> {
594    let rest = rest.trim();
595    if let Some(inner) = rest.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
596        inner
597            .split(',')
598            .map(|s| s.trim().trim_matches('"').to_string())
599            .filter(|s| !s.is_empty())
600            .collect()
601    } else {
602        vec![rest.to_string()]
603    }
604}
605
606/// Parse a `VOLUME` body — either JSON-array form (`["/a", "/b"]`, same
607/// shape as [`parse_argv`]) or plain whitespace-separated paths
608/// (`VOLUME /a /b`). At least one path is required; a bare `VOLUME`
609/// with nothing after it is a typed error rather than a silently
610/// empty declaration.
611fn parse_volume(rest: &str, line_no: usize) -> Result<DockerfileInstruction, SpecError> {
612    let trimmed = rest.trim();
613    if trimmed.is_empty() {
614        return Err(SpecError::Interp {
615            phase: "parse-line".into(),
616            message: format!("line {line_no}: VOLUME with no paths"),
617        });
618    }
619    let paths = if trimmed.starts_with('[') {
620        parse_argv(trimmed)
621    } else {
622        trimmed.split_whitespace().map(ToString::to_string).collect()
623    };
624    if paths.is_empty() {
625        return Err(SpecError::Interp {
626            phase: "parse-line".into(),
627            message: format!("line {line_no}: VOLUME with no paths"),
628        });
629    }
630    Ok(DockerfileInstruction::Volume { paths })
631}
632
633/// Split leading `--flag` / `--flag=value` tokens from the remainder
634/// of an instruction body (used by `RUN --mount=...` and
635/// `COPY --from=...`).
636fn split_flags(rest: &str) -> (Vec<String>, &str) {
637    let mut flags = Vec::new();
638    let mut remainder = rest.trim_start();
639    while let Some(stripped) = remainder.strip_prefix("--") {
640        let end = stripped.find(char::is_whitespace).unwrap_or(stripped.len());
641        let mut flag = String::with_capacity(2 + end);
642        flag.push_str("--");
643        flag.push_str(&stripped[..end]);
644        flags.push(flag);
645        remainder = remainder[2 + end..].trim_start();
646    }
647    (flags, remainder)
648}
649
650// ── Canonical spec ────────────────────────────────────────────────────
651
652pub const CANONICAL_DOCKERFILE_LISP: &str = include_str!("../specs/dockerfile.lisp");
653
654/// Compile every authored `(defdockerfile-graph ...)` instance.
655///
656/// # Errors
657///
658/// Returns an error if the Lisp source fails to parse.
659pub fn load_canonical() -> Result<Vec<DockerfileGraphSpec>, SpecError> {
660    crate::loader::load_all::<DockerfileGraphSpec>(CANONICAL_DOCKERFILE_LISP)
661}
662
663/// Return the canonical instance whose `name` matches.
664///
665/// # Errors
666///
667/// Returns an error if the spec fails to parse or `name` is missing.
668pub fn load_named(name: &str) -> Result<DockerfileGraphSpec, SpecError> {
669    load_canonical()?
670        .into_iter()
671        .find(|d| d.name == name)
672        .ok_or_else(|| SpecError::Load(format!(
673            "no (defdockerfile-graph) with :name {name:?}",
674        )))
675}
676
677/// Convenience wrapper: run [`apply`] against a canonical instance's
678/// own `source_text`, via a [`MockDockerfileEnvironment`] pre-loaded
679/// with that text under its own `name` as the path.
680///
681/// # Errors
682///
683/// Propagates any error from [`apply`] or [`load_named`].
684pub fn apply_canonical(name: &str, build_args: &[(&str, &str)]) -> Result<DockerfileGraph, SpecError> {
685    let spec = load_named(name)?;
686    let mut env = MockDockerfileEnvironment::default().with_dockerfile(name, &spec.source_text);
687    for (k, v) in build_args {
688        env = env.with_build_arg(k, v);
689    }
690    apply(&DockerfileArgs { path: name.to_string() }, &env)
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    #[test]
698    fn canonical_dockerfile_specs_parse() {
699        let specs = load_canonical().expect("canonical dockerfile specs must compile");
700        assert_eq!(specs.len(), 2);
701    }
702
703    #[test]
704    fn simple_three_instruction_shape() {
705        let graph = apply_canonical("simple-three-instruction", &[]).unwrap();
706        let kinds: Vec<InstructionKind> = graph.nodes.iter().map(|n| n.kind).collect();
707        assert_eq!(
708            kinds,
709            vec![InstructionKind::From, InstructionKind::Run, InstructionKind::Cmd],
710        );
711    }
712
713    #[test]
714    fn nonroot_gateway_shape() {
715        let graph = apply_canonical("nonroot-gateway", &[("TARGETARCH", "amd64")]).unwrap();
716        // FROM, ARG TARGETARCH, ARG FIPS, RUN(apt), RUN(--mount FIPS),
717        // ENV, WORKDIR, COPY, RUN(adduser), ENTRYPOINT, CMD = 11 nodes.
718        assert_eq!(graph.nodes.len(), 11);
719
720        let run_with_mount = graph
721            .nodes
722            .iter()
723            .find(|n| matches!(&n.instruction, DockerfileInstruction::Run { mount_bind_source: Some(_), .. }))
724            .expect("expected a RUN with --mount=type=bind");
725        match &run_with_mount.instruction {
726            DockerfileInstruction::Run { mount_bind_source, mount_bind_target, command } => {
727                assert_eq!(mount_bind_source.as_deref(), Some("."));
728                assert_eq!(mount_bind_target.as_deref(), Some("/build"));
729                assert!(command.contains("amd64"), "TARGETARCH should have been substituted: {command}");
730            }
731            _ => unreachable!(),
732        }
733    }
734
735    #[test]
736    fn identical_inputs_produce_hash_identical_graphs() {
737        let g1 = apply_canonical("nonroot-gateway", &[("TARGETARCH", "arm64")]).unwrap();
738        let g2 = apply_canonical("nonroot-gateway", &[("TARGETARCH", "arm64")]).unwrap();
739        assert_eq!(g1, g2);
740        assert_eq!(g1.root_hash(), g2.root_hash());
741        for (a, b) in g1.nodes.iter().zip(g2.nodes.iter()) {
742            assert_eq!(a.content_hash, b.content_hash);
743        }
744    }
745
746    #[test]
747    fn different_build_arg_changes_downstream_hashes_only() {
748        let amd = apply_canonical("nonroot-gateway", &[("TARGETARCH", "amd64")]).unwrap();
749        let arm = apply_canonical("nonroot-gateway", &[("TARGETARCH", "arm64")]).unwrap();
750
751        // Nodes strictly before the ARG TARGETARCH resolution (index 0:
752        // FROM) share content that doesn't depend on the arg value, but
753        // this implementation folds resolved_args into every node's
754        // hash (parent-chain style), so upstream-of-the-ARG nodes differ
755        // only from the point the ARG itself is declared onward. Prove
756        // the concrete cache-leverage property instead: the FROM node
757        // (the true root, before any ARG) is hash-identical across runs.
758        assert_eq!(amd.nodes[0].content_hash, arm.nodes[0].content_hash);
759
760        // From the RUN with the substituted TARGETARCH onward, hashes
761        // diverge.
762        let diverge_from = amd
763            .nodes
764            .iter()
765            .zip(arm.nodes.iter())
766            .position(|(a, b)| a.content_hash != b.content_hash)
767            .expect("amd64 vs arm64 runs must diverge somewhere");
768        assert!(diverge_from > 0, "must not diverge at the FROM root");
769
770        // Every node from that point on differs (parent-hash chaining
771        // means downstream divergence propagates monotonically).
772        for (a, b) in amd.nodes[diverge_from..].iter().zip(arm.nodes[diverge_from..].iter()) {
773            assert_ne!(a.content_hash, b.content_hash);
774        }
775    }
776
777    #[test]
778    fn changing_one_instruction_only_invalidates_downstream() {
779        let mut env = MockDockerfileEnvironment::default().with_dockerfile(
780            "a",
781            "FROM debian:bookworm-slim\nRUN echo one\nRUN echo two\nCMD [\"/bin/true\"]\n",
782        );
783        let g1 = apply(&DockerfileArgs { path: "a".into() }, &env).unwrap();
784
785        env = MockDockerfileEnvironment::default().with_dockerfile(
786            "a",
787            "FROM debian:bookworm-slim\nRUN echo one\nRUN echo CHANGED\nCMD [\"/bin/true\"]\n",
788        );
789        let g2 = apply(&DockerfileArgs { path: "a".into() }, &env).unwrap();
790
791        // FROM and the first RUN are untouched upstream siblings.
792        assert_eq!(g1.nodes[0].content_hash, g2.nodes[0].content_hash);
793        assert_eq!(g1.nodes[1].content_hash, g2.nodes[1].content_hash);
794        // The changed RUN and everything after it (CMD, whose parent
795        // hash chains through it) differ.
796        assert_ne!(g1.nodes[2].content_hash, g2.nodes[2].content_hash);
797        assert_ne!(g1.nodes[3].content_hash, g2.nodes[3].content_hash);
798    }
799
800    #[test]
801    fn copy_with_no_source_is_a_typed_error() {
802        let env = MockDockerfileEnvironment::default()
803            .with_dockerfile("bad", "FROM x\nCOPY onlydestination\n");
804        let err = apply(&DockerfileArgs { path: "bad".into() }, &env).unwrap_err();
805        match err {
806            SpecError::Interp { phase, .. } => assert_eq!(phase, "parse-line"),
807            _ => panic!("expected parse-line error"),
808        }
809    }
810
811    #[test]
812    fn unresolvable_arg_reference_is_a_typed_error() {
813        let env = MockDockerfileEnvironment::default()
814            .with_dockerfile("bad", "FROM x\nRUN echo $NEVER_DECLARED\n");
815        let err = apply(&DockerfileArgs { path: "bad".into() }, &env).unwrap_err();
816        match err {
817            SpecError::Interp { phase, message } => {
818                assert_eq!(phase, "unresolved-arg-reference");
819                assert!(message.contains("NEVER_DECLARED"));
820            }
821            _ => panic!("expected unresolved-arg-reference error"),
822        }
823    }
824
825    #[test]
826    fn missing_dockerfile_is_a_typed_error() {
827        let env = MockDockerfileEnvironment::default();
828        let err = apply(&DockerfileArgs { path: "missing".into() }, &env).unwrap_err();
829        match err {
830            SpecError::Interp { phase, .. } => assert_eq!(phase, "read-dockerfile"),
831            _ => panic!("expected read-dockerfile error"),
832        }
833    }
834
835    #[test]
836    fn unsupported_instruction_is_a_typed_error() {
837        let env = MockDockerfileEnvironment::default()
838            .with_dockerfile("bad", "FROM x\nHEALTHCHECK CMD curl -f http://localhost/ || exit 1\n");
839        let err = apply(&DockerfileArgs { path: "bad".into() }, &env).unwrap_err();
840        match err {
841            SpecError::Interp { phase, message } => {
842                assert_eq!(phase, "parse-line");
843                assert!(message.contains("HEALTHCHECK"));
844            }
845            _ => panic!("expected parse-line error"),
846        }
847    }
848
849    #[test]
850    fn line_continuation_is_joined() {
851        let env = MockDockerfileEnvironment::default().with_dockerfile(
852            "cont",
853            "FROM x\nRUN apt-get update && \\\n    apt-get install -y curl\n",
854        );
855        let graph = apply(&DockerfileArgs { path: "cont".into() }, &env).unwrap();
856        assert_eq!(graph.nodes.len(), 2);
857        match &graph.nodes[1].instruction {
858            DockerfileInstruction::Run { command, .. } => {
859                assert!(command.contains("apt-get install"));
860            }
861            _ => panic!("expected RUN"),
862        }
863    }
864
865    #[test]
866    fn volume_plain_form_is_parsed() {
867        let env = MockDockerfileEnvironment::default()
868            .with_dockerfile("v", "FROM x\nVOLUME /data\n");
869        let graph = apply(&DockerfileArgs { path: "v".into() }, &env).unwrap();
870        assert_eq!(graph.nodes.len(), 2);
871        match &graph.nodes[1].instruction {
872            DockerfileInstruction::Volume { paths } => {
873                assert_eq!(paths, &vec!["/data".to_string()]);
874            }
875            _ => panic!("expected VOLUME"),
876        }
877        assert_eq!(graph.nodes[1].kind, InstructionKind::Volume);
878    }
879
880    #[test]
881    fn volume_json_array_form_is_parsed() {
882        let env = MockDockerfileEnvironment::default()
883            .with_dockerfile("v", "FROM x\nVOLUME [\"/data\", \"/logs\"]\n");
884        let graph = apply(&DockerfileArgs { path: "v".into() }, &env).unwrap();
885        match &graph.nodes[1].instruction {
886            DockerfileInstruction::Volume { paths } => {
887                assert_eq!(paths, &vec!["/data".to_string(), "/logs".to_string()]);
888            }
889            _ => panic!("expected VOLUME"),
890        }
891    }
892
893    #[test]
894    fn volume_produces_a_graph_node() {
895        let env = MockDockerfileEnvironment::default()
896            .with_dockerfile("v", "FROM x\nVOLUME /data /logs\n");
897        let graph = apply(&DockerfileArgs { path: "v".into() }, &env).unwrap();
898        assert_eq!(graph.nodes.len(), 2);
899        assert_eq!(graph.nodes[1].kind, InstructionKind::Volume);
900        assert!(graph.nodes[1].parent_hash.is_some());
901    }
902
903    #[test]
904    fn volume_hashing_is_deterministic() {
905        let env = MockDockerfileEnvironment::default()
906            .with_dockerfile("v", "FROM x\nVOLUME /data /logs\n");
907        let g1 = apply(&DockerfileArgs { path: "v".into() }, &env).unwrap();
908        let g2 = apply(&DockerfileArgs { path: "v".into() }, &env).unwrap();
909        assert_eq!(g1, g2);
910        assert_eq!(g1.nodes[1].content_hash, g2.nodes[1].content_hash);
911    }
912
913    #[test]
914    fn empty_volume_is_a_typed_error() {
915        let env = MockDockerfileEnvironment::default()
916            .with_dockerfile("bad", "FROM x\nVOLUME\n");
917        let err = apply(&DockerfileArgs { path: "bad".into() }, &env).unwrap_err();
918        match err {
919            SpecError::Interp { phase, message } => {
920                assert_eq!(phase, "parse-line");
921                assert!(message.contains("VOLUME"));
922            }
923            _ => panic!("expected parse-line error"),
924        }
925    }
926
927    /// Regression fixture: the real `VOLUME` lines from akeyless's base
928    /// image Dockerfile (akeylesslabs/akeyless-main-repo,
929    /// `supa-charge-proof` branch,
930    /// `tools/deployment/docker/base/Dockerfile`, fetched 2026-07-08 via
931    /// the GitHub contents API) — this exact declarative shape (two
932    /// separate plain-form `VOLUME` lines with a trailing slash) is what
933    /// surfaced the gap during the supa-charge-akeyless-ci Phase 5 proof
934    /// run.
935    #[test]
936    fn real_akeyless_base_dockerfile_volume_lines_regression() {
937        let env = MockDockerfileEnvironment::default().with_dockerfile(
938            "akeyless-base",
939            "FROM ubuntu:24.04\n\
940             VOLUME /akeyless_shared_vol/\n\
941             # External shared volume to save log files\n\
942             VOLUME /var/log/akeyless/\n",
943        );
944        let graph = apply(&DockerfileArgs { path: "akeyless-base".into() }, &env).unwrap();
945        assert_eq!(graph.nodes.len(), 3);
946        match &graph.nodes[1].instruction {
947            DockerfileInstruction::Volume { paths } => {
948                assert_eq!(paths, &vec!["/akeyless_shared_vol/".to_string()]);
949            }
950            _ => panic!("expected VOLUME"),
951        }
952        match &graph.nodes[2].instruction {
953            DockerfileInstruction::Volume { paths } => {
954                assert_eq!(paths, &vec!["/var/log/akeyless/".to_string()]);
955            }
956            _ => panic!("expected VOLUME"),
957        }
958    }
959}