1use std::collections::{BTreeMap, BTreeSet};
37
38use serde::{Deserialize, Serialize};
39use tatara_lisp::DeriveTataraDomain;
40
41use crate::SpecError;
42
43#[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#[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#[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 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#[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#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
217pub struct DockerfileGraph {
218 pub nodes: Vec<DockerfileNode>,
219}
220
221impl DockerfileGraph {
222 #[must_use]
225 pub fn root_hash(&self) -> Option<&str> {
226 self.nodes.last().map(|n| n.content_hash.as_str())
227 }
228}
229
230pub trait DockerfileEnvironment {
238 fn read_dockerfile(&self, path: &str) -> Result<String, String>;
248
249 fn resolve_build_arg(&self, name: &str) -> Option<String>;
254}
255
256#[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
291pub struct DockerfileArgs {
297 pub path: String,
298}
299
300pub 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
356fn 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
409const WELL_KNOWN_ENV_VARS: &[&str] = &["PATH", "HOME", "TERM"];
414
415#[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
444fn 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
644fn 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
660fn 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
687fn 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
704pub const CANONICAL_DOCKERFILE_LISP: &str = include_str!("../specs/dockerfile.lisp");
707
708pub fn load_canonical() -> Result<Vec<DockerfileGraphSpec>, SpecError> {
714 crate::loader::load_all::<DockerfileGraphSpec>(CANONICAL_DOCKERFILE_LISP)
715}
716
717pub 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
731pub 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 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 assert_eq!(amd.nodes[0].content_hash, arm.nodes[0].content_hash);
813
814 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 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 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 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 #[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 #[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}