1use std::collections::BTreeMap;
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 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
351fn 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
404fn 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
590fn 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
606fn 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
633fn 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
650pub const CANONICAL_DOCKERFILE_LISP: &str = include_str!("../specs/dockerfile.lisp");
653
654pub fn load_canonical() -> Result<Vec<DockerfileGraphSpec>, SpecError> {
660 crate::loader::load_all::<DockerfileGraphSpec>(CANONICAL_DOCKERFILE_LISP)
661}
662
663pub 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
677pub 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 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 assert_eq!(amd.nodes[0].content_hash, arm.nodes[0].content_hash);
759
760 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 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 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 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 #[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}