1use std::collections::HashMap;
7use std::path::Path;
8
9use dockerfile_parser::{Dockerfile as RawDockerfile, Instruction as RawInstruction};
10use serde::{Deserialize, Serialize};
11
12use crate::error::{BuildError, Result};
13
14use super::instruction::{
15 AddInstruction, ArgInstruction, CopyInstruction, EnvInstruction, ExposeInstruction,
16 ExposeProtocol, HealthcheckInstruction, Instruction, RunInstruction, ShellOrExec,
17};
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub enum ImageRef {
22 Registry {
24 image: String,
26 tag: Option<String>,
28 digest: Option<String>,
30 },
31 Stage(String),
33 Scratch,
35}
36
37impl ImageRef {
38 #[must_use]
40 pub fn parse(s: &str) -> Self {
41 let s = s.trim();
42
43 if s.eq_ignore_ascii_case("scratch") {
45 return Self::Scratch;
46 }
47
48 if let Some((image, digest)) = s.rsplit_once('@') {
50 return Self::Registry {
51 image: image.to_string(),
52 tag: None,
53 digest: Some(digest.to_string()),
54 };
55 }
56
57 let colon_count = s.matches(':').count();
59 if colon_count > 0 {
60 if let Some((prefix, suffix)) = s.rsplit_once(':') {
61 if !suffix.contains('/') {
63 return Self::Registry {
64 image: prefix.to_string(),
65 tag: Some(suffix.to_string()),
66 digest: None,
67 };
68 }
69 }
70 }
71
72 Self::Registry {
74 image: s.to_string(),
75 tag: None,
76 digest: None,
77 }
78 }
79
80 #[must_use]
82 pub fn to_string_ref(&self) -> String {
83 match self {
84 Self::Registry { image, tag, digest } => {
85 let mut s = image.clone();
86 if let Some(t) = tag {
87 s.push(':');
88 s.push_str(t);
89 }
90 if let Some(d) = digest {
91 s.push('@');
92 s.push_str(d);
93 }
94 s
95 }
96 Self::Stage(name) => name.clone(),
97 Self::Scratch => "scratch".to_string(),
98 }
99 }
100
101 #[must_use]
103 pub fn is_stage(&self) -> bool {
104 matches!(self, Self::Stage(_))
105 }
106
107 #[must_use]
109 pub fn is_scratch(&self) -> bool {
110 matches!(self, Self::Scratch)
111 }
112
113 #[must_use]
125 pub fn qualify(&self) -> Self {
126 match self {
127 Self::Scratch | Self::Stage(_) => self.clone(),
128 Self::Registry { image, tag, digest } => {
129 let qualified = qualify_image_name(image);
130 Self::Registry {
131 image: qualified,
132 tag: tag.clone(),
133 digest: digest.clone(),
134 }
135 }
136 }
137 }
138}
139
140fn qualify_image_name(image: &str) -> String {
147 let parts: Vec<&str> = image.split('/').collect();
148
149 if parts.is_empty() {
150 return format!("docker.io/library/{image}");
151 }
152
153 let first = parts[0];
154 if first.contains('.') || first.contains(':') || first == "localhost" {
155 return image.to_string();
156 }
157
158 if parts.len() == 1 {
159 format!("docker.io/library/{image}")
160 } else {
161 format!("docker.io/{image}")
162 }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct Stage {
168 pub index: usize,
170
171 pub name: Option<String>,
173
174 pub base_image: ImageRef,
176
177 pub platform: Option<String>,
179
180 pub instructions: Vec<Instruction>,
182}
183
184impl Stage {
185 #[must_use]
187 pub fn identifier(&self) -> String {
188 self.name.clone().unwrap_or_else(|| self.index.to_string())
189 }
190
191 #[must_use]
193 pub fn matches(&self, name_or_index: &str) -> bool {
194 if let Some(ref name) = self.name {
195 if name == name_or_index {
196 return true;
197 }
198 }
199
200 if let Ok(idx) = name_or_index.parse::<usize>() {
201 return idx == self.index;
202 }
203
204 false
205 }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct Dockerfile {
211 pub global_args: Vec<ArgInstruction>,
213
214 pub stages: Vec<Stage>,
216}
217
218impl Dockerfile {
219 pub fn parse(content: &str) -> Result<Self> {
225 let raw = RawDockerfile::parse(content).map_err(|e| BuildError::DockerfileParse {
226 message: e.to_string(),
227 line: 1,
228 })?;
229
230 Self::from_raw(raw)
231 }
232
233 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
239 let content =
240 std::fs::read_to_string(path.as_ref()).map_err(|e| BuildError::ContextRead {
241 path: path.as_ref().to_path_buf(),
242 source: e,
243 })?;
244
245 Self::parse(&content)
246 }
247
248 fn from_raw(raw: RawDockerfile) -> Result<Self> {
250 let mut global_args = Vec::new();
251 let mut stages = Vec::new();
252 let mut current_stage: Option<Stage> = None;
253 let mut stage_index = 0;
254
255 for instruction in raw.instructions {
256 match &instruction {
257 RawInstruction::From(from) => {
258 if let Some(stage) = current_stage.take() {
260 stages.push(stage);
261 }
262
263 let base_image = ImageRef::parse(&from.image.content);
265
266 let name = from.alias.as_ref().map(|a| a.content.clone());
268
269 let platform = from
271 .flags
272 .iter()
273 .find(|f| f.name.content.as_str() == "platform")
274 .map(|f| f.value.to_string());
275
276 current_stage = Some(Stage {
277 index: stage_index,
278 name,
279 base_image,
280 platform,
281 instructions: Vec::new(),
282 });
283
284 stage_index += 1;
285 }
286
287 RawInstruction::Arg(arg) => {
288 let arg_inst = ArgInstruction {
289 name: arg.name.to_string(),
290 default: arg.value.as_ref().map(std::string::ToString::to_string),
291 };
292
293 if current_stage.is_none() {
294 global_args.push(arg_inst);
295 } else if let Some(ref mut stage) = current_stage {
296 stage.instructions.push(Instruction::Arg(arg_inst));
297 }
298 }
299
300 _ => {
301 if let Some(ref mut stage) = current_stage {
302 if let Some(inst) = Self::convert_instruction(&instruction)? {
303 stage.instructions.push(inst);
304 }
305 }
306 }
307 }
308 }
309
310 if let Some(stage) = current_stage {
312 stages.push(stage);
313 }
314
315 let _stage_names: HashMap<String, usize> = stages
319 .iter()
320 .filter_map(|s| s.name.as_ref().map(|n| (n.clone(), s.index)))
321 .collect();
322 let _num_stages = stages.len();
323
324 Ok(Self {
325 global_args,
326 stages,
327 })
328 }
329
330 #[allow(clippy::too_many_lines)]
332 fn convert_instruction(raw: &RawInstruction) -> Result<Option<Instruction>> {
333 let instruction = match raw {
334 RawInstruction::From(_) => {
335 return Ok(None);
336 }
337
338 RawInstruction::Run(run) => {
339 let command = match &run.expr {
340 dockerfile_parser::ShellOrExecExpr::Shell(s) => {
341 ShellOrExec::Shell(s.to_string())
342 }
343 dockerfile_parser::ShellOrExecExpr::Exec(args) => {
344 ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
345 }
346 };
347
348 Instruction::Run(RunInstruction {
349 command,
350 mounts: Vec::new(),
351 network: None,
352 security: None,
353 })
354 }
355
356 RawInstruction::Copy(copy) => {
357 let from = copy
358 .flags
359 .iter()
360 .find(|f| f.name.content.as_str() == "from")
361 .map(|f| f.value.to_string());
362
363 let chown = copy
364 .flags
365 .iter()
366 .find(|f| f.name.content.as_str() == "chown")
367 .map(|f| f.value.to_string());
368
369 let chmod = copy
370 .flags
371 .iter()
372 .find(|f| f.name.content.as_str() == "chmod")
373 .map(|f| f.value.to_string());
374
375 let link = copy.flags.iter().any(|f| f.name.content.as_str() == "link");
376
377 let all_paths: Vec<String> = copy
379 .sources
380 .iter()
381 .map(std::string::ToString::to_string)
382 .collect();
383
384 if all_paths.is_empty() {
385 return Err(BuildError::InvalidInstruction {
386 instruction: "COPY".to_string(),
387 reason: "COPY requires at least one source and a destination".to_string(),
388 });
389 }
390
391 let (sources, dest) = all_paths.split_at(all_paths.len().saturating_sub(1));
392 let destination = dest.first().cloned().unwrap_or_default();
393
394 Instruction::Copy(CopyInstruction {
395 sources: sources.to_vec(),
396 destination,
397 from,
398 chown,
399 chmod,
400 link,
401 exclude: Vec::new(),
402 })
403 }
404
405 RawInstruction::Entrypoint(ep) => {
406 let command = match &ep.expr {
407 dockerfile_parser::ShellOrExecExpr::Shell(s) => {
408 ShellOrExec::Shell(s.to_string())
409 }
410 dockerfile_parser::ShellOrExecExpr::Exec(args) => {
411 ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
412 }
413 };
414 Instruction::Entrypoint(command)
415 }
416
417 RawInstruction::Cmd(cmd) => {
418 let command = match &cmd.expr {
419 dockerfile_parser::ShellOrExecExpr::Shell(s) => {
420 ShellOrExec::Shell(s.to_string())
421 }
422 dockerfile_parser::ShellOrExecExpr::Exec(args) => {
423 ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
424 }
425 };
426 Instruction::Cmd(command)
427 }
428
429 RawInstruction::Env(env) => {
430 let mut vars = HashMap::new();
431 for var in &env.vars {
432 vars.insert(var.key.to_string(), var.value.to_string());
433 }
434 Instruction::Env(EnvInstruction { vars })
435 }
436
437 RawInstruction::Label(label) => {
438 let mut labels = HashMap::new();
439 for l in &label.labels {
440 labels.insert(l.name.to_string(), l.value.to_string());
441 }
442 Instruction::Label(labels)
443 }
444
445 RawInstruction::Arg(arg) => Instruction::Arg(ArgInstruction {
446 name: arg.name.to_string(),
447 default: arg.value.as_ref().map(std::string::ToString::to_string),
448 }),
449
450 RawInstruction::Misc(misc) => {
451 let instruction_upper = misc.instruction.content.to_uppercase();
452 match instruction_upper.as_str() {
453 "WORKDIR" => Instruction::Workdir(misc.arguments.to_string()),
454
455 "USER" => Instruction::User(misc.arguments.to_string()),
456
457 "VOLUME" => {
458 let args = misc.arguments.to_string();
459 let volumes = if args.trim().starts_with('[') {
460 serde_json::from_str(&args).unwrap_or_else(|_| vec![args])
461 } else {
462 args.split_whitespace().map(String::from).collect()
463 };
464 Instruction::Volume(volumes)
465 }
466
467 "EXPOSE" => {
468 let args = misc.arguments.to_string();
469 let (port_str, protocol) = if let Some((p, proto)) = args.split_once('/') {
470 let proto = match proto.to_lowercase().as_str() {
471 "udp" => ExposeProtocol::Udp,
472 _ => ExposeProtocol::Tcp,
473 };
474 (p, proto)
475 } else {
476 (args.as_str(), ExposeProtocol::Tcp)
477 };
478
479 let port: u16 = port_str.trim().parse().map_err(|_| {
480 BuildError::InvalidInstruction {
481 instruction: "EXPOSE".to_string(),
482 reason: format!("Invalid port number: {port_str}"),
483 }
484 })?;
485
486 Instruction::Expose(ExposeInstruction { port, protocol })
487 }
488
489 "SHELL" => {
490 let args = misc.arguments.to_string();
491 let shell: Vec<String> = serde_json::from_str(&args).map_err(|_| {
492 BuildError::InvalidInstruction {
493 instruction: "SHELL".to_string(),
494 reason: "SHELL requires a JSON array".to_string(),
495 }
496 })?;
497 Instruction::Shell(shell)
498 }
499
500 "STOPSIGNAL" => Instruction::Stopsignal(misc.arguments.to_string()),
501
502 "HEALTHCHECK" => {
503 let args = misc.arguments.to_string().trim().to_string();
504 if args.eq_ignore_ascii_case("NONE") {
505 Instruction::Healthcheck(HealthcheckInstruction::None)
506 } else {
507 let command = if let Some(stripped) = args.strip_prefix("CMD ") {
508 ShellOrExec::Shell(stripped.to_string())
509 } else {
510 ShellOrExec::Shell(args)
511 };
512 Instruction::Healthcheck(HealthcheckInstruction::cmd(command))
513 }
514 }
515
516 "ONBUILD" => {
517 tracing::warn!("ONBUILD instruction parsing not fully implemented");
518 return Ok(None);
519 }
520
521 "MAINTAINER" => {
522 let mut labels = HashMap::new();
523 labels.insert("maintainer".to_string(), misc.arguments.to_string());
524 Instruction::Label(labels)
525 }
526
527 "ADD" => {
528 let args = misc.arguments.to_string();
529 let parts: Vec<String> =
530 args.split_whitespace().map(String::from).collect();
531
532 if parts.len() < 2 {
533 return Err(BuildError::InvalidInstruction {
534 instruction: "ADD".to_string(),
535 reason: "ADD requires at least one source and a destination"
536 .to_string(),
537 });
538 }
539
540 let (sources, dest) = parts.split_at(parts.len() - 1);
541 let destination = dest.first().cloned().unwrap_or_default();
542
543 Instruction::Add(AddInstruction {
544 sources: sources.to_vec(),
545 destination,
546 chown: None,
547 chmod: None,
548 link: false,
549 checksum: None,
550 keep_git_dir: false,
551 })
552 }
553
554 other => {
555 tracing::warn!("Unknown Dockerfile instruction: {}", other);
556 return Ok(None);
557 }
558 }
559 }
560 };
561
562 Ok(Some(instruction))
563 }
564
565 #[must_use]
567 pub fn get_stage(&self, name_or_index: &str) -> Option<&Stage> {
568 self.stages.iter().find(|s| s.matches(name_or_index))
569 }
570
571 #[must_use]
573 pub fn final_stage(&self) -> Option<&Stage> {
574 self.stages.last()
575 }
576
577 #[must_use]
579 pub fn stage_names(&self) -> Vec<String> {
580 self.stages.iter().map(Stage::identifier).collect()
581 }
582
583 #[must_use]
585 pub fn has_stage(&self, name_or_index: &str) -> bool {
586 self.get_stage(name_or_index).is_some()
587 }
588
589 #[must_use]
591 pub fn stage_count(&self) -> usize {
592 self.stages.len()
593 }
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 #[test]
601 fn test_image_ref_parse_simple() {
602 let img = ImageRef::parse("alpine");
603 assert!(matches!(
604 img,
605 ImageRef::Registry {
606 ref image,
607 tag: None,
608 digest: None
609 } if image == "alpine"
610 ));
611 }
612
613 #[test]
614 fn test_image_ref_parse_with_tag() {
615 let img = ImageRef::parse("alpine:3.18");
616 assert!(matches!(
617 img,
618 ImageRef::Registry {
619 ref image,
620 tag: Some(ref t),
621 digest: None
622 } if image == "alpine" && t == "3.18"
623 ));
624 }
625
626 #[test]
627 fn test_image_ref_parse_with_digest() {
628 let img = ImageRef::parse("alpine@sha256:abc123");
629 assert!(matches!(
630 img,
631 ImageRef::Registry {
632 ref image,
633 tag: None,
634 digest: Some(ref d)
635 } if image == "alpine" && d == "sha256:abc123"
636 ));
637 }
638
639 #[test]
640 fn test_image_ref_parse_scratch() {
641 let img = ImageRef::parse("scratch");
642 assert!(matches!(img, ImageRef::Scratch));
643
644 let img = ImageRef::parse("SCRATCH");
645 assert!(matches!(img, ImageRef::Scratch));
646 }
647
648 #[test]
649 fn test_image_ref_parse_registry_with_port() {
650 let img = ImageRef::parse("localhost:5000/myimage:latest");
651 assert!(matches!(
652 img,
653 ImageRef::Registry {
654 ref image,
655 tag: Some(ref t),
656 ..
657 } if image == "localhost:5000/myimage" && t == "latest"
658 ));
659 }
660
661 #[test]
666 fn test_qualify_official_image_no_tag() {
667 let img = ImageRef::parse("alpine");
668 let q = img.qualify();
669 assert!(matches!(
670 q,
671 ImageRef::Registry { ref image, tag: None, digest: None }
672 if image == "docker.io/library/alpine"
673 ));
674 }
675
676 #[test]
677 fn test_qualify_official_image_with_tag() {
678 let img = ImageRef::parse("rust:1.90-bookworm");
679 let q = img.qualify();
680 assert!(matches!(
681 q,
682 ImageRef::Registry { ref image, tag: Some(ref t), digest: None }
683 if image == "docker.io/library/rust" && t == "1.90-bookworm"
684 ));
685 }
686
687 #[test]
688 fn test_qualify_user_image() {
689 let img = ImageRef::parse("lukemathwalker/cargo-chef:latest-rust-1.90");
690 let q = img.qualify();
691 assert!(matches!(
692 q,
693 ImageRef::Registry { ref image, tag: Some(ref t), .. }
694 if image == "docker.io/lukemathwalker/cargo-chef" && t == "latest-rust-1.90"
695 ));
696 }
697
698 #[test]
699 fn test_qualify_already_qualified_ghcr() {
700 let img = ImageRef::parse("ghcr.io/org/image:v1");
701 let q = img.qualify();
702 assert_eq!(q.to_string_ref(), "ghcr.io/org/image:v1");
703 }
704
705 #[test]
706 fn test_qualify_already_qualified_quay() {
707 let img = ImageRef::parse("quay.io/org/image:latest");
708 let q = img.qualify();
709 assert_eq!(q.to_string_ref(), "quay.io/org/image:latest");
710 }
711
712 #[test]
713 fn test_qualify_already_qualified_custom_registry() {
714 let img = ImageRef::parse("registry.example.com/org/image:v2");
715 let q = img.qualify();
716 assert_eq!(q.to_string_ref(), "registry.example.com/org/image:v2");
717 }
718
719 #[test]
720 fn test_qualify_localhost_with_port() {
721 let img = ImageRef::parse("localhost:5000/myimage:latest");
722 let q = img.qualify();
723 assert_eq!(q.to_string_ref(), "localhost:5000/myimage:latest");
724 }
725
726 #[test]
727 fn test_qualify_localhost_without_port() {
728 let img = ImageRef::parse("localhost/myimage:v1");
729 let q = img.qualify();
730 assert_eq!(q.to_string_ref(), "localhost/myimage:v1");
731 }
732
733 #[test]
734 fn test_qualify_with_digest() {
735 let img = ImageRef::parse("alpine@sha256:abc123def");
736 let q = img.qualify();
737 assert!(matches!(
738 q,
739 ImageRef::Registry { ref image, tag: None, digest: Some(ref d) }
740 if image == "docker.io/library/alpine" && d == "sha256:abc123def"
741 ));
742 }
743
744 #[test]
745 fn test_qualify_docker_io_explicit() {
746 let img = ImageRef::parse("docker.io/library/nginx:alpine");
747 let q = img.qualify();
748 assert_eq!(q.to_string_ref(), "docker.io/library/nginx:alpine");
749 }
750
751 #[test]
752 fn test_qualify_scratch() {
753 let img = ImageRef::parse("scratch");
754 let q = img.qualify();
755 assert!(matches!(q, ImageRef::Scratch));
756 }
757
758 #[test]
759 fn test_qualify_stage_ref() {
760 let img = ImageRef::Stage("builder".to_string());
761 let q = img.qualify();
762 assert!(matches!(q, ImageRef::Stage(ref name) if name == "builder"));
763 }
764
765 #[test]
766 fn test_parse_simple_dockerfile() {
767 let content = r#"
768FROM alpine:3.18
769RUN apk add --no-cache curl
770COPY . /app
771WORKDIR /app
772CMD ["./app"]
773"#;
774
775 let dockerfile = Dockerfile::parse(content).unwrap();
776 assert_eq!(dockerfile.stages.len(), 1);
777
778 let stage = &dockerfile.stages[0];
779 assert_eq!(stage.index, 0);
780 assert!(stage.name.is_none());
781 assert_eq!(stage.instructions.len(), 4);
782 }
783
784 #[test]
785 fn test_parse_multistage_dockerfile() {
786 let content = r#"
787FROM golang:1.21 AS builder
788WORKDIR /src
789COPY . .
790RUN go build -o /app
791
792FROM alpine:3.18
793COPY --from=builder /app /app
794CMD ["/app"]
795"#;
796
797 let dockerfile = Dockerfile::parse(content).unwrap();
798 assert_eq!(dockerfile.stages.len(), 2);
799
800 let builder = &dockerfile.stages[0];
801 assert_eq!(builder.name, Some("builder".to_string()));
802
803 let runtime = &dockerfile.stages[1];
804 assert!(runtime.name.is_none());
805
806 let copy = runtime
807 .instructions
808 .iter()
809 .find(|i| matches!(i, Instruction::Copy(_)));
810 assert!(copy.is_some());
811 if let Some(Instruction::Copy(c)) = copy {
812 assert_eq!(c.from, Some("builder".to_string()));
813 }
814 }
815
816 #[test]
817 fn test_parse_global_args() {
818 let content = r#"
819ARG BASE_IMAGE=alpine:3.18
820FROM ${BASE_IMAGE}
821RUN echo "hello"
822"#;
823
824 let dockerfile = Dockerfile::parse(content).unwrap();
825 assert_eq!(dockerfile.global_args.len(), 1);
826 assert_eq!(dockerfile.global_args[0].name, "BASE_IMAGE");
827 assert_eq!(
828 dockerfile.global_args[0].default,
829 Some("alpine:3.18".to_string())
830 );
831 }
832
833 #[test]
834 fn test_get_stage_by_name() {
835 let content = r#"
836FROM alpine:3.18 AS base
837RUN echo "base"
838
839FROM base AS builder
840RUN echo "builder"
841"#;
842
843 let dockerfile = Dockerfile::parse(content).unwrap();
844
845 let base = dockerfile.get_stage("base");
846 assert!(base.is_some());
847 assert_eq!(base.unwrap().index, 0);
848
849 let builder = dockerfile.get_stage("builder");
850 assert!(builder.is_some());
851 assert_eq!(builder.unwrap().index, 1);
852
853 let stage_0 = dockerfile.get_stage("0");
854 assert!(stage_0.is_some());
855 assert_eq!(stage_0.unwrap().name, Some("base".to_string()));
856 }
857
858 #[test]
859 fn test_final_stage() {
860 let content = r#"
861FROM alpine:3.18 AS builder
862RUN echo "builder"
863
864FROM scratch
865COPY --from=builder /app /app
866"#;
867
868 let dockerfile = Dockerfile::parse(content).unwrap();
869 let final_stage = dockerfile.final_stage().unwrap();
870
871 assert_eq!(final_stage.index, 1);
872 assert!(matches!(final_stage.base_image, ImageRef::Scratch));
873 }
874
875 #[test]
876 fn test_parse_env_instruction() {
877 let content = r#"
878FROM alpine
879ENV FOO=bar BAZ=qux
880"#;
881
882 let dockerfile = Dockerfile::parse(content).unwrap();
883 let stage = &dockerfile.stages[0];
884
885 let env = stage
886 .instructions
887 .iter()
888 .find(|i| matches!(i, Instruction::Env(_)));
889 assert!(env.is_some());
890
891 if let Some(Instruction::Env(e)) = env {
892 assert_eq!(e.vars.get("FOO"), Some(&"bar".to_string()));
893 assert_eq!(e.vars.get("BAZ"), Some(&"qux".to_string()));
894 }
895 }
896}