1mod executor;
26mod install;
27
28pub use executor::*;
29#[cfg(unix)]
30pub use install::buildd as buildd_install;
31#[cfg(unix)]
32pub use install::buildd::{ensure_buildd_sidecar, InstallOutcome as SidecarInstallOutcome};
33pub use install::{
34 current_platform, install_instructions, is_platform_supported, BuildahInstallation,
35 BuildahInstaller, InstallError,
36};
37
38use crate::backend::ImageOs;
39use crate::dockerfile::{
40 escape_json_string, AddInstruction, CopyInstruction, EnvInstruction, ExposeInstruction,
41 HealthcheckInstruction, Instruction, RunInstruction, RunNetwork, ShellOrExec,
42};
43
44use std::collections::HashMap;
45use std::path::{Path, PathBuf};
46
47const LINUX_DEFAULT_SHELL: &[&str] = &["/bin/sh", "-c"];
53
54const WINDOWS_DEFAULT_SHELL: &[&str] = &["cmd.exe", "/S", "/C"];
59
60fn default_shell_for(os: ImageOs) -> Vec<String> {
63 let raw: &[&str] = match os {
64 ImageOs::Linux | ImageOs::Darwin => LINUX_DEFAULT_SHELL,
66 ImageOs::Windows => WINDOWS_DEFAULT_SHELL,
67 };
68 raw.iter().map(|s| (*s).to_string()).collect()
69}
70
71#[derive(Debug, Clone)]
73pub struct BuildahCommand {
74 pub program: String,
76
77 pub args: Vec<String>,
79
80 pub env: HashMap<String, String>,
82}
83
84impl BuildahCommand {
85 #[must_use]
87 pub fn new(subcommand: &str) -> Self {
88 Self {
89 program: "buildah".to_string(),
90 args: vec![subcommand.to_string()],
91 env: HashMap::new(),
92 }
93 }
94
95 #[must_use]
97 pub fn arg(mut self, arg: impl Into<String>) -> Self {
98 self.args.push(arg.into());
99 self
100 }
101
102 #[must_use]
104 pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
105 self.args.extend(args.into_iter().map(Into::into));
106 self
107 }
108
109 #[must_use]
111 pub fn arg_opt(self, flag: &str, value: Option<impl Into<String>>) -> Self {
112 if let Some(v) = value {
113 self.arg(flag).arg(v)
114 } else {
115 self
116 }
117 }
118
119 #[must_use]
121 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
122 self.env.insert(key.into(), value.into());
123 self
124 }
125
126 #[must_use]
128 pub fn to_command_string(&self) -> String {
129 let mut parts = vec![self.program.clone()];
130 parts.extend(self.args.iter().map(|a| {
131 if a.contains(' ') || a.contains('"') {
132 format!("\"{}\"", a.replace('"', "\\\""))
133 } else {
134 a.clone()
135 }
136 }));
137 parts.join(" ")
138 }
139
140 #[must_use]
148 pub fn from_image(image: &str) -> Self {
149 Self::new("from").arg(image)
150 }
151
152 #[must_use]
156 pub fn from_image_named(image: &str, name: &str) -> Self {
157 Self::new("from").arg("--name").arg(name).arg(image)
158 }
159
160 #[must_use]
164 pub fn from_scratch() -> Self {
165 Self::new("from").arg("scratch")
166 }
167
168 #[must_use]
172 pub fn rm(container: &str) -> Self {
173 Self::new("rm").arg(container)
174 }
175
176 #[must_use]
180 pub fn commit(container: &str, image_name: &str) -> Self {
181 Self::new("commit").arg(container).arg(image_name)
182 }
183
184 #[must_use]
186 pub fn commit_with_opts(
187 container: &str,
188 image_name: &str,
189 format: Option<&str>,
190 squash: bool,
191 ) -> Self {
192 let mut cmd = Self::new("commit");
193
194 if let Some(fmt) = format {
195 cmd = cmd.arg("--format").arg(fmt);
196 }
197
198 if squash {
199 cmd = cmd.arg("--squash");
200 }
201
202 cmd.arg(container).arg(image_name)
203 }
204
205 #[must_use]
209 pub fn tag(image: &str, new_name: &str) -> Self {
210 Self::new("tag").arg(image).arg(new_name)
211 }
212
213 #[must_use]
217 pub fn rmi(image: &str) -> Self {
218 Self::new("rmi").arg(image)
219 }
220
221 #[must_use]
226 pub fn rmi_force(image: &str) -> Self {
227 Self::new("rmi").arg("-f").arg(image)
228 }
229
230 #[must_use]
239 pub fn pull(image: &str, policy: Option<&str>) -> Self {
240 let mut cmd = Self::new("pull");
241 if let Some(p) = policy {
242 cmd = cmd.arg("--policy").arg(p);
243 }
244 cmd.arg(image)
245 }
246
247 #[must_use]
251 pub fn push(image: &str) -> Self {
252 Self::new("push").arg(image)
253 }
254
255 #[must_use]
263 pub fn push_with_creds(image: &str, creds: Option<&str>) -> Self {
264 let mut cmd = Self::new("push");
265 if let Some(creds) = creds {
266 cmd = cmd.arg("--creds").arg(creds);
267 }
268 cmd.arg(image)
269 }
270
271 #[must_use]
275 pub fn push_to(image: &str, destination: &str) -> Self {
276 Self::new("push").arg(image).arg(destination)
277 }
278
279 #[must_use]
283 pub fn inspect(name: &str) -> Self {
284 Self::new("inspect").arg(name)
285 }
286
287 #[must_use]
291 pub fn inspect_format(name: &str, format: &str) -> Self {
292 Self::new("inspect").arg("--format").arg(format).arg(name)
293 }
294
295 #[must_use]
299 pub fn images() -> Self {
300 Self::new("images")
301 }
302
303 #[must_use]
307 pub fn containers() -> Self {
308 Self::new("containers")
309 }
310
311 #[must_use]
323 pub fn run_shell(container: &str, command: &str) -> Self {
324 Self::run_shell_custom(container, LINUX_DEFAULT_SHELL, command)
325 }
326
327 #[must_use]
335 pub fn run_shell_custom(
336 container: &str,
337 shell: impl IntoIterator<Item = impl AsRef<str>>,
338 command: &str,
339 ) -> Self {
340 Self::run_shell_custom_with_net(container, shell, command, None)
341 }
342
343 #[must_use]
350 pub fn run_shell_custom_with_net(
351 container: &str,
352 shell: impl IntoIterator<Item = impl AsRef<str>>,
353 command: &str,
354 net: Option<RunNetwork>,
355 ) -> Self {
356 let mut cmd = Self::new("run");
357 if let Some(mode) = net {
358 match mode {
359 RunNetwork::Host => cmd = cmd.arg("--net=host"),
360 RunNetwork::None => cmd = cmd.arg("--net=none"),
361 RunNetwork::Default => {}
362 }
363 }
364 cmd = cmd.arg(container).arg("--");
365 for s in shell {
366 cmd = cmd.arg(s.as_ref().to_string());
367 }
368 cmd.arg(command)
369 }
370
371 #[must_use]
375 pub fn run_shell_for_os(container: &str, command: &str, os: ImageOs) -> Self {
376 let shell = default_shell_for(os);
377 Self::run_shell_custom(container, &shell, command)
378 }
379
380 #[must_use]
384 pub fn run_exec(container: &str, args: &[String]) -> Self {
385 Self::run_exec_with_net(container, args, None)
386 }
387
388 #[must_use]
401 pub fn run_exec_with_net(container: &str, args: &[String], net: Option<RunNetwork>) -> Self {
402 let mut cmd = Self::new("run");
403 if let Some(mode) = net {
404 match mode {
405 RunNetwork::Host => cmd = cmd.arg("--net=host"),
406 RunNetwork::None => cmd = cmd.arg("--net=none"),
407 RunNetwork::Default => {}
408 }
409 }
410 cmd = cmd.arg(container).arg("--");
411 for arg in args {
412 cmd = cmd.arg(arg);
413 }
414 cmd
415 }
416
417 #[must_use]
419 pub fn run(container: &str, command: &ShellOrExec) -> Self {
420 match command {
421 ShellOrExec::Shell(s) => Self::run_shell(container, s),
422 ShellOrExec::Exec(args) => Self::run_exec(container, args),
423 }
424 }
425
426 #[must_use]
436 pub fn run_with_mounts(container: &str, run: &RunInstruction) -> Self {
437 Self::run_with_mounts_shell(container, run, LINUX_DEFAULT_SHELL)
438 }
439
440 #[must_use]
446 pub fn run_with_mounts_shell(
447 container: &str,
448 run: &RunInstruction,
449 shell: impl IntoIterator<Item = impl AsRef<str>>,
450 ) -> Self {
451 let mut cmd = Self::new("run");
452
453 for mount in &run.mounts {
455 cmd = cmd.arg(format!("--mount={}", mount.to_buildah_arg()));
456 }
457
458 let mut env_keys: Vec<&String> = run.env.keys().collect();
463 env_keys.sort();
464 for key in env_keys {
465 if let Some(value) = run.env.get(key) {
466 cmd = cmd.arg(format!("--env={key}={value}"));
467 }
468 }
469
470 if let Some(net) = run.network {
478 match net {
479 RunNetwork::Host => {
480 cmd = cmd.arg("--net=host");
481 }
482 RunNetwork::None => {
483 cmd = cmd.arg("--net=none");
484 }
485 RunNetwork::Default => {}
486 }
487 }
488
489 cmd = cmd.arg(container).arg("--");
491
492 match &run.command {
493 ShellOrExec::Shell(s) => {
494 for part in shell {
495 cmd = cmd.arg(part.as_ref().to_string());
496 }
497 cmd.arg(s)
498 }
499 ShellOrExec::Exec(args) => {
500 for arg in args {
501 cmd = cmd.arg(arg);
502 }
503 cmd
504 }
505 }
506 }
507
508 #[must_use]
516 pub fn copy(container: &str, sources: &[String], dest: &str) -> Self {
517 let mut cmd = Self::new("copy").arg(container);
518 for src in sources {
519 cmd = cmd.arg(src);
520 }
521 cmd.arg(dest)
522 }
523
524 #[must_use]
528 pub fn copy_from(container: &str, from: &str, sources: &[String], dest: &str) -> Self {
529 let mut cmd = Self::new("copy").arg("--from").arg(from).arg(container);
530 for src in sources {
531 cmd = cmd.arg(src);
532 }
533 cmd.arg(dest)
534 }
535
536 #[must_use]
552 pub fn copy_empty_dir(container: &str, empty_src: &Path, dest: &str) -> Self {
553 let mut src = empty_src.to_string_lossy().into_owned();
558 if !src.ends_with('/') {
559 src.push('/');
560 }
561 src.push('.');
562 Self::new("copy").arg(container).arg(src).arg(dest)
563 }
564
565 #[must_use]
567 pub fn copy_instruction(container: &str, copy: &CopyInstruction) -> Self {
568 let mut cmd = Self::new("copy");
569
570 if let Some(ref from) = copy.from {
571 cmd = cmd.arg("--from").arg(from);
572 }
573
574 if let Some(ref chown) = copy.chown {
575 cmd = cmd.arg("--chown").arg(chown);
576 }
577
578 if let Some(ref chmod) = copy.chmod {
579 cmd = cmd.arg("--chmod").arg(chmod);
580 }
581
582 cmd = cmd.arg(container);
583
584 for src in ©.sources {
585 cmd = cmd.arg(src);
586 }
587
588 cmd.arg(©.destination)
589 }
590
591 #[must_use]
593 pub fn add(container: &str, sources: &[String], dest: &str) -> Self {
594 let mut cmd = Self::new("add").arg(container);
595 for src in sources {
596 cmd = cmd.arg(src);
597 }
598 cmd.arg(dest)
599 }
600
601 #[must_use]
603 pub fn add_instruction(container: &str, add: &AddInstruction) -> Self {
604 let mut cmd = Self::new("add");
605
606 if let Some(ref chown) = add.chown {
607 cmd = cmd.arg("--chown").arg(chown);
608 }
609
610 if let Some(ref chmod) = add.chmod {
611 cmd = cmd.arg("--chmod").arg(chmod);
612 }
613
614 cmd = cmd.arg(container);
615
616 for src in &add.sources {
617 cmd = cmd.arg(src);
618 }
619
620 cmd.arg(&add.destination)
621 }
622
623 #[must_use]
631 pub fn config_env(container: &str, key: &str, value: &str) -> Self {
632 Self::new("config")
633 .arg("--env")
634 .arg(format!("{key}={value}"))
635 .arg(container)
636 }
637
638 #[must_use]
640 pub fn config_envs(container: &str, env: &EnvInstruction) -> Vec<Self> {
641 env.vars
642 .iter()
643 .map(|(k, v)| Self::config_env(container, k, v))
644 .collect()
645 }
646
647 #[must_use]
651 pub fn config_workdir(container: &str, dir: &str) -> Self {
652 Self::new("config")
653 .arg("--workingdir")
654 .arg(dir)
655 .arg(container)
656 }
657
658 #[must_use]
662 pub fn config_expose(container: &str, expose: &ExposeInstruction) -> Self {
663 let port_spec = format!(
664 "{}/{}",
665 expose.port,
666 match expose.protocol {
667 crate::dockerfile::ExposeProtocol::Tcp => "tcp",
668 crate::dockerfile::ExposeProtocol::Udp => "udp",
669 }
670 );
671 Self::new("config")
672 .arg("--port")
673 .arg(port_spec)
674 .arg(container)
675 }
676
677 #[must_use]
681 pub fn config_entrypoint_shell(container: &str, command: &str) -> Self {
682 Self::new("config")
683 .arg("--entrypoint")
684 .arg(format!(
685 "[\"/bin/sh\", \"-c\", \"{}\"]",
686 escape_json_string(command)
687 ))
688 .arg(container)
689 }
690
691 #[must_use]
695 pub fn config_entrypoint_exec(container: &str, args: &[String]) -> Self {
696 let json_array = format!(
697 "[{}]",
698 args.iter()
699 .map(|a| format!("\"{}\"", escape_json_string(a)))
700 .collect::<Vec<_>>()
701 .join(", ")
702 );
703 Self::new("config")
704 .arg("--entrypoint")
705 .arg(json_array)
706 .arg(container)
707 }
708
709 #[must_use]
711 pub fn config_entrypoint(container: &str, command: &ShellOrExec) -> Self {
712 match command {
713 ShellOrExec::Shell(s) => Self::config_entrypoint_shell(container, s),
714 ShellOrExec::Exec(args) => Self::config_entrypoint_exec(container, args),
715 }
716 }
717
718 #[must_use]
720 pub fn config_cmd_shell(container: &str, command: &str) -> Self {
721 Self::new("config")
722 .arg("--cmd")
723 .arg(format!("/bin/sh -c \"{}\"", escape_json_string(command)))
724 .arg(container)
725 }
726
727 #[must_use]
729 pub fn config_cmd_exec(container: &str, args: &[String]) -> Self {
730 let json_array = format!(
731 "[{}]",
732 args.iter()
733 .map(|a| format!("\"{}\"", escape_json_string(a)))
734 .collect::<Vec<_>>()
735 .join(", ")
736 );
737 Self::new("config")
738 .arg("--cmd")
739 .arg(json_array)
740 .arg(container)
741 }
742
743 #[must_use]
745 pub fn config_cmd(container: &str, command: &ShellOrExec) -> Self {
746 match command {
747 ShellOrExec::Shell(s) => Self::config_cmd_shell(container, s),
748 ShellOrExec::Exec(args) => Self::config_cmd_exec(container, args),
749 }
750 }
751
752 #[must_use]
756 pub fn config_user(container: &str, user: &str) -> Self {
757 Self::new("config").arg("--user").arg(user).arg(container)
758 }
759
760 #[must_use]
764 pub fn config_label(container: &str, key: &str, value: &str) -> Self {
765 Self::new("config")
766 .arg("--label")
767 .arg(format!("{key}={value}"))
768 .arg(container)
769 }
770
771 #[must_use]
773 pub fn config_labels(container: &str, labels: &HashMap<String, String>) -> Vec<Self> {
774 labels
775 .iter()
776 .map(|(k, v)| Self::config_label(container, k, v))
777 .collect()
778 }
779
780 #[must_use]
784 pub fn config_volume(container: &str, path: &str) -> Self {
785 Self::new("config").arg("--volume").arg(path).arg(container)
786 }
787
788 #[must_use]
792 pub fn config_stopsignal(container: &str, signal: &str) -> Self {
793 Self::new("config")
794 .arg("--stop-signal")
795 .arg(signal)
796 .arg(container)
797 }
798
799 #[must_use]
803 pub fn config_shell(container: &str, shell: &[String]) -> Self {
804 let json_array = format!(
805 "[{}]",
806 shell
807 .iter()
808 .map(|a| format!("\"{}\"", escape_json_string(a)))
809 .collect::<Vec<_>>()
810 .join(", ")
811 );
812 Self::new("config")
813 .arg("--shell")
814 .arg(json_array)
815 .arg(container)
816 }
817
818 #[must_use]
820 pub fn config_healthcheck(container: &str, healthcheck: &HealthcheckInstruction) -> Self {
821 match healthcheck {
822 HealthcheckInstruction::None => Self::new("config")
823 .arg("--healthcheck")
824 .arg("NONE")
825 .arg(container),
826 HealthcheckInstruction::Check {
827 command,
828 interval,
829 timeout,
830 start_period,
831 retries,
832 ..
833 } => {
834 let mut cmd = Self::new("config");
835
836 let cmd_str = match command {
837 ShellOrExec::Shell(s) => format!("CMD {s}"),
838 ShellOrExec::Exec(args) => {
839 format!(
840 "CMD [{}]",
841 args.iter()
842 .map(|a| format!("\"{}\"", escape_json_string(a)))
843 .collect::<Vec<_>>()
844 .join(", ")
845 )
846 }
847 };
848
849 cmd = cmd.arg("--healthcheck").arg(cmd_str);
850
851 if let Some(i) = interval {
852 cmd = cmd
853 .arg("--healthcheck-interval")
854 .arg(format!("{}s", i.as_secs()));
855 }
856
857 if let Some(t) = timeout {
858 cmd = cmd
859 .arg("--healthcheck-timeout")
860 .arg(format!("{}s", t.as_secs()));
861 }
862
863 if let Some(sp) = start_period {
864 cmd = cmd
865 .arg("--healthcheck-start-period")
866 .arg(format!("{}s", sp.as_secs()));
867 }
868
869 if let Some(r) = retries {
870 cmd = cmd.arg("--healthcheck-retries").arg(r.to_string());
871 }
872
873 cmd.arg(container)
874 }
875 }
876 }
877
878 #[must_use]
886 pub fn manifest_create(name: &str) -> Self {
887 Self::new("manifest").arg("create").arg(name)
888 }
889
890 #[must_use]
894 pub fn manifest_add(list: &str, image: &str) -> Self {
895 Self::new("manifest").arg("add").arg(list).arg(image)
896 }
897
898 #[must_use]
902 pub fn manifest_push(list: &str, destination: &str) -> Self {
903 Self::new("manifest")
904 .arg("push")
905 .arg("--all")
906 .arg(list)
907 .arg(destination)
908 }
909
910 #[must_use]
918 pub fn manifest_push_with_creds(list: &str, destination: &str, creds: Option<&str>) -> Self {
919 let mut cmd = Self::new("manifest").arg("push").arg("--all");
920 if let Some(creds) = creds {
921 cmd = cmd.arg("--creds").arg(creds);
922 }
923 cmd.arg(list).arg(destination)
924 }
925
926 #[must_use]
930 pub fn manifest_rm(list: &str) -> Self {
931 Self::new("manifest").arg("rm").arg(list)
932 }
933
934 #[must_use]
954 pub fn from_instruction(container: &str, instruction: &Instruction) -> Vec<Self> {
955 DockerfileTranslator::new(ImageOs::Linux).translate(container, instruction)
956 }
957
958 #[must_use]
989 pub fn build(
990 dockerfile_path: &Path,
991 context: &Path,
992 options: &crate::builder::BuildOptions,
993 effective_build_args: &std::collections::BTreeMap<String, String>,
994 ssh_ids: &[String],
995 secret_ids: &[String],
996 ) -> Self {
997 let mut cmd = Self::new("build");
998
999 cmd = cmd
1001 .arg("-f")
1002 .arg(dockerfile_path.to_string_lossy().into_owned());
1003
1004 for tag in &options.tags {
1006 cmd = cmd.arg("--tag").arg(tag.clone());
1007 }
1008
1009 if let Some(target) = &options.target {
1011 cmd = cmd.arg("--target").arg(target.clone());
1012 }
1013
1014 if options.no_cache {
1016 cmd = cmd.arg("--no-cache");
1017 }
1018 if options.layers {
1019 cmd = cmd.arg("--layers");
1020 }
1021 if options.squash {
1022 cmd = cmd.arg("--squash");
1023 }
1024
1025 if let Some(format) = &options.format {
1027 cmd = cmd.arg("--format").arg(format.clone());
1028 }
1029
1030 if let Some(platform) = &options.platform {
1032 cmd = cmd.arg("--platform").arg(platform.clone());
1033 }
1034
1035 for (key, value) in effective_build_args {
1037 cmd = cmd.arg("--build-arg").arg(format!("{key}={value}"));
1038 }
1039
1040 if options.host_network {
1042 cmd = cmd.arg("--network=host");
1043 }
1044
1045 match options.pull {
1047 crate::builder::PullBaseMode::Newer => cmd = cmd.arg("--pull=ifnewer"),
1048 crate::builder::PullBaseMode::Always => cmd = cmd.arg("--pull=always"),
1049 crate::builder::PullBaseMode::Never => cmd = cmd.arg("--pull=never"),
1050 }
1051
1052 if let Some(cache_from) = &options.cache_from {
1054 cmd = cmd.arg("--cache-from").arg(cache_from.clone());
1055 }
1056 if let Some(cache_to) = &options.cache_to {
1057 cmd = cmd.arg("--cache-to").arg(cache_to.clone());
1058 }
1059 if let Some(cache_ttl) = &options.cache_ttl {
1060 cmd = cmd
1061 .arg("--cache-ttl")
1062 .arg(format!("{}s", cache_ttl.as_secs()));
1063 }
1064
1065 #[cfg(unix)]
1069 {
1070 if !nix::unistd::Uid::current().is_root() {
1071 cmd = cmd.arg("--isolation=chroot");
1072 }
1073 }
1074
1075 for id in ssh_ids {
1077 cmd = cmd.arg("--ssh").arg(id.clone());
1078 }
1079
1080 for spec in secret_ids {
1082 cmd = cmd.arg("--secret").arg(spec.clone());
1083 }
1084
1085 cmd = cmd.arg(context.to_string_lossy().into_owned());
1087
1088 cmd
1089 }
1090}
1091
1092#[derive(Debug, Clone)]
1113pub struct DockerfileTranslator {
1114 target_os: ImageOs,
1115 shell_override: Option<Vec<String>>,
1118 host_network: bool,
1124 empty_src_dir: Option<PathBuf>,
1133}
1134
1135impl DockerfileTranslator {
1136 #[must_use]
1138 pub fn new(target_os: ImageOs) -> Self {
1139 Self {
1140 target_os,
1141 shell_override: None,
1142 host_network: false,
1143 empty_src_dir: None,
1144 }
1145 }
1146
1147 #[must_use]
1159 pub fn with_empty_src_dir(mut self, dir: PathBuf) -> Self {
1160 self.empty_src_dir = Some(dir);
1161 self
1162 }
1163
1164 #[must_use]
1173 pub fn with_host_network(mut self, on: bool) -> Self {
1174 self.host_network = on;
1175 self
1176 }
1177
1178 #[must_use]
1180 pub fn target_os(&self) -> ImageOs {
1181 self.target_os
1182 }
1183
1184 #[must_use]
1188 pub fn active_shell(&self) -> Vec<String> {
1189 match &self.shell_override {
1190 Some(s) => s.clone(),
1191 None => default_shell_for(self.target_os),
1192 }
1193 }
1194
1195 pub fn set_shell_override(&mut self, shell: Vec<String>) {
1199 self.shell_override = Some(shell);
1200 }
1201
1202 #[allow(clippy::too_many_lines)]
1209 pub fn translate(&mut self, container: &str, instruction: &Instruction) -> Vec<BuildahCommand> {
1210 match instruction {
1211 Instruction::Run(run) => {
1212 let shell = self.active_shell();
1213 let effective_run: std::borrow::Cow<'_, RunInstruction> = if self.host_network {
1219 let mut owned = run.clone();
1220 owned.network = Some(RunNetwork::Host);
1221 std::borrow::Cow::Owned(owned)
1222 } else {
1223 std::borrow::Cow::Borrowed(run)
1224 };
1225 let run_ref: &RunInstruction = &effective_run;
1226
1227 let needs_pre_container_flags = !run_ref.mounts.is_empty()
1231 || !run_ref.env.is_empty()
1232 || run_ref.network.is_some();
1233
1234 if needs_pre_container_flags {
1235 vec![BuildahCommand::run_with_mounts_shell(
1236 container, run_ref, &shell,
1237 )]
1238 } else {
1239 match &run_ref.command {
1240 ShellOrExec::Shell(s) => {
1241 vec![BuildahCommand::run_shell_custom(container, &shell, s)]
1242 }
1243 ShellOrExec::Exec(args) => vec![BuildahCommand::run_exec(container, args)],
1244 }
1245 }
1246 }
1247
1248 Instruction::Copy(copy) => {
1249 vec![BuildahCommand::copy_instruction(container, copy)]
1250 }
1251
1252 Instruction::Add(add) => {
1253 vec![BuildahCommand::add_instruction(container, add)]
1254 }
1255
1256 Instruction::Env(env) => BuildahCommand::config_envs(container, env),
1257
1258 Instruction::Workdir(dir) => self.translate_workdir(container, dir),
1259
1260 Instruction::Expose(expose) => {
1261 vec![BuildahCommand::config_expose(container, expose)]
1262 }
1263
1264 Instruction::Label(labels) => BuildahCommand::config_labels(container, labels),
1265
1266 Instruction::User(user) => {
1267 vec![BuildahCommand::config_user(container, user)]
1268 }
1269
1270 Instruction::Entrypoint(cmd) => {
1271 vec![BuildahCommand::config_entrypoint(container, cmd)]
1272 }
1273
1274 Instruction::Cmd(cmd) => {
1275 vec![BuildahCommand::config_cmd(container, cmd)]
1276 }
1277
1278 Instruction::Volume(paths) => paths
1279 .iter()
1280 .map(|p| BuildahCommand::config_volume(container, p))
1281 .collect(),
1282
1283 Instruction::Shell(shell) => {
1284 self.set_shell_override(shell.clone());
1291 vec![BuildahCommand::config_shell(container, shell)]
1292 }
1293
1294 Instruction::Arg(_) => {
1295 vec![]
1297 }
1298
1299 Instruction::Stopsignal(signal) => {
1300 vec![BuildahCommand::config_stopsignal(container, signal)]
1301 }
1302
1303 Instruction::Healthcheck(hc) => {
1304 vec![BuildahCommand::config_healthcheck(container, hc)]
1305 }
1306
1307 Instruction::Onbuild(_) => {
1308 tracing::warn!("ONBUILD instruction not supported in buildah conversion");
1310 vec![]
1311 }
1312 }
1313 }
1314
1315 fn translate_workdir(&self, container: &str, dir: &str) -> Vec<BuildahCommand> {
1332 let net = self.host_network.then_some(RunNetwork::Host);
1340 match self.target_os {
1341 ImageOs::Linux | ImageOs::Darwin => {
1343 if let Some(empty_src) = self.empty_src_dir.as_deref() {
1352 vec![
1353 BuildahCommand::copy_empty_dir(container, empty_src, dir),
1354 BuildahCommand::config_workdir(container, dir),
1355 ]
1356 } else {
1357 vec![
1358 BuildahCommand::run_exec_with_net(
1359 container,
1360 &["mkdir".to_string(), "-p".to_string(), dir.to_string()],
1361 net,
1362 ),
1363 BuildahCommand::config_workdir(container, dir),
1364 ]
1365 }
1366 }
1367 ImageOs::Windows => {
1368 let guarded = format!(r#"if not exist "{dir}" mkdir "{dir}""#);
1373 vec![
1374 BuildahCommand::run_shell_custom_with_net(
1375 container,
1376 WINDOWS_DEFAULT_SHELL,
1377 &guarded,
1378 net,
1379 ),
1380 BuildahCommand::config_workdir(container, dir),
1381 ]
1382 }
1383 }
1384 }
1385}
1386
1387#[cfg(any(target_os = "windows", test))]
1410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1411pub(crate) enum DetectedPmKind {
1412 Apt,
1414 Apk,
1416 YumOrDnf,
1418}
1419
1420#[cfg(any(target_os = "windows", test))]
1422#[derive(Debug, Clone, PartialEq, Eq)]
1423pub(crate) enum ShellSubcommand {
1424 Verbatim(String),
1426 PackageManagerSync,
1431 Install {
1433 kind: DetectedPmKind,
1434 packages: Vec<String>,
1435 },
1436}
1437
1438#[cfg(any(target_os = "windows", test))]
1443pub(crate) fn detect_install_in_subcommand(
1444 subcommand: &str,
1445) -> Option<(DetectedPmKind, Vec<String>)> {
1446 let tokens: Vec<&str> = subcommand.split_whitespace().collect();
1447 if tokens.is_empty() {
1448 return None;
1449 }
1450 let (kind, after_verb_idx) = match tokens[0] {
1453 "sudo" if tokens.len() >= 2 => detect_pm_verb(&tokens[1..]).map(|(k, n)| (k, n + 1))?,
1454 _ => detect_pm_verb(&tokens)?,
1455 };
1456 let args = &tokens[after_verb_idx..];
1457 let mut packages = Vec::new();
1458 for arg in args {
1459 if arg.starts_with('-') {
1460 continue;
1461 }
1462 packages.push((*arg).to_string());
1463 }
1464 if packages.is_empty() {
1465 return None;
1466 }
1467 Some((kind, packages))
1468}
1469
1470#[cfg(any(target_os = "windows", test))]
1474fn detect_pm_verb(tokens: &[&str]) -> Option<(DetectedPmKind, usize)> {
1475 match (tokens.first().copied(), tokens.get(1).copied()) {
1476 (Some("apt-get" | "apt"), Some("install")) => Some((DetectedPmKind::Apt, 2)),
1477 (Some("apk"), Some("add")) => Some((DetectedPmKind::Apk, 2)),
1478 (Some("yum" | "dnf"), Some("install")) => Some((DetectedPmKind::YumOrDnf, 2)),
1479 _ => None,
1480 }
1481}
1482
1483#[cfg(any(target_os = "windows", test))]
1488pub(crate) fn is_package_manager_sync(subcommand: &str) -> bool {
1489 let tokens: Vec<&str> = subcommand.split_whitespace().collect();
1490 let stripped: &[&str] = if tokens.first().copied() == Some("sudo") {
1491 &tokens[1..]
1492 } else {
1493 &tokens
1494 };
1495 matches!(
1496 (stripped.first().copied(), stripped.get(1).copied()),
1497 (Some("apt-get" | "apt" | "apk"), Some("update"))
1498 | (
1499 Some("yum" | "dnf"),
1500 Some("check-update" | "update" | "makecache")
1501 )
1502 )
1503}
1504
1505#[cfg(any(target_os = "windows", test))]
1511pub(crate) fn split_shell_subcommands(raw: &str) -> Vec<String> {
1512 let mut out = Vec::new();
1513 let mut current = String::new();
1514 let mut chars = raw.chars().peekable();
1515 while let Some(c) = chars.next() {
1516 match c {
1517 '&' if chars.peek() == Some(&'&') => {
1518 chars.next();
1519 if !current.trim().is_empty() {
1520 out.push(current.trim().to_string());
1521 }
1522 current.clear();
1523 }
1524 ';' => {
1525 if !current.trim().is_empty() {
1526 out.push(current.trim().to_string());
1527 }
1528 current.clear();
1529 }
1530 other => current.push(other),
1531 }
1532 }
1533 if !current.trim().is_empty() {
1534 out.push(current.trim().to_string());
1535 }
1536 out
1537}
1538
1539#[cfg(any(target_os = "windows", test))]
1547#[derive(Debug, Clone, PartialEq, Eq)]
1548pub(crate) struct RelocatableArtifact {
1549 pub name: String,
1551 pub url: String,
1553 pub asset_name: String,
1555}
1556
1557#[cfg(any(target_os = "windows", test))]
1563#[derive(Debug, Clone, Default, PartialEq, Eq)]
1564pub(crate) struct TranslatedRun {
1565 pub command_line: String,
1567 pub skipped_packages: Vec<String>,
1570 pub relocatable: Vec<RelocatableArtifact>,
1574}
1575
1576#[cfg(any(target_os = "windows", test))]
1582pub(crate) fn rejoin_subcommands(parts: &[ShellSubcommand]) -> String {
1583 let mut emitted: Vec<String> = Vec::new();
1584 for part in parts {
1585 match part {
1586 ShellSubcommand::Verbatim(s) => emitted.push(s.clone()),
1587 ShellSubcommand::PackageManagerSync => {
1588 }
1590 ShellSubcommand::Install { packages, .. } => {
1591 if packages.is_empty() {
1592 continue;
1593 }
1594 let mut joined = String::from("choco install -y");
1595 for pkg in packages {
1596 joined.push(' ');
1597 joined.push_str(pkg);
1598 }
1599 emitted.push(joined);
1600 }
1601 }
1602 }
1603 emitted.join(" && ")
1604}
1605
1606#[cfg(any(target_os = "windows", test))]
1611pub(crate) fn wrap_in_cmd(body: &str) -> String {
1612 if body.is_empty() {
1613 return "cmd /c \"\"".to_string();
1614 }
1615 let escaped = body.replace('"', "\\\"");
1616 format!("cmd /c \"{escaped}\"")
1617}
1618
1619#[cfg(any(target_os = "windows", test))]
1638fn package_matches_toolchain(linux_pkg: &str, toolchain_language: &str) -> bool {
1639 let pkg = linux_pkg.to_ascii_lowercase();
1640 match toolchain_language.to_ascii_lowercase().as_str() {
1641 "go" => matches!(pkg.as_str(), "golang" | "go"),
1642 "node" => matches!(pkg.as_str(), "nodejs" | "node"),
1643 "python" => matches!(pkg.as_str(), "python3" | "python"),
1644 "rust" => matches!(pkg.as_str(), "rust" | "rustc" | "cargo"),
1645 "deno" => pkg == "deno",
1646 "bun" => pkg == "bun",
1647 _ => false,
1648 }
1649}
1650
1651#[cfg(any(target_os = "windows", test))]
1652impl DockerfileTranslator {
1653 pub(crate) async fn translate_run_command(
1683 &self,
1684 cmd: &ShellOrExec,
1685 source_distro: &str,
1686 provisioned_toolchain_language: Option<&str>,
1687 ) -> Result<TranslatedRun, crate::error::BuildError> {
1688 match self.target_os {
1689 ImageOs::Linux | ImageOs::Darwin => match cmd {
1692 ShellOrExec::Shell(s) => Ok(TranslatedRun {
1693 command_line: s.clone(),
1694 ..TranslatedRun::default()
1695 }),
1696 ShellOrExec::Exec(args) => Ok(TranslatedRun {
1697 command_line: args.join(" "),
1698 ..TranslatedRun::default()
1699 }),
1700 },
1701 ImageOs::Windows => match cmd {
1702 ShellOrExec::Exec(args) => Ok(TranslatedRun {
1703 command_line: args.join(" "),
1704 ..TranslatedRun::default()
1705 }),
1706 ShellOrExec::Shell(raw) => {
1707 self.translate_shell_command(raw, source_distro, provisioned_toolchain_language)
1708 .await
1709 }
1710 },
1711 }
1712 }
1713
1714 pub(crate) async fn translate_shell_command(
1725 &self,
1726 raw: &str,
1727 source_distro: &str,
1728 provisioned_toolchain_language: Option<&str>,
1729 ) -> Result<TranslatedRun, crate::error::BuildError> {
1730 use crate::windows_image_resolver::ResolvedWindowsPackage;
1731
1732 if matches!(self.target_os, ImageOs::Linux) {
1733 return Ok(TranslatedRun {
1734 command_line: raw.to_string(),
1735 ..TranslatedRun::default()
1736 });
1737 }
1738
1739 let subcommands = split_shell_subcommands(raw);
1740 if subcommands.is_empty() {
1741 return Ok(TranslatedRun {
1744 command_line: wrap_in_cmd(""),
1745 ..TranslatedRun::default()
1746 });
1747 }
1748
1749 let mut classified: Vec<ShellSubcommand> = Vec::with_capacity(subcommands.len());
1750 let mut all_packages: Vec<String> = Vec::new();
1751 for sub in &subcommands {
1752 if is_package_manager_sync(sub) {
1753 classified.push(ShellSubcommand::PackageManagerSync);
1754 continue;
1755 }
1756 if let Some((kind, mut packages)) = detect_install_in_subcommand(sub) {
1757 if let Some(lang) = provisioned_toolchain_language {
1762 packages.retain(|p| !package_matches_toolchain(p, lang));
1763 }
1764 if packages.is_empty() {
1765 continue;
1769 }
1770 all_packages.extend(packages.iter().cloned());
1771 classified.push(ShellSubcommand::Install { kind, packages });
1772 continue;
1773 }
1774 classified.push(ShellSubcommand::Verbatim(sub.clone()));
1775 }
1776
1777 if all_packages.is_empty() {
1778 let rejoined = rejoin_subcommands(&classified);
1782 return Ok(TranslatedRun {
1783 command_line: wrap_in_cmd(&rejoined),
1784 ..TranslatedRun::default()
1785 });
1786 }
1787
1788 let resolved =
1796 crate::windows_image_resolver::resolve_windows_packages(&all_packages, source_distro)
1797 .await?;
1798
1799 let mut lookup: HashMap<String, ResolvedWindowsPackage> = HashMap::new();
1801 for r in resolved {
1802 lookup.insert(r.name().to_string(), r);
1803 }
1804
1805 let mut skipped_out: Vec<String> = Vec::new();
1806 let mut relocatable_out: Vec<RelocatableArtifact> = Vec::new();
1807 for part in &mut classified {
1808 if let ShellSubcommand::Install { kind: _, packages } = part {
1809 let mut choco_pkgs: Vec<String> = Vec::new();
1810 for pkg in packages.iter() {
1811 match lookup.get(pkg) {
1812 Some(
1815 ResolvedWindowsPackage::DirectRelease {
1816 name,
1817 url,
1818 asset_name,
1819 }
1820 | ResolvedWindowsPackage::RelocatableArchive {
1821 name,
1822 url,
1823 asset_name,
1824 },
1825 ) => {
1826 relocatable_out.push(RelocatableArtifact {
1827 name: name.clone(),
1828 url: url.clone(),
1829 asset_name: asset_name.clone(),
1830 });
1831 }
1832 Some(ResolvedWindowsPackage::ChocoFallback { choco_name, .. }) => {
1835 choco_pkgs.push(choco_name.clone());
1836 }
1837 Some(ResolvedWindowsPackage::Skip { .. }) => skipped_out.push(pkg.clone()),
1839 None => {
1843 return Err(crate::error::BuildError::ChocoResolutionFailed {
1844 package: pkg.clone(),
1845 source_distro: source_distro.to_string(),
1846 });
1847 }
1848 }
1849 }
1850 *packages = choco_pkgs;
1853 }
1854 }
1855
1856 Ok(TranslatedRun {
1857 command_line: wrap_in_cmd(&rejoin_subcommands(&classified)),
1858 skipped_packages: skipped_out,
1859 relocatable: relocatable_out,
1860 })
1861 }
1862}
1863
1864#[cfg(test)]
1866mod tests {
1867 use super::*;
1868 use crate::dockerfile::RunInstruction;
1869
1870 #[test]
1871 fn test_from_image() {
1872 let cmd = BuildahCommand::from_image("alpine:3.18");
1873 assert_eq!(cmd.program, "buildah");
1874 assert_eq!(cmd.args, vec!["from", "alpine:3.18"]);
1875 }
1876
1877 #[test]
1878 fn test_build_flag_map() {
1879 use crate::builder::{BuildOptions, PullBaseMode};
1880 use std::collections::BTreeMap;
1881 use std::path::Path;
1882
1883 let options = BuildOptions {
1884 tags: vec!["app:latest".into(), "app:1.0".into()],
1885 target: Some("runtime".into()),
1886 no_cache: true,
1887 layers: true,
1888 squash: true,
1889 format: Some("docker".into()),
1890 platform: Some("linux/amd64,linux/arm64".into()),
1891 host_network: true,
1892 pull: PullBaseMode::Always,
1893 cache_from: Some("type=registry,ref=ex/cache".into()),
1894 cache_to: Some("type=registry,ref=ex/cache".into()),
1895 cache_ttl: Some(std::time::Duration::from_secs(3600)),
1896 ..BuildOptions::default()
1897 };
1898 let mut args = BTreeMap::new();
1899 args.insert("VERSION".to_string(), "1.0".to_string());
1900 args.insert("CHANNEL".to_string(), "stable".to_string());
1901
1902 let cmd = BuildahCommand::build(
1903 Path::new("/ctx/.zlayer-rendered-Dockerfile"),
1904 Path::new("/ctx"),
1905 &options,
1906 &args,
1907 &["default".to_string()],
1908 &[],
1909 );
1910 let a = &cmd.args;
1911
1912 assert_eq!(a[0], "build");
1913 let f_idx = a.iter().position(|x| x == "-f").expect("-f present");
1915 assert_eq!(a[f_idx + 1], "/ctx/.zlayer-rendered-Dockerfile");
1916 let tag_count = a.iter().filter(|x| x.as_str() == "--tag").count();
1918 assert_eq!(tag_count, 2);
1919 let t_idx = a.iter().position(|x| x == "--target").expect("--target");
1921 assert_eq!(a[t_idx + 1], "runtime");
1922 assert!(a.iter().any(|x| x == "--no-cache"));
1923 assert!(a.iter().any(|x| x == "--layers"));
1924 assert!(a.iter().any(|x| x == "--squash"));
1925 let fmt_idx = a.iter().position(|x| x == "--format").expect("--format");
1927 assert_eq!(a[fmt_idx + 1], "docker");
1928 let p_idx = a
1930 .iter()
1931 .position(|x| x == "--platform")
1932 .expect("--platform");
1933 assert_eq!(a[p_idx + 1], "linux/amd64,linux/arm64");
1934 let ba: Vec<&String> = a
1936 .iter()
1937 .enumerate()
1938 .filter(|(i, x)| x.as_str() == "--build-arg" && *i + 1 < a.len())
1939 .map(|(i, _)| &a[i + 1])
1940 .collect();
1941 assert_eq!(ba, vec!["CHANNEL=stable", "VERSION=1.0"]);
1942 assert!(a.iter().any(|x| x == "--network=host"));
1943 assert!(a.iter().any(|x| x == "--pull=always"));
1944 let cf = a
1946 .iter()
1947 .position(|x| x == "--cache-from")
1948 .expect("cache-from");
1949 assert_eq!(a[cf + 1], "type=registry,ref=ex/cache");
1950 let ct = a.iter().position(|x| x == "--cache-to").expect("cache-to");
1951 assert_eq!(a[ct + 1], "type=registry,ref=ex/cache");
1952 let cttl = a
1953 .iter()
1954 .position(|x| x == "--cache-ttl")
1955 .expect("cache-ttl");
1956 assert_eq!(a[cttl + 1], "3600s");
1957 let ssh = a.iter().position(|x| x == "--ssh").expect("--ssh");
1959 assert_eq!(a[ssh + 1], "default");
1960 assert_eq!(a.last().map(String::as_str), Some("/ctx"));
1962 }
1963
1964 #[test]
1965 fn test_build_omits_layers_when_false() {
1966 use crate::builder::BuildOptions;
1967 use std::collections::BTreeMap;
1968 use std::path::Path;
1969
1970 let options = BuildOptions {
1971 layers: false,
1972 ..BuildOptions::default()
1973 };
1974 let cmd = BuildahCommand::build(
1975 Path::new("/ctx/Dockerfile"),
1976 Path::new("/ctx"),
1977 &options,
1978 &BTreeMap::new(),
1979 &[],
1980 &[],
1981 );
1982 assert!(!cmd.args.iter().any(|x| x == "--layers"));
1983 assert!(cmd.args.iter().any(|x| x == "--pull=ifnewer"));
1985 }
1986
1987 #[test]
1988 fn test_pull_no_policy() {
1989 let cmd = BuildahCommand::pull("ghcr.io/astral-sh/uv:0.5.0", None);
1990 assert_eq!(cmd.program, "buildah");
1991 assert_eq!(cmd.args, vec!["pull", "ghcr.io/astral-sh/uv:0.5.0"]);
1992 }
1993
1994 #[test]
1995 fn test_pull_with_policy() {
1996 let cmd = BuildahCommand::pull("ghcr.io/astral-sh/uv:0.5.0", Some("newer"));
1997 assert_eq!(
1998 cmd.args,
1999 vec!["pull", "--policy", "newer", "ghcr.io/astral-sh/uv:0.5.0"]
2000 );
2001 }
2002
2003 #[test]
2004 fn test_run_shell() {
2005 let cmd = BuildahCommand::run_shell("container-1", "apt-get update");
2006 assert_eq!(
2007 cmd.args,
2008 vec![
2009 "run",
2010 "container-1",
2011 "--",
2012 "/bin/sh",
2013 "-c",
2014 "apt-get update"
2015 ]
2016 );
2017 }
2018
2019 #[test]
2020 fn test_run_exec() {
2021 let args = vec!["echo".to_string(), "hello".to_string()];
2022 let cmd = BuildahCommand::run_exec("container-1", &args);
2023 assert_eq!(cmd.args, vec!["run", "container-1", "--", "echo", "hello"]);
2024 }
2025
2026 #[test]
2027 fn test_copy() {
2028 let sources = vec!["src/".to_string(), "Cargo.toml".to_string()];
2029 let cmd = BuildahCommand::copy("container-1", &sources, "/app/");
2030 assert_eq!(
2031 cmd.args,
2032 vec!["copy", "container-1", "src/", "Cargo.toml", "/app/"]
2033 );
2034 }
2035
2036 #[test]
2037 fn test_copy_from() {
2038 let sources = vec!["/app".to_string()];
2039 let cmd = BuildahCommand::copy_from("container-1", "builder", &sources, "/app");
2040 assert_eq!(
2041 cmd.args,
2042 vec!["copy", "--from", "builder", "container-1", "/app", "/app"]
2043 );
2044 }
2045
2046 #[test]
2047 fn test_copy_from_external_image_reference_is_preserved() {
2048 use crate::dockerfile::CopyInstruction;
2054
2055 let copy = CopyInstruction {
2056 sources: vec!["/uv".to_string()],
2057 destination: "/usr/local/bin/uv".to_string(),
2058 from: Some("ghcr.io/astral-sh/uv:0.5.0".to_string()),
2059 chown: None,
2060 chmod: None,
2061 link: false,
2062 exclude: Vec::new(),
2063 };
2064 let instruction = Instruction::Copy(copy);
2065 let cmds = BuildahCommand::from_instruction("container-1", &instruction);
2066
2067 assert_eq!(
2068 cmds.len(),
2069 1,
2070 "COPY translates to a single buildah copy command"
2071 );
2072 assert_eq!(
2073 cmds[0].args,
2074 vec![
2075 "copy",
2076 "--from",
2077 "ghcr.io/astral-sh/uv:0.5.0",
2078 "container-1",
2079 "/uv",
2080 "/usr/local/bin/uv",
2081 ],
2082 "external image reference must be passed through to buildah unchanged",
2083 );
2084 }
2085
2086 #[test]
2087 fn test_config_env() {
2088 let cmd = BuildahCommand::config_env("container-1", "PATH", "/usr/local/bin");
2089 assert_eq!(
2090 cmd.args,
2091 vec!["config", "--env", "PATH=/usr/local/bin", "container-1"]
2092 );
2093 }
2094
2095 #[test]
2096 fn test_config_workdir() {
2097 let cmd = BuildahCommand::config_workdir("container-1", "/app");
2098 assert_eq!(
2099 cmd.args,
2100 vec!["config", "--workingdir", "/app", "container-1"]
2101 );
2102 }
2103
2104 #[test]
2105 fn test_config_entrypoint_exec() {
2106 let args = vec!["/app".to_string(), "--config".to_string()];
2107 let cmd = BuildahCommand::config_entrypoint_exec("container-1", &args);
2108 assert!(cmd.args.contains(&"--entrypoint".to_string()));
2109 assert!(cmd
2110 .args
2111 .iter()
2112 .any(|a| a.contains('[') && a.contains("/app")));
2113 }
2114
2115 #[test]
2116 fn test_commit() {
2117 let cmd = BuildahCommand::commit("container-1", "myimage:latest");
2118 assert_eq!(cmd.args, vec!["commit", "container-1", "myimage:latest"]);
2119 }
2120
2121 #[test]
2122 fn test_to_command_string() {
2123 let cmd = BuildahCommand::config_env("container-1", "VAR", "value with spaces");
2124 let s = cmd.to_command_string();
2125 assert!(s.starts_with("buildah config"));
2126 assert!(s.contains("VAR=value with spaces"));
2127 }
2128
2129 #[test]
2130 fn test_from_instruction_run() {
2131 let instruction = Instruction::Run(RunInstruction {
2132 command: ShellOrExec::Shell("echo hello".to_string()),
2133 mounts: vec![],
2134 network: None,
2135 security: None,
2136 env: HashMap::new(),
2137 });
2138
2139 let cmds = BuildahCommand::from_instruction("container-1", &instruction);
2140 assert_eq!(cmds.len(), 1);
2141 assert!(cmds[0].args.contains(&"run".to_string()));
2142 }
2143
2144 #[test]
2145 fn test_from_instruction_workdir_creates_and_configures() {
2146 let instruction = Instruction::Workdir("/workspace".to_string());
2150 let cmds = BuildahCommand::from_instruction("container-1", &instruction);
2151
2152 assert_eq!(cmds.len(), 2, "WORKDIR should emit mkdir + config");
2153
2154 let run_args = &cmds[0].args;
2155 assert_eq!(run_args[0], "run");
2156 assert_eq!(run_args[1], "container-1");
2157 assert_eq!(run_args[2], "--");
2158 assert_eq!(run_args[3], "mkdir");
2159 assert_eq!(run_args[4], "-p");
2160 assert_eq!(run_args[5], "/workspace");
2161
2162 assert_eq!(
2163 cmds[1].args,
2164 vec!["config", "--workingdir", "/workspace", "container-1"]
2165 );
2166 }
2167
2168 #[test]
2169 fn test_from_instruction_env_multiple() {
2170 let mut vars = HashMap::new();
2171 vars.insert("FOO".to_string(), "bar".to_string());
2172 vars.insert("BAZ".to_string(), "qux".to_string());
2173
2174 let instruction = Instruction::Env(EnvInstruction { vars });
2175 let cmds = BuildahCommand::from_instruction("container-1", &instruction);
2176
2177 assert_eq!(cmds.len(), 2);
2179 for cmd in &cmds {
2180 assert!(cmd.args.contains(&"config".to_string()));
2181 assert!(cmd.args.contains(&"--env".to_string()));
2182 }
2183 }
2184
2185 #[test]
2186 fn test_escape_json_string() {
2187 assert_eq!(escape_json_string("hello"), "hello");
2188 assert_eq!(escape_json_string("hello \"world\""), "hello \\\"world\\\"");
2189 assert_eq!(escape_json_string("line1\nline2"), "line1\\nline2");
2190 }
2191
2192 #[test]
2193 fn test_run_with_mounts_cache() {
2194 use crate::dockerfile::{CacheSharing, RunMount};
2195
2196 let run = RunInstruction {
2197 command: ShellOrExec::Shell("apt-get update".to_string()),
2198 mounts: vec![RunMount::Cache {
2199 target: "/var/cache/apt".to_string(),
2200 id: Some("apt-cache".to_string()),
2201 sharing: CacheSharing::Shared,
2202 readonly: false,
2203 }],
2204 network: None,
2205 security: None,
2206 env: HashMap::new(),
2207 };
2208
2209 let cmd = BuildahCommand::run_with_mounts("container-1", &run);
2210
2211 let mount_idx = cmd
2213 .args
2214 .iter()
2215 .position(|a| a.starts_with("--mount="))
2216 .expect("should have --mount arg");
2217 let container_idx = cmd
2218 .args
2219 .iter()
2220 .position(|a| a == "container-1")
2221 .expect("should have container id");
2222
2223 assert!(
2224 mount_idx < container_idx,
2225 "--mount should come before container ID"
2226 );
2227
2228 assert!(cmd.args[mount_idx].contains("type=cache"));
2230 assert!(cmd.args[mount_idx].contains("target=/var/cache/apt"));
2231 assert!(cmd.args[mount_idx].contains("id=apt-cache"));
2232 assert!(cmd.args[mount_idx].contains("sharing=shared"));
2233 }
2234
2235 #[test]
2236 fn test_run_with_multiple_mounts() {
2237 use crate::dockerfile::{CacheSharing, RunMount};
2238
2239 let run = RunInstruction {
2240 command: ShellOrExec::Shell("cargo build".to_string()),
2241 mounts: vec![
2242 RunMount::Cache {
2243 target: "/usr/local/cargo/registry".to_string(),
2244 id: Some("cargo-registry".to_string()),
2245 sharing: CacheSharing::Shared,
2246 readonly: false,
2247 },
2248 RunMount::Cache {
2249 target: "/app/target".to_string(),
2250 id: Some("cargo-target".to_string()),
2251 sharing: CacheSharing::Locked,
2252 readonly: false,
2253 },
2254 ],
2255 network: None,
2256 security: None,
2257 env: HashMap::new(),
2258 };
2259
2260 let cmd = BuildahCommand::run_with_mounts("container-1", &run);
2261
2262 let mount_count = cmd
2264 .args
2265 .iter()
2266 .filter(|a| a.starts_with("--mount="))
2267 .count();
2268 assert_eq!(mount_count, 2, "should have 2 mount arguments");
2269
2270 let container_idx = cmd
2272 .args
2273 .iter()
2274 .position(|a| a == "container-1")
2275 .expect("should have container id");
2276
2277 for (idx, arg) in cmd.args.iter().enumerate() {
2278 if arg.starts_with("--mount=") {
2279 assert!(
2280 idx < container_idx,
2281 "--mount at index {idx} should come before container ID at {container_idx}",
2282 );
2283 }
2284 }
2285 }
2286
2287 #[test]
2288 fn test_from_instruction_run_with_mounts() {
2289 use crate::dockerfile::{CacheSharing, RunMount};
2290
2291 let instruction = Instruction::Run(RunInstruction {
2292 command: ShellOrExec::Shell("npm install".to_string()),
2293 mounts: vec![RunMount::Cache {
2294 target: "/root/.npm".to_string(),
2295 id: Some("npm-cache".to_string()),
2296 sharing: CacheSharing::Shared,
2297 readonly: false,
2298 }],
2299 network: None,
2300 security: None,
2301 env: HashMap::new(),
2302 });
2303
2304 let cmds = BuildahCommand::from_instruction("container-1", &instruction);
2305 assert_eq!(cmds.len(), 1);
2306
2307 let cmd = &cmds[0];
2308 assert!(
2309 cmd.args.iter().any(|a| a.starts_with("--mount=")),
2310 "should include --mount argument"
2311 );
2312 }
2313
2314 #[test]
2315 fn test_run_with_mounts_exec_form() {
2316 use crate::dockerfile::{CacheSharing, RunMount};
2317
2318 let run = RunInstruction {
2319 command: ShellOrExec::Exec(vec![
2320 "pip".to_string(),
2321 "install".to_string(),
2322 "-r".to_string(),
2323 "requirements.txt".to_string(),
2324 ]),
2325 mounts: vec![RunMount::Cache {
2326 target: "/root/.cache/pip".to_string(),
2327 id: Some("pip-cache".to_string()),
2328 sharing: CacheSharing::Shared,
2329 readonly: false,
2330 }],
2331 network: None,
2332 security: None,
2333 env: HashMap::new(),
2334 };
2335
2336 let cmd = BuildahCommand::run_with_mounts("container-1", &run);
2337
2338 assert!(cmd.args.contains(&"--".to_string()));
2340 assert!(cmd.args.contains(&"pip".to_string()));
2341 assert!(cmd.args.contains(&"install".to_string()));
2342 }
2343
2344 #[test]
2345 fn test_run_with_mounts_emits_env_flags_sorted() {
2346 let mut env = HashMap::new();
2351 env.insert("B".to_string(), "2".to_string());
2352 env.insert("A".to_string(), "1".to_string());
2353
2354 let run = RunInstruction {
2355 command: ShellOrExec::Shell("env".to_string()),
2356 mounts: vec![],
2357 network: None,
2358 security: None,
2359 env,
2360 };
2361
2362 let cmd = BuildahCommand::run_with_mounts("container-1", &run);
2363
2364 let env_positions: Vec<(usize, &String)> = cmd
2366 .args
2367 .iter()
2368 .enumerate()
2369 .filter(|(_, a)| a.starts_with("--env="))
2370 .collect();
2371 assert_eq!(
2372 env_positions.len(),
2373 2,
2374 "expected 2 --env args, got {env_positions:?}"
2375 );
2376 assert_eq!(env_positions[0].1, "--env=A=1");
2377 assert_eq!(env_positions[1].1, "--env=B=2");
2378
2379 let container_idx = cmd
2381 .args
2382 .iter()
2383 .position(|a| a == "container-1")
2384 .expect("container ID present");
2385 for (idx, _) in &env_positions {
2386 assert!(
2387 *idx < container_idx,
2388 "--env at {idx} must precede container ID at {container_idx}"
2389 );
2390 }
2391
2392 let sep_idx = cmd
2394 .args
2395 .iter()
2396 .position(|a| a == "--")
2397 .expect("-- separator present");
2398 for (idx, _) in &env_positions {
2399 assert!(
2400 *idx < sep_idx,
2401 "--env at {idx} must precede `--` at {sep_idx}"
2402 );
2403 }
2404 }
2405
2406 #[test]
2407 fn test_translator_routes_env_only_run_through_mounts_path() {
2408 let mut env = HashMap::new();
2412 env.insert("FOO".to_string(), "bar".to_string());
2413
2414 let run = RunInstruction {
2415 command: ShellOrExec::Shell("echo $FOO".to_string()),
2416 mounts: vec![],
2417 network: None,
2418 security: None,
2419 env,
2420 };
2421
2422 let cmds = DockerfileTranslator::new(ImageOs::Linux)
2423 .translate("container-1", &Instruction::Run(run));
2424 assert_eq!(cmds.len(), 1);
2425
2426 let cmd = &cmds[0];
2427 assert!(
2428 cmd.args.iter().any(|a| a == "--env=FOO=bar"),
2429 "expected --env=FOO=bar in args: {:?}",
2430 cmd.args
2431 );
2432 }
2433
2434 #[test]
2445 fn test_run_with_mounts_emits_net_host_before_container() {
2446 let run = RunInstruction {
2447 command: ShellOrExec::Shell("apt-get update".to_string()),
2448 mounts: vec![],
2449 network: Some(crate::dockerfile::RunNetwork::Host),
2450 security: None,
2451 env: HashMap::new(),
2452 };
2453
2454 let cmd = BuildahCommand::run_with_mounts("container-1", &run);
2455
2456 let net_idx = cmd
2457 .args
2458 .iter()
2459 .position(|a| a == "--net=host")
2460 .expect("expected --net=host arg");
2461 let container_idx = cmd
2462 .args
2463 .iter()
2464 .position(|a| a == "container-1")
2465 .expect("container id present");
2466 let sep_idx = cmd
2467 .args
2468 .iter()
2469 .position(|a| a == "--")
2470 .expect("-- separator present");
2471 assert!(
2472 net_idx < container_idx,
2473 "--net=host (idx {net_idx}) must precede container ID (idx {container_idx})"
2474 );
2475 assert!(
2476 net_idx < sep_idx,
2477 "--net=host (idx {net_idx}) must precede `--` (idx {sep_idx})"
2478 );
2479 }
2480
2481 #[test]
2482 fn test_run_with_mounts_emits_net_none() {
2483 let run = RunInstruction {
2484 command: ShellOrExec::Shell("hostname".to_string()),
2485 mounts: vec![],
2486 network: Some(crate::dockerfile::RunNetwork::None),
2487 security: None,
2488 env: HashMap::new(),
2489 };
2490
2491 let cmd = BuildahCommand::run_with_mounts("container-1", &run);
2492 assert!(
2493 cmd.args.iter().any(|a| a == "--net=none"),
2494 "expected --net=none in args, got: {:?}",
2495 cmd.args
2496 );
2497 }
2498
2499 #[test]
2500 fn test_run_with_mounts_default_network_omits_net_flag() {
2501 let run = RunInstruction {
2502 command: ShellOrExec::Shell("true".to_string()),
2503 mounts: vec![],
2504 network: Some(crate::dockerfile::RunNetwork::Default),
2505 security: None,
2506 env: HashMap::new(),
2507 };
2508
2509 let cmd = BuildahCommand::run_with_mounts("container-1", &run);
2510 assert!(
2511 !cmd.args.iter().any(|a| a.starts_with("--net")),
2512 "RunNetwork::Default must NOT emit any --net flag, got: {:?}",
2513 cmd.args
2514 );
2515 }
2516
2517 #[test]
2518 fn test_run_with_mounts_no_network_field_omits_net_flag() {
2519 let run = RunInstruction {
2520 command: ShellOrExec::Shell("true".to_string()),
2521 mounts: vec![],
2522 network: None,
2523 security: None,
2524 env: HashMap::new(),
2525 };
2526
2527 let cmd = BuildahCommand::run_with_mounts("container-1", &run);
2528 assert!(
2529 !cmd.args.iter().any(|a| a.starts_with("--net")),
2530 "network=None must NOT emit any --net flag, got: {:?}",
2531 cmd.args
2532 );
2533 }
2534
2535 #[test]
2536 fn test_translator_host_network_forces_net_host_on_run_with_none_network() {
2537 let run = RunInstruction {
2541 command: ShellOrExec::Shell("apt-get update".to_string()),
2542 mounts: vec![],
2543 network: None,
2544 security: None,
2545 env: HashMap::new(),
2546 };
2547
2548 let cmds = DockerfileTranslator::new(ImageOs::Linux)
2549 .with_host_network(true)
2550 .translate("c1", &Instruction::Run(run));
2551
2552 assert_eq!(cmds.len(), 1, "expected exactly one buildah command");
2553 let cmd = &cmds[0];
2554 assert!(
2555 cmd.args.iter().any(|a| a == "--net=host"),
2556 "expected --net=host in args (host_network=true should force it even when run.network is None), got: {:?}",
2557 cmd.args
2558 );
2559 }
2560
2561 #[test]
2562 fn test_translator_host_network_overrides_per_instruction_network_none() {
2563 let run = RunInstruction {
2565 command: ShellOrExec::Shell("apt-get install -y curl".to_string()),
2566 mounts: vec![],
2567 network: Some(crate::dockerfile::RunNetwork::None),
2568 security: None,
2569 env: HashMap::new(),
2570 };
2571
2572 let cmds = DockerfileTranslator::new(ImageOs::Linux)
2573 .with_host_network(true)
2574 .translate("c1", &Instruction::Run(run));
2575
2576 assert_eq!(cmds.len(), 1);
2577 let cmd = &cmds[0];
2578 assert!(
2579 cmd.args.iter().any(|a| a == "--net=host"),
2580 "host_network=true must override RunNetwork::None, got: {:?}",
2581 cmd.args
2582 );
2583 assert!(
2584 !cmd.args.iter().any(|a| a == "--net=none"),
2585 "host_network=true must REPLACE (not append to) RunNetwork::None, got: {:?}",
2586 cmd.args
2587 );
2588 }
2589
2590 #[test]
2591 fn test_translator_host_network_routes_bare_run_through_mounts_path() {
2592 let run = RunInstruction {
2596 command: ShellOrExec::Shell("echo hi".to_string()),
2597 mounts: vec![],
2598 network: None,
2599 security: None,
2600 env: HashMap::new(),
2601 };
2602
2603 let cmds = DockerfileTranslator::new(ImageOs::Linux)
2604 .with_host_network(true)
2605 .translate("c1", &Instruction::Run(run));
2606
2607 assert_eq!(cmds.len(), 1);
2608 let cmd = &cmds[0];
2609 assert!(
2610 cmd.args.iter().any(|a| a == "--net=host"),
2611 "bare RUN with host_network=true must emit --net=host, got: {:?}",
2612 cmd.args
2613 );
2614 }
2615
2616 #[test]
2617 fn test_translator_host_network_routes_env_only_run_with_net_host() {
2618 let mut env = HashMap::new();
2621 env.insert("FOO".to_string(), "bar".to_string());
2622
2623 let run = RunInstruction {
2624 command: ShellOrExec::Shell("echo $FOO".to_string()),
2625 mounts: vec![],
2626 network: None,
2627 security: None,
2628 env,
2629 };
2630
2631 let cmds = DockerfileTranslator::new(ImageOs::Linux)
2632 .with_host_network(true)
2633 .translate("c1", &Instruction::Run(run));
2634
2635 assert_eq!(cmds.len(), 1);
2636 let cmd = &cmds[0];
2637
2638 let env_idx = cmd
2639 .args
2640 .iter()
2641 .position(|a| a == "--env=FOO=bar")
2642 .expect("--env=FOO=bar present");
2643 let net_idx = cmd
2644 .args
2645 .iter()
2646 .position(|a| a == "--net=host")
2647 .expect("--net=host present");
2648 let container_idx = cmd
2649 .args
2650 .iter()
2651 .position(|a| a == "c1")
2652 .expect("container id present");
2653 assert!(env_idx < container_idx);
2654 assert!(net_idx < container_idx);
2655 }
2656
2657 #[test]
2658 fn test_translator_host_network_routes_mount_only_run_with_net_host() {
2659 use crate::dockerfile::{CacheSharing, RunMount};
2660
2661 let run = RunInstruction {
2662 command: ShellOrExec::Shell("npm install".to_string()),
2663 mounts: vec![RunMount::Cache {
2664 target: "/root/.npm".to_string(),
2665 id: Some("npm-cache".to_string()),
2666 sharing: CacheSharing::Shared,
2667 readonly: false,
2668 }],
2669 network: None,
2670 security: None,
2671 env: HashMap::new(),
2672 };
2673
2674 let cmds = DockerfileTranslator::new(ImageOs::Linux)
2675 .with_host_network(true)
2676 .translate("c1", &Instruction::Run(run));
2677
2678 assert_eq!(cmds.len(), 1);
2679 let cmd = &cmds[0];
2680 assert!(
2681 cmd.args.iter().any(|a| a.starts_with("--mount=")),
2682 "--mount must be present"
2683 );
2684 assert!(
2685 cmd.args.iter().any(|a| a == "--net=host"),
2686 "--net=host must be present alongside --mount when host_network=true"
2687 );
2688 }
2689
2690 #[test]
2691 fn test_translator_host_network_default_off_does_not_emit_net_flag() {
2692 let run = RunInstruction {
2696 command: ShellOrExec::Shell("true".to_string()),
2697 mounts: vec![],
2698 network: None,
2699 security: None,
2700 env: HashMap::new(),
2701 };
2702
2703 let cmds =
2704 DockerfileTranslator::new(ImageOs::Linux).translate("c1", &Instruction::Run(run));
2705 assert_eq!(cmds.len(), 1);
2706 let cmd = &cmds[0];
2707 assert!(
2708 !cmd.args.iter().any(|a| a.starts_with("--net")),
2709 "default translator (host_network=false) must NOT emit --net flag, got: {:?}",
2710 cmd.args
2711 );
2712 }
2713
2714 #[test]
2715 fn test_manifest_create() {
2716 let cmd = BuildahCommand::manifest_create("myapp:latest");
2717 assert_eq!(cmd.program, "buildah");
2718 assert_eq!(cmd.args, vec!["manifest", "create", "myapp:latest"]);
2719 }
2720
2721 #[test]
2722 fn test_manifest_add() {
2723 let cmd = BuildahCommand::manifest_add("myapp:latest", "myapp-amd64:latest");
2724 assert_eq!(
2725 cmd.args,
2726 vec!["manifest", "add", "myapp:latest", "myapp-amd64:latest"]
2727 );
2728 }
2729
2730 #[test]
2731 fn test_manifest_push() {
2732 let cmd =
2733 BuildahCommand::manifest_push("myapp:latest", "docker://registry.example.com/myapp");
2734 assert_eq!(
2735 cmd.args,
2736 vec![
2737 "manifest",
2738 "push",
2739 "--all",
2740 "myapp:latest",
2741 "docker://registry.example.com/myapp"
2742 ]
2743 );
2744 }
2745
2746 #[test]
2747 fn test_manifest_rm() {
2748 let cmd = BuildahCommand::manifest_rm("myapp:latest");
2749 assert_eq!(cmd.args, vec!["manifest", "rm", "myapp:latest"]);
2750 }
2751
2752 #[test]
2753 fn test_rmi_force() {
2754 let cmd = BuildahCommand::rmi_force("myapp:latest");
2757 assert_eq!(cmd.program, "buildah");
2758 assert_eq!(cmd.args, vec!["rmi", "-f", "myapp:latest"]);
2759 }
2760
2761 #[test]
2762 fn test_push_with_creds_orders_creds_before_image() {
2763 let cmd = BuildahCommand::push_with_creds("forge.example.com/app:ci", Some("user:token"));
2766 assert_eq!(
2767 cmd.args,
2768 vec!["push", "--creds", "user:token", "forge.example.com/app:ci",]
2769 );
2770 let creds_idx = cmd.args.iter().position(|a| a == "--creds").unwrap();
2771 let image_idx = cmd
2772 .args
2773 .iter()
2774 .position(|a| a == "forge.example.com/app:ci")
2775 .unwrap();
2776 assert!(creds_idx < image_idx, "--creds must precede the image ref");
2777 }
2778
2779 #[test]
2780 fn test_push_with_creds_none_is_plain_push() {
2781 let cmd = BuildahCommand::push_with_creds("forge.example.com/app:ci", None);
2782 assert_eq!(cmd.args, vec!["push", "forge.example.com/app:ci"]);
2783 }
2784
2785 #[test]
2786 fn test_manifest_push_with_creds_orders_flags_before_positionals() {
2787 let cmd = BuildahCommand::manifest_push_with_creds(
2788 "myapp:latest",
2789 "docker://forge.example.com/myapp:ci",
2790 Some("user:token"),
2791 );
2792 assert_eq!(
2793 cmd.args,
2794 vec![
2795 "manifest",
2796 "push",
2797 "--all",
2798 "--creds",
2799 "user:token",
2800 "myapp:latest",
2801 "docker://forge.example.com/myapp:ci",
2802 ]
2803 );
2804 let creds_idx = cmd.args.iter().position(|a| a == "--creds").unwrap();
2805 let list_idx = cmd.args.iter().position(|a| a == "myapp:latest").unwrap();
2806 assert!(
2807 creds_idx < list_idx,
2808 "--creds must precede the manifest list name"
2809 );
2810 }
2811
2812 #[test]
2813 fn test_manifest_push_with_creds_none_matches_plain() {
2814 let cmd = BuildahCommand::manifest_push_with_creds(
2815 "myapp:latest",
2816 "docker://forge.example.com/myapp:ci",
2817 None,
2818 );
2819 assert_eq!(
2820 cmd.args,
2821 vec![
2822 "manifest",
2823 "push",
2824 "--all",
2825 "myapp:latest",
2826 "docker://forge.example.com/myapp:ci",
2827 ]
2828 );
2829 }
2830
2831 #[test]
2836 fn test_run_shell_for_os_linux() {
2837 let cmd = BuildahCommand::run_shell_for_os("c1", "echo hello", ImageOs::Linux);
2838 assert_eq!(
2839 cmd.args,
2840 vec!["run", "c1", "--", "/bin/sh", "-c", "echo hello"]
2841 );
2842 }
2843
2844 #[test]
2845 fn test_run_shell_for_os_windows() {
2846 let cmd = BuildahCommand::run_shell_for_os("c1", "echo hello", ImageOs::Windows);
2847 assert_eq!(
2848 cmd.args,
2849 vec!["run", "c1", "--", "cmd.exe", "/S", "/C", "echo hello"]
2850 );
2851 }
2852
2853 #[test]
2854 fn test_run_shell_for_os_darwin_matches_linux() {
2855 let darwin = BuildahCommand::run_shell_for_os("c1", "echo hello", ImageOs::Darwin);
2859 let linux = BuildahCommand::run_shell_for_os("c1", "echo hello", ImageOs::Linux);
2860 assert_eq!(darwin.args, linux.args);
2861 assert_eq!(
2862 darwin.args,
2863 vec!["run", "c1", "--", "/bin/sh", "-c", "echo hello"]
2864 );
2865
2866 assert_eq!(
2868 DockerfileTranslator::new(ImageOs::Darwin).active_shell(),
2869 DockerfileTranslator::new(ImageOs::Linux).active_shell(),
2870 );
2871 }
2872
2873 #[test]
2874 fn test_run_shell_custom_powershell() {
2875 let shell = ["powershell", "-Command"];
2876 let cmd = BuildahCommand::run_shell_custom("c1", shell, "Get-Process");
2877 assert_eq!(
2878 cmd.args,
2879 vec!["run", "c1", "--", "powershell", "-Command", "Get-Process"]
2880 );
2881 }
2882
2883 #[test]
2884 fn test_translator_linux_run_shell_default() {
2885 let mut t = DockerfileTranslator::new(ImageOs::Linux);
2886 let instr = Instruction::Run(RunInstruction::shell("apt-get update"));
2887 let cmds = t.translate("c1", &instr);
2888 assert_eq!(cmds.len(), 1);
2889 assert_eq!(
2890 cmds[0].args,
2891 vec!["run", "c1", "--", "/bin/sh", "-c", "apt-get update"]
2892 );
2893 }
2894
2895 #[test]
2896 fn test_translator_windows_run_shell_default() {
2897 let mut t = DockerfileTranslator::new(ImageOs::Windows);
2898 let instr = Instruction::Run(RunInstruction::shell("dir C:\\"));
2899 let cmds = t.translate("c1", &instr);
2900 assert_eq!(cmds.len(), 1);
2901 assert_eq!(
2902 cmds[0].args,
2903 vec!["run", "c1", "--", "cmd.exe", "/S", "/C", "dir C:\\"]
2904 );
2905 }
2906
2907 #[test]
2908 fn test_translator_shell_override_linux_bash() {
2909 let mut t = DockerfileTranslator::new(ImageOs::Linux);
2911
2912 let shell_instr = Instruction::Shell(vec!["/bin/bash".to_string(), "-lc".to_string()]);
2913 let shell_cmds = t.translate("c1", &shell_instr);
2914 assert_eq!(shell_cmds.len(), 1);
2916 assert!(shell_cmds[0].args.contains(&"--shell".to_string()));
2917
2918 let run_instr = Instruction::Run(RunInstruction::shell("set -e; echo $SHELL"));
2919 let run_cmds = t.translate("c1", &run_instr);
2920 assert_eq!(run_cmds.len(), 1);
2921 assert_eq!(
2922 run_cmds[0].args,
2923 vec!["run", "c1", "--", "/bin/bash", "-lc", "set -e; echo $SHELL"]
2924 );
2925 }
2926
2927 #[test]
2928 fn test_translator_shell_override_windows_powershell() {
2929 let mut t = DockerfileTranslator::new(ImageOs::Windows);
2932
2933 let shell_instr =
2934 Instruction::Shell(vec!["powershell".to_string(), "-Command".to_string()]);
2935 t.translate("c1", &shell_instr);
2936
2937 let run_instr = Instruction::Run(RunInstruction::shell("Get-Process"));
2938 let run_cmds = t.translate("c1", &run_instr);
2939 assert_eq!(run_cmds.len(), 1);
2940 assert_eq!(
2941 run_cmds[0].args,
2942 vec!["run", "c1", "--", "powershell", "-Command", "Get-Process"]
2943 );
2944 }
2945
2946 #[test]
2947 fn test_translator_shell_override_persists_across_runs() {
2948 let mut t = DockerfileTranslator::new(ImageOs::Linux);
2950 t.translate(
2951 "c1",
2952 &Instruction::Shell(vec!["/bin/bash".to_string(), "-c".to_string()]),
2953 );
2954
2955 for _ in 0..2 {
2956 let cmds = t.translate("c1", &Instruction::Run(RunInstruction::shell("echo hi")));
2957 assert_eq!(
2958 cmds[0].args,
2959 vec!["run", "c1", "--", "/bin/bash", "-c", "echo hi"]
2960 );
2961 }
2962 }
2963
2964 #[test]
2965 fn test_translator_exec_form_ignores_shell_override() {
2966 let mut t = DockerfileTranslator::new(ImageOs::Windows);
2969 t.translate(
2970 "c1",
2971 &Instruction::Shell(vec!["powershell".to_string(), "-Command".to_string()]),
2972 );
2973
2974 let run = Instruction::Run(RunInstruction::exec(vec![
2975 "myapp.exe".to_string(),
2976 "--flag".to_string(),
2977 ]));
2978 let cmds = t.translate("c1", &run);
2979 assert_eq!(cmds[0].args, vec!["run", "c1", "--", "myapp.exe", "--flag"]);
2980 }
2981
2982 #[test]
2983 fn test_translator_workdir_linux() {
2984 let mut t = DockerfileTranslator::new(ImageOs::Linux);
2985 let cmds = t.translate("c1", &Instruction::Workdir("/app".to_string()));
2986 assert_eq!(cmds.len(), 2);
2987 assert_eq!(cmds[0].args, vec!["run", "c1", "--", "mkdir", "-p", "/app"]);
2988 assert_eq!(cmds[1].args, vec!["config", "--workingdir", "/app", "c1"]);
2989 }
2990
2991 #[test]
2992 fn test_translator_workdir_linux_with_empty_src_dir() {
2993 let empty = std::path::PathBuf::from("/tmp/zlayer-empty-test");
2999 let mut t = DockerfileTranslator::new(ImageOs::Linux).with_empty_src_dir(empty);
3000 let cmds = t.translate("c1", &Instruction::Workdir("/app".to_string()));
3001 assert_eq!(cmds.len(), 2);
3002 assert_eq!(
3003 cmds[0].args,
3004 vec!["copy", "c1", "/tmp/zlayer-empty-test/.", "/app"],
3005 "WORKDIR with empty_src_dir must emit `buildah copy <empty>/. <dir>` to materialize the dir without running a shell",
3006 );
3007 assert_eq!(cmds[1].args, vec!["config", "--workingdir", "/app", "c1"]);
3008 }
3009
3010 #[test]
3011 fn test_translator_workdir_linux_with_empty_src_dir_ignores_host_network() {
3012 let empty = std::path::PathBuf::from("/var/tmp/empty");
3016 let mut t = DockerfileTranslator::new(ImageOs::Linux)
3017 .with_host_network(true)
3018 .with_empty_src_dir(empty);
3019 let cmds = t.translate("c1", &Instruction::Workdir("/app".to_string()));
3020 assert_eq!(cmds.len(), 2);
3021 assert!(
3022 !cmds[0].args.iter().any(|a| a.starts_with("--net")),
3023 "buildah copy must never carry --net flags, got: {:?}",
3024 cmds[0].args
3025 );
3026 assert_eq!(cmds[0].args[0], "copy");
3027 assert_eq!(cmds[1].args, vec!["config", "--workingdir", "/app", "c1"]);
3028 }
3029
3030 #[test]
3031 fn test_translator_workdir_windows() {
3032 let mut t = DockerfileTranslator::new(ImageOs::Windows);
3033 let cmds = t.translate("c1", &Instruction::Workdir("C:\\app".to_string()));
3034 assert_eq!(cmds.len(), 2);
3035 assert_eq!(
3038 cmds[0].args,
3039 vec![
3040 "run",
3041 "c1",
3042 "--",
3043 "cmd.exe",
3044 "/S",
3045 "/C",
3046 r#"if not exist "C:\app" mkdir "C:\app""#
3047 ]
3048 );
3049 assert_eq!(
3050 cmds[1].args,
3051 vec!["config", "--workingdir", "C:\\app", "c1"]
3052 );
3053 }
3054
3055 #[test]
3056 fn test_translator_workdir_host_network_linux_emits_net_host() {
3057 let mut t = DockerfileTranslator::new(ImageOs::Linux).with_host_network(true);
3066 let cmds = t.translate("c1", &Instruction::Workdir("/app".to_string()));
3067 assert_eq!(cmds.len(), 2);
3068 assert_eq!(
3069 cmds[0].args,
3070 vec!["run", "--net=host", "c1", "--", "mkdir", "-p", "/app"],
3071 "WORKDIR mkdir with host_network=true must emit --net=host BEFORE the container ID",
3072 );
3073 assert_eq!(cmds[1].args, vec!["config", "--workingdir", "/app", "c1"]);
3076 }
3077
3078 #[test]
3079 fn test_translator_workdir_no_host_network_omits_net_flag() {
3080 let mut t = DockerfileTranslator::new(ImageOs::Linux).with_host_network(false);
3086 let cmds = t.translate("c1", &Instruction::Workdir("/app".to_string()));
3087 assert_eq!(cmds.len(), 2);
3088 assert!(
3089 !cmds[0].args.iter().any(|a| a.starts_with("--net")),
3090 "WORKDIR with host_network=false must NOT emit any --net flag, got: {:?}",
3091 cmds[0].args
3092 );
3093 assert_eq!(cmds[0].args, vec!["run", "c1", "--", "mkdir", "-p", "/app"]);
3094 }
3095
3096 #[test]
3097 fn test_translator_workdir_host_network_windows_emits_net_host() {
3098 let mut t = DockerfileTranslator::new(ImageOs::Windows).with_host_network(true);
3104 let cmds = t.translate("c1", &Instruction::Workdir("C:\\app".to_string()));
3105 assert_eq!(cmds.len(), 2);
3106 let net_idx = cmds[0]
3107 .args
3108 .iter()
3109 .position(|a| a == "--net=host")
3110 .expect("expected --net=host on Windows WORKDIR with host_network=true");
3111 let container_idx = cmds[0]
3112 .args
3113 .iter()
3114 .position(|a| a == "c1")
3115 .expect("container ID present");
3116 let sep_idx = cmds[0]
3117 .args
3118 .iter()
3119 .position(|a| a == "--")
3120 .expect("`--` separator present");
3121 assert!(
3122 net_idx < container_idx && container_idx < sep_idx,
3123 "argument order must be: run --net=host <container> -- ... (got {:?})",
3124 cmds[0].args
3125 );
3126 }
3127
3128 #[test]
3129 fn test_translator_workdir_windows_path_with_spaces() {
3130 let mut t = DockerfileTranslator::new(ImageOs::Windows);
3134 let cmds = t.translate(
3135 "c1",
3136 &Instruction::Workdir("C:\\Program Files\\app".to_string()),
3137 );
3138 assert_eq!(cmds.len(), 2);
3139 let mkdir_cmd = &cmds[0].args[6];
3140 assert_eq!(
3141 mkdir_cmd,
3142 r#"if not exist "C:\Program Files\app" mkdir "C:\Program Files\app""#
3143 );
3144 }
3145
3146 #[test]
3147 fn test_from_instruction_preserves_linux_byte_identical_output() {
3148 let run = Instruction::Run(RunInstruction::shell("echo hello"));
3153 let legacy = BuildahCommand::from_instruction("c1", &run);
3154 let via_translator = DockerfileTranslator::new(ImageOs::Linux).translate("c1", &run);
3155 assert_eq!(legacy.len(), via_translator.len());
3156 for (a, b) in legacy.iter().zip(via_translator.iter()) {
3157 assert_eq!(a.args, b.args);
3158 assert_eq!(a.program, b.program);
3159 }
3160
3161 let workdir = Instruction::Workdir("/workspace".to_string());
3163 let legacy = BuildahCommand::from_instruction("c1", &workdir);
3164 assert_eq!(legacy.len(), 2);
3165 assert_eq!(
3166 legacy[0].args,
3167 vec!["run", "c1", "--", "mkdir", "-p", "/workspace"]
3168 );
3169 assert_eq!(
3170 legacy[1].args,
3171 vec!["config", "--workingdir", "/workspace", "c1"]
3172 );
3173 }
3174
3175 #[test]
3176 fn test_translator_active_shell_reflects_override() {
3177 let mut t = DockerfileTranslator::new(ImageOs::Linux);
3178 assert_eq!(t.active_shell(), vec!["/bin/sh", "-c"]);
3179
3180 t.set_shell_override(vec!["/bin/bash".to_string(), "-lc".to_string()]);
3181 assert_eq!(t.active_shell(), vec!["/bin/bash", "-lc"]);
3182 }
3183
3184 #[test]
3185 fn test_translator_target_os_accessor() {
3186 assert_eq!(
3187 DockerfileTranslator::new(ImageOs::Linux).target_os(),
3188 ImageOs::Linux
3189 );
3190 assert_eq!(
3191 DockerfileTranslator::new(ImageOs::Windows).target_os(),
3192 ImageOs::Windows
3193 );
3194 }
3195
3196 #[test]
3197 fn test_translator_windows_run_with_mounts_uses_cmd_exe() {
3198 use crate::dockerfile::{CacheSharing, RunMount};
3199
3200 let mut t = DockerfileTranslator::new(ImageOs::Windows);
3201 let run = RunInstruction {
3202 command: ShellOrExec::Shell("echo cached".to_string()),
3203 mounts: vec![RunMount::Cache {
3204 target: "C:\\cache".to_string(),
3205 id: Some("win-cache".to_string()),
3206 sharing: CacheSharing::Shared,
3207 readonly: false,
3208 }],
3209 network: None,
3210 security: None,
3211 env: HashMap::new(),
3212 };
3213
3214 let cmds = t.translate("c1", &Instruction::Run(run));
3215 assert_eq!(cmds.len(), 1);
3216
3217 let mount_idx = cmds[0]
3219 .args
3220 .iter()
3221 .position(|a| a.starts_with("--mount="))
3222 .expect("mount arg present");
3223 let container_idx = cmds[0]
3224 .args
3225 .iter()
3226 .position(|a| a == "c1")
3227 .expect("container ID present");
3228 assert!(mount_idx < container_idx);
3229
3230 assert!(cmds[0].args.iter().any(|a| a == "cmd.exe"));
3232 assert!(cmds[0].args.iter().any(|a| a == "/S"));
3233 assert!(cmds[0].args.iter().any(|a| a == "/C"));
3234 assert!(!cmds[0].args.iter().any(|a| a == "/bin/sh"));
3235 }
3236
3237 use crate::windows_image_resolver::{ChocoMapMetadata, ChocoMapShard};
3242
3243 static CACHE_ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
3247
3248 fn write_shard_fixture(
3251 cache_root: &std::path::Path,
3252 distro: &str,
3253 shard: &str,
3254 mappings: &[(&str, &str)],
3255 ) {
3256 let fixture = ChocoMapShard {
3257 metadata: ChocoMapMetadata {
3258 generated_at: "2026-05-21T00:00:00Z".to_string(),
3259 source: "chocolatey.org".to_string(),
3260 distro: distro.to_string(),
3261 shard: shard.to_string(),
3262 total_mappings: mappings.len() as u64,
3263 },
3264 mappings: mappings
3265 .iter()
3266 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
3267 .collect(),
3268 };
3269 let shard_dir = cache_root.join("package-maps-choco-v1").join(distro);
3270 std::fs::create_dir_all(&shard_dir).unwrap();
3271 std::fs::write(
3272 shard_dir.join(format!("{shard}.json")),
3273 serde_json::to_string(&fixture).unwrap(),
3274 )
3275 .unwrap();
3276 }
3277
3278 fn redirect_cache_dir() -> (
3284 std::sync::MutexGuard<'static, ()>,
3285 tempfile::TempDir,
3286 std::path::PathBuf,
3287 ) {
3288 let guard = CACHE_ENV_GUARD
3293 .lock()
3294 .unwrap_or_else(std::sync::PoisonError::into_inner);
3295 let tmp = tempfile::tempdir().unwrap();
3296 let cache_root = tmp.path().to_path_buf();
3297 std::env::set_var("ZLAYER_PACKAGE_MAP_CACHE_DIR", &cache_root);
3302 std::env::set_var("XDG_CACHE_HOME", &cache_root);
3303 std::env::set_var("LOCALAPPDATA", &cache_root);
3304 std::env::set_var("ZLAYER_WINDOWS_DISCOVER_DISABLE", "1");
3308 (guard, tmp, cache_root)
3309 }
3310
3311 fn block_on<F: std::future::Future>(fut: F) -> F::Output {
3312 tokio::runtime::Builder::new_current_thread()
3313 .enable_all()
3314 .build()
3315 .expect("runtime")
3316 .block_on(fut)
3317 }
3318
3319 #[test]
3320 fn detect_apt_install_in_run() {
3321 let parts = split_shell_subcommands("apt-get update && apt-get install -y curl git");
3322 assert_eq!(parts.len(), 2);
3323 assert!(is_package_manager_sync(&parts[0]));
3324 let detected = detect_install_in_subcommand(&parts[1])
3325 .expect("install sub-command must be recognised");
3326 assert_eq!(detected.0, DetectedPmKind::Apt);
3327 assert_eq!(detected.1, vec!["curl".to_string(), "git".to_string()]);
3328 }
3329
3330 #[test]
3331 fn detect_yum_install_in_run() {
3332 let detected = detect_install_in_subcommand("yum install -y httpd")
3333 .expect("yum install -y httpd must be recognised");
3334 assert_eq!(detected.0, DetectedPmKind::YumOrDnf);
3335 assert_eq!(detected.1, vec!["httpd".to_string()]);
3336
3337 let detected = detect_install_in_subcommand("dnf install -y nginx php-fpm")
3338 .expect("dnf install -y must be recognised");
3339 assert_eq!(detected.0, DetectedPmKind::YumOrDnf);
3340 assert_eq!(detected.1, vec!["nginx".to_string(), "php-fpm".to_string()]);
3341 }
3342
3343 #[test]
3344 fn detect_apk_install_in_run() {
3345 let detected = detect_install_in_subcommand("apk add --no-cache nodejs npm")
3346 .expect("apk add must be recognised");
3347 assert_eq!(detected.0, DetectedPmKind::Apk);
3348 assert_eq!(detected.1, vec!["nodejs".to_string(), "npm".to_string()]);
3349 }
3350
3351 #[test]
3352 fn detect_no_install_returns_none() {
3353 assert!(detect_install_in_subcommand("echo hello").is_none());
3354 assert!(detect_install_in_subcommand("ls /tmp").is_none());
3355 assert!(detect_install_in_subcommand("apt-getinstall -y curl").is_none());
3356 assert!(detect_install_in_subcommand("apt-get install -y").is_none());
3357 let parts = split_shell_subcommands("echo hello && ls /tmp");
3358 assert_eq!(parts.len(), 2);
3359 for p in &parts {
3360 assert!(detect_install_in_subcommand(p).is_none());
3361 assert!(!is_package_manager_sync(p));
3362 }
3363 }
3364
3365 #[test]
3366 fn split_shell_subcommands_honours_and_and_semicolon() {
3367 let parts = split_shell_subcommands("a && b ; c");
3368 assert_eq!(
3369 parts,
3370 vec!["a".to_string(), "b".to_string(), "c".to_string()]
3371 );
3372 }
3373
3374 #[test]
3375 fn split_shell_subcommands_drops_empty_segments() {
3376 let parts = split_shell_subcommands(" && a && ; b ;");
3377 assert_eq!(parts, vec!["a".to_string(), "b".to_string()]);
3378 }
3379
3380 #[test]
3381 fn is_package_manager_sync_matches_common_variants() {
3382 assert!(is_package_manager_sync("apt-get update"));
3383 assert!(is_package_manager_sync("apt update"));
3384 assert!(is_package_manager_sync("apk update"));
3385 assert!(is_package_manager_sync("yum check-update"));
3386 assert!(is_package_manager_sync("dnf makecache"));
3387 assert!(is_package_manager_sync("sudo apt-get update"));
3388 assert!(!is_package_manager_sync("apt-get install -y curl"));
3389 assert!(!is_package_manager_sync("echo hello"));
3390 }
3391
3392 #[test]
3393 fn rejoin_emits_choco_install_for_install_subcommand() {
3394 let parts = vec![
3395 ShellSubcommand::Verbatim("echo before".to_string()),
3396 ShellSubcommand::PackageManagerSync,
3397 ShellSubcommand::Install {
3398 kind: DetectedPmKind::Apt,
3399 packages: vec!["curl".to_string(), "git".to_string()],
3400 },
3401 ShellSubcommand::Verbatim("echo after".to_string()),
3402 ];
3403 let out = rejoin_subcommands(&parts);
3404 assert_eq!(
3405 out,
3406 "echo before && choco install -y curl git && echo after"
3407 );
3408 }
3409
3410 #[test]
3411 fn wrap_in_cmd_escapes_embedded_quotes() {
3412 let wrapped = wrap_in_cmd(r#"echo "hello""#);
3413 assert!(wrapped.starts_with("cmd /c \""));
3414 assert!(wrapped.contains(r#"\"hello\""#));
3415 assert!(wrapped.ends_with('"'));
3416 }
3417
3418 #[test]
3419 fn translate_run_apt_to_choco_with_in_memory_shard() {
3420 let (_guard, _tmp, cache_root) = redirect_cache_dir();
3423 write_shard_fixture(
3424 &cache_root,
3425 "debian-12",
3426 "c",
3427 &[("curl", "curl"), ("linux-headers-generic", "__skip__")],
3428 );
3429 write_shard_fixture(
3430 &cache_root,
3431 "debian-12",
3432 "l",
3433 &[("linux-headers-generic", "__skip__")],
3434 );
3435
3436 let translator = DockerfileTranslator::new(ImageOs::Windows);
3437 let TranslatedRun {
3438 command_line: rewritten,
3439 skipped_packages: skipped,
3440 ..
3441 } = block_on(translator.translate_shell_command(
3442 "apt-get install -y curl linux-headers-generic",
3443 "debian-12",
3444 None,
3445 ))
3446 .expect("translate succeeds when every package resolves");
3447 assert!(
3448 rewritten.contains("choco install -y curl"),
3449 "rewritten command must include curl: {rewritten}"
3450 );
3451 assert!(
3452 !rewritten.contains("linux-headers-generic"),
3453 "skipped package must NOT appear in rewritten command: {rewritten}"
3454 );
3455 assert_eq!(skipped, vec!["linux-headers-generic".to_string()]);
3456 }
3457
3458 #[test]
3464 fn translate_shell_command_skips_provisioned_toolchain() {
3465 let (_guard, _tmp, cache_root) = redirect_cache_dir();
3470 write_shard_fixture(&cache_root, "debian-12", "g", &[("git", "git")]);
3471
3472 let translator = DockerfileTranslator::new(ImageOs::Windows);
3473 let TranslatedRun {
3474 command_line: rewritten,
3475 skipped_packages: skipped,
3476 ..
3477 } = block_on(translator.translate_shell_command(
3478 "apt-get install -y golang git",
3479 "debian-12",
3480 Some("go"),
3481 ))
3482 .expect("translate succeeds when remaining package resolves");
3483 assert!(
3484 !rewritten.contains("golang"),
3485 "provisioned-toolchain package must NOT appear in choco install: {rewritten}"
3486 );
3487 assert!(
3488 rewritten.contains("choco install -y git"),
3489 "non-toolchain package must still be installed: {rewritten}"
3490 );
3491 assert!(
3492 skipped.is_empty(),
3493 "toolchain drops are not reported as resolver-skipped: {skipped:?}"
3494 );
3495 }
3496
3497 #[test]
3498 fn translate_shell_command_keeps_unrelated_pkg_with_toolchain() {
3499 let (_guard, _tmp, cache_root) = redirect_cache_dir();
3503 write_shard_fixture(&cache_root, "debian-12", "c", &[("curl", "curl")]);
3504
3505 let translator = DockerfileTranslator::new(ImageOs::Windows);
3506 let TranslatedRun {
3507 command_line: rewritten,
3508 skipped_packages: skipped,
3509 ..
3510 } = block_on(translator.translate_shell_command(
3511 "apt-get install -y curl",
3512 "debian-12",
3513 Some("go"),
3514 ))
3515 .expect("translate succeeds");
3516 assert!(
3517 rewritten.contains("choco install -y curl"),
3518 "curl must still be installed: {rewritten}"
3519 );
3520 assert!(skipped.is_empty(), "no resolver-skipped: {skipped:?}");
3521 }
3522
3523 #[test]
3524 fn translate_run_command_linux_is_passthrough() {
3525 let translator = DockerfileTranslator::new(ImageOs::Linux);
3529 let TranslatedRun {
3530 command_line: shell_out,
3531 skipped_packages: skipped,
3532 ..
3533 } = block_on(translator.translate_run_command(
3534 &ShellOrExec::Shell("apt-get install -y curl".to_string()),
3535 "debian-12",
3536 None,
3537 ))
3538 .expect("Linux passthrough never fails");
3539 assert_eq!(shell_out, "apt-get install -y curl");
3540 assert!(skipped.is_empty());
3541
3542 let TranslatedRun {
3543 command_line: exec_out,
3544 skipped_packages: skipped,
3545 ..
3546 } = block_on(translator.translate_run_command(
3547 &ShellOrExec::Exec(vec!["echo".to_string(), "hi".to_string()]),
3548 "debian-12",
3549 None,
3550 ))
3551 .expect("Linux passthrough never fails");
3552 assert_eq!(exec_out, "echo hi");
3553 assert!(skipped.is_empty());
3554 }
3555
3556 #[test]
3557 fn translate_run_command_windows_exec_is_passthrough() {
3558 let translator = DockerfileTranslator::new(ImageOs::Windows);
3562 let TranslatedRun {
3563 command_line: out,
3564 skipped_packages: skipped,
3565 ..
3566 } = block_on(translator.translate_run_command(
3567 &ShellOrExec::Exec(vec![
3568 "C:\\app\\bin\\srv.exe".to_string(),
3569 "--port".to_string(),
3570 "80".to_string(),
3571 ]),
3572 "debian-12",
3573 None,
3574 ))
3575 .expect("exec-form passthrough never fails");
3576 assert_eq!(out, "C:\\app\\bin\\srv.exe --port 80");
3577 assert!(skipped.is_empty());
3578 }
3579
3580 #[test]
3581 fn translate_shell_command_no_toolchain_installs_all() {
3582 let (_guard, _tmp, cache_root) = redirect_cache_dir();
3588 write_shard_fixture(
3589 &cache_root,
3590 "debian-12",
3591 "g",
3592 &[("golang", "golang"), ("git", "git")],
3593 );
3594
3595 let translator = DockerfileTranslator::new(ImageOs::Windows);
3596 let TranslatedRun {
3597 command_line: rewritten,
3598 skipped_packages: skipped,
3599 ..
3600 } = block_on(translator.translate_shell_command(
3601 "apt-get install -y golang git",
3602 "debian-12",
3603 None,
3604 ))
3605 .expect("translate succeeds");
3606 assert!(
3607 rewritten.contains("choco install -y golang git"),
3608 "both packages must be installed: {rewritten}"
3609 );
3610 assert!(skipped.is_empty(), "no resolver-skipped: {skipped:?}");
3611 }
3612}