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 sources: Vec<String> = copy
379 .sources
380 .iter()
381 .map(std::string::ToString::to_string)
382 .collect();
383 let destination = copy.destination.to_string();
384
385 Instruction::Copy(CopyInstruction {
386 sources,
387 destination,
388 from,
389 chown,
390 chmod,
391 link,
392 exclude: Vec::new(),
393 })
394 }
395
396 RawInstruction::Entrypoint(ep) => {
397 let command = match &ep.expr {
398 dockerfile_parser::ShellOrExecExpr::Shell(s) => {
399 ShellOrExec::Shell(s.to_string())
400 }
401 dockerfile_parser::ShellOrExecExpr::Exec(args) => {
402 ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
403 }
404 };
405 Instruction::Entrypoint(command)
406 }
407
408 RawInstruction::Cmd(cmd) => {
409 let command = match &cmd.expr {
410 dockerfile_parser::ShellOrExecExpr::Shell(s) => {
411 ShellOrExec::Shell(s.to_string())
412 }
413 dockerfile_parser::ShellOrExecExpr::Exec(args) => {
414 ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
415 }
416 };
417 Instruction::Cmd(command)
418 }
419
420 RawInstruction::Env(env) => {
421 let mut vars = HashMap::new();
422 for var in &env.vars {
423 vars.insert(var.key.to_string(), var.value.to_string());
424 }
425 Instruction::Env(EnvInstruction { vars })
426 }
427
428 RawInstruction::Label(label) => {
429 let mut labels = HashMap::new();
430 for l in &label.labels {
431 labels.insert(l.name.to_string(), l.value.to_string());
432 }
433 Instruction::Label(labels)
434 }
435
436 RawInstruction::Arg(arg) => Instruction::Arg(ArgInstruction {
437 name: arg.name.to_string(),
438 default: arg.value.as_ref().map(std::string::ToString::to_string),
439 }),
440
441 RawInstruction::Misc(misc) => {
442 let instruction_upper = misc.instruction.content.to_uppercase();
443 match instruction_upper.as_str() {
444 "WORKDIR" => Instruction::Workdir(misc.arguments.to_string()),
445
446 "USER" => Instruction::User(misc.arguments.to_string()),
447
448 "VOLUME" => {
449 let args = misc.arguments.to_string();
450 let volumes = if args.trim().starts_with('[') {
451 serde_json::from_str(&args).unwrap_or_else(|_| vec![args])
452 } else {
453 args.split_whitespace().map(String::from).collect()
454 };
455 Instruction::Volume(volumes)
456 }
457
458 "EXPOSE" => {
459 let args = misc.arguments.to_string();
460 let (port_str, protocol) = if let Some((p, proto)) = args.split_once('/') {
461 let proto = match proto.to_lowercase().as_str() {
462 "udp" => ExposeProtocol::Udp,
463 _ => ExposeProtocol::Tcp,
464 };
465 (p, proto)
466 } else {
467 (args.as_str(), ExposeProtocol::Tcp)
468 };
469
470 let port: u16 = port_str.trim().parse().map_err(|_| {
471 BuildError::InvalidInstruction {
472 instruction: "EXPOSE".to_string(),
473 reason: format!("Invalid port number: {port_str}"),
474 }
475 })?;
476
477 Instruction::Expose(ExposeInstruction { port, protocol })
478 }
479
480 "SHELL" => {
481 let args = misc.arguments.to_string();
482 let shell: Vec<String> = serde_json::from_str(&args).map_err(|_| {
483 BuildError::InvalidInstruction {
484 instruction: "SHELL".to_string(),
485 reason: "SHELL requires a JSON array".to_string(),
486 }
487 })?;
488 Instruction::Shell(shell)
489 }
490
491 "STOPSIGNAL" => Instruction::Stopsignal(misc.arguments.to_string()),
492
493 "HEALTHCHECK" => {
494 let args = misc.arguments.to_string().trim().to_string();
495 if args.eq_ignore_ascii_case("NONE") {
496 Instruction::Healthcheck(HealthcheckInstruction::None)
497 } else {
498 let command = if let Some(stripped) = args.strip_prefix("CMD ") {
499 ShellOrExec::Shell(stripped.to_string())
500 } else {
501 ShellOrExec::Shell(args)
502 };
503 Instruction::Healthcheck(HealthcheckInstruction::cmd(command))
504 }
505 }
506
507 "ONBUILD" => {
508 tracing::warn!("ONBUILD instruction parsing not fully implemented");
509 return Ok(None);
510 }
511
512 "MAINTAINER" => {
513 let mut labels = HashMap::new();
514 labels.insert("maintainer".to_string(), misc.arguments.to_string());
515 Instruction::Label(labels)
516 }
517
518 "ADD" => {
519 let args = misc.arguments.to_string();
520 let parts: Vec<String> =
521 args.split_whitespace().map(String::from).collect();
522
523 if parts.len() < 2 {
524 return Err(BuildError::InvalidInstruction {
525 instruction: "ADD".to_string(),
526 reason: "ADD requires at least one source and a destination"
527 .to_string(),
528 });
529 }
530
531 let (sources, dest) = parts.split_at(parts.len() - 1);
532 let destination = dest.first().cloned().unwrap_or_default();
533
534 Instruction::Add(AddInstruction {
535 sources: sources.to_vec(),
536 destination,
537 chown: None,
538 chmod: None,
539 link: false,
540 checksum: None,
541 keep_git_dir: false,
542 })
543 }
544
545 other => {
546 tracing::warn!("Unknown Dockerfile instruction: {}", other);
547 return Ok(None);
548 }
549 }
550 }
551 };
552
553 Ok(Some(instruction))
554 }
555
556 #[must_use]
558 pub fn get_stage(&self, name_or_index: &str) -> Option<&Stage> {
559 self.stages.iter().find(|s| s.matches(name_or_index))
560 }
561
562 #[must_use]
564 pub fn final_stage(&self) -> Option<&Stage> {
565 self.stages.last()
566 }
567
568 #[must_use]
570 pub fn stage_names(&self) -> Vec<String> {
571 self.stages.iter().map(Stage::identifier).collect()
572 }
573
574 #[must_use]
576 pub fn has_stage(&self, name_or_index: &str) -> bool {
577 self.get_stage(name_or_index).is_some()
578 }
579
580 #[must_use]
582 pub fn stage_count(&self) -> usize {
583 self.stages.len()
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 #[test]
592 fn test_image_ref_parse_simple() {
593 let img = ImageRef::parse("alpine");
594 assert!(matches!(
595 img,
596 ImageRef::Registry {
597 ref image,
598 tag: None,
599 digest: None
600 } if image == "alpine"
601 ));
602 }
603
604 #[test]
605 fn test_image_ref_parse_with_tag() {
606 let img = ImageRef::parse("alpine:3.18");
607 assert!(matches!(
608 img,
609 ImageRef::Registry {
610 ref image,
611 tag: Some(ref t),
612 digest: None
613 } if image == "alpine" && t == "3.18"
614 ));
615 }
616
617 #[test]
618 fn test_image_ref_parse_with_digest() {
619 let img = ImageRef::parse("alpine@sha256:abc123");
620 assert!(matches!(
621 img,
622 ImageRef::Registry {
623 ref image,
624 tag: None,
625 digest: Some(ref d)
626 } if image == "alpine" && d == "sha256:abc123"
627 ));
628 }
629
630 #[test]
631 fn test_image_ref_parse_scratch() {
632 let img = ImageRef::parse("scratch");
633 assert!(matches!(img, ImageRef::Scratch));
634
635 let img = ImageRef::parse("SCRATCH");
636 assert!(matches!(img, ImageRef::Scratch));
637 }
638
639 #[test]
640 fn test_image_ref_parse_registry_with_port() {
641 let img = ImageRef::parse("localhost:5000/myimage:latest");
642 assert!(matches!(
643 img,
644 ImageRef::Registry {
645 ref image,
646 tag: Some(ref t),
647 ..
648 } if image == "localhost:5000/myimage" && t == "latest"
649 ));
650 }
651
652 #[test]
657 fn test_qualify_official_image_no_tag() {
658 let img = ImageRef::parse("alpine");
659 let q = img.qualify();
660 assert!(matches!(
661 q,
662 ImageRef::Registry { ref image, tag: None, digest: None }
663 if image == "docker.io/library/alpine"
664 ));
665 }
666
667 #[test]
668 fn test_qualify_official_image_with_tag() {
669 let img = ImageRef::parse("rust:1.90-bookworm");
670 let q = img.qualify();
671 assert!(matches!(
672 q,
673 ImageRef::Registry { ref image, tag: Some(ref t), digest: None }
674 if image == "docker.io/library/rust" && t == "1.90-bookworm"
675 ));
676 }
677
678 #[test]
679 fn test_qualify_user_image() {
680 let img = ImageRef::parse("lukemathwalker/cargo-chef:latest-rust-1.90");
681 let q = img.qualify();
682 assert!(matches!(
683 q,
684 ImageRef::Registry { ref image, tag: Some(ref t), .. }
685 if image == "docker.io/lukemathwalker/cargo-chef" && t == "latest-rust-1.90"
686 ));
687 }
688
689 #[test]
690 fn test_qualify_already_qualified_ghcr() {
691 let img = ImageRef::parse("ghcr.io/org/image:v1");
692 let q = img.qualify();
693 assert_eq!(q.to_string_ref(), "ghcr.io/org/image:v1");
694 }
695
696 #[test]
697 fn test_qualify_already_qualified_quay() {
698 let img = ImageRef::parse("quay.io/org/image:latest");
699 let q = img.qualify();
700 assert_eq!(q.to_string_ref(), "quay.io/org/image:latest");
701 }
702
703 #[test]
704 fn test_qualify_already_qualified_custom_registry() {
705 let img = ImageRef::parse("registry.example.com/org/image:v2");
706 let q = img.qualify();
707 assert_eq!(q.to_string_ref(), "registry.example.com/org/image:v2");
708 }
709
710 #[test]
711 fn test_qualify_localhost_with_port() {
712 let img = ImageRef::parse("localhost:5000/myimage:latest");
713 let q = img.qualify();
714 assert_eq!(q.to_string_ref(), "localhost:5000/myimage:latest");
715 }
716
717 #[test]
718 fn test_qualify_localhost_without_port() {
719 let img = ImageRef::parse("localhost/myimage:v1");
720 let q = img.qualify();
721 assert_eq!(q.to_string_ref(), "localhost/myimage:v1");
722 }
723
724 #[test]
725 fn test_qualify_with_digest() {
726 let img = ImageRef::parse("alpine@sha256:abc123def");
727 let q = img.qualify();
728 assert!(matches!(
729 q,
730 ImageRef::Registry { ref image, tag: None, digest: Some(ref d) }
731 if image == "docker.io/library/alpine" && d == "sha256:abc123def"
732 ));
733 }
734
735 #[test]
736 fn test_qualify_docker_io_explicit() {
737 let img = ImageRef::parse("docker.io/library/nginx:alpine");
738 let q = img.qualify();
739 assert_eq!(q.to_string_ref(), "docker.io/library/nginx:alpine");
740 }
741
742 #[test]
743 fn test_qualify_scratch() {
744 let img = ImageRef::parse("scratch");
745 let q = img.qualify();
746 assert!(matches!(q, ImageRef::Scratch));
747 }
748
749 #[test]
750 fn test_qualify_stage_ref() {
751 let img = ImageRef::Stage("builder".to_string());
752 let q = img.qualify();
753 assert!(matches!(q, ImageRef::Stage(ref name) if name == "builder"));
754 }
755
756 #[test]
757 fn test_parse_simple_dockerfile() {
758 let content = r#"
759FROM alpine:3.18
760RUN apk add --no-cache curl
761COPY . /app
762WORKDIR /app
763CMD ["./app"]
764"#;
765
766 let dockerfile = Dockerfile::parse(content).unwrap();
767 assert_eq!(dockerfile.stages.len(), 1);
768
769 let stage = &dockerfile.stages[0];
770 assert_eq!(stage.index, 0);
771 assert!(stage.name.is_none());
772 assert_eq!(stage.instructions.len(), 4);
773 }
774
775 #[test]
776 fn test_parse_multistage_dockerfile() {
777 let content = r#"
778FROM golang:1.21 AS builder
779WORKDIR /src
780COPY . .
781RUN go build -o /app
782
783FROM alpine:3.18
784COPY --from=builder /app /app
785CMD ["/app"]
786"#;
787
788 let dockerfile = Dockerfile::parse(content).unwrap();
789 assert_eq!(dockerfile.stages.len(), 2);
790
791 let builder = &dockerfile.stages[0];
792 assert_eq!(builder.name, Some("builder".to_string()));
793
794 let runtime = &dockerfile.stages[1];
795 assert!(runtime.name.is_none());
796
797 let copy = runtime
798 .instructions
799 .iter()
800 .find(|i| matches!(i, Instruction::Copy(_)));
801 assert!(copy.is_some());
802 if let Some(Instruction::Copy(c)) = copy {
803 assert_eq!(c.from, Some("builder".to_string()));
804 }
805 }
806
807 #[test]
808 fn test_parse_global_args() {
809 let content = r#"
810ARG BASE_IMAGE=alpine:3.18
811FROM ${BASE_IMAGE}
812RUN echo "hello"
813"#;
814
815 let dockerfile = Dockerfile::parse(content).unwrap();
816 assert_eq!(dockerfile.global_args.len(), 1);
817 assert_eq!(dockerfile.global_args[0].name, "BASE_IMAGE");
818 assert_eq!(
819 dockerfile.global_args[0].default,
820 Some("alpine:3.18".to_string())
821 );
822 }
823
824 #[test]
825 fn test_get_stage_by_name() {
826 let content = r#"
827FROM alpine:3.18 AS base
828RUN echo "base"
829
830FROM base AS builder
831RUN echo "builder"
832"#;
833
834 let dockerfile = Dockerfile::parse(content).unwrap();
835
836 let base = dockerfile.get_stage("base");
837 assert!(base.is_some());
838 assert_eq!(base.unwrap().index, 0);
839
840 let builder = dockerfile.get_stage("builder");
841 assert!(builder.is_some());
842 assert_eq!(builder.unwrap().index, 1);
843
844 let stage_0 = dockerfile.get_stage("0");
845 assert!(stage_0.is_some());
846 assert_eq!(stage_0.unwrap().name, Some("base".to_string()));
847 }
848
849 #[test]
850 fn test_final_stage() {
851 let content = r#"
852FROM alpine:3.18 AS builder
853RUN echo "builder"
854
855FROM scratch
856COPY --from=builder /app /app
857"#;
858
859 let dockerfile = Dockerfile::parse(content).unwrap();
860 let final_stage = dockerfile.final_stage().unwrap();
861
862 assert_eq!(final_stage.index, 1);
863 assert!(matches!(final_stage.base_image, ImageRef::Scratch));
864 }
865
866 #[test]
867 fn test_parse_env_instruction() {
868 let content = r"
869FROM alpine
870ENV FOO=bar BAZ=qux
871";
872
873 let dockerfile = Dockerfile::parse(content).unwrap();
874 let stage = &dockerfile.stages[0];
875
876 let env = stage
877 .instructions
878 .iter()
879 .find(|i| matches!(i, Instruction::Env(_)));
880 assert!(env.is_some());
881
882 if let Some(Instruction::Env(e)) = env {
883 assert_eq!(e.vars.get("FOO"), Some(&"bar".to_string()));
884 assert_eq!(e.vars.get("BAZ"), Some(&"qux".to_string()));
885 }
886 }
887}