1use std::collections::HashMap;
20use std::fmt::Write as _;
21
22use super::instruction::{
23 AddInstruction, CopyInstruction, EnvInstruction, ExposeProtocol, HealthcheckInstruction,
24 Instruction, RunInstruction, RunMount, RunNetwork, RunSecurity, ShellOrExec,
25};
26use super::parser::{Dockerfile, Stage};
27use super::variable::expand_variables;
28
29#[must_use]
35pub(crate) fn escape_json_string(s: &str) -> String {
36 s.replace('\\', "\\\\")
37 .replace('"', "\\\"")
38 .replace('\n', "\\n")
39 .replace('\r', "\\r")
40 .replace('\t', "\\t")
41}
42
43fn shell_quote(s: &str) -> String {
48 if s.is_empty() {
49 return "\"\"".to_string();
50 }
51 let needs_quote = s.chars().any(|c| {
52 c.is_whitespace()
53 || matches!(
54 c,
55 '"' | '\''
56 | '\\'
57 | '$'
58 | '`'
59 | '&'
60 | '|'
61 | ';'
62 | '<'
63 | '>'
64 | '('
65 | ')'
66 | '*'
67 | '?'
68 | '['
69 | ']'
70 | '{'
71 | '}'
72 | '#'
73 | '~'
74 | '!'
75 )
76 });
77 if !needs_quote {
78 return s.to_string();
79 }
80 let mut out = String::with_capacity(s.len() + 2);
83 out.push('"');
84 for c in s.chars() {
85 match c {
86 '"' | '\\' | '$' | '`' => {
87 out.push('\\');
88 out.push(c);
89 }
90 _ => out.push(c),
91 }
92 }
93 out.push('"');
94 out
95}
96
97fn render_json_array(args: &[String]) -> String {
99 let inner: Vec<String> = args
100 .iter()
101 .map(|a| format!("\"{}\"", escape_json_string(a)))
102 .collect();
103 format!("[{}]", inner.join(", "))
104}
105
106fn render_inline_env(env: &HashMap<String, String>) -> String {
108 let mut keys: Vec<&String> = env.keys().collect();
109 keys.sort();
110 keys.iter()
111 .map(|k| format!("{}={}", k, shell_quote(&env[*k])))
112 .collect::<Vec<_>>()
113 .join(" ")
114}
115
116pub fn merge_default_cache_mounts(df: &mut Dockerfile, options: &crate::builder::BuildOptions) {
132 if options.default_cache_mounts.is_empty() {
133 return;
134 }
135 for stage in &mut df.stages {
136 for instruction in &mut stage.instructions {
137 let Instruction::Run(run) = instruction else {
138 continue;
139 };
140 for default_mount in &options.default_cache_mounts {
141 let RunMount::Cache { target, .. } = default_mount else {
142 continue;
143 };
144 let already_has = run
145 .mounts
146 .iter()
147 .any(|m| matches!(m, RunMount::Cache { target: t, .. } if t == target));
148 if !already_has {
149 run.mounts.push(default_mount.clone());
150 }
151 }
152 }
153 }
154}
155
156#[must_use]
171#[allow(clippy::implicit_hasher)]
172pub fn expand_dockerfile(df: &Dockerfile, build_args: &HashMap<String, String>) -> Dockerfile {
173 let mut out = df.clone();
174
175 for stage in &mut out.stages {
176 let mut arg_values: HashMap<String, String> = build_args.clone();
180 for global_arg in &df.global_args {
181 if !arg_values.contains_key(&global_arg.name) {
182 if let Some(default) = &global_arg.default {
183 arg_values.insert(global_arg.name.clone(), default.clone());
184 }
185 }
186 }
187 let mut env_values: HashMap<String, String> = HashMap::new();
188
189 for instruction in &mut stage.instructions {
190 *instruction = expand_instruction(instruction, &mut arg_values, &mut env_values);
191 }
192 }
193
194 out
195}
196
197fn declared_arg_names(df: &Dockerfile) -> Vec<String> {
201 let mut names: Vec<String> = df.global_args.iter().map(|a| a.name.clone()).collect();
202 for stage in &df.stages {
203 for instruction in &stage.instructions {
204 if let Instruction::Arg(arg) = instruction {
205 names.push(arg.name.clone());
206 }
207 }
208 }
209 names
210}
211
212#[allow(clippy::implicit_hasher)]
232pub fn forward_build_arg_env(
233 df: &Dockerfile,
234 args: &mut std::collections::BTreeMap<String, String>,
235) {
236 for name in declared_arg_names(df) {
237 if args.get(&name).is_some_and(|v| !v.is_empty()) {
239 continue;
240 }
241 match std::env::var(&name) {
242 Ok(value) if !value.is_empty() => {
243 args.insert(name, value);
244 }
245 _ => {}
246 }
247 }
248}
249
250fn expand_instruction(
255 instruction: &Instruction,
256 arg_values: &mut HashMap<String, String>,
257 env_values: &mut HashMap<String, String>,
258) -> Instruction {
259 match instruction {
260 Instruction::Run(run) => {
261 let mut run = run.clone();
262 run.command = match &run.command {
263 ShellOrExec::Shell(s) => {
264 ShellOrExec::Shell(expand_variables(s, arg_values, env_values))
265 }
266 ShellOrExec::Exec(args) => ShellOrExec::Exec(
267 args.iter()
268 .map(|a| expand_variables(a, arg_values, env_values))
269 .collect(),
270 ),
271 };
272 run.env = run
277 .env
278 .iter()
279 .map(|(k, v)| (k.clone(), expand_variables(v, arg_values, env_values)))
280 .collect();
281 Instruction::Run(run)
282 }
283 Instruction::Env(env) => {
284 let mut vars = HashMap::with_capacity(env.vars.len());
285 for (key, value) in &env.vars {
286 let expanded = expand_variables(value, arg_values, env_values);
287 env_values.insert(key.clone(), expanded.clone());
288 vars.insert(key.clone(), expanded);
289 }
290 Instruction::Env(EnvInstruction { vars })
291 }
292 Instruction::Copy(copy) => {
293 let mut copy = copy.clone();
294 copy.sources = copy
295 .sources
296 .iter()
297 .map(|s| expand_variables(s, arg_values, env_values))
298 .collect();
299 copy.destination = expand_variables(©.destination, arg_values, env_values);
300 Instruction::Copy(copy)
301 }
302 Instruction::Add(add) => {
303 let mut add = add.clone();
304 add.sources = add
305 .sources
306 .iter()
307 .map(|s| expand_variables(s, arg_values, env_values))
308 .collect();
309 add.destination = expand_variables(&add.destination, arg_values, env_values);
310 Instruction::Add(add)
311 }
312 Instruction::Workdir(dir) => {
313 Instruction::Workdir(expand_variables(dir, arg_values, env_values))
314 }
315 Instruction::User(user) => {
316 Instruction::User(expand_variables(user, arg_values, env_values))
317 }
318 Instruction::Label(labels) => {
319 let expanded = labels
320 .iter()
321 .map(|(k, v)| (k.clone(), expand_variables(v, arg_values, env_values)))
322 .collect();
323 Instruction::Label(expanded)
324 }
325 Instruction::Arg(arg) => {
326 if !arg_values.contains_key(&arg.name) {
330 if let Some(default) = &arg.default {
331 let expanded = expand_variables(default, arg_values, env_values);
332 arg_values.insert(arg.name.clone(), expanded);
333 }
334 }
335 instruction.clone()
336 }
337 other => other.clone(),
338 }
339}
340
341#[must_use]
348pub fn render_dockerfile(df: &Dockerfile) -> String {
349 let mut out = String::new();
350
351 for arg in &df.global_args {
353 match &arg.default {
354 Some(default) => {
355 let _ = writeln!(out, "ARG {}={}", arg.name, default);
356 }
357 None => {
358 let _ = writeln!(out, "ARG {}", arg.name);
359 }
360 }
361 }
362
363 for (idx, stage) in df.stages.iter().enumerate() {
364 if idx > 0 || !df.global_args.is_empty() {
365 out.push('\n');
366 }
367 render_stage(stage, &mut out);
368 }
369
370 out
371}
372
373fn render_stage(stage: &Stage, out: &mut String) {
375 out.push_str("FROM ");
376 if let Some(platform) = &stage.platform {
377 let _ = write!(out, "--platform={platform} ");
378 }
379 out.push_str(&stage.base_image.to_string());
380 if let Some(name) = &stage.name {
381 let _ = write!(out, " AS {name}");
382 }
383 out.push('\n');
384
385 for instruction in &stage.instructions {
386 out.push_str(&render_instruction(instruction));
387 out.push('\n');
388 }
389}
390
391#[allow(clippy::too_many_lines)]
393fn render_instruction(instruction: &Instruction) -> String {
394 match instruction {
395 Instruction::Run(run) => render_run(run),
396 Instruction::Copy(copy) => render_copy(copy),
397 Instruction::Add(add) => render_add(add),
398 Instruction::Env(env) => {
399 let mut keys: Vec<&String> = env.vars.keys().collect();
400 keys.sort();
401 let parts: Vec<String> = keys
402 .iter()
403 .map(|k| format!("{}={}", k, shell_quote(&env.vars[*k])))
404 .collect();
405 format!("ENV {}", parts.join(" "))
406 }
407 Instruction::Workdir(dir) => format!("WORKDIR {dir}"),
408 Instruction::Expose(expose) => {
409 let proto = match expose.protocol {
410 ExposeProtocol::Tcp => "tcp",
411 ExposeProtocol::Udp => "udp",
412 };
413 format!("EXPOSE {}/{proto}", expose.port)
414 }
415 Instruction::Label(labels) => {
416 let mut keys: Vec<&String> = labels.keys().collect();
417 keys.sort();
418 let parts: Vec<String> = keys
419 .iter()
420 .map(|k| format!("{}=\"{}\"", k, escape_json_string(&labels[*k])))
421 .collect();
422 format!("LABEL {}", parts.join(" "))
423 }
424 Instruction::User(user) => format!("USER {user}"),
425 Instruction::Entrypoint(cmd) => render_shell_or_exec("ENTRYPOINT", cmd),
426 Instruction::Cmd(cmd) => render_shell_or_exec("CMD", cmd),
427 Instruction::Volume(paths) => format!("VOLUME {}", render_json_array(paths)),
428 Instruction::Shell(shell) => format!("SHELL {}", render_json_array(shell)),
429 Instruction::Arg(arg) => match &arg.default {
430 Some(default) => format!("ARG {}={}", arg.name, default),
431 None => format!("ARG {}", arg.name),
432 },
433 Instruction::Stopsignal(signal) => format!("STOPSIGNAL {signal}"),
434 Instruction::Healthcheck(health) => render_healthcheck(health),
435 Instruction::Onbuild(inner) => format!("ONBUILD {}", render_instruction(inner)),
436 }
437}
438
439fn render_shell_or_exec(keyword: &str, cmd: &ShellOrExec) -> String {
442 match cmd {
443 ShellOrExec::Exec(args) => format!("{keyword} {}", render_json_array(args)),
444 ShellOrExec::Shell(s) => format!("{keyword} {s}"),
445 }
446}
447
448fn render_run(run: &RunInstruction) -> String {
451 let mut flags = String::new();
452 for mount in &run.mounts {
453 let _ = write!(flags, "--mount={} ", mount.to_buildah_arg());
454 }
455 if let Some(network) = &run.network {
456 let net = match network {
457 RunNetwork::Default => "default",
458 RunNetwork::None => "none",
459 RunNetwork::Host => "host",
460 };
461 let _ = write!(flags, "--network={net} ");
462 }
463 if let Some(security) = &run.security {
464 let sec = match security {
465 RunSecurity::Sandbox => "sandbox",
466 RunSecurity::Insecure => "insecure",
467 };
468 let _ = write!(flags, "--security={sec} ");
469 }
470
471 let env_prefix = if run.env.is_empty() {
472 String::new()
473 } else {
474 format!("{} ", render_inline_env(&run.env))
475 };
476
477 match &run.command {
478 ShellOrExec::Shell(s) => {
479 format!("RUN {flags}{env_prefix}{s}")
480 }
481 ShellOrExec::Exec(args) => {
482 if run.env.is_empty() {
483 format!("RUN {flags}{}", render_json_array(args))
485 } else {
486 let joined = args
490 .iter()
491 .map(|a| shell_quote(a))
492 .collect::<Vec<_>>()
493 .join(" ");
494 format!("RUN {flags}{env_prefix}{joined}")
495 }
496 }
497 }
498}
499
500fn render_copy(copy: &CopyInstruction) -> String {
502 let mut s = String::from("COPY ");
503 if let Some(from) = ©.from {
504 let _ = write!(s, "--from={from} ");
505 }
506 if let Some(chown) = ©.chown {
507 let _ = write!(s, "--chown={chown} ");
508 }
509 if let Some(chmod) = ©.chmod {
510 let _ = write!(s, "--chmod={chmod} ");
511 }
512 if copy.link {
513 s.push_str("--link ");
514 }
515 for exclude in ©.exclude {
516 let _ = write!(s, "--exclude={exclude} ");
517 }
518 s.push_str(©.sources.join(" "));
519 s.push(' ');
520 s.push_str(©.destination);
521 s
522}
523
524fn render_add(add: &AddInstruction) -> String {
526 let mut s = String::from("ADD ");
527 if let Some(chown) = &add.chown {
528 let _ = write!(s, "--chown={chown} ");
529 }
530 if let Some(chmod) = &add.chmod {
531 let _ = write!(s, "--chmod={chmod} ");
532 }
533 if add.link {
534 s.push_str("--link ");
535 }
536 if let Some(checksum) = &add.checksum {
537 let _ = write!(s, "--checksum={checksum} ");
538 }
539 s.push_str(&add.sources.join(" "));
540 s.push(' ');
541 s.push_str(&add.destination);
542 s
543}
544
545fn render_healthcheck(health: &HealthcheckInstruction) -> String {
547 match health {
548 HealthcheckInstruction::None => "HEALTHCHECK NONE".to_string(),
549 HealthcheckInstruction::Check {
550 command,
551 interval,
552 timeout,
553 start_period,
554 start_interval,
555 retries,
556 } => {
557 let mut s = String::from("HEALTHCHECK");
558 if let Some(d) = interval {
559 let _ = write!(s, " --interval={}s", d.as_secs());
560 }
561 if let Some(d) = timeout {
562 let _ = write!(s, " --timeout={}s", d.as_secs());
563 }
564 if let Some(d) = start_period {
565 let _ = write!(s, " --start-period={}s", d.as_secs());
566 }
567 if let Some(d) = start_interval {
568 let _ = write!(s, " --start-interval={}s", d.as_secs());
569 }
570 if let Some(r) = retries {
571 let _ = write!(s, " --retries={r}");
572 }
573 match command {
574 ShellOrExec::Exec(args) => {
575 let _ = write!(s, " CMD {}", render_json_array(args));
576 }
577 ShellOrExec::Shell(cmd) => {
578 let _ = write!(s, " CMD {cmd}");
579 }
580 }
581 s
582 }
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use crate::dockerfile::{
590 AddInstruction, ArgInstruction, CacheSharing, CopyInstruction, DockerfileFromTarget,
591 EnvInstruction, ExposeInstruction, HealthcheckInstruction, Instruction, RunInstruction,
592 RunMount, ShellOrExec, Stage,
593 };
594 use std::str::FromStr;
595 use std::time::Duration;
596 use zlayer_types::ImageReference;
597
598 fn img(s: &str) -> DockerfileFromTarget {
599 DockerfileFromTarget::Image(ImageReference::from_str(s).unwrap())
600 }
601
602 fn normalize(inst: &Instruction) -> Instruction {
608 match inst {
609 Instruction::Workdir(s) => Instruction::Workdir(s.trim().to_string()),
610 Instruction::User(s) => Instruction::User(s.trim().to_string()),
611 Instruction::Stopsignal(s) => Instruction::Stopsignal(s.trim().to_string()),
612 other => other.clone(),
613 }
614 }
615
616 fn assert_instructions_eq(expected: &[Instruction], actual: &[Instruction]) {
619 assert_eq!(
620 expected.len(),
621 actual.len(),
622 "instruction count mismatch\nexpected: {expected:#?}\nactual: {actual:#?}"
623 );
624 for (e, a) in expected.iter().zip(actual.iter()) {
625 assert_eq!(normalize(e), normalize(a), "instruction mismatch");
626 }
627 }
628
629 #[test]
630 fn forward_build_arg_env_fills_only_declared_empty_args() {
631 use std::collections::BTreeMap;
632
633 let declared_empty = "ZLAYER_TEST_FWD_DECLARED_EMPTY";
635 let declared_explicit = "ZLAYER_TEST_FWD_DECLARED_EXPLICIT";
636 let declared_noenv = "ZLAYER_TEST_FWD_DECLARED_NOENV";
637 let undeclared = "ZLAYER_TEST_FWD_UNDECLARED";
638
639 std::env::set_var(declared_empty, "from-env");
640 std::env::set_var(declared_explicit, "from-env");
641 std::env::set_var(undeclared, "from-env");
642 std::env::remove_var(declared_noenv);
643
644 let df = Dockerfile {
647 global_args: vec![
648 ArgInstruction::new(declared_empty),
649 ArgInstruction::new(declared_noenv),
650 ],
651 stages: vec![Stage {
652 index: 0,
653 name: None,
654 base_image: img("alpine:3.18"),
655 platform: None,
656 instructions: vec![Instruction::Arg(ArgInstruction::new(declared_explicit))],
657 }],
658 };
659
660 let mut args: BTreeMap<String, String> = BTreeMap::new();
661 args.insert(declared_empty.to_string(), String::new());
663 args.insert(declared_explicit.to_string(), "explicit".to_string());
665
666 forward_build_arg_env(&df, &mut args);
667
668 assert_eq!(
670 args.get(declared_empty).map(String::as_str),
671 Some("from-env")
672 );
673 assert_eq!(
675 args.get(declared_explicit).map(String::as_str),
676 Some("explicit")
677 );
678 assert!(!args.contains_key(declared_noenv));
680 assert!(!args.contains_key(undeclared));
682
683 std::env::remove_var(declared_empty);
684 std::env::remove_var(declared_explicit);
685 std::env::remove_var(undeclared);
686 }
687
688 #[test]
689 fn round_trip_multistage_representative() {
690 let stage0 = Stage {
691 index: 0,
692 name: Some("builder".to_string()),
693 base_image: img("golang:1.21"),
694 platform: None,
695 instructions: vec![
696 Instruction::Workdir("/src".to_string()),
697 Instruction::Copy(CopyInstruction::new(vec![".".to_string()], ".".to_string())),
698 Instruction::Run(RunInstruction::shell("go build -o /app")),
699 Instruction::Run(RunInstruction::exec(vec![
700 "/bin/true".to_string(),
701 "arg with space".to_string(),
702 ])),
703 Instruction::Arg(ArgInstruction::with_default("VERSION", "1.0")),
704 Instruction::Env(EnvInstruction::new("FOO", "bar baz")),
705 Instruction::Expose(ExposeInstruction::tcp(8080)),
706 Instruction::Stopsignal("SIGTERM".to_string()),
707 ],
708 };
709
710 let mut label_map = std::collections::HashMap::new();
711 label_map.insert(
712 "org.opencontainers.image.title".to_string(),
713 "my app".to_string(),
714 );
715
716 let stage1 = Stage {
717 index: 1,
718 name: None,
719 base_image: img("alpine:3.18"),
720 platform: None,
721 instructions: vec![
722 Instruction::Copy(
723 CopyInstruction::new(vec!["/app".to_string()], "/app".to_string())
724 .from_stage("builder")
725 .chown("1000:1000"),
726 ),
727 Instruction::User("appuser".to_string()),
728 Instruction::Label(label_map),
729 Instruction::Volume(vec!["/data".to_string(), "/cache".to_string()]),
730 Instruction::Shell(vec!["/bin/sh".to_string(), "-c".to_string()]),
731 Instruction::Entrypoint(ShellOrExec::Exec(vec!["/app".to_string()])),
732 Instruction::Cmd(ShellOrExec::Shell("--serve".to_string())),
733 ],
734 };
735
736 let df = Dockerfile {
737 global_args: vec![ArgInstruction::with_default("BASE_TAG", "latest")],
738 stages: vec![stage0, stage1],
739 };
740
741 let text = render_dockerfile(&df);
742 let reparsed = Dockerfile::parse(&text)
743 .unwrap_or_else(|e| panic!("re-parse failed: {e}\n---\n{text}\n---"));
744
745 assert_eq!(
746 reparsed.global_args.len(),
747 df.global_args.len(),
748 "global args mismatch\n{text}"
749 );
750 assert_eq!(
751 reparsed.stages.len(),
752 df.stages.len(),
753 "stage count\n{text}"
754 );
755
756 for (orig, re) in df.stages.iter().zip(reparsed.stages.iter()) {
757 assert_eq!(orig.name, re.name, "stage name\n{text}");
758 assert_eq!(
759 orig.base_image.to_string(),
760 re.base_image.to_string(),
761 "base image\n{text}"
762 );
763 assert_instructions_eq(&orig.instructions, &re.instructions);
764 }
765 }
766
767 #[test]
768 fn render_healthcheck_variants() {
769 assert_eq!(
771 render_instruction(&Instruction::Healthcheck(HealthcheckInstruction::None)),
772 "HEALTHCHECK NONE"
773 );
774
775 let hc = HealthcheckInstruction::Check {
779 command: ShellOrExec::Shell("curl -f http://localhost/ || exit 1".to_string()),
780 interval: Some(Duration::from_secs(30)),
781 timeout: Some(Duration::from_secs(5)),
782 start_period: Some(Duration::from_secs(10)),
783 start_interval: Some(Duration::from_secs(2)),
784 retries: Some(3),
785 };
786 assert_eq!(
787 render_instruction(&Instruction::Healthcheck(hc)),
788 "HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --start-interval=2s \
789 --retries=3 CMD curl -f http://localhost/ || exit 1"
790 );
791
792 let hc_exec = HealthcheckInstruction::cmd(ShellOrExec::Exec(vec![
794 "curl".to_string(),
795 "-f".to_string(),
796 "http://localhost/".to_string(),
797 ]));
798 assert_eq!(
799 render_instruction(&Instruction::Healthcheck(hc_exec)),
800 r#"HEALTHCHECK CMD ["curl", "-f", "http://localhost/"]"#
801 );
802 }
803
804 #[test]
805 fn render_run_with_cache_mount() {
806 let mut run = RunInstruction::shell("apt-get update");
811 run.mounts = vec![RunMount::Cache {
812 target: "/var/cache/apt".to_string(),
813 id: Some("apt".to_string()),
814 sharing: CacheSharing::Shared,
815 readonly: false,
816 }];
817
818 let line = render_instruction(&Instruction::Run(run));
819 assert_eq!(
820 line,
821 "RUN --mount=type=cache,target=/var/cache/apt,id=apt,sharing=shared apt-get update"
822 );
823 }
824
825 #[test]
826 fn round_trip_run_exec_no_env() {
827 let df = Dockerfile {
828 global_args: vec![],
829 stages: vec![Stage {
830 index: 0,
831 name: None,
832 base_image: img("alpine"),
833 platform: None,
834 instructions: vec![Instruction::Run(RunInstruction::exec(vec![
835 "echo".to_string(),
836 "hello world".to_string(),
837 ]))],
838 }],
839 };
840
841 let text = render_dockerfile(&df);
842 assert!(
843 text.contains(r#"RUN ["echo", "hello world"]"#),
844 "got:\n{text}"
845 );
846
847 let reparsed = Dockerfile::parse(&text).unwrap();
848 assert_instructions_eq(&df.stages[0].instructions, &reparsed.stages[0].instructions);
849 }
850
851 #[test]
852 fn round_trip_run_with_per_step_env() {
853 let mut run = RunInstruction::shell("make build");
856 run.env.insert("CC".to_string(), "clang".to_string());
857 run.env.insert("FLAGS".to_string(), "-O2 -g".to_string());
858
859 let df = Dockerfile {
860 global_args: vec![],
861 stages: vec![Stage {
862 index: 0,
863 name: None,
864 base_image: img("alpine"),
865 platform: None,
866 instructions: vec![Instruction::Run(run)],
867 }],
868 };
869
870 let text = render_dockerfile(&df);
871 assert!(
873 text.contains(r#"RUN CC=clang FLAGS="-O2 -g" make build"#),
874 "got:\n{text}"
875 );
876 let reparsed = Dockerfile::parse(&text).unwrap();
879 assert_eq!(reparsed.stages[0].instructions.len(), 1);
880 assert!(matches!(
881 &reparsed.stages[0].instructions[0],
882 Instruction::Run(_)
883 ));
884 }
885
886 #[test]
887 fn round_trip_onbuild() {
888 let df = Dockerfile {
889 global_args: vec![],
890 stages: vec![Stage {
891 index: 0,
892 name: None,
893 base_image: img("alpine"),
894 platform: None,
895 instructions: vec![Instruction::Onbuild(Box::new(Instruction::Run(
896 RunInstruction::shell("echo onbuild"),
897 )))],
898 }],
899 };
900
901 let text = render_dockerfile(&df);
902 assert!(text.contains("ONBUILD RUN echo onbuild"), "got:\n{text}");
903 }
906
907 #[test]
908 fn render_add_with_checksum() {
909 let mut add = AddInstruction::new(
910 vec!["https://example.com/file.tar.gz".to_string()],
911 "/opt/file.tar.gz".to_string(),
912 );
913 add.checksum = Some("sha256:abc".to_string());
914 add.chown = Some("0:0".to_string());
915
916 let line = render_instruction(&Instruction::Add(add));
917 assert_eq!(
918 line,
919 "ADD --chown=0:0 --checksum=sha256:abc https://example.com/file.tar.gz /opt/file.tar.gz"
920 );
921 }
922
923 #[test]
924 fn expand_dockerfile_bug3_run_env_and_command() {
925 let mut run = RunInstruction::shell("auth --token ${FORGEJO_TOKEN}");
928 run.env
929 .insert("TOKEN".to_string(), "${FORGEJO_TOKEN}".to_string());
930
931 let df = Dockerfile {
932 global_args: vec![],
933 stages: vec![Stage {
934 index: 0,
935 name: None,
936 base_image: img("alpine"),
937 platform: None,
938 instructions: vec![Instruction::Run(run)],
939 }],
940 };
941
942 let build_args: std::collections::HashMap<String, String> =
943 [("FORGEJO_TOKEN".to_string(), "s3cr3t".to_string())]
944 .into_iter()
945 .collect();
946
947 let expanded = expand_dockerfile(&df, &build_args);
948 let Instruction::Run(run) = &expanded.stages[0].instructions[0] else {
949 panic!("expected RUN");
950 };
951
952 assert_eq!(
953 run.env.get("TOKEN"),
954 Some(&"s3cr3t".to_string()),
955 "BUG3: run.env value must be substituted"
956 );
957 match &run.command {
958 ShellOrExec::Shell(s) => assert_eq!(s, "auth --token s3cr3t"),
959 ShellOrExec::Exec(args) => panic!("expected shell command, got exec: {args:?}"),
960 }
961 }
962
963 #[test]
964 fn merge_default_cache_mounts_adds_and_dedups() {
965 use crate::builder::BuildOptions;
966 use crate::dockerfile::{CacheSharing, RunInstruction, RunMount};
967
968 let run_bare = RunInstruction::shell("apt-get update");
970
971 let mut run_has_same = RunInstruction::shell("pip install foo");
974 run_has_same.mounts.push(RunMount::Cache {
975 target: "/var/cache/apt".to_string(),
976 id: Some("step".to_string()),
977 sharing: CacheSharing::Locked,
978 readonly: false,
979 });
980
981 let df = Dockerfile {
982 global_args: vec![],
983 stages: vec![Stage {
984 index: 0,
985 name: None,
986 base_image: img("alpine"),
987 platform: None,
988 instructions: vec![
989 Instruction::Run(run_bare),
990 Instruction::Run(run_has_same),
991 Instruction::Workdir("/app".to_string()),
993 ],
994 }],
995 };
996
997 let mut ir = df;
998 let options = BuildOptions {
999 default_cache_mounts: vec![RunMount::Cache {
1000 target: "/var/cache/apt".to_string(),
1001 id: Some("auto".to_string()),
1002 sharing: CacheSharing::Shared,
1003 readonly: false,
1004 }],
1005 ..BuildOptions::default()
1006 };
1007
1008 super::merge_default_cache_mounts(&mut ir, &options);
1009
1010 let Instruction::Run(r0) = &ir.stages[0].instructions[0] else {
1011 panic!("expected RUN");
1012 };
1013 assert_eq!(r0.mounts.len(), 1, "bare RUN gains the default cache mount");
1014 assert!(matches!(
1015 &r0.mounts[0],
1016 RunMount::Cache { target, id, .. }
1017 if target == "/var/cache/apt" && id.as_deref() == Some("auto")
1018 ));
1019
1020 let Instruction::Run(r1) = &ir.stages[0].instructions[1] else {
1021 panic!("expected RUN");
1022 };
1023 assert_eq!(
1024 r1.mounts.len(),
1025 1,
1026 "RUN with same-target cache mount is NOT duplicated"
1027 );
1028 assert!(matches!(
1029 &r1.mounts[0],
1030 RunMount::Cache { id, .. } if id.as_deref() == Some("step")
1031 ));
1032
1033 assert!(matches!(
1034 &ir.stages[0].instructions[2],
1035 Instruction::Workdir(_)
1036 ));
1037 }
1038
1039 #[test]
1040 fn merge_default_cache_mounts_noop_when_empty() {
1041 use crate::builder::BuildOptions;
1042 use crate::dockerfile::RunInstruction;
1043
1044 let df = Dockerfile {
1045 global_args: vec![],
1046 stages: vec![Stage {
1047 index: 0,
1048 name: None,
1049 base_image: img("alpine"),
1050 platform: None,
1051 instructions: vec![Instruction::Run(RunInstruction::shell("echo hi"))],
1052 }],
1053 };
1054 let mut ir = df;
1055 super::merge_default_cache_mounts(&mut ir, &BuildOptions::default());
1056 let Instruction::Run(r) = &ir.stages[0].instructions[0] else {
1057 panic!("expected RUN");
1058 };
1059 assert!(r.mounts.is_empty());
1060 }
1061
1062 #[test]
1063 fn expand_dockerfile_arg_and_env_accumulation() {
1064 let df = Dockerfile {
1066 global_args: vec![],
1067 stages: vec![Stage {
1068 index: 0,
1069 name: None,
1070 base_image: img("alpine"),
1071 platform: None,
1072 instructions: vec![
1073 Instruction::Arg(ArgInstruction::with_default("GREETING", "hi")),
1074 Instruction::Env(EnvInstruction::new("WHO", "${GREETING} world")),
1075 Instruction::Run(RunInstruction::shell("echo ${WHO}")),
1076 ],
1077 }],
1078 };
1079
1080 let expanded = expand_dockerfile(&df, &std::collections::HashMap::new());
1081 let Instruction::Env(env) = &expanded.stages[0].instructions[1] else {
1082 panic!("expected ENV");
1083 };
1084 assert_eq!(env.vars.get("WHO"), Some(&"hi world".to_string()));
1085
1086 let Instruction::Run(run) = &expanded.stages[0].instructions[2] else {
1087 panic!("expected RUN");
1088 };
1089 match &run.command {
1090 ShellOrExec::Shell(s) => assert_eq!(s, "echo hi world"),
1091 ShellOrExec::Exec(args) => panic!("expected shell, got exec: {args:?}"),
1092 }
1093 }
1094}