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::backend::ImageOs;
35use crate::dockerfile::{
36    AddInstruction, CopyInstruction, EnvInstruction, ExposeInstruction, HealthcheckInstruction,
37    Instruction, RunInstruction, ShellOrExec,
38};
39
40use std::collections::HashMap;
41
42/// Default shell used for `RUN <cmd>` (shell form) on Linux targets.
43///
44/// Matches the historical default used by Docker / buildah and keeps the
45/// generated buildah command byte-identical to what we emitted before the
46/// OS-aware translator landed.
47const LINUX_DEFAULT_SHELL: &[&str] = &["/bin/sh", "-c"];
48
49/// Default shell used for `RUN <cmd>` (shell form) on Windows targets.
50///
51/// Matches Docker's Windows default (`cmd /S /C`) used when no `SHELL`
52/// instruction has overridden it.
53const WINDOWS_DEFAULT_SHELL: &[&str] = &["cmd.exe", "/S", "/C"];
54
55/// Return the default shell-form prefix for an OS when no `SHELL` instruction
56/// has been seen.
57fn 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/// A buildah command ready for execution
66#[derive(Debug, Clone)]
67pub struct BuildahCommand {
68    /// The program to execute (typically "buildah")
69    pub program: String,
70
71    /// Command arguments
72    pub args: Vec<String>,
73
74    /// Optional environment variables for the command
75    pub env: HashMap<String, String>,
76}
77
78impl BuildahCommand {
79    /// Create a new buildah command
80    #[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    /// Add an argument
90    #[must_use]
91    pub fn arg(mut self, arg: impl Into<String>) -> Self {
92        self.args.push(arg.into());
93        self
94    }
95
96    /// Add multiple arguments
97    #[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    /// Add an optional argument (only added if value is Some)
104    #[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    /// Add an environment variable for command execution
114    #[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    /// Convert to a command line string for display/logging
121    #[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    // =========================================================================
135    // Container Lifecycle Commands
136    // =========================================================================
137
138    /// Create a new working container from an image
139    ///
140    /// `buildah from <image>`
141    #[must_use]
142    pub fn from_image(image: &str) -> Self {
143        Self::new("from").arg(image)
144    }
145
146    /// Create a new working container from an image with a specific name
147    ///
148    /// `buildah from --name <name> <image>`
149    #[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    /// Create a scratch container
155    ///
156    /// `buildah from scratch`
157    #[must_use]
158    pub fn from_scratch() -> Self {
159        Self::new("from").arg("scratch")
160    }
161
162    /// Remove a working container
163    ///
164    /// `buildah rm <container>`
165    #[must_use]
166    pub fn rm(container: &str) -> Self {
167        Self::new("rm").arg(container)
168    }
169
170    /// Commit a container to create an image
171    ///
172    /// `buildah commit <container> <image>`
173    #[must_use]
174    pub fn commit(container: &str, image_name: &str) -> Self {
175        Self::new("commit").arg(container).arg(image_name)
176    }
177
178    /// Commit with additional options
179    #[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    /// Tag an image with a new name
200    ///
201    /// `buildah tag <image> <new-name>`
202    #[must_use]
203    pub fn tag(image: &str, new_name: &str) -> Self {
204        Self::new("tag").arg(image).arg(new_name)
205    }
206
207    /// Remove an image
208    ///
209    /// `buildah rmi <image>`
210    #[must_use]
211    pub fn rmi(image: &str) -> Self {
212        Self::new("rmi").arg(image)
213    }
214
215    /// Push an image to a registry
216    ///
217    /// `buildah push <image>`
218    #[must_use]
219    pub fn push(image: &str) -> Self {
220        Self::new("push").arg(image)
221    }
222
223    /// Push an image to a registry with options
224    ///
225    /// `buildah push [options] <image> [destination]`
226    #[must_use]
227    pub fn push_to(image: &str, destination: &str) -> Self {
228        Self::new("push").arg(image).arg(destination)
229    }
230
231    /// Inspect an image or container
232    ///
233    /// `buildah inspect <name>`
234    #[must_use]
235    pub fn inspect(name: &str) -> Self {
236        Self::new("inspect").arg(name)
237    }
238
239    /// Inspect an image or container with format
240    ///
241    /// `buildah inspect --format <format> <name>`
242    #[must_use]
243    pub fn inspect_format(name: &str, format: &str) -> Self {
244        Self::new("inspect").arg("--format").arg(format).arg(name)
245    }
246
247    /// List images
248    ///
249    /// `buildah images`
250    #[must_use]
251    pub fn images() -> Self {
252        Self::new("images")
253    }
254
255    /// List containers
256    ///
257    /// `buildah containers`
258    #[must_use]
259    pub fn containers() -> Self {
260        Self::new("containers")
261    }
262
263    // =========================================================================
264    // Run Commands
265    // =========================================================================
266
267    /// Run a command in the container (shell form) using the Linux default shell.
268    ///
269    /// `buildah run <container> -- /bin/sh -c "<command>"`
270    ///
271    /// For OS-aware translation (Windows targets, or honoring a `SHELL`
272    /// override), prefer [`Self::run_shell_custom`] or the stateful
273    /// [`DockerfileTranslator`].
274    #[must_use]
275    pub fn run_shell(container: &str, command: &str) -> Self {
276        Self::run_shell_custom(container, LINUX_DEFAULT_SHELL, command)
277    }
278
279    /// Run a command in the container (shell form) using an explicit shell.
280    ///
281    /// `buildah run <container> -- <shell...> <command>`
282    ///
283    /// The `shell` slice is emitted verbatim before the command argument, e.g.
284    /// `["cmd.exe", "/S", "/C"]` for Windows or `["/bin/bash", "-lc"]` for a
285    /// bash-login override.
286    #[must_use]
287    pub fn run_shell_custom(
288        container: &str,
289        shell: impl IntoIterator<Item = impl AsRef<str>>,
290        command: &str,
291    ) -> Self {
292        let mut cmd = Self::new("run").arg(container).arg("--");
293        for s in shell {
294            cmd = cmd.arg(s.as_ref().to_string());
295        }
296        cmd.arg(command)
297    }
298
299    /// Run a command in the container (shell form) using the OS default shell.
300    ///
301    /// Linux → `/bin/sh -c`, Windows → `cmd.exe /S /C`.
302    #[must_use]
303    pub fn run_shell_for_os(container: &str, command: &str, os: ImageOs) -> Self {
304        let shell = default_shell_for(os);
305        Self::run_shell_custom(container, &shell, command)
306    }
307
308    /// Run a command in the container (exec form)
309    ///
310    /// `buildah run <container> -- <args...>`
311    #[must_use]
312    pub fn run_exec(container: &str, args: &[String]) -> Self {
313        let mut cmd = Self::new("run").arg(container).arg("--");
314        for arg in args {
315            cmd = cmd.arg(arg);
316        }
317        cmd
318    }
319
320    /// Run a command based on `ShellOrExec`
321    #[must_use]
322    pub fn run(container: &str, command: &ShellOrExec) -> Self {
323        match command {
324            ShellOrExec::Shell(s) => Self::run_shell(container, s),
325            ShellOrExec::Exec(args) => Self::run_exec(container, args),
326        }
327    }
328
329    /// Run a command with mount specifications from a `RunInstruction`.
330    ///
331    /// Buildah requires `--mount` arguments to appear BEFORE the container ID:
332    /// `buildah run [--mount=...] <container> -- <command>`
333    ///
334    /// This method properly orders the arguments to ensure mounts are applied.
335    ///
336    /// Uses the Linux default shell (`/bin/sh -c`) for shell-form commands.
337    /// For OS-aware translation use [`Self::run_with_mounts_shell`].
338    #[must_use]
339    pub fn run_with_mounts(container: &str, run: &RunInstruction) -> Self {
340        Self::run_with_mounts_shell(container, run, LINUX_DEFAULT_SHELL)
341    }
342
343    /// Run a command with mount specifications, using an explicit shell for
344    /// shell-form commands.
345    ///
346    /// Exec-form commands ignore `shell` and are emitted verbatim, matching
347    /// Docker/Buildah semantics.
348    #[must_use]
349    pub fn run_with_mounts_shell(
350        container: &str,
351        run: &RunInstruction,
352        shell: impl IntoIterator<Item = impl AsRef<str>>,
353    ) -> Self {
354        let mut cmd = Self::new("run");
355
356        // Add --mount arguments BEFORE the container ID
357        for mount in &run.mounts {
358            cmd = cmd.arg(format!("--mount={}", mount.to_buildah_arg()));
359        }
360
361        // Now add container and the command
362        cmd = cmd.arg(container).arg("--");
363
364        match &run.command {
365            ShellOrExec::Shell(s) => {
366                for part in shell {
367                    cmd = cmd.arg(part.as_ref().to_string());
368                }
369                cmd.arg(s)
370            }
371            ShellOrExec::Exec(args) => {
372                for arg in args {
373                    cmd = cmd.arg(arg);
374                }
375                cmd
376            }
377        }
378    }
379
380    // =========================================================================
381    // Copy/Add Commands
382    // =========================================================================
383
384    /// Copy files into the container
385    ///
386    /// `buildah copy <container> <src...> <dest>`
387    #[must_use]
388    pub fn copy(container: &str, sources: &[String], dest: &str) -> Self {
389        let mut cmd = Self::new("copy").arg(container);
390        for src in sources {
391            cmd = cmd.arg(src);
392        }
393        cmd.arg(dest)
394    }
395
396    /// Copy files from another container/image
397    ///
398    /// `buildah copy --from=<source> <container> <src...> <dest>`
399    #[must_use]
400    pub fn copy_from(container: &str, from: &str, sources: &[String], dest: &str) -> Self {
401        let mut cmd = Self::new("copy").arg("--from").arg(from).arg(container);
402        for src in sources {
403            cmd = cmd.arg(src);
404        }
405        cmd.arg(dest)
406    }
407
408    /// Copy with all options from `CopyInstruction`
409    #[must_use]
410    pub fn copy_instruction(container: &str, copy: &CopyInstruction) -> Self {
411        let mut cmd = Self::new("copy");
412
413        if let Some(ref from) = copy.from {
414            cmd = cmd.arg("--from").arg(from);
415        }
416
417        if let Some(ref chown) = copy.chown {
418            cmd = cmd.arg("--chown").arg(chown);
419        }
420
421        if let Some(ref chmod) = copy.chmod {
422            cmd = cmd.arg("--chmod").arg(chmod);
423        }
424
425        cmd = cmd.arg(container);
426
427        for src in &copy.sources {
428            cmd = cmd.arg(src);
429        }
430
431        cmd.arg(&copy.destination)
432    }
433
434    /// Add files (like copy but with URL support and extraction)
435    #[must_use]
436    pub fn add(container: &str, sources: &[String], dest: &str) -> Self {
437        let mut cmd = Self::new("add").arg(container);
438        for src in sources {
439            cmd = cmd.arg(src);
440        }
441        cmd.arg(dest)
442    }
443
444    /// Add with all options from `AddInstruction`
445    #[must_use]
446    pub fn add_instruction(container: &str, add: &AddInstruction) -> Self {
447        let mut cmd = Self::new("add");
448
449        if let Some(ref chown) = add.chown {
450            cmd = cmd.arg("--chown").arg(chown);
451        }
452
453        if let Some(ref chmod) = add.chmod {
454            cmd = cmd.arg("--chmod").arg(chmod);
455        }
456
457        cmd = cmd.arg(container);
458
459        for src in &add.sources {
460            cmd = cmd.arg(src);
461        }
462
463        cmd.arg(&add.destination)
464    }
465
466    // =========================================================================
467    // Config Commands
468    // =========================================================================
469
470    /// Set an environment variable
471    ///
472    /// `buildah config --env KEY=VALUE <container>`
473    #[must_use]
474    pub fn config_env(container: &str, key: &str, value: &str) -> Self {
475        Self::new("config")
476            .arg("--env")
477            .arg(format!("{key}={value}"))
478            .arg(container)
479    }
480
481    /// Set multiple environment variables
482    #[must_use]
483    pub fn config_envs(container: &str, env: &EnvInstruction) -> Vec<Self> {
484        env.vars
485            .iter()
486            .map(|(k, v)| Self::config_env(container, k, v))
487            .collect()
488    }
489
490    /// Set the working directory
491    ///
492    /// `buildah config --workingdir <dir> <container>`
493    #[must_use]
494    pub fn config_workdir(container: &str, dir: &str) -> Self {
495        Self::new("config")
496            .arg("--workingdir")
497            .arg(dir)
498            .arg(container)
499    }
500
501    /// Expose a port
502    ///
503    /// `buildah config --port <port>/<proto> <container>`
504    #[must_use]
505    pub fn config_expose(container: &str, expose: &ExposeInstruction) -> Self {
506        let port_spec = format!(
507            "{}/{}",
508            expose.port,
509            match expose.protocol {
510                crate::dockerfile::ExposeProtocol::Tcp => "tcp",
511                crate::dockerfile::ExposeProtocol::Udp => "udp",
512            }
513        );
514        Self::new("config")
515            .arg("--port")
516            .arg(port_spec)
517            .arg(container)
518    }
519
520    /// Set the entrypoint (shell form)
521    ///
522    /// `buildah config --entrypoint '<command>' <container>`
523    #[must_use]
524    pub fn config_entrypoint_shell(container: &str, command: &str) -> Self {
525        Self::new("config")
526            .arg("--entrypoint")
527            .arg(format!(
528                "[\"/bin/sh\", \"-c\", \"{}\"]",
529                escape_json_string(command)
530            ))
531            .arg(container)
532    }
533
534    /// Set the entrypoint (exec form)
535    ///
536    /// `buildah config --entrypoint '["exe", "arg1"]' <container>`
537    #[must_use]
538    pub fn config_entrypoint_exec(container: &str, args: &[String]) -> Self {
539        let json_array = format!(
540            "[{}]",
541            args.iter()
542                .map(|a| format!("\"{}\"", escape_json_string(a)))
543                .collect::<Vec<_>>()
544                .join(", ")
545        );
546        Self::new("config")
547            .arg("--entrypoint")
548            .arg(json_array)
549            .arg(container)
550    }
551
552    /// Set the entrypoint based on `ShellOrExec`
553    #[must_use]
554    pub fn config_entrypoint(container: &str, command: &ShellOrExec) -> Self {
555        match command {
556            ShellOrExec::Shell(s) => Self::config_entrypoint_shell(container, s),
557            ShellOrExec::Exec(args) => Self::config_entrypoint_exec(container, args),
558        }
559    }
560
561    /// Set the default command (shell form)
562    #[must_use]
563    pub fn config_cmd_shell(container: &str, command: &str) -> Self {
564        Self::new("config")
565            .arg("--cmd")
566            .arg(format!("/bin/sh -c \"{}\"", escape_json_string(command)))
567            .arg(container)
568    }
569
570    /// Set the default command (exec form)
571    #[must_use]
572    pub fn config_cmd_exec(container: &str, args: &[String]) -> Self {
573        let json_array = format!(
574            "[{}]",
575            args.iter()
576                .map(|a| format!("\"{}\"", escape_json_string(a)))
577                .collect::<Vec<_>>()
578                .join(", ")
579        );
580        Self::new("config")
581            .arg("--cmd")
582            .arg(json_array)
583            .arg(container)
584    }
585
586    /// Set the default command based on `ShellOrExec`
587    #[must_use]
588    pub fn config_cmd(container: &str, command: &ShellOrExec) -> Self {
589        match command {
590            ShellOrExec::Shell(s) => Self::config_cmd_shell(container, s),
591            ShellOrExec::Exec(args) => Self::config_cmd_exec(container, args),
592        }
593    }
594
595    /// Set the user
596    ///
597    /// `buildah config --user <user> <container>`
598    #[must_use]
599    pub fn config_user(container: &str, user: &str) -> Self {
600        Self::new("config").arg("--user").arg(user).arg(container)
601    }
602
603    /// Set a label
604    ///
605    /// `buildah config --label KEY=VALUE <container>`
606    #[must_use]
607    pub fn config_label(container: &str, key: &str, value: &str) -> Self {
608        Self::new("config")
609            .arg("--label")
610            .arg(format!("{key}={value}"))
611            .arg(container)
612    }
613
614    /// Set multiple labels
615    #[must_use]
616    pub fn config_labels(container: &str, labels: &HashMap<String, String>) -> Vec<Self> {
617        labels
618            .iter()
619            .map(|(k, v)| Self::config_label(container, k, v))
620            .collect()
621    }
622
623    /// Set volumes
624    ///
625    /// `buildah config --volume <path> <container>`
626    #[must_use]
627    pub fn config_volume(container: &str, path: &str) -> Self {
628        Self::new("config").arg("--volume").arg(path).arg(container)
629    }
630
631    /// Set the stop signal
632    ///
633    /// `buildah config --stop-signal <signal> <container>`
634    #[must_use]
635    pub fn config_stopsignal(container: &str, signal: &str) -> Self {
636        Self::new("config")
637            .arg("--stop-signal")
638            .arg(signal)
639            .arg(container)
640    }
641
642    /// Set the shell
643    ///
644    /// `buildah config --shell '["shell", "args"]' <container>`
645    #[must_use]
646    pub fn config_shell(container: &str, shell: &[String]) -> Self {
647        let json_array = format!(
648            "[{}]",
649            shell
650                .iter()
651                .map(|a| format!("\"{}\"", escape_json_string(a)))
652                .collect::<Vec<_>>()
653                .join(", ")
654        );
655        Self::new("config")
656            .arg("--shell")
657            .arg(json_array)
658            .arg(container)
659    }
660
661    /// Set healthcheck
662    #[must_use]
663    pub fn config_healthcheck(container: &str, healthcheck: &HealthcheckInstruction) -> Self {
664        match healthcheck {
665            HealthcheckInstruction::None => Self::new("config")
666                .arg("--healthcheck")
667                .arg("NONE")
668                .arg(container),
669            HealthcheckInstruction::Check {
670                command,
671                interval,
672                timeout,
673                start_period,
674                retries,
675                ..
676            } => {
677                let mut cmd = Self::new("config");
678
679                let cmd_str = match command {
680                    ShellOrExec::Shell(s) => format!("CMD {s}"),
681                    ShellOrExec::Exec(args) => {
682                        format!(
683                            "CMD [{}]",
684                            args.iter()
685                                .map(|a| format!("\"{}\"", escape_json_string(a)))
686                                .collect::<Vec<_>>()
687                                .join(", ")
688                        )
689                    }
690                };
691
692                cmd = cmd.arg("--healthcheck").arg(cmd_str);
693
694                if let Some(i) = interval {
695                    cmd = cmd
696                        .arg("--healthcheck-interval")
697                        .arg(format!("{}s", i.as_secs()));
698                }
699
700                if let Some(t) = timeout {
701                    cmd = cmd
702                        .arg("--healthcheck-timeout")
703                        .arg(format!("{}s", t.as_secs()));
704                }
705
706                if let Some(sp) = start_period {
707                    cmd = cmd
708                        .arg("--healthcheck-start-period")
709                        .arg(format!("{}s", sp.as_secs()));
710                }
711
712                if let Some(r) = retries {
713                    cmd = cmd.arg("--healthcheck-retries").arg(r.to_string());
714                }
715
716                cmd.arg(container)
717            }
718        }
719    }
720
721    // =========================================================================
722    // Manifest Commands
723    // =========================================================================
724
725    /// Create a new manifest list.
726    ///
727    /// `buildah manifest create <name>`
728    #[must_use]
729    pub fn manifest_create(name: &str) -> Self {
730        Self::new("manifest").arg("create").arg(name)
731    }
732
733    /// Add an image to a manifest list.
734    ///
735    /// `buildah manifest add <list> <image>`
736    #[must_use]
737    pub fn manifest_add(list: &str, image: &str) -> Self {
738        Self::new("manifest").arg("add").arg(list).arg(image)
739    }
740
741    /// Push a manifest list and all referenced images.
742    ///
743    /// `buildah manifest push --all <list> <destination>`
744    #[must_use]
745    pub fn manifest_push(list: &str, destination: &str) -> Self {
746        Self::new("manifest")
747            .arg("push")
748            .arg("--all")
749            .arg(list)
750            .arg(destination)
751    }
752
753    /// Remove a manifest list.
754    ///
755    /// `buildah manifest rm <list>`
756    #[must_use]
757    pub fn manifest_rm(list: &str) -> Self {
758        Self::new("manifest").arg("rm").arg(list)
759    }
760
761    // =========================================================================
762    // Convert Instruction to Commands
763    // =========================================================================
764
765    /// Convert a Dockerfile instruction to buildah command(s) using the Linux
766    /// default shell (`/bin/sh -c`) and POSIX `mkdir -p` semantics.
767    ///
768    /// This is a convenience wrapper around [`DockerfileTranslator`] with
769    /// `target_os = ImageOs::Linux` and no `SHELL` override. It preserves the
770    /// historical byte-for-byte behavior for every call site that existed
771    /// before OS-aware translation landed in Phase L-3.
772    ///
773    /// For Windows targets or when a Dockerfile-level `SHELL` instruction
774    /// needs to be honored across subsequent `RUN`s, construct a
775    /// [`DockerfileTranslator`] explicitly and call
776    /// [`DockerfileTranslator::translate`].
777    ///
778    /// Some instructions map to multiple buildah commands (e.g., multiple
779    /// ENV vars, or WORKDIR emitting both `mkdir` and `config --workingdir`).
780    #[must_use]
781    pub fn from_instruction(container: &str, instruction: &Instruction) -> Vec<Self> {
782        DockerfileTranslator::new(ImageOs::Linux).translate(container, instruction)
783    }
784}
785
786/// Stateful translator from [`Instruction`] to [`BuildahCommand`] sequences.
787///
788/// Tracks the target OS and the most recent `SHELL` instruction so that
789/// shell-form `RUN` / `CMD` / `ENTRYPOINT` use the correct shell for the
790/// target platform:
791///
792/// - **Linux, no SHELL override** — `RUN cmd` → `buildah run -- /bin/sh -c "cmd"`
793/// - **Windows, no SHELL override** — `RUN cmd` → `buildah run -- cmd.exe /S /C "cmd"`
794/// - **Any OS with `SHELL ["pwsh", "-Command"]`** — subsequent `RUN cmd` uses
795///   `buildah run -- pwsh -Command "cmd"`
796///
797/// The translator is stateful because Dockerfile `SHELL` instructions persist
798/// across subsequent `RUN`/`CMD`/`ENTRYPOINT` translations until another
799/// `SHELL` replaces them. Callers that translate a multi-instruction stage
800/// should reuse a single translator instance across the full instruction
801/// stream.
802///
803/// This translator is designed to be shared between the buildah backend and
804/// the Phase L-4 HCS (Windows host compute service) backend, so neither needs
805/// to re-implement the shell-form / workdir branching.
806#[derive(Debug, Clone)]
807pub struct DockerfileTranslator {
808    target_os: ImageOs,
809    /// Most recent `SHELL` instruction override, if any. When `None` the
810    /// translator falls back to [`default_shell_for`] for the target OS.
811    shell_override: Option<Vec<String>>,
812}
813
814impl DockerfileTranslator {
815    /// Create a new translator for a given target OS, with no `SHELL` override.
816    #[must_use]
817    pub fn new(target_os: ImageOs) -> Self {
818        Self {
819            target_os,
820            shell_override: None,
821        }
822    }
823
824    /// Return the target OS this translator emits commands for.
825    #[must_use]
826    pub fn target_os(&self) -> ImageOs {
827        self.target_os
828    }
829
830    /// Return the current shell-form prefix: the `SHELL` override if one was
831    /// applied, else the OS default (`/bin/sh -c` on Linux, `cmd.exe /S /C` on
832    /// Windows).
833    #[must_use]
834    pub fn active_shell(&self) -> Vec<String> {
835        match &self.shell_override {
836            Some(s) => s.clone(),
837            None => default_shell_for(self.target_os),
838        }
839    }
840
841    /// Replace the translator's `SHELL` override, matching the effect of a
842    /// Dockerfile `SHELL ["…"]` instruction on subsequent RUN/CMD/ENTRYPOINT
843    /// shell-form commands.
844    pub fn set_shell_override(&mut self, shell: Vec<String>) {
845        self.shell_override = Some(shell);
846    }
847
848    /// Translate a single instruction into zero or more [`BuildahCommand`]s.
849    ///
850    /// Stateful: `SHELL` instructions update the translator's shell override,
851    /// so subsequent `RUN` / `CMD` / `ENTRYPOINT` shell-form translations pick
852    /// up the new shell. `WORKDIR` emits an OS-appropriate pre-mkdir followed
853    /// by `buildah config --workingdir`.
854    #[allow(clippy::too_many_lines)]
855    pub fn translate(&mut self, container: &str, instruction: &Instruction) -> Vec<BuildahCommand> {
856        match instruction {
857            Instruction::Run(run) => {
858                let shell = self.active_shell();
859                if run.mounts.is_empty() {
860                    match &run.command {
861                        ShellOrExec::Shell(s) => {
862                            vec![BuildahCommand::run_shell_custom(container, &shell, s)]
863                        }
864                        ShellOrExec::Exec(args) => vec![BuildahCommand::run_exec(container, args)],
865                    }
866                } else {
867                    vec![BuildahCommand::run_with_mounts_shell(
868                        container, run, &shell,
869                    )]
870                }
871            }
872
873            Instruction::Copy(copy) => {
874                vec![BuildahCommand::copy_instruction(container, copy)]
875            }
876
877            Instruction::Add(add) => {
878                vec![BuildahCommand::add_instruction(container, add)]
879            }
880
881            Instruction::Env(env) => BuildahCommand::config_envs(container, env),
882
883            Instruction::Workdir(dir) => self.translate_workdir(container, dir),
884
885            Instruction::Expose(expose) => {
886                vec![BuildahCommand::config_expose(container, expose)]
887            }
888
889            Instruction::Label(labels) => BuildahCommand::config_labels(container, labels),
890
891            Instruction::User(user) => {
892                vec![BuildahCommand::config_user(container, user)]
893            }
894
895            Instruction::Entrypoint(cmd) => {
896                vec![BuildahCommand::config_entrypoint(container, cmd)]
897            }
898
899            Instruction::Cmd(cmd) => {
900                vec![BuildahCommand::config_cmd(container, cmd)]
901            }
902
903            Instruction::Volume(paths) => paths
904                .iter()
905                .map(|p| BuildahCommand::config_volume(container, p))
906                .collect(),
907
908            Instruction::Shell(shell) => {
909                // SHELL instruction: update the translator's shell override
910                // AND emit the metadata config --shell so committed images
911                // record the user-declared shell. Both matter: the override
912                // changes how we translate subsequent RUN/CMD/ENTRYPOINT
913                // shell-form instructions in THIS build; the metadata is what
914                // tools like `docker inspect` show on the resulting image.
915                self.set_shell_override(shell.clone());
916                vec![BuildahCommand::config_shell(container, shell)]
917            }
918
919            Instruction::Arg(_) => {
920                // ARG is handled during variable expansion, not as a buildah command
921                vec![]
922            }
923
924            Instruction::Stopsignal(signal) => {
925                vec![BuildahCommand::config_stopsignal(container, signal)]
926            }
927
928            Instruction::Healthcheck(hc) => {
929                vec![BuildahCommand::config_healthcheck(container, hc)]
930            }
931
932            Instruction::Onbuild(_) => {
933                // ONBUILD would need special handling
934                tracing::warn!("ONBUILD instruction not supported in buildah conversion");
935                vec![]
936            }
937        }
938    }
939
940    /// Emit the commands needed to realise a `WORKDIR <dir>` instruction for
941    /// the target OS.
942    ///
943    /// # Linux
944    ///
945    /// Emits `mkdir -p <dir>` followed by `buildah config --workingdir`. This
946    /// matches Docker's WORKDIR semantics: the directory must exist in the
947    /// rootfs before a process can `chdir()` into it, and `buildah config
948    /// --workingdir` alone is metadata-only.
949    ///
950    /// # Windows
951    ///
952    /// Emits `cmd /S /C "if not exist <dir> mkdir <dir>"` before the
953    /// metadata write. Windows `mkdir` (unlike `mkdir -p`) errors out when the
954    /// directory exists, so we guard with `if not exist` to stay idempotent
955    /// across repeated WORKDIR instructions in the same Dockerfile.
956    fn translate_workdir(&self, container: &str, dir: &str) -> Vec<BuildahCommand> {
957        match self.target_os {
958            ImageOs::Linux => {
959                vec![
960                    BuildahCommand::run_exec(
961                        container,
962                        &["mkdir".to_string(), "-p".to_string(), dir.to_string()],
963                    ),
964                    BuildahCommand::config_workdir(container, dir),
965                ]
966            }
967            ImageOs::Windows => {
968                // Quote the path so paths with spaces (e.g. `C:\Program Files\app`)
969                // survive cmd.exe parsing. `cmd /S /C` strips the outer quotes
970                // before executing, so double-quote the full command and escape
971                // any inner quotes.
972                let guarded = format!(r#"if not exist "{dir}" mkdir "{dir}""#);
973                vec![
974                    BuildahCommand::run_shell_custom(container, WINDOWS_DEFAULT_SHELL, &guarded),
975                    BuildahCommand::config_workdir(container, dir),
976                ]
977            }
978        }
979    }
980}
981
982/// Escape a string for use in JSON
983fn escape_json_string(s: &str) -> String {
984    s.replace('\\', "\\\\")
985        .replace('"', "\\\"")
986        .replace('\n', "\\n")
987        .replace('\r', "\\r")
988        .replace('\t', "\\t")
989}
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994    use crate::dockerfile::RunInstruction;
995
996    #[test]
997    fn test_from_image() {
998        let cmd = BuildahCommand::from_image("alpine:3.18");
999        assert_eq!(cmd.program, "buildah");
1000        assert_eq!(cmd.args, vec!["from", "alpine:3.18"]);
1001    }
1002
1003    #[test]
1004    fn test_run_shell() {
1005        let cmd = BuildahCommand::run_shell("container-1", "apt-get update");
1006        assert_eq!(
1007            cmd.args,
1008            vec![
1009                "run",
1010                "container-1",
1011                "--",
1012                "/bin/sh",
1013                "-c",
1014                "apt-get update"
1015            ]
1016        );
1017    }
1018
1019    #[test]
1020    fn test_run_exec() {
1021        let args = vec!["echo".to_string(), "hello".to_string()];
1022        let cmd = BuildahCommand::run_exec("container-1", &args);
1023        assert_eq!(cmd.args, vec!["run", "container-1", "--", "echo", "hello"]);
1024    }
1025
1026    #[test]
1027    fn test_copy() {
1028        let sources = vec!["src/".to_string(), "Cargo.toml".to_string()];
1029        let cmd = BuildahCommand::copy("container-1", &sources, "/app/");
1030        assert_eq!(
1031            cmd.args,
1032            vec!["copy", "container-1", "src/", "Cargo.toml", "/app/"]
1033        );
1034    }
1035
1036    #[test]
1037    fn test_copy_from() {
1038        let sources = vec!["/app".to_string()];
1039        let cmd = BuildahCommand::copy_from("container-1", "builder", &sources, "/app");
1040        assert_eq!(
1041            cmd.args,
1042            vec!["copy", "--from", "builder", "container-1", "/app", "/app"]
1043        );
1044    }
1045
1046    #[test]
1047    fn test_config_env() {
1048        let cmd = BuildahCommand::config_env("container-1", "PATH", "/usr/local/bin");
1049        assert_eq!(
1050            cmd.args,
1051            vec!["config", "--env", "PATH=/usr/local/bin", "container-1"]
1052        );
1053    }
1054
1055    #[test]
1056    fn test_config_workdir() {
1057        let cmd = BuildahCommand::config_workdir("container-1", "/app");
1058        assert_eq!(
1059            cmd.args,
1060            vec!["config", "--workingdir", "/app", "container-1"]
1061        );
1062    }
1063
1064    #[test]
1065    fn test_config_entrypoint_exec() {
1066        let args = vec!["/app".to_string(), "--config".to_string()];
1067        let cmd = BuildahCommand::config_entrypoint_exec("container-1", &args);
1068        assert!(cmd.args.contains(&"--entrypoint".to_string()));
1069        assert!(cmd
1070            .args
1071            .iter()
1072            .any(|a| a.contains('[') && a.contains("/app")));
1073    }
1074
1075    #[test]
1076    fn test_commit() {
1077        let cmd = BuildahCommand::commit("container-1", "myimage:latest");
1078        assert_eq!(cmd.args, vec!["commit", "container-1", "myimage:latest"]);
1079    }
1080
1081    #[test]
1082    fn test_to_command_string() {
1083        let cmd = BuildahCommand::config_env("container-1", "VAR", "value with spaces");
1084        let s = cmd.to_command_string();
1085        assert!(s.starts_with("buildah config"));
1086        assert!(s.contains("VAR=value with spaces"));
1087    }
1088
1089    #[test]
1090    fn test_from_instruction_run() {
1091        let instruction = Instruction::Run(RunInstruction {
1092            command: ShellOrExec::Shell("echo hello".to_string()),
1093            mounts: vec![],
1094            network: None,
1095            security: None,
1096        });
1097
1098        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
1099        assert_eq!(cmds.len(), 1);
1100        assert!(cmds[0].args.contains(&"run".to_string()));
1101    }
1102
1103    #[test]
1104    fn test_from_instruction_workdir_creates_and_configures() {
1105        // WORKDIR must both create the dir in the rootfs (like Docker) AND
1106        // update image metadata. Emitting only `config --workingdir` leaves
1107        // containers chdir-ing to a missing directory at init time.
1108        let instruction = Instruction::Workdir("/workspace".to_string());
1109        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
1110
1111        assert_eq!(cmds.len(), 2, "WORKDIR should emit mkdir + config");
1112
1113        let run_args = &cmds[0].args;
1114        assert_eq!(run_args[0], "run");
1115        assert_eq!(run_args[1], "container-1");
1116        assert_eq!(run_args[2], "--");
1117        assert_eq!(run_args[3], "mkdir");
1118        assert_eq!(run_args[4], "-p");
1119        assert_eq!(run_args[5], "/workspace");
1120
1121        assert_eq!(
1122            cmds[1].args,
1123            vec!["config", "--workingdir", "/workspace", "container-1"]
1124        );
1125    }
1126
1127    #[test]
1128    fn test_from_instruction_env_multiple() {
1129        let mut vars = HashMap::new();
1130        vars.insert("FOO".to_string(), "bar".to_string());
1131        vars.insert("BAZ".to_string(), "qux".to_string());
1132
1133        let instruction = Instruction::Env(EnvInstruction { vars });
1134        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
1135
1136        // Should produce two config commands (one per env var)
1137        assert_eq!(cmds.len(), 2);
1138        for cmd in &cmds {
1139            assert!(cmd.args.contains(&"config".to_string()));
1140            assert!(cmd.args.contains(&"--env".to_string()));
1141        }
1142    }
1143
1144    #[test]
1145    fn test_escape_json_string() {
1146        assert_eq!(escape_json_string("hello"), "hello");
1147        assert_eq!(escape_json_string("hello \"world\""), "hello \\\"world\\\"");
1148        assert_eq!(escape_json_string("line1\nline2"), "line1\\nline2");
1149    }
1150
1151    #[test]
1152    fn test_run_with_mounts_cache() {
1153        use crate::dockerfile::{CacheSharing, RunMount};
1154
1155        let run = RunInstruction {
1156            command: ShellOrExec::Shell("apt-get update".to_string()),
1157            mounts: vec![RunMount::Cache {
1158                target: "/var/cache/apt".to_string(),
1159                id: Some("apt-cache".to_string()),
1160                sharing: CacheSharing::Shared,
1161                readonly: false,
1162            }],
1163            network: None,
1164            security: None,
1165        };
1166
1167        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
1168
1169        // Verify --mount comes BEFORE container ID
1170        let mount_idx = cmd
1171            .args
1172            .iter()
1173            .position(|a| a.starts_with("--mount="))
1174            .expect("should have --mount arg");
1175        let container_idx = cmd
1176            .args
1177            .iter()
1178            .position(|a| a == "container-1")
1179            .expect("should have container id");
1180
1181        assert!(
1182            mount_idx < container_idx,
1183            "--mount should come before container ID"
1184        );
1185
1186        // Verify mount argument content
1187        assert!(cmd.args[mount_idx].contains("type=cache"));
1188        assert!(cmd.args[mount_idx].contains("target=/var/cache/apt"));
1189        assert!(cmd.args[mount_idx].contains("id=apt-cache"));
1190        assert!(cmd.args[mount_idx].contains("sharing=shared"));
1191    }
1192
1193    #[test]
1194    fn test_run_with_multiple_mounts() {
1195        use crate::dockerfile::{CacheSharing, RunMount};
1196
1197        let run = RunInstruction {
1198            command: ShellOrExec::Shell("cargo build".to_string()),
1199            mounts: vec![
1200                RunMount::Cache {
1201                    target: "/usr/local/cargo/registry".to_string(),
1202                    id: Some("cargo-registry".to_string()),
1203                    sharing: CacheSharing::Shared,
1204                    readonly: false,
1205                },
1206                RunMount::Cache {
1207                    target: "/app/target".to_string(),
1208                    id: Some("cargo-target".to_string()),
1209                    sharing: CacheSharing::Locked,
1210                    readonly: false,
1211                },
1212            ],
1213            network: None,
1214            security: None,
1215        };
1216
1217        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
1218
1219        // Count --mount arguments
1220        let mount_count = cmd
1221            .args
1222            .iter()
1223            .filter(|a| a.starts_with("--mount="))
1224            .count();
1225        assert_eq!(mount_count, 2, "should have 2 mount arguments");
1226
1227        // Verify all mounts come before container ID
1228        let container_idx = cmd
1229            .args
1230            .iter()
1231            .position(|a| a == "container-1")
1232            .expect("should have container id");
1233
1234        for (idx, arg) in cmd.args.iter().enumerate() {
1235            if arg.starts_with("--mount=") {
1236                assert!(
1237                    idx < container_idx,
1238                    "--mount at index {idx} should come before container ID at {container_idx}",
1239                );
1240            }
1241        }
1242    }
1243
1244    #[test]
1245    fn test_from_instruction_run_with_mounts() {
1246        use crate::dockerfile::{CacheSharing, RunMount};
1247
1248        let instruction = Instruction::Run(RunInstruction {
1249            command: ShellOrExec::Shell("npm install".to_string()),
1250            mounts: vec![RunMount::Cache {
1251                target: "/root/.npm".to_string(),
1252                id: Some("npm-cache".to_string()),
1253                sharing: CacheSharing::Shared,
1254                readonly: false,
1255            }],
1256            network: None,
1257            security: None,
1258        });
1259
1260        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
1261        assert_eq!(cmds.len(), 1);
1262
1263        let cmd = &cmds[0];
1264        assert!(
1265            cmd.args.iter().any(|a| a.starts_with("--mount=")),
1266            "should include --mount argument"
1267        );
1268    }
1269
1270    #[test]
1271    fn test_run_with_mounts_exec_form() {
1272        use crate::dockerfile::{CacheSharing, RunMount};
1273
1274        let run = RunInstruction {
1275            command: ShellOrExec::Exec(vec![
1276                "pip".to_string(),
1277                "install".to_string(),
1278                "-r".to_string(),
1279                "requirements.txt".to_string(),
1280            ]),
1281            mounts: vec![RunMount::Cache {
1282                target: "/root/.cache/pip".to_string(),
1283                id: Some("pip-cache".to_string()),
1284                sharing: CacheSharing::Shared,
1285                readonly: false,
1286            }],
1287            network: None,
1288            security: None,
1289        };
1290
1291        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
1292
1293        // Should have mount, container, --, and then the exec args
1294        assert!(cmd.args.contains(&"--".to_string()));
1295        assert!(cmd.args.contains(&"pip".to_string()));
1296        assert!(cmd.args.contains(&"install".to_string()));
1297    }
1298
1299    #[test]
1300    fn test_manifest_create() {
1301        let cmd = BuildahCommand::manifest_create("myapp:latest");
1302        assert_eq!(cmd.program, "buildah");
1303        assert_eq!(cmd.args, vec!["manifest", "create", "myapp:latest"]);
1304    }
1305
1306    #[test]
1307    fn test_manifest_add() {
1308        let cmd = BuildahCommand::manifest_add("myapp:latest", "myapp-amd64:latest");
1309        assert_eq!(
1310            cmd.args,
1311            vec!["manifest", "add", "myapp:latest", "myapp-amd64:latest"]
1312        );
1313    }
1314
1315    #[test]
1316    fn test_manifest_push() {
1317        let cmd =
1318            BuildahCommand::manifest_push("myapp:latest", "docker://registry.example.com/myapp");
1319        assert_eq!(
1320            cmd.args,
1321            vec![
1322                "manifest",
1323                "push",
1324                "--all",
1325                "myapp:latest",
1326                "docker://registry.example.com/myapp"
1327            ]
1328        );
1329    }
1330
1331    #[test]
1332    fn test_manifest_rm() {
1333        let cmd = BuildahCommand::manifest_rm("myapp:latest");
1334        assert_eq!(cmd.args, vec!["manifest", "rm", "myapp:latest"]);
1335    }
1336
1337    // -------------------------------------------------------------------------
1338    // L-3: OS-aware translation tests
1339    // -------------------------------------------------------------------------
1340
1341    #[test]
1342    fn test_run_shell_for_os_linux() {
1343        let cmd = BuildahCommand::run_shell_for_os("c1", "echo hello", ImageOs::Linux);
1344        assert_eq!(
1345            cmd.args,
1346            vec!["run", "c1", "--", "/bin/sh", "-c", "echo hello"]
1347        );
1348    }
1349
1350    #[test]
1351    fn test_run_shell_for_os_windows() {
1352        let cmd = BuildahCommand::run_shell_for_os("c1", "echo hello", ImageOs::Windows);
1353        assert_eq!(
1354            cmd.args,
1355            vec!["run", "c1", "--", "cmd.exe", "/S", "/C", "echo hello"]
1356        );
1357    }
1358
1359    #[test]
1360    fn test_run_shell_custom_powershell() {
1361        let shell = ["powershell", "-Command"];
1362        let cmd = BuildahCommand::run_shell_custom("c1", shell, "Get-Process");
1363        assert_eq!(
1364            cmd.args,
1365            vec!["run", "c1", "--", "powershell", "-Command", "Get-Process"]
1366        );
1367    }
1368
1369    #[test]
1370    fn test_translator_linux_run_shell_default() {
1371        let mut t = DockerfileTranslator::new(ImageOs::Linux);
1372        let instr = Instruction::Run(RunInstruction::shell("apt-get update"));
1373        let cmds = t.translate("c1", &instr);
1374        assert_eq!(cmds.len(), 1);
1375        assert_eq!(
1376            cmds[0].args,
1377            vec!["run", "c1", "--", "/bin/sh", "-c", "apt-get update"]
1378        );
1379    }
1380
1381    #[test]
1382    fn test_translator_windows_run_shell_default() {
1383        let mut t = DockerfileTranslator::new(ImageOs::Windows);
1384        let instr = Instruction::Run(RunInstruction::shell("dir C:\\"));
1385        let cmds = t.translate("c1", &instr);
1386        assert_eq!(cmds.len(), 1);
1387        assert_eq!(
1388            cmds[0].args,
1389            vec!["run", "c1", "--", "cmd.exe", "/S", "/C", "dir C:\\"]
1390        );
1391    }
1392
1393    #[test]
1394    fn test_translator_shell_override_linux_bash() {
1395        // SHELL ["/bin/bash", "-lc"] then RUN cmd → uses bash
1396        let mut t = DockerfileTranslator::new(ImageOs::Linux);
1397
1398        let shell_instr = Instruction::Shell(vec!["/bin/bash".to_string(), "-lc".to_string()]);
1399        let shell_cmds = t.translate("c1", &shell_instr);
1400        // SHELL emits the metadata config --shell
1401        assert_eq!(shell_cmds.len(), 1);
1402        assert!(shell_cmds[0].args.contains(&"--shell".to_string()));
1403
1404        let run_instr = Instruction::Run(RunInstruction::shell("set -e; echo $SHELL"));
1405        let run_cmds = t.translate("c1", &run_instr);
1406        assert_eq!(run_cmds.len(), 1);
1407        assert_eq!(
1408            run_cmds[0].args,
1409            vec!["run", "c1", "--", "/bin/bash", "-lc", "set -e; echo $SHELL"]
1410        );
1411    }
1412
1413    #[test]
1414    fn test_translator_shell_override_windows_powershell() {
1415        // SHELL ["powershell", "-Command"] then RUN cmd on Windows → uses
1416        // powershell, not the default cmd.exe /S /C.
1417        let mut t = DockerfileTranslator::new(ImageOs::Windows);
1418
1419        let shell_instr =
1420            Instruction::Shell(vec!["powershell".to_string(), "-Command".to_string()]);
1421        t.translate("c1", &shell_instr);
1422
1423        let run_instr = Instruction::Run(RunInstruction::shell("Get-Process"));
1424        let run_cmds = t.translate("c1", &run_instr);
1425        assert_eq!(run_cmds.len(), 1);
1426        assert_eq!(
1427            run_cmds[0].args,
1428            vec!["run", "c1", "--", "powershell", "-Command", "Get-Process"]
1429        );
1430    }
1431
1432    #[test]
1433    fn test_translator_shell_override_persists_across_runs() {
1434        // Two RUNs after a single SHELL should both use the overridden shell.
1435        let mut t = DockerfileTranslator::new(ImageOs::Linux);
1436        t.translate(
1437            "c1",
1438            &Instruction::Shell(vec!["/bin/bash".to_string(), "-c".to_string()]),
1439        );
1440
1441        for _ in 0..2 {
1442            let cmds = t.translate("c1", &Instruction::Run(RunInstruction::shell("echo hi")));
1443            assert_eq!(
1444                cmds[0].args,
1445                vec!["run", "c1", "--", "/bin/bash", "-c", "echo hi"]
1446            );
1447        }
1448    }
1449
1450    #[test]
1451    fn test_translator_exec_form_ignores_shell_override() {
1452        // Exec-form RUN must not be wrapped with the shell prefix, even
1453        // when a SHELL override is active — matches Docker semantics.
1454        let mut t = DockerfileTranslator::new(ImageOs::Windows);
1455        t.translate(
1456            "c1",
1457            &Instruction::Shell(vec!["powershell".to_string(), "-Command".to_string()]),
1458        );
1459
1460        let run = Instruction::Run(RunInstruction::exec(vec![
1461            "myapp.exe".to_string(),
1462            "--flag".to_string(),
1463        ]));
1464        let cmds = t.translate("c1", &run);
1465        assert_eq!(cmds[0].args, vec!["run", "c1", "--", "myapp.exe", "--flag"]);
1466    }
1467
1468    #[test]
1469    fn test_translator_workdir_linux() {
1470        let mut t = DockerfileTranslator::new(ImageOs::Linux);
1471        let cmds = t.translate("c1", &Instruction::Workdir("/app".to_string()));
1472        assert_eq!(cmds.len(), 2);
1473        assert_eq!(cmds[0].args, vec!["run", "c1", "--", "mkdir", "-p", "/app"]);
1474        assert_eq!(cmds[1].args, vec!["config", "--workingdir", "/app", "c1"]);
1475    }
1476
1477    #[test]
1478    fn test_translator_workdir_windows() {
1479        let mut t = DockerfileTranslator::new(ImageOs::Windows);
1480        let cmds = t.translate("c1", &Instruction::Workdir("C:\\app".to_string()));
1481        assert_eq!(cmds.len(), 2);
1482        // Pre-mkdir guarded with `if not exist` so a repeated WORKDIR in the
1483        // same Dockerfile doesn't cause Windows mkdir to error on existence.
1484        assert_eq!(
1485            cmds[0].args,
1486            vec![
1487                "run",
1488                "c1",
1489                "--",
1490                "cmd.exe",
1491                "/S",
1492                "/C",
1493                r#"if not exist "C:\app" mkdir "C:\app""#
1494            ]
1495        );
1496        assert_eq!(
1497            cmds[1].args,
1498            vec!["config", "--workingdir", "C:\\app", "c1"]
1499        );
1500    }
1501
1502    #[test]
1503    fn test_translator_workdir_windows_path_with_spaces() {
1504        // Paths containing spaces (e.g. `C:\Program Files\app`) must be quoted
1505        // for cmd.exe, which is why the mkdir command we emit wraps the path
1506        // in double-quotes.
1507        let mut t = DockerfileTranslator::new(ImageOs::Windows);
1508        let cmds = t.translate(
1509            "c1",
1510            &Instruction::Workdir("C:\\Program Files\\app".to_string()),
1511        );
1512        assert_eq!(cmds.len(), 2);
1513        let mkdir_cmd = &cmds[0].args[6];
1514        assert_eq!(
1515            mkdir_cmd,
1516            r#"if not exist "C:\Program Files\app" mkdir "C:\Program Files\app""#
1517        );
1518    }
1519
1520    #[test]
1521    fn test_from_instruction_preserves_linux_byte_identical_output() {
1522        // Backward-compat guarantee: the legacy `from_instruction` entrypoint
1523        // must emit the exact same byte-for-byte commands as it did before
1524        // the translator refactor. The existing Linux callers (buildah backend)
1525        // rely on this.
1526        let run = Instruction::Run(RunInstruction::shell("echo hello"));
1527        let legacy = BuildahCommand::from_instruction("c1", &run);
1528        let via_translator = DockerfileTranslator::new(ImageOs::Linux).translate("c1", &run);
1529        assert_eq!(legacy.len(), via_translator.len());
1530        for (a, b) in legacy.iter().zip(via_translator.iter()) {
1531            assert_eq!(a.args, b.args);
1532            assert_eq!(a.program, b.program);
1533        }
1534
1535        // WORKDIR must still emit mkdir -p + config --workingdir
1536        let workdir = Instruction::Workdir("/workspace".to_string());
1537        let legacy = BuildahCommand::from_instruction("c1", &workdir);
1538        assert_eq!(legacy.len(), 2);
1539        assert_eq!(
1540            legacy[0].args,
1541            vec!["run", "c1", "--", "mkdir", "-p", "/workspace"]
1542        );
1543        assert_eq!(
1544            legacy[1].args,
1545            vec!["config", "--workingdir", "/workspace", "c1"]
1546        );
1547    }
1548
1549    #[test]
1550    fn test_translator_active_shell_reflects_override() {
1551        let mut t = DockerfileTranslator::new(ImageOs::Linux);
1552        assert_eq!(t.active_shell(), vec!["/bin/sh", "-c"]);
1553
1554        t.set_shell_override(vec!["/bin/bash".to_string(), "-lc".to_string()]);
1555        assert_eq!(t.active_shell(), vec!["/bin/bash", "-lc"]);
1556    }
1557
1558    #[test]
1559    fn test_translator_target_os_accessor() {
1560        assert_eq!(
1561            DockerfileTranslator::new(ImageOs::Linux).target_os(),
1562            ImageOs::Linux
1563        );
1564        assert_eq!(
1565            DockerfileTranslator::new(ImageOs::Windows).target_os(),
1566            ImageOs::Windows
1567        );
1568    }
1569
1570    #[test]
1571    fn test_translator_windows_run_with_mounts_uses_cmd_exe() {
1572        use crate::dockerfile::{CacheSharing, RunMount};
1573
1574        let mut t = DockerfileTranslator::new(ImageOs::Windows);
1575        let run = RunInstruction {
1576            command: ShellOrExec::Shell("echo cached".to_string()),
1577            mounts: vec![RunMount::Cache {
1578                target: "C:\\cache".to_string(),
1579                id: Some("win-cache".to_string()),
1580                sharing: CacheSharing::Shared,
1581                readonly: false,
1582            }],
1583            network: None,
1584            security: None,
1585        };
1586
1587        let cmds = t.translate("c1", &Instruction::Run(run));
1588        assert_eq!(cmds.len(), 1);
1589
1590        // --mount= must precede container ID
1591        let mount_idx = cmds[0]
1592            .args
1593            .iter()
1594            .position(|a| a.starts_with("--mount="))
1595            .expect("mount arg present");
1596        let container_idx = cmds[0]
1597            .args
1598            .iter()
1599            .position(|a| a == "c1")
1600            .expect("container ID present");
1601        assert!(mount_idx < container_idx);
1602
1603        // Shell form must use cmd.exe /S /C, not /bin/sh -c
1604        assert!(cmds[0].args.iter().any(|a| a == "cmd.exe"));
1605        assert!(cmds[0].args.iter().any(|a| a == "/S"));
1606        assert!(cmds[0].args.iter().any(|a| a == "/C"));
1607        assert!(!cmds[0].args.iter().any(|a| a == "/bin/sh"));
1608    }
1609}