1use std::collections::HashMap;
7use std::path::Path;
8use std::str::FromStr;
9
10use dockerfile_parser::{Dockerfile as RawDockerfile, Instruction as RawInstruction};
11use serde::{Deserialize, Serialize};
12use zlayer_types::ImageReference;
13
14use crate::error::{BuildError, Result};
15
16use super::instruction::{
17 AddInstruction, ArgInstruction, CopyInstruction, EnvInstruction, ExposeInstruction,
18 ExposeProtocol, HealthcheckInstruction, Instruction, RunInstruction, ShellOrExec,
19};
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub enum DockerfileFromTarget {
32 Image(ImageReference),
34 Stage(String),
36 Scratch,
38}
39
40impl DockerfileFromTarget {
41 #[must_use]
53 pub fn parse(s: &str) -> Self {
54 let s = s.trim();
55
56 if s.eq_ignore_ascii_case("scratch") {
57 return Self::Scratch;
58 }
59
60 match ImageReference::from_str(s) {
61 Ok(r) => Self::Image(r),
62 Err(_) => Self::Stage(s.to_string()),
63 }
64 }
65
66 #[must_use]
68 pub fn is_stage(&self) -> bool {
69 matches!(self, Self::Stage(_))
70 }
71
72 #[must_use]
74 pub fn is_scratch(&self) -> bool {
75 matches!(self, Self::Scratch)
76 }
77}
78
79impl std::fmt::Display for DockerfileFromTarget {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 Self::Image(r) => write!(f, "{r}"),
83 Self::Stage(name) => f.write_str(name),
84 Self::Scratch => f.write_str("scratch"),
85 }
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Stage {
92 pub index: usize,
94
95 pub name: Option<String>,
97
98 pub base_image: DockerfileFromTarget,
100
101 pub platform: Option<String>,
103
104 pub instructions: Vec<Instruction>,
106}
107
108impl Stage {
109 #[must_use]
111 pub fn identifier(&self) -> String {
112 self.name.clone().unwrap_or_else(|| self.index.to_string())
113 }
114
115 #[must_use]
117 pub fn matches(&self, name_or_index: &str) -> bool {
118 if let Some(ref name) = self.name {
119 if name == name_or_index {
120 return true;
121 }
122 }
123
124 if let Ok(idx) = name_or_index.parse::<usize>() {
125 return idx == self.index;
126 }
127
128 false
129 }
130}
131
132fn expand_from_args(input: &str, vars: &HashMap<String, String>) -> String {
139 let mut out = String::with_capacity(input.len());
140 let mut chars = input.char_indices().peekable();
141 while let Some((_, c)) = chars.next() {
142 if c != '$' {
143 out.push(c);
144 continue;
145 }
146 match chars.peek() {
147 Some(&(_, '{')) => {
148 chars.next(); let mut body = String::new();
150 let mut closed = false;
151 for (_, bc) in chars.by_ref() {
152 if bc == '}' {
153 closed = true;
154 break;
155 }
156 body.push(bc);
157 }
158 if !closed {
159 out.push_str("${");
161 out.push_str(&body);
162 continue;
163 }
164 if let Some((name, default)) = body.split_once(":-") {
165 match vars.get(name).filter(|v| !v.is_empty()) {
166 Some(v) => out.push_str(v),
167 None => out.push_str(default),
168 }
169 } else if let Some((name, alt)) = body.split_once(":+") {
170 if vars.get(name).is_some_and(|v| !v.is_empty()) {
171 out.push_str(alt);
172 }
173 } else {
174 out.push_str(vars.get(&body).map_or("", String::as_str));
175 }
176 }
177 Some(&(_, next)) if next.is_ascii_alphabetic() || next == '_' => {
178 let mut name = String::new();
179 while let Some(&(_, nc)) = chars.peek() {
180 if nc.is_ascii_alphanumeric() || nc == '_' {
181 name.push(nc);
182 chars.next();
183 } else {
184 break;
185 }
186 }
187 out.push_str(vars.get(&name).map_or("", String::as_str));
188 }
189 Some(&(_, '$')) => {
191 chars.next();
192 out.push('$');
193 }
194 _ => out.push('$'),
195 }
196 }
197 out
198}
199
200#[derive(Debug, Clone, Copy, Default)]
203struct QuoteState {
204 in_double: bool,
205 in_single: bool,
206}
207
208impl QuoteState {
209 fn in_quotes(self) -> bool {
210 self.in_double || self.in_single
211 }
212}
213
214enum LineState {
216 Top,
218 ArgEnv(QuoteState),
220 Other,
222}
223
224fn ends_with_continuation(body: &str) -> bool {
227 body.trim_end_matches([' ', '\t']).ends_with('\\')
228}
229
230fn rewrite_empty_assignments_in_line(
238 body: &str,
239 quotes: &mut QuoteState,
240 out: &mut String,
241 changed: &mut bool,
242) -> bool {
243 let mut continues = false;
244 let mut prev_is_key_char = false;
245 let mut chars = body.char_indices().peekable();
246
247 while let Some((idx, c)) = chars.next() {
248 if quotes.in_quotes() {
249 match c {
250 '\\' => {
251 out.push(c);
252 if let Some((_, esc)) = chars.next() {
253 out.push(esc);
254 } else {
255 continues = true;
259 }
260 }
261 '"' if quotes.in_double => {
262 quotes.in_double = false;
263 out.push(c);
264 }
265 '\'' if quotes.in_single => {
266 quotes.in_single = false;
267 out.push(c);
268 }
269 _ => out.push(c),
270 }
271 prev_is_key_char = false;
272 continue;
273 }
274
275 match c {
276 '"' => {
277 quotes.in_double = true;
278 out.push(c);
279 prev_is_key_char = false;
280 }
281 '\'' => {
282 quotes.in_single = true;
283 out.push(c);
284 prev_is_key_char = false;
285 }
286 '\\' if body[idx + 1..].chars().all(|w| w == ' ' || w == '\t') => {
288 continues = true;
289 out.push_str(&body[idx..]);
290 break;
291 }
292 '=' if prev_is_key_char => {
293 out.push(c);
294 if matches!(chars.peek(), None | Some(&(_, ' ' | '\t'))) {
298 out.push_str("\"\"");
299 *changed = true;
300 }
301 prev_is_key_char = false;
302 }
303 _ => {
304 out.push(c);
305 prev_is_key_char = c.is_ascii_alphanumeric() || c == '_';
306 }
307 }
308 }
309
310 continues || quotes.in_quotes()
314}
315
316fn normalize_empty_assignments(content: &str) -> std::borrow::Cow<'_, str> {
330 let mut out = String::with_capacity(content.len() + 8);
331 let mut changed = false;
332 let mut state = LineState::Top;
333
334 for raw_line in content.split_inclusive('\n') {
335 let (body, terminator) = match raw_line.strip_suffix('\n') {
338 Some(rest) => match rest.strip_suffix('\r') {
339 Some(rest) => (rest, "\r\n"),
340 None => (rest, "\n"),
341 },
342 None => (raw_line, ""),
343 };
344
345 state = match state {
346 LineState::Top => {
347 let trimmed = body.trim_start();
348 let keyword_len = trimmed
349 .chars()
350 .take_while(char::is_ascii_alphabetic)
351 .count();
352 let keyword = &trimmed[..keyword_len];
353 let after_keyword = trimmed[keyword_len..].chars().next();
354 let is_arg_env = (keyword.eq_ignore_ascii_case("arg")
355 || keyword.eq_ignore_ascii_case("env")
356 || keyword.eq_ignore_ascii_case("label"))
357 && matches!(after_keyword, Some(' ' | '\t' | '\\'));
358
359 if is_arg_env {
360 let prefix_len = body.len() - trimmed.len() + keyword_len;
361 out.push_str(&body[..prefix_len]);
362 let mut quotes = QuoteState::default();
363 if rewrite_empty_assignments_in_line(
364 &trimmed[keyword_len..],
365 &mut quotes,
366 &mut out,
367 &mut changed,
368 ) {
369 LineState::ArgEnv(quotes)
370 } else {
371 LineState::Top
372 }
373 } else {
374 out.push_str(body);
375 let is_blank_or_comment = trimmed.is_empty() || trimmed.starts_with('#');
376 if !is_blank_or_comment && ends_with_continuation(body) {
377 LineState::Other
378 } else {
379 LineState::Top
380 }
381 }
382 }
383 LineState::ArgEnv(mut quotes) => {
384 let trimmed = body.trim_start();
385 if !quotes.in_quotes() && (trimmed.is_empty() || trimmed.starts_with('#')) {
386 out.push_str(body);
389 LineState::ArgEnv(quotes)
390 } else if rewrite_empty_assignments_in_line(
391 body,
392 &mut quotes,
393 &mut out,
394 &mut changed,
395 ) {
396 LineState::ArgEnv(quotes)
397 } else {
398 LineState::Top
399 }
400 }
401 LineState::Other => {
402 out.push_str(body);
403 let trimmed = body.trim_start();
404 let is_blank_or_comment = trimmed.is_empty() || trimmed.starts_with('#');
405 if is_blank_or_comment || ends_with_continuation(body) {
406 LineState::Other
407 } else {
408 LineState::Top
409 }
410 }
411 };
412
413 out.push_str(terminator);
414 }
415
416 if changed {
417 std::borrow::Cow::Owned(out)
418 } else {
419 std::borrow::Cow::Borrowed(content)
420 }
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct Dockerfile {
426 pub global_args: Vec<ArgInstruction>,
428
429 pub stages: Vec<Stage>,
431}
432
433impl Dockerfile {
434 pub fn resolve_from_args(&mut self, build_args: &HashMap<String, String>) {
450 let mut vars: HashMap<String, String> = HashMap::new();
454 for arg in &self.global_args {
455 let value = build_args
456 .get(&arg.name)
457 .cloned()
458 .or_else(|| arg.default.clone())
459 .unwrap_or_default();
460 vars.insert(arg.name.clone(), value);
461 }
462
463 let mut known_stage_names: std::collections::HashSet<String> =
464 std::collections::HashSet::new();
465 for stage in &mut self.stages {
466 if let DockerfileFromTarget::Stage(raw) = &stage.base_image {
467 if raw.contains('$') {
468 let expanded = expand_from_args(raw, &vars);
469 if !expanded.trim().is_empty() {
470 let mut target = DockerfileFromTarget::parse(&expanded);
471 if matches!(target, DockerfileFromTarget::Image(_))
475 && known_stage_names.contains(expanded.trim())
476 {
477 target = DockerfileFromTarget::Stage(expanded.trim().to_string());
478 }
479 stage.base_image = target;
480 }
481 }
482 }
483 if let Some(name) = &stage.name {
484 known_stage_names.insert(name.clone());
485 }
486 }
487 }
488
489 pub fn parse(content: &str) -> Result<Self> {
495 let content = normalize_empty_assignments(content);
499 let raw = RawDockerfile::parse(&content).map_err(|e| BuildError::DockerfileParse {
500 message: e.to_string(),
501 line: 1,
502 })?;
503
504 Self::from_raw(raw)
505 }
506
507 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
513 let content =
514 std::fs::read_to_string(path.as_ref()).map_err(|e| BuildError::ContextRead {
515 path: path.as_ref().to_path_buf(),
516 source: e,
517 })?;
518
519 Self::parse(&content)
520 }
521
522 fn from_raw(raw: RawDockerfile) -> Result<Self> {
524 let mut global_args = Vec::new();
525 let mut stages = Vec::new();
526 let mut current_stage: Option<Stage> = None;
527 let mut stage_index = 0;
528 let mut known_stage_names: std::collections::HashSet<String> =
532 std::collections::HashSet::new();
533
534 for instruction in raw.instructions {
535 match &instruction {
536 RawInstruction::From(from) => {
537 if let Some(stage) = current_stage.take() {
539 stages.push(stage);
540 }
541
542 let raw_from = from.image.content.trim().to_string();
544 let mut base_image = DockerfileFromTarget::parse(&raw_from);
545
546 if matches!(base_image, DockerfileFromTarget::Image(_))
552 && known_stage_names.contains(&raw_from)
553 {
554 base_image = DockerfileFromTarget::Stage(raw_from.clone());
555 }
556
557 let name = from.alias.as_ref().map(|a| a.content.clone());
559
560 if let Some(ref n) = name {
561 known_stage_names.insert(n.clone());
562 }
563
564 let platform = from
566 .flags
567 .iter()
568 .find(|f| f.name.content.as_str() == "platform")
569 .map(|f| f.value.to_string());
570
571 current_stage = Some(Stage {
572 index: stage_index,
573 name,
574 base_image,
575 platform,
576 instructions: Vec::new(),
577 });
578
579 stage_index += 1;
580 }
581
582 RawInstruction::Arg(arg) => {
583 let arg_inst = ArgInstruction {
584 name: arg.name.to_string(),
585 default: arg.value.as_ref().map(std::string::ToString::to_string),
586 };
587
588 if current_stage.is_none() {
589 global_args.push(arg_inst);
590 } else if let Some(ref mut stage) = current_stage {
591 stage.instructions.push(Instruction::Arg(arg_inst));
592 }
593 }
594
595 _ => {
596 if let Some(ref mut stage) = current_stage {
597 if let Some(inst) = Self::convert_instruction(&instruction)? {
598 stage.instructions.push(inst);
599 }
600 }
601 }
602 }
603 }
604
605 if let Some(stage) = current_stage {
607 stages.push(stage);
608 }
609
610 let _stage_names: HashMap<String, usize> = stages
614 .iter()
615 .filter_map(|s| s.name.as_ref().map(|n| (n.clone(), s.index)))
616 .collect();
617 let _num_stages = stages.len();
618
619 Ok(Self {
620 global_args,
621 stages,
622 })
623 }
624
625 #[allow(clippy::too_many_lines)]
627 fn convert_instruction(raw: &RawInstruction) -> Result<Option<Instruction>> {
628 let instruction = match raw {
629 RawInstruction::From(_) => {
630 return Ok(None);
631 }
632
633 RawInstruction::Run(run) => {
634 let command = match &run.expr {
635 dockerfile_parser::ShellOrExecExpr::Shell(s) => {
636 ShellOrExec::Shell(s.to_string())
637 }
638 dockerfile_parser::ShellOrExecExpr::Exec(args) => {
639 ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
640 }
641 };
642
643 Instruction::Run(RunInstruction {
644 command,
645 mounts: Vec::new(),
646 network: None,
647 security: None,
648 env: HashMap::new(),
649 })
650 }
651
652 RawInstruction::Copy(copy) => {
653 let from = copy
654 .flags
655 .iter()
656 .find(|f| f.name.content.as_str() == "from")
657 .map(|f| f.value.to_string());
658
659 let chown = copy
660 .flags
661 .iter()
662 .find(|f| f.name.content.as_str() == "chown")
663 .map(|f| f.value.to_string());
664
665 let chmod = copy
666 .flags
667 .iter()
668 .find(|f| f.name.content.as_str() == "chmod")
669 .map(|f| f.value.to_string());
670
671 let link = copy.flags.iter().any(|f| f.name.content.as_str() == "link");
672
673 let sources: Vec<String> = copy
675 .sources
676 .iter()
677 .map(std::string::ToString::to_string)
678 .collect();
679 let destination = copy.destination.to_string();
680
681 Instruction::Copy(CopyInstruction {
682 sources,
683 destination,
684 from,
685 chown,
686 chmod,
687 link,
688 exclude: Vec::new(),
689 })
690 }
691
692 RawInstruction::Entrypoint(ep) => {
693 let command = match &ep.expr {
694 dockerfile_parser::ShellOrExecExpr::Shell(s) => {
695 ShellOrExec::Shell(s.to_string())
696 }
697 dockerfile_parser::ShellOrExecExpr::Exec(args) => {
698 ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
699 }
700 };
701 Instruction::Entrypoint(command)
702 }
703
704 RawInstruction::Cmd(cmd) => {
705 let command = match &cmd.expr {
706 dockerfile_parser::ShellOrExecExpr::Shell(s) => {
707 ShellOrExec::Shell(s.to_string())
708 }
709 dockerfile_parser::ShellOrExecExpr::Exec(args) => {
710 ShellOrExec::Exec(args.elements.iter().map(|s| s.content.clone()).collect())
711 }
712 };
713 Instruction::Cmd(command)
714 }
715
716 RawInstruction::Env(env) => {
717 let mut vars = HashMap::new();
718 for var in &env.vars {
719 vars.insert(var.key.to_string(), var.value.to_string());
720 }
721 Instruction::Env(EnvInstruction { vars })
722 }
723
724 RawInstruction::Label(label) => {
725 let mut labels = HashMap::new();
726 for l in &label.labels {
727 labels.insert(l.name.to_string(), l.value.to_string());
728 }
729 Instruction::Label(labels)
730 }
731
732 RawInstruction::Arg(arg) => Instruction::Arg(ArgInstruction {
733 name: arg.name.to_string(),
734 default: arg.value.as_ref().map(std::string::ToString::to_string),
735 }),
736
737 RawInstruction::Misc(misc) => {
738 let instruction_upper = misc.instruction.content.to_uppercase();
739 match instruction_upper.as_str() {
740 "WORKDIR" => Instruction::Workdir(misc.arguments.to_string()),
741
742 "USER" => Instruction::User(misc.arguments.to_string()),
743
744 "VOLUME" => {
745 let args = misc.arguments.to_string();
746 let volumes = if args.trim().starts_with('[') {
747 serde_json::from_str(&args).unwrap_or_else(|_| vec![args])
748 } else {
749 args.split_whitespace().map(String::from).collect()
750 };
751 Instruction::Volume(volumes)
752 }
753
754 "EXPOSE" => {
755 let args = misc.arguments.to_string();
756 let (port_str, protocol) = if let Some((p, proto)) = args.split_once('/') {
757 let proto = match proto.to_lowercase().as_str() {
758 "udp" => ExposeProtocol::Udp,
759 _ => ExposeProtocol::Tcp,
760 };
761 (p, proto)
762 } else {
763 (args.as_str(), ExposeProtocol::Tcp)
764 };
765
766 let port: u16 = port_str.trim().parse().map_err(|_| {
767 BuildError::InvalidInstruction {
768 instruction: "EXPOSE".to_string(),
769 reason: format!("Invalid port number: {port_str}"),
770 }
771 })?;
772
773 Instruction::Expose(ExposeInstruction { port, protocol })
774 }
775
776 "SHELL" => {
777 let args = misc.arguments.to_string();
778 let shell: Vec<String> = serde_json::from_str(&args).map_err(|_| {
779 BuildError::InvalidInstruction {
780 instruction: "SHELL".to_string(),
781 reason: "SHELL requires a JSON array".to_string(),
782 }
783 })?;
784 Instruction::Shell(shell)
785 }
786
787 "STOPSIGNAL" => Instruction::Stopsignal(misc.arguments.to_string()),
788
789 "HEALTHCHECK" => {
790 let args = misc.arguments.to_string().trim().to_string();
791 if args.eq_ignore_ascii_case("NONE") {
792 Instruction::Healthcheck(HealthcheckInstruction::None)
793 } else {
794 let command = if let Some(stripped) = args.strip_prefix("CMD ") {
795 ShellOrExec::Shell(stripped.to_string())
796 } else {
797 ShellOrExec::Shell(args)
798 };
799 Instruction::Healthcheck(HealthcheckInstruction::cmd(command))
800 }
801 }
802
803 "ONBUILD" => {
804 tracing::warn!("ONBUILD instruction parsing not fully implemented");
805 return Ok(None);
806 }
807
808 "MAINTAINER" => {
809 let mut labels = HashMap::new();
810 labels.insert("maintainer".to_string(), misc.arguments.to_string());
811 Instruction::Label(labels)
812 }
813
814 "ADD" => {
815 let args = misc.arguments.to_string();
816 let parts: Vec<String> =
817 args.split_whitespace().map(String::from).collect();
818
819 if parts.len() < 2 {
820 return Err(BuildError::InvalidInstruction {
821 instruction: "ADD".to_string(),
822 reason: "ADD requires at least one source and a destination"
823 .to_string(),
824 });
825 }
826
827 let (sources, dest) = parts.split_at(parts.len() - 1);
828 let destination = dest.first().cloned().unwrap_or_default();
829
830 Instruction::Add(AddInstruction {
831 sources: sources.to_vec(),
832 destination,
833 chown: None,
834 chmod: None,
835 link: false,
836 checksum: None,
837 keep_git_dir: false,
838 })
839 }
840
841 other => {
842 tracing::warn!("Unknown Dockerfile instruction: {}", other);
843 return Ok(None);
844 }
845 }
846 }
847 };
848
849 Ok(Some(instruction))
850 }
851
852 #[must_use]
854 pub fn get_stage(&self, name_or_index: &str) -> Option<&Stage> {
855 self.stages.iter().find(|s| s.matches(name_or_index))
856 }
857
858 #[must_use]
860 pub fn final_stage(&self) -> Option<&Stage> {
861 self.stages.last()
862 }
863
864 #[must_use]
866 pub fn stage_names(&self) -> Vec<String> {
867 self.stages.iter().map(Stage::identifier).collect()
868 }
869
870 #[must_use]
872 pub fn has_stage(&self, name_or_index: &str) -> bool {
873 self.get_stage(name_or_index).is_some()
874 }
875
876 #[must_use]
878 pub fn stage_count(&self) -> usize {
879 self.stages.len()
880 }
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886
887 #[test]
888 fn test_parse_simple_dockerfile() {
889 let content = r#"
890FROM alpine:3.18
891RUN apk add --no-cache curl
892COPY . /app
893WORKDIR /app
894CMD ["./app"]
895"#;
896
897 let dockerfile = Dockerfile::parse(content).unwrap();
898 assert_eq!(dockerfile.stages.len(), 1);
899
900 let stage = &dockerfile.stages[0];
901 assert_eq!(stage.index, 0);
902 assert!(stage.name.is_none());
903 assert_eq!(stage.instructions.len(), 4);
904 }
905
906 #[test]
907 fn test_parse_multistage_dockerfile() {
908 let content = r#"
909FROM golang:1.21 AS builder
910WORKDIR /src
911COPY . .
912RUN go build -o /app
913
914FROM alpine:3.18
915COPY --from=builder /app /app
916CMD ["/app"]
917"#;
918
919 let dockerfile = Dockerfile::parse(content).unwrap();
920 assert_eq!(dockerfile.stages.len(), 2);
921
922 let builder = &dockerfile.stages[0];
923 assert_eq!(builder.name, Some("builder".to_string()));
924
925 let runtime = &dockerfile.stages[1];
926 assert!(runtime.name.is_none());
927
928 let copy = runtime
929 .instructions
930 .iter()
931 .find(|i| matches!(i, Instruction::Copy(_)));
932 assert!(copy.is_some());
933 if let Some(Instruction::Copy(c)) = copy {
934 assert_eq!(c.from, Some("builder".to_string()));
935 }
936 }
937
938 #[test]
939 fn test_parse_copy_from_external_image_reference() {
940 let content = r"
944FROM alpine:3.18
945COPY --from=ghcr.io/astral-sh/uv:0.5.0 /uv /usr/local/bin/uv
946RUN /usr/local/bin/uv --version
947";
948
949 let dockerfile = Dockerfile::parse(content).unwrap();
950 assert_eq!(dockerfile.stages.len(), 1);
951
952 let copy = dockerfile.stages[0]
953 .instructions
954 .iter()
955 .find_map(|i| {
956 if let Instruction::Copy(c) = i {
957 Some(c)
958 } else {
959 None
960 }
961 })
962 .expect("COPY instruction present");
963
964 assert_eq!(
965 copy.from,
966 Some("ghcr.io/astral-sh/uv:0.5.0".to_string()),
967 "external image ref must be preserved verbatim in CopyInstruction.from",
968 );
969 assert_eq!(copy.sources, vec!["/uv".to_string()]);
970 assert_eq!(copy.destination, "/usr/local/bin/uv".to_string());
971
972 assert!(dockerfile.get_stage("ghcr.io/astral-sh/uv:0.5.0").is_none());
975 }
976
977 #[test]
978 fn expand_from_args_all_forms() {
979 let vars: HashMap<String, String> = [
980 ("BASE".to_string(), "ghcr.io/org/img".to_string()),
981 ("TAG".to_string(), "1.2".to_string()),
982 ("EMPTY".to_string(), String::new()),
983 ]
984 .into_iter()
985 .collect();
986
987 assert_eq!(
988 expand_from_args("${BASE}:${TAG}", &vars),
989 "ghcr.io/org/img:1.2"
990 );
991 assert_eq!(expand_from_args("$BASE", &vars), "ghcr.io/org/img");
992 assert_eq!(
993 expand_from_args("img:${MISSING:-latest}", &vars),
994 "img:latest"
995 );
996 assert_eq!(
997 expand_from_args("img:${EMPTY:-fallback}", &vars),
998 "img:fallback"
999 );
1000 assert_eq!(expand_from_args("img:${TAG:+pinned}", &vars), "img:pinned");
1001 assert_eq!(expand_from_args("img:${MISSING:+pinned}", &vars), "img:");
1002 assert_eq!(expand_from_args("${MISSING}", &vars), "");
1003 assert_eq!(expand_from_args("a$$b", &vars), "a$b");
1006 assert_eq!(expand_from_args("price$", &vars), "price$");
1007 }
1008
1009 #[test]
1010 fn resolve_from_args_expands_from_lines() {
1011 let content = r"
1012ARG BASE_IMAGE=ghcr.io/org/alpine:latest
1013ARG BASE_TAG=latest
1014FROM ${BASE_IMAGE} AS builder
1015RUN echo hi
1016FROM ghcr.io/org/alpine:${BASE_TAG}
1017COPY --from=builder /x /x
1018";
1019 let mut dockerfile = Dockerfile::parse(content).unwrap();
1020 assert!(dockerfile.stages[0].base_image.is_stage());
1022 assert!(dockerfile.stages[1].base_image.is_stage());
1023
1024 let build_args: HashMap<String, String> = [("BASE_TAG".to_string(), "3.20".to_string())]
1025 .into_iter()
1026 .collect();
1027 dockerfile.resolve_from_args(&build_args);
1028
1029 match &dockerfile.stages[0].base_image {
1030 DockerfileFromTarget::Image(r) => {
1031 assert_eq!(r.to_string(), "ghcr.io/org/alpine:latest");
1032 }
1033 other => panic!("stage 0 not resolved to an image: {other:?}"),
1034 }
1035 match &dockerfile.stages[1].base_image {
1036 DockerfileFromTarget::Image(r) => {
1037 assert_eq!(r.to_string(), "ghcr.io/org/alpine:3.20");
1039 }
1040 other => panic!("stage 1 not resolved to an image: {other:?}"),
1041 }
1042 }
1043
1044 #[test]
1045 fn resolve_from_args_keeps_stage_references() {
1046 let content = r"
1047ARG BASE=ghcr.io/org/alpine:latest
1048FROM ${BASE} AS builder
1049RUN echo hi
1050FROM builder
1051RUN echo again
1052";
1053 let mut dockerfile = Dockerfile::parse(content).unwrap();
1054 dockerfile.resolve_from_args(&HashMap::new());
1055 assert!(matches!(
1056 &dockerfile.stages[0].base_image,
1057 DockerfileFromTarget::Image(_)
1058 ));
1059 assert_eq!(
1061 dockerfile.stages[1].base_image,
1062 DockerfileFromTarget::Stage("builder".to_string())
1063 );
1064 }
1065
1066 #[test]
1067 fn test_parse_global_args() {
1068 let content = r#"
1069ARG BASE_IMAGE=alpine:3.18
1070FROM ${BASE_IMAGE}
1071RUN echo "hello"
1072"#;
1073
1074 let dockerfile = Dockerfile::parse(content).unwrap();
1075 assert_eq!(dockerfile.global_args.len(), 1);
1076 assert_eq!(dockerfile.global_args[0].name, "BASE_IMAGE");
1077 assert_eq!(
1078 dockerfile.global_args[0].default,
1079 Some("alpine:3.18".to_string())
1080 );
1081 }
1082
1083 #[test]
1084 fn test_get_stage_by_name() {
1085 let content = r#"
1086FROM alpine:3.18 AS base
1087RUN echo "base"
1088
1089FROM base AS builder
1090RUN echo "builder"
1091"#;
1092
1093 let dockerfile = Dockerfile::parse(content).unwrap();
1094
1095 let base = dockerfile.get_stage("base");
1096 assert!(base.is_some());
1097 assert_eq!(base.unwrap().index, 0);
1098
1099 let builder = dockerfile.get_stage("builder");
1100 assert!(builder.is_some());
1101 assert_eq!(builder.unwrap().index, 1);
1102
1103 let stage_0 = dockerfile.get_stage("0");
1104 assert!(stage_0.is_some());
1105 assert_eq!(stage_0.unwrap().name, Some("base".to_string()));
1106 }
1107
1108 #[test]
1109 fn test_final_stage() {
1110 let content = r#"
1111FROM alpine:3.18 AS builder
1112RUN echo "builder"
1113
1114FROM scratch
1115COPY --from=builder /app /app
1116"#;
1117
1118 let dockerfile = Dockerfile::parse(content).unwrap();
1119 let final_stage = dockerfile.final_stage().unwrap();
1120
1121 assert_eq!(final_stage.index, 1);
1122 assert!(matches!(
1123 final_stage.base_image,
1124 DockerfileFromTarget::Scratch
1125 ));
1126 }
1127
1128 #[test]
1129 fn test_parse_env_instruction() {
1130 let content = r"
1131FROM alpine
1132ENV FOO=bar BAZ=qux
1133";
1134
1135 let dockerfile = Dockerfile::parse(content).unwrap();
1136 let stage = &dockerfile.stages[0];
1137
1138 let env = stage
1139 .instructions
1140 .iter()
1141 .find(|i| matches!(i, Instruction::Env(_)));
1142 assert!(env.is_some());
1143
1144 if let Some(Instruction::Env(e)) = env {
1145 assert_eq!(e.vars.get("FOO"), Some(&"bar".to_string()));
1146 assert_eq!(e.vars.get("BAZ"), Some(&"qux".to_string()));
1147 }
1148 }
1149
1150 fn stage_args(stage: &Stage) -> Vec<(String, Option<String>)> {
1152 stage
1153 .instructions
1154 .iter()
1155 .filter_map(|i| {
1156 if let Instruction::Arg(a) = i {
1157 Some((a.name.clone(), a.default.clone()))
1158 } else {
1159 None
1160 }
1161 })
1162 .collect()
1163 }
1164
1165 #[test]
1166 fn arg_empty_unquoted_default_parses_as_defined_empty() {
1167 let content = "FROM alpine:3.18\nARG CROSS_DEB_ARCH=\n";
1170 let dockerfile = Dockerfile::parse(content).unwrap();
1171 assert_eq!(
1172 stage_args(&dockerfile.stages[0]),
1173 vec![("CROSS_DEB_ARCH".to_string(), Some(String::new()))],
1174 );
1175 }
1176
1177 #[test]
1178 fn arg_empty_unquoted_default_parses_as_global_arg() {
1179 let content = "ARG EMPTYDEFAULT=\nFROM alpine:3.18\n";
1182 let dockerfile = Dockerfile::parse(content).unwrap();
1183 assert_eq!(dockerfile.global_args.len(), 1);
1184 assert_eq!(dockerfile.global_args[0].name, "EMPTYDEFAULT");
1185 assert_eq!(dockerfile.global_args[0].default, Some(String::new()));
1186 }
1187
1188 #[test]
1189 fn label_empty_unquoted_value_parses() {
1190 let content = "FROM alpine:3.18\nLABEL emptyone= other=\"x\"\n";
1193 let dockerfile = Dockerfile::parse(content).unwrap();
1194 assert_eq!(dockerfile.stages.len(), 1);
1195 }
1196
1197 #[test]
1198 fn arg_quoted_empty_default_parses_as_defined_empty() {
1199 let content = "FROM alpine:3.18\nARG NAME=\"\"\n";
1200 let dockerfile = Dockerfile::parse(content).unwrap();
1201 assert_eq!(
1202 stage_args(&dockerfile.stages[0]),
1203 vec![("NAME".to_string(), Some(String::new()))],
1204 );
1205 }
1206
1207 #[test]
1208 fn arg_no_default_and_plain_default_still_parse() {
1209 let content = "FROM alpine:3.18\nARG X\nARG Y=z\n";
1210 let dockerfile = Dockerfile::parse(content).unwrap();
1211 assert_eq!(
1212 stage_args(&dockerfile.stages[0]),
1213 vec![
1214 ("X".to_string(), None),
1215 ("Y".to_string(), Some("z".to_string())),
1216 ],
1217 );
1218 }
1219
1220 #[test]
1221 fn env_empty_values_parse() {
1222 let content = "FROM alpine:3.18\nENV EMPTYONE=\nENV A= B=c\n";
1225 let dockerfile = Dockerfile::parse(content).unwrap();
1226 let stage = &dockerfile.stages[0];
1227
1228 let envs: Vec<&EnvInstruction> = stage
1229 .instructions
1230 .iter()
1231 .filter_map(|i| {
1232 if let Instruction::Env(e) = i {
1233 Some(e)
1234 } else {
1235 None
1236 }
1237 })
1238 .collect();
1239 assert_eq!(envs.len(), 2);
1240 assert_eq!(envs[0].vars.get("EMPTYONE"), Some(&String::new()));
1241 assert_eq!(envs[1].vars.get("A"), Some(&String::new()));
1242 assert_eq!(envs[1].vars.get("B"), Some(&"c".to_string()));
1243 }
1244
1245 #[test]
1246 fn env_quoted_value_containing_equals_is_untouched() {
1247 let content = "FROM alpine:3.18\nENV A=\"x= \" B=\n";
1250 let dockerfile = Dockerfile::parse(content).unwrap();
1251 let stage = &dockerfile.stages[0];
1252 if let Some(Instruction::Env(e)) = stage
1253 .instructions
1254 .iter()
1255 .find(|i| matches!(i, Instruction::Env(_)))
1256 {
1257 assert_eq!(e.vars.get("A"), Some(&"x= ".to_string()));
1258 assert_eq!(e.vars.get("B"), Some(&String::new()));
1259 } else {
1260 panic!("ENV instruction missing");
1261 }
1262 }
1263
1264 #[test]
1265 fn run_shell_content_with_trailing_equals_is_untouched() {
1266 let content = "FROM alpine:3.18\nRUN export FOO= && \\\n BAR= true\n";
1270 let dockerfile = Dockerfile::parse(content).unwrap();
1271 let stage = &dockerfile.stages[0];
1272 if let Some(Instruction::Run(run)) = stage
1273 .instructions
1274 .iter()
1275 .find(|i| matches!(i, Instruction::Run(_)))
1276 {
1277 let ShellOrExec::Shell(cmd) = &run.command else {
1278 panic!("expected shell-form RUN");
1279 };
1280 assert!(cmd.contains("FOO="), "cmd: {cmd}");
1281 assert!(!cmd.contains("FOO=\"\""), "cmd: {cmd}");
1282 assert!(cmd.contains("BAR="), "cmd: {cmd}");
1283 assert!(!cmd.contains("BAR=\"\""), "cmd: {cmd}");
1284 } else {
1285 panic!("RUN instruction missing");
1286 }
1287 }
1288
1289 #[test]
1290 fn env_empty_value_on_continuation_line_parses() {
1291 let content = "FROM alpine:3.18\nENV A=1 \\\n B=\n";
1292 let dockerfile = Dockerfile::parse(content).unwrap();
1293 let stage = &dockerfile.stages[0];
1294 if let Some(Instruction::Env(e)) = stage
1295 .instructions
1296 .iter()
1297 .find(|i| matches!(i, Instruction::Env(_)))
1298 {
1299 assert_eq!(e.vars.get("A"), Some(&"1".to_string()));
1300 assert_eq!(e.vars.get("B"), Some(&String::new()));
1301 } else {
1302 panic!("ENV instruction missing");
1303 }
1304 }
1305
1306 #[test]
1307 fn normalize_empty_assignments_rewrites_only_arg_env() {
1308 assert_eq!(normalize_empty_assignments("ARG A=\n"), "ARG A=\"\"\n");
1310 assert_eq!(
1311 normalize_empty_assignments("ENV A= B=c\n"),
1312 "ENV A=\"\" B=c\n"
1313 );
1314 assert_eq!(
1316 normalize_empty_assignments("ENV A=\"x= \" B=\n"),
1317 "ENV A=\"x= \" B=\"\"\n"
1318 );
1319 assert_eq!(
1321 normalize_empty_assignments("ENV A=1 \\\n B=\n"),
1322 "ENV A=1 \\\n B=\"\"\n"
1323 );
1324 assert_eq!(
1327 normalize_empty_assignments("ARG A=\\\nx\n"),
1328 "ARG A=\\\nx\n"
1329 );
1330 assert_eq!(
1332 normalize_empty_assignments("RUN export FOO= && \\\n BAR= true\n"),
1333 "RUN export FOO= && \\\n BAR= true\n"
1334 );
1335 assert_eq!(
1337 normalize_empty_assignments("ENVIRONMENT= foo\n"),
1338 "ENVIRONMENT= foo\n"
1339 );
1340 assert!(matches!(
1342 normalize_empty_assignments("FROM alpine\nRUN echo hi\n"),
1343 std::borrow::Cow::Borrowed(_)
1344 ));
1345 assert_eq!(
1347 normalize_empty_assignments("ARG A=\r\nARG B="),
1348 "ARG A=\"\"\r\nARG B=\"\""
1349 );
1350 }
1351
1352 #[test]
1353 fn cross_generated_dockerfile_parses_end_to_end() {
1354 let content = "FROM cross/x86_64-unknown-linux-musl:0.2.5\n\
1357 ARG CROSS_DEB_ARCH=\n\
1358 ARG CROSS_CMD\n\
1359 RUN eval \"${CROSS_CMD}\"\n";
1360 let dockerfile = Dockerfile::parse(content).unwrap();
1361 assert_eq!(dockerfile.stages.len(), 1);
1362
1363 let stage = &dockerfile.stages[0];
1364 assert_eq!(
1365 stage_args(stage),
1366 vec![
1367 ("CROSS_DEB_ARCH".to_string(), Some(String::new())),
1368 ("CROSS_CMD".to_string(), None),
1369 ],
1370 );
1371 assert!(stage
1372 .instructions
1373 .iter()
1374 .any(|i| matches!(i, Instruction::Run(_))));
1375 }
1376}