Skip to main content

zlayer_builder/buildah/
mod.rs

1//! Buildah command generation and execution
2//!
3//! This module provides types and functions for converting Dockerfile instructions
4//! into buildah commands and executing them.
5//!
6//! # Installation
7//!
8//! The [`BuildahInstaller`] type can be used to find existing buildah installations
9//! or provide helpful installation instructions when buildah is not found.
10//!
11//! ```no_run
12//! use zlayer_builder::buildah::{BuildahInstaller, BuildahExecutor};
13//!
14//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
15//! // Ensure buildah is available
16//! let installer = BuildahInstaller::new();
17//! let installation = installer.ensure().await?;
18//!
19//! // Create executor using found installation
20//! let executor = BuildahExecutor::with_path(installation.path);
21//! # Ok(())
22//! # }
23//! ```
24
25mod 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::dockerfile::{
35    AddInstruction, CopyInstruction, EnvInstruction, ExposeInstruction, HealthcheckInstruction,
36    Instruction, RunInstruction, ShellOrExec,
37};
38
39use std::collections::HashMap;
40
41/// A buildah command ready for execution
42#[derive(Debug, Clone)]
43pub struct BuildahCommand {
44    /// The program to execute (typically "buildah")
45    pub program: String,
46
47    /// Command arguments
48    pub args: Vec<String>,
49
50    /// Optional environment variables for the command
51    pub env: HashMap<String, String>,
52}
53
54impl BuildahCommand {
55    /// Create a new buildah command
56    pub fn new(subcommand: &str) -> Self {
57        Self {
58            program: "buildah".to_string(),
59            args: vec![subcommand.to_string()],
60            env: HashMap::new(),
61        }
62    }
63
64    /// Add an argument
65    pub fn arg(mut self, arg: impl Into<String>) -> Self {
66        self.args.push(arg.into());
67        self
68    }
69
70    /// Add multiple arguments
71    pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
72        self.args.extend(args.into_iter().map(Into::into));
73        self
74    }
75
76    /// Add an optional argument (only added if value is Some)
77    pub fn arg_opt(self, flag: &str, value: Option<impl Into<String>>) -> Self {
78        if let Some(v) = value {
79            self.arg(flag).arg(v)
80        } else {
81            self
82        }
83    }
84
85    /// Add an environment variable for command execution
86    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
87        self.env.insert(key.into(), value.into());
88        self
89    }
90
91    /// Convert to a command line string for display/logging
92    pub fn to_command_string(&self) -> String {
93        let mut parts = vec![self.program.clone()];
94        parts.extend(self.args.iter().map(|a| {
95            if a.contains(' ') || a.contains('"') {
96                format!("\"{}\"", a.replace('"', "\\\""))
97            } else {
98                a.clone()
99            }
100        }));
101        parts.join(" ")
102    }
103
104    // =========================================================================
105    // Container Lifecycle Commands
106    // =========================================================================
107
108    /// Create a new working container from an image
109    ///
110    /// `buildah from <image>`
111    pub fn from_image(image: &str) -> Self {
112        Self::new("from").arg(image)
113    }
114
115    /// Create a new working container from an image with a specific name
116    ///
117    /// `buildah from --name <name> <image>`
118    pub fn from_image_named(image: &str, name: &str) -> Self {
119        Self::new("from").arg("--name").arg(name).arg(image)
120    }
121
122    /// Create a scratch container
123    ///
124    /// `buildah from scratch`
125    pub fn from_scratch() -> Self {
126        Self::new("from").arg("scratch")
127    }
128
129    /// Remove a working container
130    ///
131    /// `buildah rm <container>`
132    pub fn rm(container: &str) -> Self {
133        Self::new("rm").arg(container)
134    }
135
136    /// Commit a container to create an image
137    ///
138    /// `buildah commit <container> <image>`
139    pub fn commit(container: &str, image_name: &str) -> Self {
140        Self::new("commit").arg(container).arg(image_name)
141    }
142
143    /// Commit with additional options
144    pub fn commit_with_opts(
145        container: &str,
146        image_name: &str,
147        format: Option<&str>,
148        squash: bool,
149    ) -> Self {
150        let mut cmd = Self::new("commit");
151
152        if let Some(fmt) = format {
153            cmd = cmd.arg("--format").arg(fmt);
154        }
155
156        if squash {
157            cmd = cmd.arg("--squash");
158        }
159
160        cmd.arg(container).arg(image_name)
161    }
162
163    /// Tag an image with a new name
164    ///
165    /// `buildah tag <image> <new-name>`
166    pub fn tag(image: &str, new_name: &str) -> Self {
167        Self::new("tag").arg(image).arg(new_name)
168    }
169
170    /// Remove an image
171    ///
172    /// `buildah rmi <image>`
173    pub fn rmi(image: &str) -> Self {
174        Self::new("rmi").arg(image)
175    }
176
177    /// Push an image to a registry
178    ///
179    /// `buildah push <image>`
180    pub fn push(image: &str) -> Self {
181        Self::new("push").arg(image)
182    }
183
184    /// Push an image to a registry with options
185    ///
186    /// `buildah push [options] <image> [destination]`
187    pub fn push_to(image: &str, destination: &str) -> Self {
188        Self::new("push").arg(image).arg(destination)
189    }
190
191    /// Inspect an image or container
192    ///
193    /// `buildah inspect <name>`
194    pub fn inspect(name: &str) -> Self {
195        Self::new("inspect").arg(name)
196    }
197
198    /// Inspect an image or container with format
199    ///
200    /// `buildah inspect --format <format> <name>`
201    pub fn inspect_format(name: &str, format: &str) -> Self {
202        Self::new("inspect").arg("--format").arg(format).arg(name)
203    }
204
205    /// List images
206    ///
207    /// `buildah images`
208    pub fn images() -> Self {
209        Self::new("images")
210    }
211
212    /// List containers
213    ///
214    /// `buildah containers`
215    pub fn containers() -> Self {
216        Self::new("containers")
217    }
218
219    // =========================================================================
220    // Run Commands
221    // =========================================================================
222
223    /// Run a command in the container (shell form)
224    ///
225    /// `buildah run <container> -- /bin/sh -c "<command>"`
226    pub fn run_shell(container: &str, command: &str) -> Self {
227        Self::new("run")
228            .arg(container)
229            .arg("--")
230            .arg("/bin/sh")
231            .arg("-c")
232            .arg(command)
233    }
234
235    /// Run a command in the container (exec form)
236    ///
237    /// `buildah run <container> -- <args...>`
238    pub fn run_exec(container: &str, args: &[String]) -> Self {
239        let mut cmd = Self::new("run").arg(container).arg("--");
240        for arg in args {
241            cmd = cmd.arg(arg);
242        }
243        cmd
244    }
245
246    /// Run a command based on ShellOrExec
247    pub fn run(container: &str, command: &ShellOrExec) -> Self {
248        match command {
249            ShellOrExec::Shell(s) => Self::run_shell(container, s),
250            ShellOrExec::Exec(args) => Self::run_exec(container, args),
251        }
252    }
253
254    /// Run a command with mount specifications from a RunInstruction.
255    ///
256    /// Buildah requires `--mount` arguments to appear BEFORE the container ID:
257    /// `buildah run [--mount=...] <container> -- <command>`
258    ///
259    /// This method properly orders the arguments to ensure mounts are applied.
260    pub fn run_with_mounts(container: &str, run: &RunInstruction) -> Self {
261        let mut cmd = Self::new("run");
262
263        // Add --mount arguments BEFORE the container ID
264        for mount in &run.mounts {
265            cmd = cmd.arg(format!("--mount={}", mount.to_buildah_arg()));
266        }
267
268        // Now add container and the command
269        cmd = cmd.arg(container).arg("--");
270
271        match &run.command {
272            ShellOrExec::Shell(s) => cmd.arg("/bin/sh").arg("-c").arg(s),
273            ShellOrExec::Exec(args) => {
274                for arg in args {
275                    cmd = cmd.arg(arg);
276                }
277                cmd
278            }
279        }
280    }
281
282    // =========================================================================
283    // Copy/Add Commands
284    // =========================================================================
285
286    /// Copy files into the container
287    ///
288    /// `buildah copy <container> <src...> <dest>`
289    pub fn copy(container: &str, sources: &[String], dest: &str) -> Self {
290        let mut cmd = Self::new("copy").arg(container);
291        for src in sources {
292            cmd = cmd.arg(src);
293        }
294        cmd.arg(dest)
295    }
296
297    /// Copy files from another container/image
298    ///
299    /// `buildah copy --from=<source> <container> <src...> <dest>`
300    pub fn copy_from(container: &str, from: &str, sources: &[String], dest: &str) -> Self {
301        let mut cmd = Self::new("copy").arg("--from").arg(from).arg(container);
302        for src in sources {
303            cmd = cmd.arg(src);
304        }
305        cmd.arg(dest)
306    }
307
308    /// Copy with all options from CopyInstruction
309    pub fn copy_instruction(container: &str, copy: &CopyInstruction) -> Self {
310        let mut cmd = Self::new("copy");
311
312        if let Some(ref from) = copy.from {
313            cmd = cmd.arg("--from").arg(from);
314        }
315
316        if let Some(ref chown) = copy.chown {
317            cmd = cmd.arg("--chown").arg(chown);
318        }
319
320        if let Some(ref chmod) = copy.chmod {
321            cmd = cmd.arg("--chmod").arg(chmod);
322        }
323
324        cmd = cmd.arg(container);
325
326        for src in &copy.sources {
327            cmd = cmd.arg(src);
328        }
329
330        cmd.arg(&copy.destination)
331    }
332
333    /// Add files (like copy but with URL support and extraction)
334    pub fn add(container: &str, sources: &[String], dest: &str) -> Self {
335        let mut cmd = Self::new("add").arg(container);
336        for src in sources {
337            cmd = cmd.arg(src);
338        }
339        cmd.arg(dest)
340    }
341
342    /// Add with all options from AddInstruction
343    pub fn add_instruction(container: &str, add: &AddInstruction) -> Self {
344        let mut cmd = Self::new("add");
345
346        if let Some(ref chown) = add.chown {
347            cmd = cmd.arg("--chown").arg(chown);
348        }
349
350        if let Some(ref chmod) = add.chmod {
351            cmd = cmd.arg("--chmod").arg(chmod);
352        }
353
354        cmd = cmd.arg(container);
355
356        for src in &add.sources {
357            cmd = cmd.arg(src);
358        }
359
360        cmd.arg(&add.destination)
361    }
362
363    // =========================================================================
364    // Config Commands
365    // =========================================================================
366
367    /// Set an environment variable
368    ///
369    /// `buildah config --env KEY=VALUE <container>`
370    pub fn config_env(container: &str, key: &str, value: &str) -> Self {
371        Self::new("config")
372            .arg("--env")
373            .arg(format!("{}={}", key, value))
374            .arg(container)
375    }
376
377    /// Set multiple environment variables
378    pub fn config_envs(container: &str, env: &EnvInstruction) -> Vec<Self> {
379        env.vars
380            .iter()
381            .map(|(k, v)| Self::config_env(container, k, v))
382            .collect()
383    }
384
385    /// Set the working directory
386    ///
387    /// `buildah config --workingdir <dir> <container>`
388    pub fn config_workdir(container: &str, dir: &str) -> Self {
389        Self::new("config")
390            .arg("--workingdir")
391            .arg(dir)
392            .arg(container)
393    }
394
395    /// Expose a port
396    ///
397    /// `buildah config --port <port>/<proto> <container>`
398    pub fn config_expose(container: &str, expose: &ExposeInstruction) -> Self {
399        let port_spec = format!(
400            "{}/{}",
401            expose.port,
402            match expose.protocol {
403                crate::dockerfile::ExposeProtocol::Tcp => "tcp",
404                crate::dockerfile::ExposeProtocol::Udp => "udp",
405            }
406        );
407        Self::new("config")
408            .arg("--port")
409            .arg(port_spec)
410            .arg(container)
411    }
412
413    /// Set the entrypoint (shell form)
414    ///
415    /// `buildah config --entrypoint '<command>' <container>`
416    pub fn config_entrypoint_shell(container: &str, command: &str) -> Self {
417        Self::new("config")
418            .arg("--entrypoint")
419            .arg(format!(
420                "[\"/bin/sh\", \"-c\", \"{}\"]",
421                escape_json_string(command)
422            ))
423            .arg(container)
424    }
425
426    /// Set the entrypoint (exec form)
427    ///
428    /// `buildah config --entrypoint '["exe", "arg1"]' <container>`
429    pub fn config_entrypoint_exec(container: &str, args: &[String]) -> Self {
430        let json_array = format!(
431            "[{}]",
432            args.iter()
433                .map(|a| format!("\"{}\"", escape_json_string(a)))
434                .collect::<Vec<_>>()
435                .join(", ")
436        );
437        Self::new("config")
438            .arg("--entrypoint")
439            .arg(json_array)
440            .arg(container)
441    }
442
443    /// Set the entrypoint based on ShellOrExec
444    pub fn config_entrypoint(container: &str, command: &ShellOrExec) -> Self {
445        match command {
446            ShellOrExec::Shell(s) => Self::config_entrypoint_shell(container, s),
447            ShellOrExec::Exec(args) => Self::config_entrypoint_exec(container, args),
448        }
449    }
450
451    /// Set the default command (shell form)
452    pub fn config_cmd_shell(container: &str, command: &str) -> Self {
453        Self::new("config")
454            .arg("--cmd")
455            .arg(format!("/bin/sh -c \"{}\"", escape_json_string(command)))
456            .arg(container)
457    }
458
459    /// Set the default command (exec form)
460    pub fn config_cmd_exec(container: &str, args: &[String]) -> Self {
461        let json_array = format!(
462            "[{}]",
463            args.iter()
464                .map(|a| format!("\"{}\"", escape_json_string(a)))
465                .collect::<Vec<_>>()
466                .join(", ")
467        );
468        Self::new("config")
469            .arg("--cmd")
470            .arg(json_array)
471            .arg(container)
472    }
473
474    /// Set the default command based on ShellOrExec
475    pub fn config_cmd(container: &str, command: &ShellOrExec) -> Self {
476        match command {
477            ShellOrExec::Shell(s) => Self::config_cmd_shell(container, s),
478            ShellOrExec::Exec(args) => Self::config_cmd_exec(container, args),
479        }
480    }
481
482    /// Set the user
483    ///
484    /// `buildah config --user <user> <container>`
485    pub fn config_user(container: &str, user: &str) -> Self {
486        Self::new("config").arg("--user").arg(user).arg(container)
487    }
488
489    /// Set a label
490    ///
491    /// `buildah config --label KEY=VALUE <container>`
492    pub fn config_label(container: &str, key: &str, value: &str) -> Self {
493        Self::new("config")
494            .arg("--label")
495            .arg(format!("{}={}", key, value))
496            .arg(container)
497    }
498
499    /// Set multiple labels
500    pub fn config_labels(container: &str, labels: &HashMap<String, String>) -> Vec<Self> {
501        labels
502            .iter()
503            .map(|(k, v)| Self::config_label(container, k, v))
504            .collect()
505    }
506
507    /// Set volumes
508    ///
509    /// `buildah config --volume <path> <container>`
510    pub fn config_volume(container: &str, path: &str) -> Self {
511        Self::new("config").arg("--volume").arg(path).arg(container)
512    }
513
514    /// Set the stop signal
515    ///
516    /// `buildah config --stop-signal <signal> <container>`
517    pub fn config_stopsignal(container: &str, signal: &str) -> Self {
518        Self::new("config")
519            .arg("--stop-signal")
520            .arg(signal)
521            .arg(container)
522    }
523
524    /// Set the shell
525    ///
526    /// `buildah config --shell '["shell", "args"]' <container>`
527    pub fn config_shell(container: &str, shell: &[String]) -> Self {
528        let json_array = format!(
529            "[{}]",
530            shell
531                .iter()
532                .map(|a| format!("\"{}\"", escape_json_string(a)))
533                .collect::<Vec<_>>()
534                .join(", ")
535        );
536        Self::new("config")
537            .arg("--shell")
538            .arg(json_array)
539            .arg(container)
540    }
541
542    /// Set healthcheck
543    pub fn config_healthcheck(container: &str, healthcheck: &HealthcheckInstruction) -> Self {
544        match healthcheck {
545            HealthcheckInstruction::None => Self::new("config")
546                .arg("--healthcheck")
547                .arg("NONE")
548                .arg(container),
549            HealthcheckInstruction::Check {
550                command,
551                interval,
552                timeout,
553                start_period,
554                retries,
555                ..
556            } => {
557                let mut cmd = Self::new("config");
558
559                let cmd_str = match command {
560                    ShellOrExec::Shell(s) => format!("CMD {}", s),
561                    ShellOrExec::Exec(args) => {
562                        format!(
563                            "CMD [{}]",
564                            args.iter()
565                                .map(|a| format!("\"{}\"", escape_json_string(a)))
566                                .collect::<Vec<_>>()
567                                .join(", ")
568                        )
569                    }
570                };
571
572                cmd = cmd.arg("--healthcheck").arg(cmd_str);
573
574                if let Some(i) = interval {
575                    cmd = cmd
576                        .arg("--healthcheck-interval")
577                        .arg(format!("{}s", i.as_secs()));
578                }
579
580                if let Some(t) = timeout {
581                    cmd = cmd
582                        .arg("--healthcheck-timeout")
583                        .arg(format!("{}s", t.as_secs()));
584                }
585
586                if let Some(sp) = start_period {
587                    cmd = cmd
588                        .arg("--healthcheck-start-period")
589                        .arg(format!("{}s", sp.as_secs()));
590                }
591
592                if let Some(r) = retries {
593                    cmd = cmd.arg("--healthcheck-retries").arg(r.to_string());
594                }
595
596                cmd.arg(container)
597            }
598        }
599    }
600
601    // =========================================================================
602    // Convert Instruction to Commands
603    // =========================================================================
604
605    /// Convert a Dockerfile instruction to buildah command(s)
606    ///
607    /// Some instructions map to multiple buildah commands (e.g., multiple ENV vars)
608    pub fn from_instruction(container: &str, instruction: &Instruction) -> Vec<Self> {
609        match instruction {
610            Instruction::Run(run) => {
611                // Use run_with_mounts if there are any mounts, otherwise use simple run
612                if run.mounts.is_empty() {
613                    vec![Self::run(container, &run.command)]
614                } else {
615                    vec![Self::run_with_mounts(container, run)]
616                }
617            }
618
619            Instruction::Copy(copy) => {
620                vec![Self::copy_instruction(container, copy)]
621            }
622
623            Instruction::Add(add) => {
624                vec![Self::add_instruction(container, add)]
625            }
626
627            Instruction::Env(env) => Self::config_envs(container, env),
628
629            Instruction::Workdir(dir) => {
630                vec![Self::config_workdir(container, dir)]
631            }
632
633            Instruction::Expose(expose) => {
634                vec![Self::config_expose(container, expose)]
635            }
636
637            Instruction::Label(labels) => Self::config_labels(container, labels),
638
639            Instruction::User(user) => {
640                vec![Self::config_user(container, user)]
641            }
642
643            Instruction::Entrypoint(cmd) => {
644                vec![Self::config_entrypoint(container, cmd)]
645            }
646
647            Instruction::Cmd(cmd) => {
648                vec![Self::config_cmd(container, cmd)]
649            }
650
651            Instruction::Volume(paths) => paths
652                .iter()
653                .map(|p| Self::config_volume(container, p))
654                .collect(),
655
656            Instruction::Shell(shell) => {
657                vec![Self::config_shell(container, shell)]
658            }
659
660            Instruction::Arg(_) => {
661                // ARG is handled during variable expansion, not as a buildah command
662                vec![]
663            }
664
665            Instruction::Stopsignal(signal) => {
666                vec![Self::config_stopsignal(container, signal)]
667            }
668
669            Instruction::Healthcheck(hc) => {
670                vec![Self::config_healthcheck(container, hc)]
671            }
672
673            Instruction::Onbuild(_) => {
674                // ONBUILD would need special handling
675                tracing::warn!("ONBUILD instruction not supported in buildah conversion");
676                vec![]
677            }
678        }
679    }
680}
681
682/// Escape a string for use in JSON
683fn escape_json_string(s: &str) -> String {
684    s.replace('\\', "\\\\")
685        .replace('"', "\\\"")
686        .replace('\n', "\\n")
687        .replace('\r', "\\r")
688        .replace('\t', "\\t")
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694    use crate::dockerfile::RunInstruction;
695
696    #[test]
697    fn test_from_image() {
698        let cmd = BuildahCommand::from_image("alpine:3.18");
699        assert_eq!(cmd.program, "buildah");
700        assert_eq!(cmd.args, vec!["from", "alpine:3.18"]);
701    }
702
703    #[test]
704    fn test_run_shell() {
705        let cmd = BuildahCommand::run_shell("container-1", "apt-get update");
706        assert_eq!(
707            cmd.args,
708            vec![
709                "run",
710                "container-1",
711                "--",
712                "/bin/sh",
713                "-c",
714                "apt-get update"
715            ]
716        );
717    }
718
719    #[test]
720    fn test_run_exec() {
721        let args = vec!["echo".to_string(), "hello".to_string()];
722        let cmd = BuildahCommand::run_exec("container-1", &args);
723        assert_eq!(cmd.args, vec!["run", "container-1", "--", "echo", "hello"]);
724    }
725
726    #[test]
727    fn test_copy() {
728        let sources = vec!["src/".to_string(), "Cargo.toml".to_string()];
729        let cmd = BuildahCommand::copy("container-1", &sources, "/app/");
730        assert_eq!(
731            cmd.args,
732            vec!["copy", "container-1", "src/", "Cargo.toml", "/app/"]
733        );
734    }
735
736    #[test]
737    fn test_copy_from() {
738        let sources = vec!["/app".to_string()];
739        let cmd = BuildahCommand::copy_from("container-1", "builder", &sources, "/app");
740        assert_eq!(
741            cmd.args,
742            vec!["copy", "--from", "builder", "container-1", "/app", "/app"]
743        );
744    }
745
746    #[test]
747    fn test_config_env() {
748        let cmd = BuildahCommand::config_env("container-1", "PATH", "/usr/local/bin");
749        assert_eq!(
750            cmd.args,
751            vec!["config", "--env", "PATH=/usr/local/bin", "container-1"]
752        );
753    }
754
755    #[test]
756    fn test_config_workdir() {
757        let cmd = BuildahCommand::config_workdir("container-1", "/app");
758        assert_eq!(
759            cmd.args,
760            vec!["config", "--workingdir", "/app", "container-1"]
761        );
762    }
763
764    #[test]
765    fn test_config_entrypoint_exec() {
766        let args = vec!["/app".to_string(), "--config".to_string()];
767        let cmd = BuildahCommand::config_entrypoint_exec("container-1", &args);
768        assert!(cmd.args.contains(&"--entrypoint".to_string()));
769        assert!(cmd
770            .args
771            .iter()
772            .any(|a| a.contains("[") && a.contains("/app")));
773    }
774
775    #[test]
776    fn test_commit() {
777        let cmd = BuildahCommand::commit("container-1", "myimage:latest");
778        assert_eq!(cmd.args, vec!["commit", "container-1", "myimage:latest"]);
779    }
780
781    #[test]
782    fn test_to_command_string() {
783        let cmd = BuildahCommand::config_env("container-1", "VAR", "value with spaces");
784        let s = cmd.to_command_string();
785        assert!(s.starts_with("buildah config"));
786        assert!(s.contains("VAR=value with spaces"));
787    }
788
789    #[test]
790    fn test_from_instruction_run() {
791        let instruction = Instruction::Run(RunInstruction {
792            command: ShellOrExec::Shell("echo hello".to_string()),
793            mounts: vec![],
794            network: None,
795            security: None,
796        });
797
798        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
799        assert_eq!(cmds.len(), 1);
800        assert!(cmds[0].args.contains(&"run".to_string()));
801    }
802
803    #[test]
804    fn test_from_instruction_env_multiple() {
805        let mut vars = HashMap::new();
806        vars.insert("FOO".to_string(), "bar".to_string());
807        vars.insert("BAZ".to_string(), "qux".to_string());
808
809        let instruction = Instruction::Env(EnvInstruction { vars });
810        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
811
812        // Should produce two config commands (one per env var)
813        assert_eq!(cmds.len(), 2);
814        for cmd in &cmds {
815            assert!(cmd.args.contains(&"config".to_string()));
816            assert!(cmd.args.contains(&"--env".to_string()));
817        }
818    }
819
820    #[test]
821    fn test_escape_json_string() {
822        assert_eq!(escape_json_string("hello"), "hello");
823        assert_eq!(escape_json_string("hello \"world\""), "hello \\\"world\\\"");
824        assert_eq!(escape_json_string("line1\nline2"), "line1\\nline2");
825    }
826
827    #[test]
828    fn test_run_with_mounts_cache() {
829        use crate::dockerfile::{CacheSharing, RunMount};
830
831        let run = RunInstruction {
832            command: ShellOrExec::Shell("apt-get update".to_string()),
833            mounts: vec![RunMount::Cache {
834                target: "/var/cache/apt".to_string(),
835                id: Some("apt-cache".to_string()),
836                sharing: CacheSharing::Shared,
837                readonly: false,
838            }],
839            network: None,
840            security: None,
841        };
842
843        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
844
845        // Verify --mount comes BEFORE container ID
846        let mount_idx = cmd
847            .args
848            .iter()
849            .position(|a| a.starts_with("--mount="))
850            .expect("should have --mount arg");
851        let container_idx = cmd
852            .args
853            .iter()
854            .position(|a| a == "container-1")
855            .expect("should have container id");
856
857        assert!(
858            mount_idx < container_idx,
859            "--mount should come before container ID"
860        );
861
862        // Verify mount argument content
863        assert!(cmd.args[mount_idx].contains("type=cache"));
864        assert!(cmd.args[mount_idx].contains("target=/var/cache/apt"));
865        assert!(cmd.args[mount_idx].contains("id=apt-cache"));
866        assert!(cmd.args[mount_idx].contains("sharing=shared"));
867    }
868
869    #[test]
870    fn test_run_with_multiple_mounts() {
871        use crate::dockerfile::{CacheSharing, RunMount};
872
873        let run = RunInstruction {
874            command: ShellOrExec::Shell("cargo build".to_string()),
875            mounts: vec![
876                RunMount::Cache {
877                    target: "/usr/local/cargo/registry".to_string(),
878                    id: Some("cargo-registry".to_string()),
879                    sharing: CacheSharing::Shared,
880                    readonly: false,
881                },
882                RunMount::Cache {
883                    target: "/app/target".to_string(),
884                    id: Some("cargo-target".to_string()),
885                    sharing: CacheSharing::Locked,
886                    readonly: false,
887                },
888            ],
889            network: None,
890            security: None,
891        };
892
893        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
894
895        // Count --mount arguments
896        let mount_count = cmd
897            .args
898            .iter()
899            .filter(|a| a.starts_with("--mount="))
900            .count();
901        assert_eq!(mount_count, 2, "should have 2 mount arguments");
902
903        // Verify all mounts come before container ID
904        let container_idx = cmd
905            .args
906            .iter()
907            .position(|a| a == "container-1")
908            .expect("should have container id");
909
910        for (idx, arg) in cmd.args.iter().enumerate() {
911            if arg.starts_with("--mount=") {
912                assert!(
913                    idx < container_idx,
914                    "--mount at index {} should come before container ID at {}",
915                    idx,
916                    container_idx
917                );
918            }
919        }
920    }
921
922    #[test]
923    fn test_from_instruction_run_with_mounts() {
924        use crate::dockerfile::{CacheSharing, RunMount};
925
926        let instruction = Instruction::Run(RunInstruction {
927            command: ShellOrExec::Shell("npm install".to_string()),
928            mounts: vec![RunMount::Cache {
929                target: "/root/.npm".to_string(),
930                id: Some("npm-cache".to_string()),
931                sharing: CacheSharing::Shared,
932                readonly: false,
933            }],
934            network: None,
935            security: None,
936        });
937
938        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
939        assert_eq!(cmds.len(), 1);
940
941        let cmd = &cmds[0];
942        assert!(
943            cmd.args.iter().any(|a| a.starts_with("--mount=")),
944            "should include --mount argument"
945        );
946    }
947
948    #[test]
949    fn test_run_with_mounts_exec_form() {
950        use crate::dockerfile::{CacheSharing, RunMount};
951
952        let run = RunInstruction {
953            command: ShellOrExec::Exec(vec![
954                "pip".to_string(),
955                "install".to_string(),
956                "-r".to_string(),
957                "requirements.txt".to_string(),
958            ]),
959            mounts: vec![RunMount::Cache {
960                target: "/root/.cache/pip".to_string(),
961                id: Some("pip-cache".to_string()),
962                sharing: CacheSharing::Shared,
963                readonly: false,
964            }],
965            network: None,
966            security: None,
967        };
968
969        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
970
971        // Should have mount, container, --, and then the exec args
972        assert!(cmd.args.contains(&"--".to_string()));
973        assert!(cmd.args.contains(&"pip".to_string()));
974        assert!(cmd.args.contains(&"install".to_string()));
975    }
976}