1mod executor;
26mod install;
27
28pub use executor::*;
29pub use install::{
30 current_platform, install_instructions, is_platform_supported, BuildahInstallation,
31 BuildahInstaller, InstallError,
32};
33
34use crate::backend::ImageOs;
35use crate::dockerfile::{
36 AddInstruction, CopyInstruction, EnvInstruction, ExposeInstruction, HealthcheckInstruction,
37 Instruction, RunInstruction, ShellOrExec,
38};
39
40use std::collections::HashMap;
41
42const LINUX_DEFAULT_SHELL: &[&str] = &["/bin/sh", "-c"];
48
49const WINDOWS_DEFAULT_SHELL: &[&str] = &["cmd.exe", "/S", "/C"];
54
55fn default_shell_for(os: ImageOs) -> Vec<String> {
58 let raw: &[&str] = match os {
59 ImageOs::Linux => LINUX_DEFAULT_SHELL,
60 ImageOs::Windows => WINDOWS_DEFAULT_SHELL,
61 };
62 raw.iter().map(|s| (*s).to_string()).collect()
63}
64
65#[derive(Debug, Clone)]
67pub struct BuildahCommand {
68 pub program: String,
70
71 pub args: Vec<String>,
73
74 pub env: HashMap<String, String>,
76}
77
78impl BuildahCommand {
79 #[must_use]
81 pub fn new(subcommand: &str) -> Self {
82 Self {
83 program: "buildah".to_string(),
84 args: vec![subcommand.to_string()],
85 env: HashMap::new(),
86 }
87 }
88
89 #[must_use]
91 pub fn arg(mut self, arg: impl Into<String>) -> Self {
92 self.args.push(arg.into());
93 self
94 }
95
96 #[must_use]
98 pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
99 self.args.extend(args.into_iter().map(Into::into));
100 self
101 }
102
103 #[must_use]
105 pub fn arg_opt(self, flag: &str, value: Option<impl Into<String>>) -> Self {
106 if let Some(v) = value {
107 self.arg(flag).arg(v)
108 } else {
109 self
110 }
111 }
112
113 #[must_use]
115 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
116 self.env.insert(key.into(), value.into());
117 self
118 }
119
120 #[must_use]
122 pub fn to_command_string(&self) -> String {
123 let mut parts = vec![self.program.clone()];
124 parts.extend(self.args.iter().map(|a| {
125 if a.contains(' ') || a.contains('"') {
126 format!("\"{}\"", a.replace('"', "\\\""))
127 } else {
128 a.clone()
129 }
130 }));
131 parts.join(" ")
132 }
133
134 #[must_use]
142 pub fn from_image(image: &str) -> Self {
143 Self::new("from").arg(image)
144 }
145
146 #[must_use]
150 pub fn from_image_named(image: &str, name: &str) -> Self {
151 Self::new("from").arg("--name").arg(name).arg(image)
152 }
153
154 #[must_use]
158 pub fn from_scratch() -> Self {
159 Self::new("from").arg("scratch")
160 }
161
162 #[must_use]
166 pub fn rm(container: &str) -> Self {
167 Self::new("rm").arg(container)
168 }
169
170 #[must_use]
174 pub fn commit(container: &str, image_name: &str) -> Self {
175 Self::new("commit").arg(container).arg(image_name)
176 }
177
178 #[must_use]
180 pub fn commit_with_opts(
181 container: &str,
182 image_name: &str,
183 format: Option<&str>,
184 squash: bool,
185 ) -> Self {
186 let mut cmd = Self::new("commit");
187
188 if let Some(fmt) = format {
189 cmd = cmd.arg("--format").arg(fmt);
190 }
191
192 if squash {
193 cmd = cmd.arg("--squash");
194 }
195
196 cmd.arg(container).arg(image_name)
197 }
198
199 #[must_use]
203 pub fn tag(image: &str, new_name: &str) -> Self {
204 Self::new("tag").arg(image).arg(new_name)
205 }
206
207 #[must_use]
211 pub fn rmi(image: &str) -> Self {
212 Self::new("rmi").arg(image)
213 }
214
215 #[must_use]
224 pub fn pull(image: &str, policy: Option<&str>) -> Self {
225 let mut cmd = Self::new("pull");
226 if let Some(p) = policy {
227 cmd = cmd.arg("--policy").arg(p);
228 }
229 cmd.arg(image)
230 }
231
232 #[must_use]
236 pub fn push(image: &str) -> Self {
237 Self::new("push").arg(image)
238 }
239
240 #[must_use]
244 pub fn push_to(image: &str, destination: &str) -> Self {
245 Self::new("push").arg(image).arg(destination)
246 }
247
248 #[must_use]
252 pub fn inspect(name: &str) -> Self {
253 Self::new("inspect").arg(name)
254 }
255
256 #[must_use]
260 pub fn inspect_format(name: &str, format: &str) -> Self {
261 Self::new("inspect").arg("--format").arg(format).arg(name)
262 }
263
264 #[must_use]
268 pub fn images() -> Self {
269 Self::new("images")
270 }
271
272 #[must_use]
276 pub fn containers() -> Self {
277 Self::new("containers")
278 }
279
280 #[must_use]
292 pub fn run_shell(container: &str, command: &str) -> Self {
293 Self::run_shell_custom(container, LINUX_DEFAULT_SHELL, command)
294 }
295
296 #[must_use]
304 pub fn run_shell_custom(
305 container: &str,
306 shell: impl IntoIterator<Item = impl AsRef<str>>,
307 command: &str,
308 ) -> Self {
309 let mut cmd = Self::new("run").arg(container).arg("--");
310 for s in shell {
311 cmd = cmd.arg(s.as_ref().to_string());
312 }
313 cmd.arg(command)
314 }
315
316 #[must_use]
320 pub fn run_shell_for_os(container: &str, command: &str, os: ImageOs) -> Self {
321 let shell = default_shell_for(os);
322 Self::run_shell_custom(container, &shell, command)
323 }
324
325 #[must_use]
329 pub fn run_exec(container: &str, args: &[String]) -> Self {
330 let mut cmd = Self::new("run").arg(container).arg("--");
331 for arg in args {
332 cmd = cmd.arg(arg);
333 }
334 cmd
335 }
336
337 #[must_use]
339 pub fn run(container: &str, command: &ShellOrExec) -> Self {
340 match command {
341 ShellOrExec::Shell(s) => Self::run_shell(container, s),
342 ShellOrExec::Exec(args) => Self::run_exec(container, args),
343 }
344 }
345
346 #[must_use]
356 pub fn run_with_mounts(container: &str, run: &RunInstruction) -> Self {
357 Self::run_with_mounts_shell(container, run, LINUX_DEFAULT_SHELL)
358 }
359
360 #[must_use]
366 pub fn run_with_mounts_shell(
367 container: &str,
368 run: &RunInstruction,
369 shell: impl IntoIterator<Item = impl AsRef<str>>,
370 ) -> Self {
371 let mut cmd = Self::new("run");
372
373 for mount in &run.mounts {
375 cmd = cmd.arg(format!("--mount={}", mount.to_buildah_arg()));
376 }
377
378 cmd = cmd.arg(container).arg("--");
380
381 match &run.command {
382 ShellOrExec::Shell(s) => {
383 for part in shell {
384 cmd = cmd.arg(part.as_ref().to_string());
385 }
386 cmd.arg(s)
387 }
388 ShellOrExec::Exec(args) => {
389 for arg in args {
390 cmd = cmd.arg(arg);
391 }
392 cmd
393 }
394 }
395 }
396
397 #[must_use]
405 pub fn copy(container: &str, sources: &[String], dest: &str) -> Self {
406 let mut cmd = Self::new("copy").arg(container);
407 for src in sources {
408 cmd = cmd.arg(src);
409 }
410 cmd.arg(dest)
411 }
412
413 #[must_use]
417 pub fn copy_from(container: &str, from: &str, sources: &[String], dest: &str) -> Self {
418 let mut cmd = Self::new("copy").arg("--from").arg(from).arg(container);
419 for src in sources {
420 cmd = cmd.arg(src);
421 }
422 cmd.arg(dest)
423 }
424
425 #[must_use]
427 pub fn copy_instruction(container: &str, copy: &CopyInstruction) -> Self {
428 let mut cmd = Self::new("copy");
429
430 if let Some(ref from) = copy.from {
431 cmd = cmd.arg("--from").arg(from);
432 }
433
434 if let Some(ref chown) = copy.chown {
435 cmd = cmd.arg("--chown").arg(chown);
436 }
437
438 if let Some(ref chmod) = copy.chmod {
439 cmd = cmd.arg("--chmod").arg(chmod);
440 }
441
442 cmd = cmd.arg(container);
443
444 for src in ©.sources {
445 cmd = cmd.arg(src);
446 }
447
448 cmd.arg(©.destination)
449 }
450
451 #[must_use]
453 pub fn add(container: &str, sources: &[String], dest: &str) -> Self {
454 let mut cmd = Self::new("add").arg(container);
455 for src in sources {
456 cmd = cmd.arg(src);
457 }
458 cmd.arg(dest)
459 }
460
461 #[must_use]
463 pub fn add_instruction(container: &str, add: &AddInstruction) -> Self {
464 let mut cmd = Self::new("add");
465
466 if let Some(ref chown) = add.chown {
467 cmd = cmd.arg("--chown").arg(chown);
468 }
469
470 if let Some(ref chmod) = add.chmod {
471 cmd = cmd.arg("--chmod").arg(chmod);
472 }
473
474 cmd = cmd.arg(container);
475
476 for src in &add.sources {
477 cmd = cmd.arg(src);
478 }
479
480 cmd.arg(&add.destination)
481 }
482
483 #[must_use]
491 pub fn config_env(container: &str, key: &str, value: &str) -> Self {
492 Self::new("config")
493 .arg("--env")
494 .arg(format!("{key}={value}"))
495 .arg(container)
496 }
497
498 #[must_use]
500 pub fn config_envs(container: &str, env: &EnvInstruction) -> Vec<Self> {
501 env.vars
502 .iter()
503 .map(|(k, v)| Self::config_env(container, k, v))
504 .collect()
505 }
506
507 #[must_use]
511 pub fn config_workdir(container: &str, dir: &str) -> Self {
512 Self::new("config")
513 .arg("--workingdir")
514 .arg(dir)
515 .arg(container)
516 }
517
518 #[must_use]
522 pub fn config_expose(container: &str, expose: &ExposeInstruction) -> Self {
523 let port_spec = format!(
524 "{}/{}",
525 expose.port,
526 match expose.protocol {
527 crate::dockerfile::ExposeProtocol::Tcp => "tcp",
528 crate::dockerfile::ExposeProtocol::Udp => "udp",
529 }
530 );
531 Self::new("config")
532 .arg("--port")
533 .arg(port_spec)
534 .arg(container)
535 }
536
537 #[must_use]
541 pub fn config_entrypoint_shell(container: &str, command: &str) -> Self {
542 Self::new("config")
543 .arg("--entrypoint")
544 .arg(format!(
545 "[\"/bin/sh\", \"-c\", \"{}\"]",
546 escape_json_string(command)
547 ))
548 .arg(container)
549 }
550
551 #[must_use]
555 pub fn config_entrypoint_exec(container: &str, args: &[String]) -> Self {
556 let json_array = format!(
557 "[{}]",
558 args.iter()
559 .map(|a| format!("\"{}\"", escape_json_string(a)))
560 .collect::<Vec<_>>()
561 .join(", ")
562 );
563 Self::new("config")
564 .arg("--entrypoint")
565 .arg(json_array)
566 .arg(container)
567 }
568
569 #[must_use]
571 pub fn config_entrypoint(container: &str, command: &ShellOrExec) -> Self {
572 match command {
573 ShellOrExec::Shell(s) => Self::config_entrypoint_shell(container, s),
574 ShellOrExec::Exec(args) => Self::config_entrypoint_exec(container, args),
575 }
576 }
577
578 #[must_use]
580 pub fn config_cmd_shell(container: &str, command: &str) -> Self {
581 Self::new("config")
582 .arg("--cmd")
583 .arg(format!("/bin/sh -c \"{}\"", escape_json_string(command)))
584 .arg(container)
585 }
586
587 #[must_use]
589 pub fn config_cmd_exec(container: &str, args: &[String]) -> Self {
590 let json_array = format!(
591 "[{}]",
592 args.iter()
593 .map(|a| format!("\"{}\"", escape_json_string(a)))
594 .collect::<Vec<_>>()
595 .join(", ")
596 );
597 Self::new("config")
598 .arg("--cmd")
599 .arg(json_array)
600 .arg(container)
601 }
602
603 #[must_use]
605 pub fn config_cmd(container: &str, command: &ShellOrExec) -> Self {
606 match command {
607 ShellOrExec::Shell(s) => Self::config_cmd_shell(container, s),
608 ShellOrExec::Exec(args) => Self::config_cmd_exec(container, args),
609 }
610 }
611
612 #[must_use]
616 pub fn config_user(container: &str, user: &str) -> Self {
617 Self::new("config").arg("--user").arg(user).arg(container)
618 }
619
620 #[must_use]
624 pub fn config_label(container: &str, key: &str, value: &str) -> Self {
625 Self::new("config")
626 .arg("--label")
627 .arg(format!("{key}={value}"))
628 .arg(container)
629 }
630
631 #[must_use]
633 pub fn config_labels(container: &str, labels: &HashMap<String, String>) -> Vec<Self> {
634 labels
635 .iter()
636 .map(|(k, v)| Self::config_label(container, k, v))
637 .collect()
638 }
639
640 #[must_use]
644 pub fn config_volume(container: &str, path: &str) -> Self {
645 Self::new("config").arg("--volume").arg(path).arg(container)
646 }
647
648 #[must_use]
652 pub fn config_stopsignal(container: &str, signal: &str) -> Self {
653 Self::new("config")
654 .arg("--stop-signal")
655 .arg(signal)
656 .arg(container)
657 }
658
659 #[must_use]
663 pub fn config_shell(container: &str, shell: &[String]) -> Self {
664 let json_array = format!(
665 "[{}]",
666 shell
667 .iter()
668 .map(|a| format!("\"{}\"", escape_json_string(a)))
669 .collect::<Vec<_>>()
670 .join(", ")
671 );
672 Self::new("config")
673 .arg("--shell")
674 .arg(json_array)
675 .arg(container)
676 }
677
678 #[must_use]
680 pub fn config_healthcheck(container: &str, healthcheck: &HealthcheckInstruction) -> Self {
681 match healthcheck {
682 HealthcheckInstruction::None => Self::new("config")
683 .arg("--healthcheck")
684 .arg("NONE")
685 .arg(container),
686 HealthcheckInstruction::Check {
687 command,
688 interval,
689 timeout,
690 start_period,
691 retries,
692 ..
693 } => {
694 let mut cmd = Self::new("config");
695
696 let cmd_str = match command {
697 ShellOrExec::Shell(s) => format!("CMD {s}"),
698 ShellOrExec::Exec(args) => {
699 format!(
700 "CMD [{}]",
701 args.iter()
702 .map(|a| format!("\"{}\"", escape_json_string(a)))
703 .collect::<Vec<_>>()
704 .join(", ")
705 )
706 }
707 };
708
709 cmd = cmd.arg("--healthcheck").arg(cmd_str);
710
711 if let Some(i) = interval {
712 cmd = cmd
713 .arg("--healthcheck-interval")
714 .arg(format!("{}s", i.as_secs()));
715 }
716
717 if let Some(t) = timeout {
718 cmd = cmd
719 .arg("--healthcheck-timeout")
720 .arg(format!("{}s", t.as_secs()));
721 }
722
723 if let Some(sp) = start_period {
724 cmd = cmd
725 .arg("--healthcheck-start-period")
726 .arg(format!("{}s", sp.as_secs()));
727 }
728
729 if let Some(r) = retries {
730 cmd = cmd.arg("--healthcheck-retries").arg(r.to_string());
731 }
732
733 cmd.arg(container)
734 }
735 }
736 }
737
738 #[must_use]
746 pub fn manifest_create(name: &str) -> Self {
747 Self::new("manifest").arg("create").arg(name)
748 }
749
750 #[must_use]
754 pub fn manifest_add(list: &str, image: &str) -> Self {
755 Self::new("manifest").arg("add").arg(list).arg(image)
756 }
757
758 #[must_use]
762 pub fn manifest_push(list: &str, destination: &str) -> Self {
763 Self::new("manifest")
764 .arg("push")
765 .arg("--all")
766 .arg(list)
767 .arg(destination)
768 }
769
770 #[must_use]
774 pub fn manifest_rm(list: &str) -> Self {
775 Self::new("manifest").arg("rm").arg(list)
776 }
777
778 #[must_use]
798 pub fn from_instruction(container: &str, instruction: &Instruction) -> Vec<Self> {
799 DockerfileTranslator::new(ImageOs::Linux).translate(container, instruction)
800 }
801}
802
803#[derive(Debug, Clone)]
824pub struct DockerfileTranslator {
825 target_os: ImageOs,
826 shell_override: Option<Vec<String>>,
829}
830
831impl DockerfileTranslator {
832 #[must_use]
834 pub fn new(target_os: ImageOs) -> Self {
835 Self {
836 target_os,
837 shell_override: None,
838 }
839 }
840
841 #[must_use]
843 pub fn target_os(&self) -> ImageOs {
844 self.target_os
845 }
846
847 #[must_use]
851 pub fn active_shell(&self) -> Vec<String> {
852 match &self.shell_override {
853 Some(s) => s.clone(),
854 None => default_shell_for(self.target_os),
855 }
856 }
857
858 pub fn set_shell_override(&mut self, shell: Vec<String>) {
862 self.shell_override = Some(shell);
863 }
864
865 #[allow(clippy::too_many_lines)]
872 pub fn translate(&mut self, container: &str, instruction: &Instruction) -> Vec<BuildahCommand> {
873 match instruction {
874 Instruction::Run(run) => {
875 let shell = self.active_shell();
876 if run.mounts.is_empty() {
877 match &run.command {
878 ShellOrExec::Shell(s) => {
879 vec![BuildahCommand::run_shell_custom(container, &shell, s)]
880 }
881 ShellOrExec::Exec(args) => vec![BuildahCommand::run_exec(container, args)],
882 }
883 } else {
884 vec![BuildahCommand::run_with_mounts_shell(
885 container, run, &shell,
886 )]
887 }
888 }
889
890 Instruction::Copy(copy) => {
891 vec![BuildahCommand::copy_instruction(container, copy)]
892 }
893
894 Instruction::Add(add) => {
895 vec![BuildahCommand::add_instruction(container, add)]
896 }
897
898 Instruction::Env(env) => BuildahCommand::config_envs(container, env),
899
900 Instruction::Workdir(dir) => self.translate_workdir(container, dir),
901
902 Instruction::Expose(expose) => {
903 vec![BuildahCommand::config_expose(container, expose)]
904 }
905
906 Instruction::Label(labels) => BuildahCommand::config_labels(container, labels),
907
908 Instruction::User(user) => {
909 vec![BuildahCommand::config_user(container, user)]
910 }
911
912 Instruction::Entrypoint(cmd) => {
913 vec![BuildahCommand::config_entrypoint(container, cmd)]
914 }
915
916 Instruction::Cmd(cmd) => {
917 vec![BuildahCommand::config_cmd(container, cmd)]
918 }
919
920 Instruction::Volume(paths) => paths
921 .iter()
922 .map(|p| BuildahCommand::config_volume(container, p))
923 .collect(),
924
925 Instruction::Shell(shell) => {
926 self.set_shell_override(shell.clone());
933 vec![BuildahCommand::config_shell(container, shell)]
934 }
935
936 Instruction::Arg(_) => {
937 vec![]
939 }
940
941 Instruction::Stopsignal(signal) => {
942 vec![BuildahCommand::config_stopsignal(container, signal)]
943 }
944
945 Instruction::Healthcheck(hc) => {
946 vec![BuildahCommand::config_healthcheck(container, hc)]
947 }
948
949 Instruction::Onbuild(_) => {
950 tracing::warn!("ONBUILD instruction not supported in buildah conversion");
952 vec![]
953 }
954 }
955 }
956
957 fn translate_workdir(&self, container: &str, dir: &str) -> Vec<BuildahCommand> {
974 match self.target_os {
975 ImageOs::Linux => {
976 vec![
977 BuildahCommand::run_exec(
978 container,
979 &["mkdir".to_string(), "-p".to_string(), dir.to_string()],
980 ),
981 BuildahCommand::config_workdir(container, dir),
982 ]
983 }
984 ImageOs::Windows => {
985 let guarded = format!(r#"if not exist "{dir}" mkdir "{dir}""#);
990 vec![
991 BuildahCommand::run_shell_custom(container, WINDOWS_DEFAULT_SHELL, &guarded),
992 BuildahCommand::config_workdir(container, dir),
993 ]
994 }
995 }
996 }
997}
998
999fn escape_json_string(s: &str) -> String {
1001 s.replace('\\', "\\\\")
1002 .replace('"', "\\\"")
1003 .replace('\n', "\\n")
1004 .replace('\r', "\\r")
1005 .replace('\t', "\\t")
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010 use super::*;
1011 use crate::dockerfile::RunInstruction;
1012
1013 #[test]
1014 fn test_from_image() {
1015 let cmd = BuildahCommand::from_image("alpine:3.18");
1016 assert_eq!(cmd.program, "buildah");
1017 assert_eq!(cmd.args, vec!["from", "alpine:3.18"]);
1018 }
1019
1020 #[test]
1021 fn test_pull_no_policy() {
1022 let cmd = BuildahCommand::pull("ghcr.io/astral-sh/uv:0.5.0", None);
1023 assert_eq!(cmd.program, "buildah");
1024 assert_eq!(cmd.args, vec!["pull", "ghcr.io/astral-sh/uv:0.5.0"]);
1025 }
1026
1027 #[test]
1028 fn test_pull_with_policy() {
1029 let cmd = BuildahCommand::pull("ghcr.io/astral-sh/uv:0.5.0", Some("newer"));
1030 assert_eq!(
1031 cmd.args,
1032 vec!["pull", "--policy", "newer", "ghcr.io/astral-sh/uv:0.5.0"]
1033 );
1034 }
1035
1036 #[test]
1037 fn test_run_shell() {
1038 let cmd = BuildahCommand::run_shell("container-1", "apt-get update");
1039 assert_eq!(
1040 cmd.args,
1041 vec![
1042 "run",
1043 "container-1",
1044 "--",
1045 "/bin/sh",
1046 "-c",
1047 "apt-get update"
1048 ]
1049 );
1050 }
1051
1052 #[test]
1053 fn test_run_exec() {
1054 let args = vec!["echo".to_string(), "hello".to_string()];
1055 let cmd = BuildahCommand::run_exec("container-1", &args);
1056 assert_eq!(cmd.args, vec!["run", "container-1", "--", "echo", "hello"]);
1057 }
1058
1059 #[test]
1060 fn test_copy() {
1061 let sources = vec!["src/".to_string(), "Cargo.toml".to_string()];
1062 let cmd = BuildahCommand::copy("container-1", &sources, "/app/");
1063 assert_eq!(
1064 cmd.args,
1065 vec!["copy", "container-1", "src/", "Cargo.toml", "/app/"]
1066 );
1067 }
1068
1069 #[test]
1070 fn test_copy_from() {
1071 let sources = vec!["/app".to_string()];
1072 let cmd = BuildahCommand::copy_from("container-1", "builder", &sources, "/app");
1073 assert_eq!(
1074 cmd.args,
1075 vec!["copy", "--from", "builder", "container-1", "/app", "/app"]
1076 );
1077 }
1078
1079 #[test]
1080 fn test_copy_from_external_image_reference_is_preserved() {
1081 use crate::dockerfile::CopyInstruction;
1087
1088 let copy = CopyInstruction {
1089 sources: vec!["/uv".to_string()],
1090 destination: "/usr/local/bin/uv".to_string(),
1091 from: Some("ghcr.io/astral-sh/uv:0.5.0".to_string()),
1092 chown: None,
1093 chmod: None,
1094 link: false,
1095 exclude: Vec::new(),
1096 };
1097 let instruction = Instruction::Copy(copy);
1098 let cmds = BuildahCommand::from_instruction("container-1", &instruction);
1099
1100 assert_eq!(
1101 cmds.len(),
1102 1,
1103 "COPY translates to a single buildah copy command"
1104 );
1105 assert_eq!(
1106 cmds[0].args,
1107 vec![
1108 "copy",
1109 "--from",
1110 "ghcr.io/astral-sh/uv:0.5.0",
1111 "container-1",
1112 "/uv",
1113 "/usr/local/bin/uv",
1114 ],
1115 "external image reference must be passed through to buildah unchanged",
1116 );
1117 }
1118
1119 #[test]
1120 fn test_config_env() {
1121 let cmd = BuildahCommand::config_env("container-1", "PATH", "/usr/local/bin");
1122 assert_eq!(
1123 cmd.args,
1124 vec!["config", "--env", "PATH=/usr/local/bin", "container-1"]
1125 );
1126 }
1127
1128 #[test]
1129 fn test_config_workdir() {
1130 let cmd = BuildahCommand::config_workdir("container-1", "/app");
1131 assert_eq!(
1132 cmd.args,
1133 vec!["config", "--workingdir", "/app", "container-1"]
1134 );
1135 }
1136
1137 #[test]
1138 fn test_config_entrypoint_exec() {
1139 let args = vec!["/app".to_string(), "--config".to_string()];
1140 let cmd = BuildahCommand::config_entrypoint_exec("container-1", &args);
1141 assert!(cmd.args.contains(&"--entrypoint".to_string()));
1142 assert!(cmd
1143 .args
1144 .iter()
1145 .any(|a| a.contains('[') && a.contains("/app")));
1146 }
1147
1148 #[test]
1149 fn test_commit() {
1150 let cmd = BuildahCommand::commit("container-1", "myimage:latest");
1151 assert_eq!(cmd.args, vec!["commit", "container-1", "myimage:latest"]);
1152 }
1153
1154 #[test]
1155 fn test_to_command_string() {
1156 let cmd = BuildahCommand::config_env("container-1", "VAR", "value with spaces");
1157 let s = cmd.to_command_string();
1158 assert!(s.starts_with("buildah config"));
1159 assert!(s.contains("VAR=value with spaces"));
1160 }
1161
1162 #[test]
1163 fn test_from_instruction_run() {
1164 let instruction = Instruction::Run(RunInstruction {
1165 command: ShellOrExec::Shell("echo hello".to_string()),
1166 mounts: vec![],
1167 network: None,
1168 security: None,
1169 });
1170
1171 let cmds = BuildahCommand::from_instruction("container-1", &instruction);
1172 assert_eq!(cmds.len(), 1);
1173 assert!(cmds[0].args.contains(&"run".to_string()));
1174 }
1175
1176 #[test]
1177 fn test_from_instruction_workdir_creates_and_configures() {
1178 let instruction = Instruction::Workdir("/workspace".to_string());
1182 let cmds = BuildahCommand::from_instruction("container-1", &instruction);
1183
1184 assert_eq!(cmds.len(), 2, "WORKDIR should emit mkdir + config");
1185
1186 let run_args = &cmds[0].args;
1187 assert_eq!(run_args[0], "run");
1188 assert_eq!(run_args[1], "container-1");
1189 assert_eq!(run_args[2], "--");
1190 assert_eq!(run_args[3], "mkdir");
1191 assert_eq!(run_args[4], "-p");
1192 assert_eq!(run_args[5], "/workspace");
1193
1194 assert_eq!(
1195 cmds[1].args,
1196 vec!["config", "--workingdir", "/workspace", "container-1"]
1197 );
1198 }
1199
1200 #[test]
1201 fn test_from_instruction_env_multiple() {
1202 let mut vars = HashMap::new();
1203 vars.insert("FOO".to_string(), "bar".to_string());
1204 vars.insert("BAZ".to_string(), "qux".to_string());
1205
1206 let instruction = Instruction::Env(EnvInstruction { vars });
1207 let cmds = BuildahCommand::from_instruction("container-1", &instruction);
1208
1209 assert_eq!(cmds.len(), 2);
1211 for cmd in &cmds {
1212 assert!(cmd.args.contains(&"config".to_string()));
1213 assert!(cmd.args.contains(&"--env".to_string()));
1214 }
1215 }
1216
1217 #[test]
1218 fn test_escape_json_string() {
1219 assert_eq!(escape_json_string("hello"), "hello");
1220 assert_eq!(escape_json_string("hello \"world\""), "hello \\\"world\\\"");
1221 assert_eq!(escape_json_string("line1\nline2"), "line1\\nline2");
1222 }
1223
1224 #[test]
1225 fn test_run_with_mounts_cache() {
1226 use crate::dockerfile::{CacheSharing, RunMount};
1227
1228 let run = RunInstruction {
1229 command: ShellOrExec::Shell("apt-get update".to_string()),
1230 mounts: vec![RunMount::Cache {
1231 target: "/var/cache/apt".to_string(),
1232 id: Some("apt-cache".to_string()),
1233 sharing: CacheSharing::Shared,
1234 readonly: false,
1235 }],
1236 network: None,
1237 security: None,
1238 };
1239
1240 let cmd = BuildahCommand::run_with_mounts("container-1", &run);
1241
1242 let mount_idx = cmd
1244 .args
1245 .iter()
1246 .position(|a| a.starts_with("--mount="))
1247 .expect("should have --mount arg");
1248 let container_idx = cmd
1249 .args
1250 .iter()
1251 .position(|a| a == "container-1")
1252 .expect("should have container id");
1253
1254 assert!(
1255 mount_idx < container_idx,
1256 "--mount should come before container ID"
1257 );
1258
1259 assert!(cmd.args[mount_idx].contains("type=cache"));
1261 assert!(cmd.args[mount_idx].contains("target=/var/cache/apt"));
1262 assert!(cmd.args[mount_idx].contains("id=apt-cache"));
1263 assert!(cmd.args[mount_idx].contains("sharing=shared"));
1264 }
1265
1266 #[test]
1267 fn test_run_with_multiple_mounts() {
1268 use crate::dockerfile::{CacheSharing, RunMount};
1269
1270 let run = RunInstruction {
1271 command: ShellOrExec::Shell("cargo build".to_string()),
1272 mounts: vec![
1273 RunMount::Cache {
1274 target: "/usr/local/cargo/registry".to_string(),
1275 id: Some("cargo-registry".to_string()),
1276 sharing: CacheSharing::Shared,
1277 readonly: false,
1278 },
1279 RunMount::Cache {
1280 target: "/app/target".to_string(),
1281 id: Some("cargo-target".to_string()),
1282 sharing: CacheSharing::Locked,
1283 readonly: false,
1284 },
1285 ],
1286 network: None,
1287 security: None,
1288 };
1289
1290 let cmd = BuildahCommand::run_with_mounts("container-1", &run);
1291
1292 let mount_count = cmd
1294 .args
1295 .iter()
1296 .filter(|a| a.starts_with("--mount="))
1297 .count();
1298 assert_eq!(mount_count, 2, "should have 2 mount arguments");
1299
1300 let container_idx = cmd
1302 .args
1303 .iter()
1304 .position(|a| a == "container-1")
1305 .expect("should have container id");
1306
1307 for (idx, arg) in cmd.args.iter().enumerate() {
1308 if arg.starts_with("--mount=") {
1309 assert!(
1310 idx < container_idx,
1311 "--mount at index {idx} should come before container ID at {container_idx}",
1312 );
1313 }
1314 }
1315 }
1316
1317 #[test]
1318 fn test_from_instruction_run_with_mounts() {
1319 use crate::dockerfile::{CacheSharing, RunMount};
1320
1321 let instruction = Instruction::Run(RunInstruction {
1322 command: ShellOrExec::Shell("npm install".to_string()),
1323 mounts: vec![RunMount::Cache {
1324 target: "/root/.npm".to_string(),
1325 id: Some("npm-cache".to_string()),
1326 sharing: CacheSharing::Shared,
1327 readonly: false,
1328 }],
1329 network: None,
1330 security: None,
1331 });
1332
1333 let cmds = BuildahCommand::from_instruction("container-1", &instruction);
1334 assert_eq!(cmds.len(), 1);
1335
1336 let cmd = &cmds[0];
1337 assert!(
1338 cmd.args.iter().any(|a| a.starts_with("--mount=")),
1339 "should include --mount argument"
1340 );
1341 }
1342
1343 #[test]
1344 fn test_run_with_mounts_exec_form() {
1345 use crate::dockerfile::{CacheSharing, RunMount};
1346
1347 let run = RunInstruction {
1348 command: ShellOrExec::Exec(vec![
1349 "pip".to_string(),
1350 "install".to_string(),
1351 "-r".to_string(),
1352 "requirements.txt".to_string(),
1353 ]),
1354 mounts: vec![RunMount::Cache {
1355 target: "/root/.cache/pip".to_string(),
1356 id: Some("pip-cache".to_string()),
1357 sharing: CacheSharing::Shared,
1358 readonly: false,
1359 }],
1360 network: None,
1361 security: None,
1362 };
1363
1364 let cmd = BuildahCommand::run_with_mounts("container-1", &run);
1365
1366 assert!(cmd.args.contains(&"--".to_string()));
1368 assert!(cmd.args.contains(&"pip".to_string()));
1369 assert!(cmd.args.contains(&"install".to_string()));
1370 }
1371
1372 #[test]
1373 fn test_manifest_create() {
1374 let cmd = BuildahCommand::manifest_create("myapp:latest");
1375 assert_eq!(cmd.program, "buildah");
1376 assert_eq!(cmd.args, vec!["manifest", "create", "myapp:latest"]);
1377 }
1378
1379 #[test]
1380 fn test_manifest_add() {
1381 let cmd = BuildahCommand::manifest_add("myapp:latest", "myapp-amd64:latest");
1382 assert_eq!(
1383 cmd.args,
1384 vec!["manifest", "add", "myapp:latest", "myapp-amd64:latest"]
1385 );
1386 }
1387
1388 #[test]
1389 fn test_manifest_push() {
1390 let cmd =
1391 BuildahCommand::manifest_push("myapp:latest", "docker://registry.example.com/myapp");
1392 assert_eq!(
1393 cmd.args,
1394 vec![
1395 "manifest",
1396 "push",
1397 "--all",
1398 "myapp:latest",
1399 "docker://registry.example.com/myapp"
1400 ]
1401 );
1402 }
1403
1404 #[test]
1405 fn test_manifest_rm() {
1406 let cmd = BuildahCommand::manifest_rm("myapp:latest");
1407 assert_eq!(cmd.args, vec!["manifest", "rm", "myapp:latest"]);
1408 }
1409
1410 #[test]
1415 fn test_run_shell_for_os_linux() {
1416 let cmd = BuildahCommand::run_shell_for_os("c1", "echo hello", ImageOs::Linux);
1417 assert_eq!(
1418 cmd.args,
1419 vec!["run", "c1", "--", "/bin/sh", "-c", "echo hello"]
1420 );
1421 }
1422
1423 #[test]
1424 fn test_run_shell_for_os_windows() {
1425 let cmd = BuildahCommand::run_shell_for_os("c1", "echo hello", ImageOs::Windows);
1426 assert_eq!(
1427 cmd.args,
1428 vec!["run", "c1", "--", "cmd.exe", "/S", "/C", "echo hello"]
1429 );
1430 }
1431
1432 #[test]
1433 fn test_run_shell_custom_powershell() {
1434 let shell = ["powershell", "-Command"];
1435 let cmd = BuildahCommand::run_shell_custom("c1", shell, "Get-Process");
1436 assert_eq!(
1437 cmd.args,
1438 vec!["run", "c1", "--", "powershell", "-Command", "Get-Process"]
1439 );
1440 }
1441
1442 #[test]
1443 fn test_translator_linux_run_shell_default() {
1444 let mut t = DockerfileTranslator::new(ImageOs::Linux);
1445 let instr = Instruction::Run(RunInstruction::shell("apt-get update"));
1446 let cmds = t.translate("c1", &instr);
1447 assert_eq!(cmds.len(), 1);
1448 assert_eq!(
1449 cmds[0].args,
1450 vec!["run", "c1", "--", "/bin/sh", "-c", "apt-get update"]
1451 );
1452 }
1453
1454 #[test]
1455 fn test_translator_windows_run_shell_default() {
1456 let mut t = DockerfileTranslator::new(ImageOs::Windows);
1457 let instr = Instruction::Run(RunInstruction::shell("dir C:\\"));
1458 let cmds = t.translate("c1", &instr);
1459 assert_eq!(cmds.len(), 1);
1460 assert_eq!(
1461 cmds[0].args,
1462 vec!["run", "c1", "--", "cmd.exe", "/S", "/C", "dir C:\\"]
1463 );
1464 }
1465
1466 #[test]
1467 fn test_translator_shell_override_linux_bash() {
1468 let mut t = DockerfileTranslator::new(ImageOs::Linux);
1470
1471 let shell_instr = Instruction::Shell(vec!["/bin/bash".to_string(), "-lc".to_string()]);
1472 let shell_cmds = t.translate("c1", &shell_instr);
1473 assert_eq!(shell_cmds.len(), 1);
1475 assert!(shell_cmds[0].args.contains(&"--shell".to_string()));
1476
1477 let run_instr = Instruction::Run(RunInstruction::shell("set -e; echo $SHELL"));
1478 let run_cmds = t.translate("c1", &run_instr);
1479 assert_eq!(run_cmds.len(), 1);
1480 assert_eq!(
1481 run_cmds[0].args,
1482 vec!["run", "c1", "--", "/bin/bash", "-lc", "set -e; echo $SHELL"]
1483 );
1484 }
1485
1486 #[test]
1487 fn test_translator_shell_override_windows_powershell() {
1488 let mut t = DockerfileTranslator::new(ImageOs::Windows);
1491
1492 let shell_instr =
1493 Instruction::Shell(vec!["powershell".to_string(), "-Command".to_string()]);
1494 t.translate("c1", &shell_instr);
1495
1496 let run_instr = Instruction::Run(RunInstruction::shell("Get-Process"));
1497 let run_cmds = t.translate("c1", &run_instr);
1498 assert_eq!(run_cmds.len(), 1);
1499 assert_eq!(
1500 run_cmds[0].args,
1501 vec!["run", "c1", "--", "powershell", "-Command", "Get-Process"]
1502 );
1503 }
1504
1505 #[test]
1506 fn test_translator_shell_override_persists_across_runs() {
1507 let mut t = DockerfileTranslator::new(ImageOs::Linux);
1509 t.translate(
1510 "c1",
1511 &Instruction::Shell(vec!["/bin/bash".to_string(), "-c".to_string()]),
1512 );
1513
1514 for _ in 0..2 {
1515 let cmds = t.translate("c1", &Instruction::Run(RunInstruction::shell("echo hi")));
1516 assert_eq!(
1517 cmds[0].args,
1518 vec!["run", "c1", "--", "/bin/bash", "-c", "echo hi"]
1519 );
1520 }
1521 }
1522
1523 #[test]
1524 fn test_translator_exec_form_ignores_shell_override() {
1525 let mut t = DockerfileTranslator::new(ImageOs::Windows);
1528 t.translate(
1529 "c1",
1530 &Instruction::Shell(vec!["powershell".to_string(), "-Command".to_string()]),
1531 );
1532
1533 let run = Instruction::Run(RunInstruction::exec(vec![
1534 "myapp.exe".to_string(),
1535 "--flag".to_string(),
1536 ]));
1537 let cmds = t.translate("c1", &run);
1538 assert_eq!(cmds[0].args, vec!["run", "c1", "--", "myapp.exe", "--flag"]);
1539 }
1540
1541 #[test]
1542 fn test_translator_workdir_linux() {
1543 let mut t = DockerfileTranslator::new(ImageOs::Linux);
1544 let cmds = t.translate("c1", &Instruction::Workdir("/app".to_string()));
1545 assert_eq!(cmds.len(), 2);
1546 assert_eq!(cmds[0].args, vec!["run", "c1", "--", "mkdir", "-p", "/app"]);
1547 assert_eq!(cmds[1].args, vec!["config", "--workingdir", "/app", "c1"]);
1548 }
1549
1550 #[test]
1551 fn test_translator_workdir_windows() {
1552 let mut t = DockerfileTranslator::new(ImageOs::Windows);
1553 let cmds = t.translate("c1", &Instruction::Workdir("C:\\app".to_string()));
1554 assert_eq!(cmds.len(), 2);
1555 assert_eq!(
1558 cmds[0].args,
1559 vec![
1560 "run",
1561 "c1",
1562 "--",
1563 "cmd.exe",
1564 "/S",
1565 "/C",
1566 r#"if not exist "C:\app" mkdir "C:\app""#
1567 ]
1568 );
1569 assert_eq!(
1570 cmds[1].args,
1571 vec!["config", "--workingdir", "C:\\app", "c1"]
1572 );
1573 }
1574
1575 #[test]
1576 fn test_translator_workdir_windows_path_with_spaces() {
1577 let mut t = DockerfileTranslator::new(ImageOs::Windows);
1581 let cmds = t.translate(
1582 "c1",
1583 &Instruction::Workdir("C:\\Program Files\\app".to_string()),
1584 );
1585 assert_eq!(cmds.len(), 2);
1586 let mkdir_cmd = &cmds[0].args[6];
1587 assert_eq!(
1588 mkdir_cmd,
1589 r#"if not exist "C:\Program Files\app" mkdir "C:\Program Files\app""#
1590 );
1591 }
1592
1593 #[test]
1594 fn test_from_instruction_preserves_linux_byte_identical_output() {
1595 let run = Instruction::Run(RunInstruction::shell("echo hello"));
1600 let legacy = BuildahCommand::from_instruction("c1", &run);
1601 let via_translator = DockerfileTranslator::new(ImageOs::Linux).translate("c1", &run);
1602 assert_eq!(legacy.len(), via_translator.len());
1603 for (a, b) in legacy.iter().zip(via_translator.iter()) {
1604 assert_eq!(a.args, b.args);
1605 assert_eq!(a.program, b.program);
1606 }
1607
1608 let workdir = Instruction::Workdir("/workspace".to_string());
1610 let legacy = BuildahCommand::from_instruction("c1", &workdir);
1611 assert_eq!(legacy.len(), 2);
1612 assert_eq!(
1613 legacy[0].args,
1614 vec!["run", "c1", "--", "mkdir", "-p", "/workspace"]
1615 );
1616 assert_eq!(
1617 legacy[1].args,
1618 vec!["config", "--workingdir", "/workspace", "c1"]
1619 );
1620 }
1621
1622 #[test]
1623 fn test_translator_active_shell_reflects_override() {
1624 let mut t = DockerfileTranslator::new(ImageOs::Linux);
1625 assert_eq!(t.active_shell(), vec!["/bin/sh", "-c"]);
1626
1627 t.set_shell_override(vec!["/bin/bash".to_string(), "-lc".to_string()]);
1628 assert_eq!(t.active_shell(), vec!["/bin/bash", "-lc"]);
1629 }
1630
1631 #[test]
1632 fn test_translator_target_os_accessor() {
1633 assert_eq!(
1634 DockerfileTranslator::new(ImageOs::Linux).target_os(),
1635 ImageOs::Linux
1636 );
1637 assert_eq!(
1638 DockerfileTranslator::new(ImageOs::Windows).target_os(),
1639 ImageOs::Windows
1640 );
1641 }
1642
1643 #[test]
1644 fn test_translator_windows_run_with_mounts_uses_cmd_exe() {
1645 use crate::dockerfile::{CacheSharing, RunMount};
1646
1647 let mut t = DockerfileTranslator::new(ImageOs::Windows);
1648 let run = RunInstruction {
1649 command: ShellOrExec::Shell("echo cached".to_string()),
1650 mounts: vec![RunMount::Cache {
1651 target: "C:\\cache".to_string(),
1652 id: Some("win-cache".to_string()),
1653 sharing: CacheSharing::Shared,
1654 readonly: false,
1655 }],
1656 network: None,
1657 security: None,
1658 };
1659
1660 let cmds = t.translate("c1", &Instruction::Run(run));
1661 assert_eq!(cmds.len(), 1);
1662
1663 let mount_idx = cmds[0]
1665 .args
1666 .iter()
1667 .position(|a| a.starts_with("--mount="))
1668 .expect("mount arg present");
1669 let container_idx = cmds[0]
1670 .args
1671 .iter()
1672 .position(|a| a == "c1")
1673 .expect("container ID present");
1674 assert!(mount_idx < container_idx);
1675
1676 assert!(cmds[0].args.iter().any(|a| a == "cmd.exe"));
1678 assert!(cmds[0].args.iter().any(|a| a == "/S"));
1679 assert!(cmds[0].args.iter().any(|a| a == "/C"));
1680 assert!(!cmds[0].args.iter().any(|a| a == "/bin/sh"));
1681 }
1682}