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