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