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