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::*;
29#[cfg(unix)]
30pub use install::buildd as buildd_install;
31#[cfg(unix)]
32pub use install::buildd::{ensure_buildd_sidecar, InstallOutcome as SidecarInstallOutcome};
33pub use install::{
34    current_platform, install_instructions, is_platform_supported, BuildahInstallation,
35    BuildahInstaller, InstallError,
36};
37
38use crate::backend::ImageOs;
39use crate::dockerfile::{
40    escape_json_string, AddInstruction, CopyInstruction, EnvInstruction, ExposeInstruction,
41    HealthcheckInstruction, Instruction, RunInstruction, RunNetwork, ShellOrExec,
42};
43
44use std::collections::HashMap;
45use std::path::{Path, PathBuf};
46
47/// Default shell used for `RUN <cmd>` (shell form) on Linux targets.
48///
49/// Matches the historical default used by Docker / buildah and keeps the
50/// generated buildah command byte-identical to what we emitted before the
51/// OS-aware translator landed.
52const LINUX_DEFAULT_SHELL: &[&str] = &["/bin/sh", "-c"];
53
54/// Default shell used for `RUN <cmd>` (shell form) on Windows targets.
55///
56/// Matches Docker's Windows default (`cmd /S /C`) used when no `SHELL`
57/// instruction has overridden it.
58const WINDOWS_DEFAULT_SHELL: &[&str] = &["cmd.exe", "/S", "/C"];
59
60/// Return the default shell-form prefix for an OS when no `SHELL` instruction
61/// has been seen.
62fn default_shell_for(os: ImageOs) -> Vec<String> {
63    let raw: &[&str] = match os {
64        // Darwin (macOS) images use the same POSIX shell default as Linux.
65        ImageOs::Linux | ImageOs::Darwin => LINUX_DEFAULT_SHELL,
66        ImageOs::Windows => WINDOWS_DEFAULT_SHELL,
67    };
68    raw.iter().map(|s| (*s).to_string()).collect()
69}
70
71/// A buildah command ready for execution
72#[derive(Debug, Clone)]
73pub struct BuildahCommand {
74    /// The program to execute (typically "buildah")
75    pub program: String,
76
77    /// Command arguments
78    pub args: Vec<String>,
79
80    /// Optional environment variables for the command
81    pub env: HashMap<String, String>,
82}
83
84impl BuildahCommand {
85    /// Create a new buildah command
86    #[must_use]
87    pub fn new(subcommand: &str) -> Self {
88        Self {
89            program: "buildah".to_string(),
90            args: vec![subcommand.to_string()],
91            env: HashMap::new(),
92        }
93    }
94
95    /// Add an argument
96    #[must_use]
97    pub fn arg(mut self, arg: impl Into<String>) -> Self {
98        self.args.push(arg.into());
99        self
100    }
101
102    /// Add multiple arguments
103    #[must_use]
104    pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
105        self.args.extend(args.into_iter().map(Into::into));
106        self
107    }
108
109    /// Add an optional argument (only added if value is Some)
110    #[must_use]
111    pub fn arg_opt(self, flag: &str, value: Option<impl Into<String>>) -> Self {
112        if let Some(v) = value {
113            self.arg(flag).arg(v)
114        } else {
115            self
116        }
117    }
118
119    /// Add an environment variable for command execution
120    #[must_use]
121    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
122        self.env.insert(key.into(), value.into());
123        self
124    }
125
126    /// Convert to a command line string for display/logging
127    #[must_use]
128    pub fn to_command_string(&self) -> String {
129        let mut parts = vec![self.program.clone()];
130        parts.extend(self.args.iter().map(|a| {
131            if a.contains(' ') || a.contains('"') {
132                format!("\"{}\"", a.replace('"', "\\\""))
133            } else {
134                a.clone()
135            }
136        }));
137        parts.join(" ")
138    }
139
140    // =========================================================================
141    // Container Lifecycle Commands
142    // =========================================================================
143
144    /// Create a new working container from an image
145    ///
146    /// `buildah from <image>`
147    #[must_use]
148    pub fn from_image(image: &str) -> Self {
149        Self::new("from").arg(image)
150    }
151
152    /// Create a new working container from an image with a specific name
153    ///
154    /// `buildah from --name <name> <image>`
155    #[must_use]
156    pub fn from_image_named(image: &str, name: &str) -> Self {
157        Self::new("from").arg("--name").arg(name).arg(image)
158    }
159
160    /// Create a scratch container
161    ///
162    /// `buildah from scratch`
163    #[must_use]
164    pub fn from_scratch() -> Self {
165        Self::new("from").arg("scratch")
166    }
167
168    /// Remove a working container
169    ///
170    /// `buildah rm <container>`
171    #[must_use]
172    pub fn rm(container: &str) -> Self {
173        Self::new("rm").arg(container)
174    }
175
176    /// Commit a container to create an image
177    ///
178    /// `buildah commit <container> <image>`
179    #[must_use]
180    pub fn commit(container: &str, image_name: &str) -> Self {
181        Self::new("commit").arg(container).arg(image_name)
182    }
183
184    /// Commit with additional options
185    #[must_use]
186    pub fn commit_with_opts(
187        container: &str,
188        image_name: &str,
189        format: Option<&str>,
190        squash: bool,
191    ) -> Self {
192        let mut cmd = Self::new("commit");
193
194        if let Some(fmt) = format {
195            cmd = cmd.arg("--format").arg(fmt);
196        }
197
198        if squash {
199            cmd = cmd.arg("--squash");
200        }
201
202        cmd.arg(container).arg(image_name)
203    }
204
205    /// Tag an image with a new name
206    ///
207    /// `buildah tag <image> <new-name>`
208    #[must_use]
209    pub fn tag(image: &str, new_name: &str) -> Self {
210        Self::new("tag").arg(image).arg(new_name)
211    }
212
213    /// Remove an image
214    ///
215    /// `buildah rmi <image>`
216    #[must_use]
217    pub fn rmi(image: &str) -> Self {
218        Self::new("rmi").arg(image)
219    }
220
221    /// Forcibly remove an image, even if it is referenced by a manifest list
222    /// or in use.
223    ///
224    /// `buildah rmi -f <image>`
225    #[must_use]
226    pub fn rmi_force(image: &str) -> Self {
227        Self::new("rmi").arg("-f").arg(image)
228    }
229
230    /// Pull an image from a registry into local storage.
231    ///
232    /// `buildah pull [--policy <policy>] <image>`
233    ///
234    /// `policy` controls when the registry is consulted: `"newer"` only pulls
235    /// when the upstream is newer than the local copy, `"always"` forces a
236    /// fresh pull, and `"never"` (or `"missing"`) is useful for offline builds.
237    /// When `policy` is `None`, buildah's default policy applies.
238    #[must_use]
239    pub fn pull(image: &str, policy: Option<&str>) -> Self {
240        let mut cmd = Self::new("pull");
241        if let Some(p) = policy {
242            cmd = cmd.arg("--policy").arg(p);
243        }
244        cmd.arg(image)
245    }
246
247    /// Push an image to a registry
248    ///
249    /// `buildah push <image>`
250    #[must_use]
251    pub fn push(image: &str) -> Self {
252        Self::new("push").arg(image)
253    }
254
255    /// Push an image to a registry, optionally with registry credentials.
256    ///
257    /// buildah requires global/command options to precede the positional
258    /// image/destination argument (`buildah push [options] IMAGE [DESTINATION]`)
259    /// — passing `--creds` after the image fails with "no options (--creds) can
260    /// be specified after the image or container name". This builds the option
261    /// flags BEFORE the positional `image`.
262    #[must_use]
263    pub fn push_with_creds(image: &str, creds: Option<&str>) -> Self {
264        let mut cmd = Self::new("push");
265        if let Some(creds) = creds {
266            cmd = cmd.arg("--creds").arg(creds);
267        }
268        cmd.arg(image)
269    }
270
271    /// Push an image to a registry with options
272    ///
273    /// `buildah push [options] <image> [destination]`
274    #[must_use]
275    pub fn push_to(image: &str, destination: &str) -> Self {
276        Self::new("push").arg(image).arg(destination)
277    }
278
279    /// Inspect an image or container
280    ///
281    /// `buildah inspect <name>`
282    #[must_use]
283    pub fn inspect(name: &str) -> Self {
284        Self::new("inspect").arg(name)
285    }
286
287    /// Inspect an image or container with format
288    ///
289    /// `buildah inspect --format <format> <name>`
290    #[must_use]
291    pub fn inspect_format(name: &str, format: &str) -> Self {
292        Self::new("inspect").arg("--format").arg(format).arg(name)
293    }
294
295    /// List images
296    ///
297    /// `buildah images`
298    #[must_use]
299    pub fn images() -> Self {
300        Self::new("images")
301    }
302
303    /// List containers
304    ///
305    /// `buildah containers`
306    #[must_use]
307    pub fn containers() -> Self {
308        Self::new("containers")
309    }
310
311    // =========================================================================
312    // Run Commands
313    // =========================================================================
314
315    /// Run a command in the container (shell form) using the Linux default shell.
316    ///
317    /// `buildah run <container> -- /bin/sh -c "<command>"`
318    ///
319    /// For OS-aware translation (Windows targets, or honoring a `SHELL`
320    /// override), prefer [`Self::run_shell_custom`] or the stateful
321    /// [`DockerfileTranslator`].
322    #[must_use]
323    pub fn run_shell(container: &str, command: &str) -> Self {
324        Self::run_shell_custom(container, LINUX_DEFAULT_SHELL, command)
325    }
326
327    /// Run a command in the container (shell form) using an explicit shell.
328    ///
329    /// `buildah run <container> -- <shell...> <command>`
330    ///
331    /// The `shell` slice is emitted verbatim before the command argument, e.g.
332    /// `["cmd.exe", "/S", "/C"]` for Windows or `["/bin/bash", "-lc"]` for a
333    /// bash-login override.
334    #[must_use]
335    pub fn run_shell_custom(
336        container: &str,
337        shell: impl IntoIterator<Item = impl AsRef<str>>,
338        command: &str,
339    ) -> Self {
340        Self::run_shell_custom_with_net(container, shell, command, None)
341    }
342
343    /// Run a command in the container (shell form) using an explicit shell,
344    /// optionally pinning the network mode.
345    ///
346    /// `buildah run [--net=<mode>] <container> -- <shell...> <command>`
347    ///
348    /// See [`Self::run_exec_with_net`] for the meaning of `net`.
349    #[must_use]
350    pub fn run_shell_custom_with_net(
351        container: &str,
352        shell: impl IntoIterator<Item = impl AsRef<str>>,
353        command: &str,
354        net: Option<RunNetwork>,
355    ) -> Self {
356        let mut cmd = Self::new("run");
357        if let Some(mode) = net {
358            match mode {
359                RunNetwork::Host => cmd = cmd.arg("--net=host"),
360                RunNetwork::None => cmd = cmd.arg("--net=none"),
361                RunNetwork::Default => {}
362            }
363        }
364        cmd = cmd.arg(container).arg("--");
365        for s in shell {
366            cmd = cmd.arg(s.as_ref().to_string());
367        }
368        cmd.arg(command)
369    }
370
371    /// Run a command in the container (shell form) using the OS default shell.
372    ///
373    /// Linux → `/bin/sh -c`, Windows → `cmd.exe /S /C`.
374    #[must_use]
375    pub fn run_shell_for_os(container: &str, command: &str, os: ImageOs) -> Self {
376        let shell = default_shell_for(os);
377        Self::run_shell_custom(container, &shell, command)
378    }
379
380    /// Run a command in the container (exec form)
381    ///
382    /// `buildah run <container> -- <args...>`
383    #[must_use]
384    pub fn run_exec(container: &str, args: &[String]) -> Self {
385        Self::run_exec_with_net(container, args, None)
386    }
387
388    /// Run a command in the container (exec form), optionally pinning the
389    /// network mode.
390    ///
391    /// `buildah run [--net=<mode>] <container> -- <args...>`
392    ///
393    /// The `--net=<mode>` flag MUST precede the container ID — buildah parses
394    /// flags up to the first positional and then treats the rest as the
395    /// command. When `net` is `None`, no `--net` flag is emitted and buildah
396    /// uses its default rootless networking. Use `Some(RunNetwork::Host)`
397    /// when the translator-level `host_network` override is on, so the
398    /// emitted `buildah run` bypasses CNI/netavark just like the dedicated
399    /// `RUN` instruction path does.
400    #[must_use]
401    pub fn run_exec_with_net(container: &str, args: &[String], net: Option<RunNetwork>) -> Self {
402        let mut cmd = Self::new("run");
403        if let Some(mode) = net {
404            match mode {
405                RunNetwork::Host => cmd = cmd.arg("--net=host"),
406                RunNetwork::None => cmd = cmd.arg("--net=none"),
407                RunNetwork::Default => {}
408            }
409        }
410        cmd = cmd.arg(container).arg("--");
411        for arg in args {
412            cmd = cmd.arg(arg);
413        }
414        cmd
415    }
416
417    /// Run a command based on `ShellOrExec`
418    #[must_use]
419    pub fn run(container: &str, command: &ShellOrExec) -> Self {
420        match command {
421            ShellOrExec::Shell(s) => Self::run_shell(container, s),
422            ShellOrExec::Exec(args) => Self::run_exec(container, args),
423        }
424    }
425
426    /// Run a command with mount specifications from a `RunInstruction`.
427    ///
428    /// Buildah requires `--mount` arguments to appear BEFORE the container ID:
429    /// `buildah run [--mount=...] <container> -- <command>`
430    ///
431    /// This method properly orders the arguments to ensure mounts are applied.
432    ///
433    /// Uses the Linux default shell (`/bin/sh -c`) for shell-form commands.
434    /// For OS-aware translation use [`Self::run_with_mounts_shell`].
435    #[must_use]
436    pub fn run_with_mounts(container: &str, run: &RunInstruction) -> Self {
437        Self::run_with_mounts_shell(container, run, LINUX_DEFAULT_SHELL)
438    }
439
440    /// Run a command with mount specifications, using an explicit shell for
441    /// shell-form commands.
442    ///
443    /// Exec-form commands ignore `shell` and are emitted verbatim, matching
444    /// Docker/Buildah semantics.
445    #[must_use]
446    pub fn run_with_mounts_shell(
447        container: &str,
448        run: &RunInstruction,
449        shell: impl IntoIterator<Item = impl AsRef<str>>,
450    ) -> Self {
451        let mut cmd = Self::new("run");
452
453        // Add --mount arguments BEFORE the container ID
454        for mount in &run.mounts {
455            cmd = cmd.arg(format!("--mount={}", mount.to_buildah_arg()));
456        }
457
458        // Add transient --env=K=V flags BEFORE the container ID. Sort by key
459        // for deterministic ordering (HashMap iteration is non-deterministic).
460        // These are scoped to this single `buildah run` invocation and are
461        // intentionally NOT persisted into the image config.
462        let mut env_keys: Vec<&String> = run.env.keys().collect();
463        env_keys.sort();
464        for key in env_keys {
465            if let Some(value) = run.env.get(key) {
466                cmd = cmd.arg(format!("--env={key}={value}"));
467            }
468        }
469
470        // Add --net=<value> BEFORE the container ID when a network mode is set.
471        // `RunNetwork::Default` is omitted (buildah's default is `private`),
472        // matching Docker's BuildKit semantics where `--network` is only
473        // emitted when the user opts out of the default. `--net` is the
474        // canonical buildah spelling per the man page (both `--net` and
475        // `--network` work, but `--net` is shorter and matches buildah's
476        // own help output).
477        if let Some(net) = run.network {
478            match net {
479                RunNetwork::Host => {
480                    cmd = cmd.arg("--net=host");
481                }
482                RunNetwork::None => {
483                    cmd = cmd.arg("--net=none");
484                }
485                RunNetwork::Default => {}
486            }
487        }
488
489        // Now add container and the command
490        cmd = cmd.arg(container).arg("--");
491
492        match &run.command {
493            ShellOrExec::Shell(s) => {
494                for part in shell {
495                    cmd = cmd.arg(part.as_ref().to_string());
496                }
497                cmd.arg(s)
498            }
499            ShellOrExec::Exec(args) => {
500                for arg in args {
501                    cmd = cmd.arg(arg);
502                }
503                cmd
504            }
505        }
506    }
507
508    // =========================================================================
509    // Copy/Add Commands
510    // =========================================================================
511
512    /// Copy files into the container
513    ///
514    /// `buildah copy <container> <src...> <dest>`
515    #[must_use]
516    pub fn copy(container: &str, sources: &[String], dest: &str) -> Self {
517        let mut cmd = Self::new("copy").arg(container);
518        for src in sources {
519            cmd = cmd.arg(src);
520        }
521        cmd.arg(dest)
522    }
523
524    /// Copy files from another container/image
525    ///
526    /// `buildah copy --from=<source> <container> <src...> <dest>`
527    #[must_use]
528    pub fn copy_from(container: &str, from: &str, sources: &[String], dest: &str) -> Self {
529        let mut cmd = Self::new("copy").arg("--from").arg(from).arg(container);
530        for src in sources {
531            cmd = cmd.arg(src);
532        }
533        cmd.arg(dest)
534    }
535
536    /// Materialize a directory in the container rootfs without running a
537    /// process inside the container.
538    ///
539    /// Emits `buildah copy <container> <empty_src>/. <dest>` where
540    /// `empty_src` is an empty host directory. `buildah copy` creates
541    /// `<dest>` (and its parents) in the rootfs as part of the copy, and
542    /// because the source has no entries nothing is actually copied —
543    /// the net effect is `mkdir -p <dest>` on the rootfs, but executed by
544    /// buildah's filesystem code, not by a shell inside the container.
545    ///
546    /// This is the shell-free equivalent of
547    /// `BuildahCommand::run_exec_with_net(ctr, &["mkdir","-p", dir], …)`,
548    /// usable on distroless / scratch / any base image that lacks
549    /// `mkdir` / `/bin/sh`. Callers MUST keep the host source directory
550    /// alive (and empty) for the lifetime of this command's execution.
551    #[must_use]
552    pub fn copy_empty_dir(container: &str, empty_src: &Path, dest: &str) -> Self {
553        // `<empty_src>/.` (note trailing `/.`) tells buildah to copy the
554        // contents of the source, not the directory entry itself. With no
555        // entries this is a no-op materialization that still creates the
556        // destination path in the rootfs.
557        let mut src = empty_src.to_string_lossy().into_owned();
558        if !src.ends_with('/') {
559            src.push('/');
560        }
561        src.push('.');
562        Self::new("copy").arg(container).arg(src).arg(dest)
563    }
564
565    /// Copy with all options from `CopyInstruction`
566    #[must_use]
567    pub fn copy_instruction(container: &str, copy: &CopyInstruction) -> Self {
568        let mut cmd = Self::new("copy");
569
570        if let Some(ref from) = copy.from {
571            cmd = cmd.arg("--from").arg(from);
572        }
573
574        if let Some(ref chown) = copy.chown {
575            cmd = cmd.arg("--chown").arg(chown);
576        }
577
578        if let Some(ref chmod) = copy.chmod {
579            cmd = cmd.arg("--chmod").arg(chmod);
580        }
581
582        cmd = cmd.arg(container);
583
584        for src in &copy.sources {
585            cmd = cmd.arg(src);
586        }
587
588        cmd.arg(&copy.destination)
589    }
590
591    /// Add files (like copy but with URL support and extraction)
592    #[must_use]
593    pub fn add(container: &str, sources: &[String], dest: &str) -> Self {
594        let mut cmd = Self::new("add").arg(container);
595        for src in sources {
596            cmd = cmd.arg(src);
597        }
598        cmd.arg(dest)
599    }
600
601    /// Add with all options from `AddInstruction`
602    #[must_use]
603    pub fn add_instruction(container: &str, add: &AddInstruction) -> Self {
604        let mut cmd = Self::new("add");
605
606        if let Some(ref chown) = add.chown {
607            cmd = cmd.arg("--chown").arg(chown);
608        }
609
610        if let Some(ref chmod) = add.chmod {
611            cmd = cmd.arg("--chmod").arg(chmod);
612        }
613
614        cmd = cmd.arg(container);
615
616        for src in &add.sources {
617            cmd = cmd.arg(src);
618        }
619
620        cmd.arg(&add.destination)
621    }
622
623    // =========================================================================
624    // Config Commands
625    // =========================================================================
626
627    /// Set an environment variable
628    ///
629    /// `buildah config --env KEY=VALUE <container>`
630    #[must_use]
631    pub fn config_env(container: &str, key: &str, value: &str) -> Self {
632        Self::new("config")
633            .arg("--env")
634            .arg(format!("{key}={value}"))
635            .arg(container)
636    }
637
638    /// Set multiple environment variables
639    #[must_use]
640    pub fn config_envs(container: &str, env: &EnvInstruction) -> Vec<Self> {
641        env.vars
642            .iter()
643            .map(|(k, v)| Self::config_env(container, k, v))
644            .collect()
645    }
646
647    /// Set the working directory
648    ///
649    /// `buildah config --workingdir <dir> <container>`
650    #[must_use]
651    pub fn config_workdir(container: &str, dir: &str) -> Self {
652        Self::new("config")
653            .arg("--workingdir")
654            .arg(dir)
655            .arg(container)
656    }
657
658    /// Expose a port
659    ///
660    /// `buildah config --port <port>/<proto> <container>`
661    #[must_use]
662    pub fn config_expose(container: &str, expose: &ExposeInstruction) -> Self {
663        let port_spec = format!(
664            "{}/{}",
665            expose.port,
666            match expose.protocol {
667                crate::dockerfile::ExposeProtocol::Tcp => "tcp",
668                crate::dockerfile::ExposeProtocol::Udp => "udp",
669            }
670        );
671        Self::new("config")
672            .arg("--port")
673            .arg(port_spec)
674            .arg(container)
675    }
676
677    /// Set the entrypoint (shell form)
678    ///
679    /// `buildah config --entrypoint '<command>' <container>`
680    #[must_use]
681    pub fn config_entrypoint_shell(container: &str, command: &str) -> Self {
682        Self::new("config")
683            .arg("--entrypoint")
684            .arg(format!(
685                "[\"/bin/sh\", \"-c\", \"{}\"]",
686                escape_json_string(command)
687            ))
688            .arg(container)
689    }
690
691    /// Set the entrypoint (exec form)
692    ///
693    /// `buildah config --entrypoint '["exe", "arg1"]' <container>`
694    #[must_use]
695    pub fn config_entrypoint_exec(container: &str, args: &[String]) -> Self {
696        let json_array = format!(
697            "[{}]",
698            args.iter()
699                .map(|a| format!("\"{}\"", escape_json_string(a)))
700                .collect::<Vec<_>>()
701                .join(", ")
702        );
703        Self::new("config")
704            .arg("--entrypoint")
705            .arg(json_array)
706            .arg(container)
707    }
708
709    /// Set the entrypoint based on `ShellOrExec`
710    #[must_use]
711    pub fn config_entrypoint(container: &str, command: &ShellOrExec) -> Self {
712        match command {
713            ShellOrExec::Shell(s) => Self::config_entrypoint_shell(container, s),
714            ShellOrExec::Exec(args) => Self::config_entrypoint_exec(container, args),
715        }
716    }
717
718    /// Set the default command (shell form)
719    #[must_use]
720    pub fn config_cmd_shell(container: &str, command: &str) -> Self {
721        Self::new("config")
722            .arg("--cmd")
723            .arg(format!("/bin/sh -c \"{}\"", escape_json_string(command)))
724            .arg(container)
725    }
726
727    /// Set the default command (exec form)
728    #[must_use]
729    pub fn config_cmd_exec(container: &str, args: &[String]) -> Self {
730        let json_array = format!(
731            "[{}]",
732            args.iter()
733                .map(|a| format!("\"{}\"", escape_json_string(a)))
734                .collect::<Vec<_>>()
735                .join(", ")
736        );
737        Self::new("config")
738            .arg("--cmd")
739            .arg(json_array)
740            .arg(container)
741    }
742
743    /// Set the default command based on `ShellOrExec`
744    #[must_use]
745    pub fn config_cmd(container: &str, command: &ShellOrExec) -> Self {
746        match command {
747            ShellOrExec::Shell(s) => Self::config_cmd_shell(container, s),
748            ShellOrExec::Exec(args) => Self::config_cmd_exec(container, args),
749        }
750    }
751
752    /// Set the user
753    ///
754    /// `buildah config --user <user> <container>`
755    #[must_use]
756    pub fn config_user(container: &str, user: &str) -> Self {
757        Self::new("config").arg("--user").arg(user).arg(container)
758    }
759
760    /// Set a label
761    ///
762    /// `buildah config --label KEY=VALUE <container>`
763    #[must_use]
764    pub fn config_label(container: &str, key: &str, value: &str) -> Self {
765        Self::new("config")
766            .arg("--label")
767            .arg(format!("{key}={value}"))
768            .arg(container)
769    }
770
771    /// Set multiple labels
772    #[must_use]
773    pub fn config_labels(container: &str, labels: &HashMap<String, String>) -> Vec<Self> {
774        labels
775            .iter()
776            .map(|(k, v)| Self::config_label(container, k, v))
777            .collect()
778    }
779
780    /// Set volumes
781    ///
782    /// `buildah config --volume <path> <container>`
783    #[must_use]
784    pub fn config_volume(container: &str, path: &str) -> Self {
785        Self::new("config").arg("--volume").arg(path).arg(container)
786    }
787
788    /// Set the stop signal
789    ///
790    /// `buildah config --stop-signal <signal> <container>`
791    #[must_use]
792    pub fn config_stopsignal(container: &str, signal: &str) -> Self {
793        Self::new("config")
794            .arg("--stop-signal")
795            .arg(signal)
796            .arg(container)
797    }
798
799    /// Set the shell
800    ///
801    /// `buildah config --shell '["shell", "args"]' <container>`
802    #[must_use]
803    pub fn config_shell(container: &str, shell: &[String]) -> Self {
804        let json_array = format!(
805            "[{}]",
806            shell
807                .iter()
808                .map(|a| format!("\"{}\"", escape_json_string(a)))
809                .collect::<Vec<_>>()
810                .join(", ")
811        );
812        Self::new("config")
813            .arg("--shell")
814            .arg(json_array)
815            .arg(container)
816    }
817
818    /// Set healthcheck
819    #[must_use]
820    pub fn config_healthcheck(container: &str, healthcheck: &HealthcheckInstruction) -> Self {
821        match healthcheck {
822            HealthcheckInstruction::None => Self::new("config")
823                .arg("--healthcheck")
824                .arg("NONE")
825                .arg(container),
826            HealthcheckInstruction::Check {
827                command,
828                interval,
829                timeout,
830                start_period,
831                retries,
832                ..
833            } => {
834                let mut cmd = Self::new("config");
835
836                let cmd_str = match command {
837                    ShellOrExec::Shell(s) => format!("CMD {s}"),
838                    ShellOrExec::Exec(args) => {
839                        format!(
840                            "CMD [{}]",
841                            args.iter()
842                                .map(|a| format!("\"{}\"", escape_json_string(a)))
843                                .collect::<Vec<_>>()
844                                .join(", ")
845                        )
846                    }
847                };
848
849                cmd = cmd.arg("--healthcheck").arg(cmd_str);
850
851                if let Some(i) = interval {
852                    cmd = cmd
853                        .arg("--healthcheck-interval")
854                        .arg(format!("{}s", i.as_secs()));
855                }
856
857                if let Some(t) = timeout {
858                    cmd = cmd
859                        .arg("--healthcheck-timeout")
860                        .arg(format!("{}s", t.as_secs()));
861                }
862
863                if let Some(sp) = start_period {
864                    cmd = cmd
865                        .arg("--healthcheck-start-period")
866                        .arg(format!("{}s", sp.as_secs()));
867                }
868
869                if let Some(r) = retries {
870                    cmd = cmd.arg("--healthcheck-retries").arg(r.to_string());
871                }
872
873                cmd.arg(container)
874            }
875        }
876    }
877
878    // =========================================================================
879    // Manifest Commands
880    // =========================================================================
881
882    /// Create a new manifest list.
883    ///
884    /// `buildah manifest create <name>`
885    #[must_use]
886    pub fn manifest_create(name: &str) -> Self {
887        Self::new("manifest").arg("create").arg(name)
888    }
889
890    /// Add an image to a manifest list.
891    ///
892    /// `buildah manifest add <list> <image>`
893    #[must_use]
894    pub fn manifest_add(list: &str, image: &str) -> Self {
895        Self::new("manifest").arg("add").arg(list).arg(image)
896    }
897
898    /// Push a manifest list and all referenced images.
899    ///
900    /// `buildah manifest push --all <list> <destination>`
901    #[must_use]
902    pub fn manifest_push(list: &str, destination: &str) -> Self {
903        Self::new("manifest")
904            .arg("push")
905            .arg("--all")
906            .arg(list)
907            .arg(destination)
908    }
909
910    /// Push a manifest list, optionally with registry credentials.
911    ///
912    /// buildah requires options to precede the positional list/destination
913    /// arguments (`buildah manifest push [options] LISTNAME DESTINATION`) —
914    /// passing `--creds` after the list/destination fails with "no options
915    /// (--creds) can be specified after the image or container name". This
916    /// builds every flag (`--all`, `--creds`) BEFORE the positionals.
917    #[must_use]
918    pub fn manifest_push_with_creds(list: &str, destination: &str, creds: Option<&str>) -> Self {
919        let mut cmd = Self::new("manifest").arg("push").arg("--all");
920        if let Some(creds) = creds {
921            cmd = cmd.arg("--creds").arg(creds);
922        }
923        cmd.arg(list).arg(destination)
924    }
925
926    /// Remove a manifest list.
927    ///
928    /// `buildah manifest rm <list>`
929    #[must_use]
930    pub fn manifest_rm(list: &str) -> Self {
931        Self::new("manifest").arg("rm").arg(list)
932    }
933
934    // =========================================================================
935    // Convert Instruction to Commands
936    // =========================================================================
937
938    /// Convert a Dockerfile instruction to buildah command(s) using the Linux
939    /// default shell (`/bin/sh -c`) and POSIX `mkdir -p` semantics.
940    ///
941    /// This is a convenience wrapper around [`DockerfileTranslator`] with
942    /// `target_os = ImageOs::Linux` and no `SHELL` override. It preserves the
943    /// historical byte-for-byte behavior for every call site that existed
944    /// before OS-aware translation landed in Phase L-3.
945    ///
946    /// For Windows targets or when a Dockerfile-level `SHELL` instruction
947    /// needs to be honored across subsequent `RUN`s, construct a
948    /// [`DockerfileTranslator`] explicitly and call
949    /// [`DockerfileTranslator::translate`].
950    ///
951    /// Some instructions map to multiple buildah commands (e.g., multiple
952    /// ENV vars, or WORKDIR emitting both `mkdir` and `config --workingdir`).
953    #[must_use]
954    pub fn from_instruction(container: &str, instruction: &Instruction) -> Vec<Self> {
955        DockerfileTranslator::new(ImageOs::Linux).translate(container, instruction)
956    }
957
958    /// Build an image with buildah's native frontend: `buildah build`.
959    ///
960    /// This drives buildah's own Dockerfile parser/executor over a
961    /// pre-rendered Dockerfile (`dockerfile_path`, written into `context` so
962    /// `COPY`/`ADD` and the `-f` path resolve), instead of the legacy
963    /// `buildah from` → per-instruction translate → `buildah commit` loop.
964    ///
965    /// Flag map:
966    ///
967    /// | source                                  | flag                            |
968    /// |-----------------------------------------|---------------------------------|
969    /// | `dockerfile_path`                       | `-f <path>`                     |
970    /// | `context`                               | trailing positional             |
971    /// | each `options.tags[i]`                  | `--tag <t>`                     |
972    /// | `options.target`                        | `--target <s>`                  |
973    /// | `options.no_cache`                      | `--no-cache`                    |
974    /// | `options.layers` (default true)         | `--layers` (omitted when false) |
975    /// | `options.squash`                        | `--squash`                      |
976    /// | `options.format`                        | `--format oci\|docker`          |
977    /// | `options.platform` (`a,b`)              | `--platform a,b`                |
978    /// | `effective_build_args` (K=V)            | `--build-arg K=V`               |
979    /// | `options.host_network`                  | `--network=host`                |
980    /// | `options.pull` Newer/Always/Never       | `--pull=ifnewer\|always\|never` |
981    /// | `options.cache_from/cache_to/cache_ttl` | `--cache-from/-to/-ttl`         |
982    /// | rootless (uid != 0, Unix)               | `--isolation=chroot`            |
983    /// | each distinct RUN ssh id                | `--ssh <id>`                    |
984    ///
985    /// `effective_build_args` is the caller-merged map of `options.build_args`
986    /// overlaid with `options.pipeline_vars`; `ssh_ids` is the de-duplicated,
987    /// ordered list of `RUN --mount=type=ssh` ids discovered in the IR.
988    #[must_use]
989    pub fn build(
990        dockerfile_path: &Path,
991        context: &Path,
992        options: &crate::builder::BuildOptions,
993        effective_build_args: &std::collections::BTreeMap<String, String>,
994        ssh_ids: &[String],
995        secret_ids: &[String],
996    ) -> Self {
997        let mut cmd = Self::new("build");
998
999        // Rendered Dockerfile.
1000        cmd = cmd
1001            .arg("-f")
1002            .arg(dockerfile_path.to_string_lossy().into_owned());
1003
1004        // Tags.
1005        for tag in &options.tags {
1006            cmd = cmd.arg("--tag").arg(tag.clone());
1007        }
1008
1009        // Target stage.
1010        if let Some(target) = &options.target {
1011            cmd = cmd.arg("--target").arg(target.clone());
1012        }
1013
1014        // Caching toggles.
1015        if options.no_cache {
1016            cmd = cmd.arg("--no-cache");
1017        }
1018        if options.layers {
1019            cmd = cmd.arg("--layers");
1020        }
1021        if options.squash {
1022            cmd = cmd.arg("--squash");
1023        }
1024
1025        // Image format.
1026        if let Some(format) = &options.format {
1027            cmd = cmd.arg("--format").arg(format.clone());
1028        }
1029
1030        // Platform (already a comma-joined `a,b` string).
1031        if let Some(platform) = &options.platform {
1032            cmd = cmd.arg("--platform").arg(platform.clone());
1033        }
1034
1035        // Build args (sorted via BTreeMap iteration order for determinism).
1036        for (key, value) in effective_build_args {
1037            cmd = cmd.arg("--build-arg").arg(format!("{key}={value}"));
1038        }
1039
1040        // Host networking — bypass CNI/netavark entirely.
1041        if options.host_network {
1042            cmd = cmd.arg("--network=host");
1043        }
1044
1045        // Pull policy.
1046        match options.pull {
1047            crate::builder::PullBaseMode::Newer => cmd = cmd.arg("--pull=ifnewer"),
1048            crate::builder::PullBaseMode::Always => cmd = cmd.arg("--pull=always"),
1049            crate::builder::PullBaseMode::Never => cmd = cmd.arg("--pull=never"),
1050        }
1051
1052        // Remote build cache.
1053        if let Some(cache_from) = &options.cache_from {
1054            cmd = cmd.arg("--cache-from").arg(cache_from.clone());
1055        }
1056        if let Some(cache_to) = &options.cache_to {
1057            cmd = cmd.arg("--cache-to").arg(cache_to.clone());
1058        }
1059        if let Some(cache_ttl) = &options.cache_ttl {
1060            cmd = cmd
1061                .arg("--cache-ttl")
1062                .arg(format!("{}s", cache_ttl.as_secs()));
1063        }
1064
1065        // Rootless isolation: mirror the sidecar's detect_default_isolation —
1066        // unprivileged callers need buildah's chroot isolation (no OCI runtime
1067        // needed and works inside the rootless user namespace).
1068        #[cfg(unix)]
1069        {
1070            if !nix::unistd::Uid::current().is_root() {
1071                cmd = cmd.arg("--isolation=chroot");
1072            }
1073        }
1074
1075        // SSH agent forwarding ids from RUN --mount=type=ssh.
1076        for id in ssh_ids {
1077            cmd = cmd.arg("--ssh").arg(id.clone());
1078        }
1079
1080        // Secret ids from RUN --mount=type=secret.
1081        for spec in secret_ids {
1082            cmd = cmd.arg("--secret").arg(spec.clone());
1083        }
1084
1085        // Build context (trailing positional).
1086        cmd = cmd.arg(context.to_string_lossy().into_owned());
1087
1088        cmd
1089    }
1090}
1091
1092/// Stateful translator from [`Instruction`] to [`BuildahCommand`] sequences.
1093///
1094/// Tracks the target OS and the most recent `SHELL` instruction so that
1095/// shell-form `RUN` / `CMD` / `ENTRYPOINT` use the correct shell for the
1096/// target platform:
1097///
1098/// - **Linux, no SHELL override** — `RUN cmd` → `buildah run -- /bin/sh -c "cmd"`
1099/// - **Windows, no SHELL override** — `RUN cmd` → `buildah run -- cmd.exe /S /C "cmd"`
1100/// - **Any OS with `SHELL ["pwsh", "-Command"]`** — subsequent `RUN cmd` uses
1101///   `buildah run -- pwsh -Command "cmd"`
1102///
1103/// The translator is stateful because Dockerfile `SHELL` instructions persist
1104/// across subsequent `RUN`/`CMD`/`ENTRYPOINT` translations until another
1105/// `SHELL` replaces them. Callers that translate a multi-instruction stage
1106/// should reuse a single translator instance across the full instruction
1107/// stream.
1108///
1109/// This translator is designed to be shared between the buildah backend and
1110/// the Phase L-4 HCS (Windows host compute service) backend, so neither needs
1111/// to re-implement the shell-form / workdir branching.
1112#[derive(Debug, Clone)]
1113pub struct DockerfileTranslator {
1114    target_os: ImageOs,
1115    /// Most recent `SHELL` instruction override, if any. When `None` the
1116    /// translator falls back to [`default_shell_for`] for the target OS.
1117    shell_override: Option<Vec<String>>,
1118    /// When `true`, every emitted `buildah run` for a `RUN` instruction will
1119    /// carry `--net=host` regardless of any per-instruction `network` value.
1120    /// Mirrors Docker's `docker build --network=host` flag and bypasses
1121    /// buildah's CNI/netavark plumbing entirely (the container shares the
1122    /// host's network namespace).
1123    host_network: bool,
1124    /// Path to an empty host directory the translator can point `buildah
1125    /// copy` at to materialize a `WORKDIR` directory in the container
1126    /// rootfs without running a process inside the container. Required for
1127    /// images whose base lacks a shell (`gcr.io/distroless/*`, `scratch`).
1128    /// When `None`, [`Self::translate_workdir`] falls back to the legacy
1129    /// `buildah run -- mkdir -p <dir>` path, which only works on bases
1130    /// that ship `mkdir`. Production callers (the `BuildahBackend`) MUST
1131    /// set this; tests and doc snippets can leave it unset.
1132    empty_src_dir: Option<PathBuf>,
1133}
1134
1135impl DockerfileTranslator {
1136    /// Create a new translator for a given target OS, with no `SHELL` override.
1137    #[must_use]
1138    pub fn new(target_os: ImageOs) -> Self {
1139        Self {
1140            target_os,
1141            shell_override: None,
1142            host_network: false,
1143            empty_src_dir: None,
1144        }
1145    }
1146
1147    /// Point `WORKDIR` translation at an empty host directory used as the
1148    /// source for `buildah copy`, which materializes the workdir in the
1149    /// container rootfs without executing a process inside the container.
1150    ///
1151    /// Without this, `WORKDIR` falls back to `buildah run -- mkdir -p <dir>`,
1152    /// which fails on distroless / scratch images that lack a shell.
1153    /// Production callers (the `BuildahBackend`'s build loop) should create
1154    /// a `TempDir` for the lifetime of the build and pass its path here.
1155    /// The translator does NOT own the directory's lifecycle — callers
1156    /// must keep it alive (and empty) until every translated WORKDIR
1157    /// `buildah copy` command has finished executing.
1158    #[must_use]
1159    pub fn with_empty_src_dir(mut self, dir: PathBuf) -> Self {
1160        self.empty_src_dir = Some(dir);
1161        self
1162    }
1163
1164    /// Force every translated `RUN` instruction to use host networking.
1165    ///
1166    /// When `on` is `true`, the translator overrides any per-instruction
1167    /// `network` value (including `None`) with [`RunNetwork::Host`] before
1168    /// emitting the buildah command. This mirrors the effect of Docker's
1169    /// `docker build --network=host` flag and bypasses buildah's CNI /
1170    /// netavark plumbing entirely. When `on` is `false` (the default),
1171    /// per-instruction `network` values are passed through unchanged.
1172    #[must_use]
1173    pub fn with_host_network(mut self, on: bool) -> Self {
1174        self.host_network = on;
1175        self
1176    }
1177
1178    /// Return the target OS this translator emits commands for.
1179    #[must_use]
1180    pub fn target_os(&self) -> ImageOs {
1181        self.target_os
1182    }
1183
1184    /// Return the current shell-form prefix: the `SHELL` override if one was
1185    /// applied, else the OS default (`/bin/sh -c` on Linux, `cmd.exe /S /C` on
1186    /// Windows).
1187    #[must_use]
1188    pub fn active_shell(&self) -> Vec<String> {
1189        match &self.shell_override {
1190            Some(s) => s.clone(),
1191            None => default_shell_for(self.target_os),
1192        }
1193    }
1194
1195    /// Replace the translator's `SHELL` override, matching the effect of a
1196    /// Dockerfile `SHELL ["…"]` instruction on subsequent RUN/CMD/ENTRYPOINT
1197    /// shell-form commands.
1198    pub fn set_shell_override(&mut self, shell: Vec<String>) {
1199        self.shell_override = Some(shell);
1200    }
1201
1202    /// Translate a single instruction into zero or more [`BuildahCommand`]s.
1203    ///
1204    /// Stateful: `SHELL` instructions update the translator's shell override,
1205    /// so subsequent `RUN` / `CMD` / `ENTRYPOINT` shell-form translations pick
1206    /// up the new shell. `WORKDIR` emits an OS-appropriate pre-mkdir followed
1207    /// by `buildah config --workingdir`.
1208    #[allow(clippy::too_many_lines)]
1209    pub fn translate(&mut self, container: &str, instruction: &Instruction) -> Vec<BuildahCommand> {
1210        match instruction {
1211            Instruction::Run(run) => {
1212                let shell = self.active_shell();
1213                // Apply the translator-level host_network override: when set,
1214                // every emitted RUN gets `--net=host` regardless of any
1215                // per-instruction network value. We clone only when we
1216                // actually need to mutate so the no-override path stays
1217                // allocation-free.
1218                let effective_run: std::borrow::Cow<'_, RunInstruction> = if self.host_network {
1219                    let mut owned = run.clone();
1220                    owned.network = Some(RunNetwork::Host);
1221                    std::borrow::Cow::Owned(owned)
1222                } else {
1223                    std::borrow::Cow::Borrowed(run)
1224                };
1225                let run_ref: &RunInstruction = &effective_run;
1226
1227                // Route through run_with_mounts_shell whenever mounts, env,
1228                // or a network mode are present, since the simple factories
1229                // don't emit those pre-container flags.
1230                let needs_pre_container_flags = !run_ref.mounts.is_empty()
1231                    || !run_ref.env.is_empty()
1232                    || run_ref.network.is_some();
1233
1234                if needs_pre_container_flags {
1235                    vec![BuildahCommand::run_with_mounts_shell(
1236                        container, run_ref, &shell,
1237                    )]
1238                } else {
1239                    match &run_ref.command {
1240                        ShellOrExec::Shell(s) => {
1241                            vec![BuildahCommand::run_shell_custom(container, &shell, s)]
1242                        }
1243                        ShellOrExec::Exec(args) => vec![BuildahCommand::run_exec(container, args)],
1244                    }
1245                }
1246            }
1247
1248            Instruction::Copy(copy) => {
1249                vec![BuildahCommand::copy_instruction(container, copy)]
1250            }
1251
1252            Instruction::Add(add) => {
1253                vec![BuildahCommand::add_instruction(container, add)]
1254            }
1255
1256            Instruction::Env(env) => BuildahCommand::config_envs(container, env),
1257
1258            Instruction::Workdir(dir) => self.translate_workdir(container, dir),
1259
1260            Instruction::Expose(expose) => {
1261                vec![BuildahCommand::config_expose(container, expose)]
1262            }
1263
1264            Instruction::Label(labels) => BuildahCommand::config_labels(container, labels),
1265
1266            Instruction::User(user) => {
1267                vec![BuildahCommand::config_user(container, user)]
1268            }
1269
1270            Instruction::Entrypoint(cmd) => {
1271                vec![BuildahCommand::config_entrypoint(container, cmd)]
1272            }
1273
1274            Instruction::Cmd(cmd) => {
1275                vec![BuildahCommand::config_cmd(container, cmd)]
1276            }
1277
1278            Instruction::Volume(paths) => paths
1279                .iter()
1280                .map(|p| BuildahCommand::config_volume(container, p))
1281                .collect(),
1282
1283            Instruction::Shell(shell) => {
1284                // SHELL instruction: update the translator's shell override
1285                // AND emit the metadata config --shell so committed images
1286                // record the user-declared shell. Both matter: the override
1287                // changes how we translate subsequent RUN/CMD/ENTRYPOINT
1288                // shell-form instructions in THIS build; the metadata is what
1289                // tools like `docker inspect` show on the resulting image.
1290                self.set_shell_override(shell.clone());
1291                vec![BuildahCommand::config_shell(container, shell)]
1292            }
1293
1294            Instruction::Arg(_) => {
1295                // ARG is handled during variable expansion, not as a buildah command
1296                vec![]
1297            }
1298
1299            Instruction::Stopsignal(signal) => {
1300                vec![BuildahCommand::config_stopsignal(container, signal)]
1301            }
1302
1303            Instruction::Healthcheck(hc) => {
1304                vec![BuildahCommand::config_healthcheck(container, hc)]
1305            }
1306
1307            Instruction::Onbuild(_) => {
1308                // ONBUILD would need special handling
1309                tracing::warn!("ONBUILD instruction not supported in buildah conversion");
1310                vec![]
1311            }
1312        }
1313    }
1314
1315    /// Emit the commands needed to realise a `WORKDIR <dir>` instruction for
1316    /// the target OS.
1317    ///
1318    /// # Linux
1319    ///
1320    /// Emits `mkdir -p <dir>` followed by `buildah config --workingdir`. This
1321    /// matches Docker's WORKDIR semantics: the directory must exist in the
1322    /// rootfs before a process can `chdir()` into it, and `buildah config
1323    /// --workingdir` alone is metadata-only.
1324    ///
1325    /// # Windows
1326    ///
1327    /// Emits `cmd /S /C "if not exist <dir> mkdir <dir>"` before the
1328    /// metadata write. Windows `mkdir` (unlike `mkdir -p`) errors out when the
1329    /// directory exists, so we guard with `if not exist` to stay idempotent
1330    /// across repeated WORKDIR instructions in the same Dockerfile.
1331    fn translate_workdir(&self, container: &str, dir: &str) -> Vec<BuildahCommand> {
1332        // Mirror `Instruction::Run`: when the translator was constructed with
1333        // `with_host_network(true)`, every emitted `buildah run` MUST carry
1334        // `--net=host` so the build bypasses buildah's rootless CNI/netavark
1335        // plumbing entirely. Without this, a WORKDIR — which runs `mkdir -p`
1336        // through `buildah run` — gets routed through netavark even though
1337        // the user opted out, and dies on the first instruction of the first
1338        // stage when the host's netavark config is broken.
1339        let net = self.host_network.then_some(RunNetwork::Host);
1340        match self.target_os {
1341            // Darwin (macOS) images share the Linux POSIX `mkdir -p` path.
1342            ImageOs::Linux | ImageOs::Darwin => {
1343                // Prefer the shell-free `buildah copy` path when the caller
1344                // supplied an empty source directory. `buildah copy <ctr>
1345                // <empty>/. <dir>` materializes `<dir>` in the rootfs
1346                // without running anything inside the container, so this
1347                // works on distroless / scratch / any base lacking
1348                // `/bin/sh` / `mkdir`. Falls back to the legacy
1349                // `buildah run -- mkdir -p` path when no source dir is
1350                // configured (tests, docs, callers that haven't migrated).
1351                if let Some(empty_src) = self.empty_src_dir.as_deref() {
1352                    vec![
1353                        BuildahCommand::copy_empty_dir(container, empty_src, dir),
1354                        BuildahCommand::config_workdir(container, dir),
1355                    ]
1356                } else {
1357                    vec![
1358                        BuildahCommand::run_exec_with_net(
1359                            container,
1360                            &["mkdir".to_string(), "-p".to_string(), dir.to_string()],
1361                            net,
1362                        ),
1363                        BuildahCommand::config_workdir(container, dir),
1364                    ]
1365                }
1366            }
1367            ImageOs::Windows => {
1368                // Quote the path so paths with spaces (e.g. `C:\Program Files\app`)
1369                // survive cmd.exe parsing. `cmd /S /C` strips the outer quotes
1370                // before executing, so double-quote the full command and escape
1371                // any inner quotes.
1372                let guarded = format!(r#"if not exist "{dir}" mkdir "{dir}""#);
1373                vec![
1374                    BuildahCommand::run_shell_custom_with_net(
1375                        container,
1376                        WINDOWS_DEFAULT_SHELL,
1377                        &guarded,
1378                        net,
1379                    ),
1380                    BuildahCommand::config_workdir(container, dir),
1381                ]
1382            }
1383        }
1384    }
1385}
1386
1387// ---------------------------------------------------------------------------
1388// Linux-package-manager → Chocolatey translation helpers
1389//
1390// These were originally housed in `crate::windows_builder` and are used to
1391// rewrite Linux package-manager invocations (`apt-get install`, `apk add`,
1392// `yum/dnf install`) inside `RUN` instructions into a Chocolatey
1393// (`choco install -y …`) equivalent when the target OS is Windows. Moving
1394// them here lets both the production `HcsBackend` and the in-process
1395// `WindowsBuilder` test harness flow through one translator instead of
1396// duplicating the logic.
1397//
1398// The free helpers below are kept `pub(crate)` so the existing
1399// `windows_builder` test module (and any other in-crate caller) can keep
1400// exercising them directly without going through `DockerfileTranslator`.
1401// ---------------------------------------------------------------------------
1402
1403/// Which Linux package manager an install sub-command was issued against.
1404//
1405// The whole apt→choco helper surface is gated on `windows || test` so
1406// non-Windows production builds don't warn about dead code — every call
1407// site lives in either the Windows-only `windows_builder` production
1408// path or a `#[cfg(test)]` unit test.
1409#[cfg(any(target_os = "windows", test))]
1410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1411pub(crate) enum DetectedPmKind {
1412    /// `apt-get install -y …` or `apt install -y …`.
1413    Apt,
1414    /// `apk add [--no-cache] …`.
1415    Apk,
1416    /// `yum install -y …` or `dnf install -y …`.
1417    YumOrDnf,
1418}
1419
1420/// One sub-command parsed out of a shell-form RUN string.
1421#[cfg(any(target_os = "windows", test))]
1422#[derive(Debug, Clone, PartialEq, Eq)]
1423pub(crate) enum ShellSubcommand {
1424    /// The literal text of a non-install sub-command, kept verbatim.
1425    Verbatim(String),
1426    /// The literal text of an `apt-get update` / `apk update` /
1427    /// `dnf check-update` style sync command. Surfaced as a distinct
1428    /// variant so we can elide it (no Chocolatey equivalent) instead of
1429    /// passing it through verbatim and breaking the shell.
1430    PackageManagerSync,
1431    /// A detected install invocation: kind + the package list.
1432    Install {
1433        kind: DetectedPmKind,
1434        packages: Vec<String>,
1435    },
1436}
1437
1438/// Detect whether a single shell sub-command is an `apt-get install`,
1439/// `apk add`, `yum install`, or `dnf install` invocation. Returns
1440/// `Some((kind, packages))` if so. Flag-only arguments (starting with
1441/// `-`) are stripped; bare positional args are treated as package names.
1442#[cfg(any(target_os = "windows", test))]
1443pub(crate) fn detect_install_in_subcommand(
1444    subcommand: &str,
1445) -> Option<(DetectedPmKind, Vec<String>)> {
1446    let tokens: Vec<&str> = subcommand.split_whitespace().collect();
1447    if tokens.is_empty() {
1448        return None;
1449    }
1450    // Drop `sudo` if present so `sudo apt-get install -y curl` is
1451    // recognised the same as the bareword form.
1452    let (kind, after_verb_idx) = match tokens[0] {
1453        "sudo" if tokens.len() >= 2 => detect_pm_verb(&tokens[1..]).map(|(k, n)| (k, n + 1))?,
1454        _ => detect_pm_verb(&tokens)?,
1455    };
1456    let args = &tokens[after_verb_idx..];
1457    let mut packages = Vec::new();
1458    for arg in args {
1459        if arg.starts_with('-') {
1460            continue;
1461        }
1462        packages.push((*arg).to_string());
1463    }
1464    if packages.is_empty() {
1465        return None;
1466    }
1467    Some((kind, packages))
1468}
1469
1470/// Recognise the `<pm> <verb>` prefix of a sub-command. Returns
1471/// `(kind, tokens_consumed)` on success — the caller then walks
1472/// `tokens[tokens_consumed..]` for the package list.
1473#[cfg(any(target_os = "windows", test))]
1474fn detect_pm_verb(tokens: &[&str]) -> Option<(DetectedPmKind, usize)> {
1475    match (tokens.first().copied(), tokens.get(1).copied()) {
1476        (Some("apt-get" | "apt"), Some("install")) => Some((DetectedPmKind::Apt, 2)),
1477        (Some("apk"), Some("add")) => Some((DetectedPmKind::Apk, 2)),
1478        (Some("yum" | "dnf"), Some("install")) => Some((DetectedPmKind::YumOrDnf, 2)),
1479        _ => None,
1480    }
1481}
1482
1483/// Recognise package-manager-sync invocations (`apt-get update`,
1484/// `apk update`, `dnf check-update`, etc.) so we can elide them in the
1485/// rewritten command — Chocolatey resolves package metadata on every
1486/// install and has no separate sync step.
1487#[cfg(any(target_os = "windows", test))]
1488pub(crate) fn is_package_manager_sync(subcommand: &str) -> bool {
1489    let tokens: Vec<&str> = subcommand.split_whitespace().collect();
1490    let stripped: &[&str] = if tokens.first().copied() == Some("sudo") {
1491        &tokens[1..]
1492    } else {
1493        &tokens
1494    };
1495    matches!(
1496        (stripped.first().copied(), stripped.get(1).copied()),
1497        (Some("apt-get" | "apt" | "apk"), Some("update"))
1498            | (
1499                Some("yum" | "dnf"),
1500                Some("check-update" | "update" | "makecache")
1501            )
1502    )
1503}
1504
1505/// Split a shell-form RUN command on `&&` and `;` boundaries, preserving
1506/// each sub-command's text. Quoted regions are NOT honoured (Docker
1507/// shell-form is itself a string passed to `cmd /c` which doesn't
1508/// preserve nested shell quoting either; matching that lenient
1509/// behaviour avoids a regex dep and keeps the implementation simple).
1510#[cfg(any(target_os = "windows", test))]
1511pub(crate) fn split_shell_subcommands(raw: &str) -> Vec<String> {
1512    let mut out = Vec::new();
1513    let mut current = String::new();
1514    let mut chars = raw.chars().peekable();
1515    while let Some(c) = chars.next() {
1516        match c {
1517            '&' if chars.peek() == Some(&'&') => {
1518                chars.next();
1519                if !current.trim().is_empty() {
1520                    out.push(current.trim().to_string());
1521                }
1522                current.clear();
1523            }
1524            ';' => {
1525                if !current.trim().is_empty() {
1526                    out.push(current.trim().to_string());
1527                }
1528                current.clear();
1529            }
1530            other => current.push(other),
1531        }
1532    }
1533    if !current.trim().is_empty() {
1534        out.push(current.trim().to_string());
1535    }
1536    out
1537}
1538
1539/// A relocatable artifact resolved for one Linux package, handed back to the
1540/// Windows builder so it can download + extract it into the rootfs layer
1541/// out-of-band (instead of via `choco install`).
1542///
1543/// This is a flat, builder-facing projection of the relocatable variants of
1544/// [`crate::windows_image_resolver::ResolvedWindowsPackage`] — the translator
1545/// only needs the download/extract triple, not the full discovery enum.
1546#[cfg(any(target_os = "windows", test))]
1547#[derive(Debug, Clone, PartialEq, Eq)]
1548pub(crate) struct RelocatableArtifact {
1549    /// Original Linux package name (drives the install prefix + PATH dir).
1550    pub name: String,
1551    /// HTTP(S) URL of the asset/archive to download.
1552    pub url: String,
1553    /// Trailing filename of `url`, used to pick the extractor.
1554    pub asset_name: String,
1555}
1556
1557/// Result of translating a single Windows `RUN` command.
1558///
1559/// Replaces the old `(String, Vec<String>)` tuple so the builder also learns
1560/// which packages resolved to relocatable artifacts (installed into the rootfs
1561/// layer separately, NOT via `choco install`).
1562#[cfg(any(target_os = "windows", test))]
1563#[derive(Debug, Clone, Default, PartialEq, Eq)]
1564pub(crate) struct TranslatedRun {
1565    /// The `cmd /c "…"`-wrapped command line to execute in the build sandbox.
1566    pub command_line: String,
1567    /// Linux-only packages with no Windows equivalent (`__skip__`), reported
1568    /// for user-facing logging.
1569    pub skipped_packages: Vec<String>,
1570    /// Packages that resolved to a relocatable artifact. The builder downloads
1571    /// and extracts each into the rootfs layer and adds its bin dir to `PATH`.
1572    /// These are elided from the `choco install` line.
1573    pub relocatable: Vec<RelocatableArtifact>,
1574}
1575
1576/// Re-join a list of [`ShellSubcommand`]s back into a single
1577/// `cmd /c`-compatible string, eliding sync sub-commands and rewriting
1578/// install sub-commands as `choco install -y …`. An `Install` sub-command
1579/// whose package list is empty (every package was relocatable or matched the
1580/// provisioned toolchain) is dropped entirely.
1581#[cfg(any(target_os = "windows", test))]
1582pub(crate) fn rejoin_subcommands(parts: &[ShellSubcommand]) -> String {
1583    let mut emitted: Vec<String> = Vec::new();
1584    for part in parts {
1585        match part {
1586            ShellSubcommand::Verbatim(s) => emitted.push(s.clone()),
1587            ShellSubcommand::PackageManagerSync => {
1588                // Eliding: no equivalent in Chocolatey.
1589            }
1590            ShellSubcommand::Install { packages, .. } => {
1591                if packages.is_empty() {
1592                    continue;
1593                }
1594                let mut joined = String::from("choco install -y");
1595                for pkg in packages {
1596                    joined.push(' ');
1597                    joined.push_str(pkg);
1598                }
1599                emitted.push(joined);
1600            }
1601        }
1602    }
1603    emitted.join(" && ")
1604}
1605
1606/// Wrap a shell command body in `cmd /c "…"` so HCS's `CreateProcess`
1607/// invokes the Windows command interpreter. Embedded double quotes are
1608/// backslash-escaped per the NT `CommandLineToArgvW` convention. An
1609/// empty body still produces a well-formed (no-op) command.
1610#[cfg(any(target_os = "windows", test))]
1611pub(crate) fn wrap_in_cmd(body: &str) -> String {
1612    if body.is_empty() {
1613        return "cmd /c \"\"".to_string();
1614    }
1615    let escaped = body.replace('"', "\\\"");
1616    format!("cmd /c \"{escaped}\"")
1617}
1618
1619/// Return `true` if `linux_pkg` is the Linux name of the language toolchain
1620/// indicated by `toolchain_language`. Used to drop packages that are
1621/// already provisioned directly into the rootfs via
1622/// `crate::windows_toolchain`, so we don't re-install (a possibly
1623/// conflicting) Chocolatey copy on top.
1624///
1625/// Match is case-insensitive on the toolchain language and exact on the
1626/// package name (lower-cased). The mapping mirrors what users typically
1627/// write in Linux Dockerfiles for each language:
1628///
1629/// | toolchain language | matching Linux package names      |
1630/// |--------------------|-----------------------------------|
1631/// | `go`               | `golang`, `go`                    |
1632/// | `node`             | `nodejs`, `node`                  |
1633/// | `python`           | `python3`, `python`               |
1634/// | `rust`             | `rust`, `rustc`, `cargo`          |
1635/// | `deno`             | `deno`                            |
1636/// | `bun`              | `bun`                             |
1637#[cfg(any(target_os = "windows", test))]
1638fn package_matches_toolchain(linux_pkg: &str, toolchain_language: &str) -> bool {
1639    let pkg = linux_pkg.to_ascii_lowercase();
1640    match toolchain_language.to_ascii_lowercase().as_str() {
1641        "go" => matches!(pkg.as_str(), "golang" | "go"),
1642        "node" => matches!(pkg.as_str(), "nodejs" | "node"),
1643        "python" => matches!(pkg.as_str(), "python3" | "python"),
1644        "rust" => matches!(pkg.as_str(), "rust" | "rustc" | "cargo"),
1645        "deno" => pkg == "deno",
1646        "bun" => pkg == "bun",
1647        _ => false,
1648    }
1649}
1650
1651#[cfg(any(target_os = "windows", test))]
1652impl DockerfileTranslator {
1653    /// Translate a `RUN` command (shell- or exec-form) for the
1654    /// translator's target OS.
1655    ///
1656    /// - **Linux**: returns the command verbatim — `(joined_string, [])` —
1657    ///   because Linux RUNs are handed straight to `/bin/sh -c` by the
1658    ///   buildah backend and need no rewriting.
1659    /// - **Windows**: exec-form is passed through (joined with spaces);
1660    ///   shell-form is forwarded to [`translate_shell_command`] which
1661    ///   detects apt/apk/yum/dnf invocations and rewrites them as
1662    ///   `choco install -y …` against the Chocolatey package map for
1663    ///   `source_distro`.
1664    ///
1665    /// If `provisioned_toolchain_language` is `Some(lang)`, any package
1666    /// matching that language ([`package_matches_toolchain`]) is **dropped**
1667    /// from the install list — the toolchain is already on `PATH` via
1668    /// `crate::windows_toolchain` and re-installing via Chocolatey would
1669    /// either fail or shadow the provisioned binary.
1670    ///
1671    /// Returns `(translated_command, skipped_packages)` where
1672    /// `skipped_packages` enumerates Linux package names whose Chocolatey
1673    /// mapping is the `__skip__` sentinel (no Windows equivalent — the
1674    /// caller typically logs them for the user).
1675    ///
1676    /// # Errors
1677    ///
1678    /// Returns [`crate::error::BuildError::ChocoResolutionFailed`] if any
1679    /// Linux package detected in the install list has no mapping in the
1680    /// Chocolatey package map for `source_distro`. Returns whatever error
1681    /// `resolve_chocolatey_packages` surfaces for cache setup failures.
1682    pub(crate) async fn translate_run_command(
1683        &self,
1684        cmd: &ShellOrExec,
1685        source_distro: &str,
1686        provisioned_toolchain_language: Option<&str>,
1687    ) -> Result<TranslatedRun, crate::error::BuildError> {
1688        match self.target_os {
1689            // Darwin (macOS) images pass RUN commands through verbatim, exactly
1690            // like Linux — no Chocolatey rewriting.
1691            ImageOs::Linux | ImageOs::Darwin => match cmd {
1692                ShellOrExec::Shell(s) => Ok(TranslatedRun {
1693                    command_line: s.clone(),
1694                    ..TranslatedRun::default()
1695                }),
1696                ShellOrExec::Exec(args) => Ok(TranslatedRun {
1697                    command_line: args.join(" "),
1698                    ..TranslatedRun::default()
1699                }),
1700            },
1701            ImageOs::Windows => match cmd {
1702                ShellOrExec::Exec(args) => Ok(TranslatedRun {
1703                    command_line: args.join(" "),
1704                    ..TranslatedRun::default()
1705                }),
1706                ShellOrExec::Shell(raw) => {
1707                    self.translate_shell_command(raw, source_distro, provisioned_toolchain_language)
1708                        .await
1709                }
1710            },
1711        }
1712    }
1713
1714    /// Translate a shell-form RUN command. Behaviour matches
1715    /// [`Self::translate_run_command`] — Linux returns the input verbatim,
1716    /// Windows rewrites Linux package-manager invocations to Chocolatey.
1717    ///
1718    /// See [`Self::translate_run_command`] for the meaning of
1719    /// `provisioned_toolchain_language`.
1720    ///
1721    /// # Errors
1722    ///
1723    /// See [`Self::translate_run_command`].
1724    pub(crate) async fn translate_shell_command(
1725        &self,
1726        raw: &str,
1727        source_distro: &str,
1728        provisioned_toolchain_language: Option<&str>,
1729    ) -> Result<TranslatedRun, crate::error::BuildError> {
1730        use crate::windows_image_resolver::ResolvedWindowsPackage;
1731
1732        if matches!(self.target_os, ImageOs::Linux) {
1733            return Ok(TranslatedRun {
1734                command_line: raw.to_string(),
1735                ..TranslatedRun::default()
1736            });
1737        }
1738
1739        let subcommands = split_shell_subcommands(raw);
1740        if subcommands.is_empty() {
1741            // Empty RUN — defer to the underlying shell which will be a
1742            // no-op. `cmd /c` accepts an empty argument list and exits 0.
1743            return Ok(TranslatedRun {
1744                command_line: wrap_in_cmd(""),
1745                ..TranslatedRun::default()
1746            });
1747        }
1748
1749        let mut classified: Vec<ShellSubcommand> = Vec::with_capacity(subcommands.len());
1750        let mut all_packages: Vec<String> = Vec::new();
1751        for sub in &subcommands {
1752            if is_package_manager_sync(sub) {
1753                classified.push(ShellSubcommand::PackageManagerSync);
1754                continue;
1755            }
1756            if let Some((kind, mut packages)) = detect_install_in_subcommand(sub) {
1757                // Drop packages already covered by the provisioned
1758                // toolchain — re-installing via Chocolatey would either
1759                // fail (version conflict) or shadow the provisioned
1760                // binary that the rest of the build relies on.
1761                if let Some(lang) = provisioned_toolchain_language {
1762                    packages.retain(|p| !package_matches_toolchain(p, lang));
1763                }
1764                if packages.is_empty() {
1765                    // Every package in this sub-command was the
1766                    // toolchain; nothing left to install, so elide the
1767                    // sub-command entirely.
1768                    continue;
1769                }
1770                all_packages.extend(packages.iter().cloned());
1771                classified.push(ShellSubcommand::Install { kind, packages });
1772                continue;
1773            }
1774            classified.push(ShellSubcommand::Verbatim(sub.clone()));
1775        }
1776
1777        if all_packages.is_empty() {
1778            // No install was detected — pass the original shell command
1779            // through `cmd /c` unchanged. We re-join from the classified
1780            // parts so an `apt-get update`-only RUN still elides correctly.
1781            let rejoined = rejoin_subcommands(&classified);
1782            return Ok(TranslatedRun {
1783                command_line: wrap_in_cmd(&rejoined),
1784                ..TranslatedRun::default()
1785            });
1786        }
1787
1788        // Bulk-resolve every package across every install sub-command in
1789        // one go. `resolve_windows_packages` prefers a *relocatable artifact*
1790        // (forge release / portable archive) for each package and only falls
1791        // back to a Chocolatey package name when no relocatable artifact is
1792        // known — mirroring the macOS resolver's bottle-vs-direct-release
1793        // preference. Relocatable packages are installed into the rootfs layer
1794        // out-of-band by the builder and elided from the `choco install` line.
1795        let resolved =
1796            crate::windows_image_resolver::resolve_windows_packages(&all_packages, source_distro)
1797                .await?;
1798
1799        // package name → resolution. Unresolved packages are simply absent.
1800        let mut lookup: HashMap<String, ResolvedWindowsPackage> = HashMap::new();
1801        for r in resolved {
1802            lookup.insert(r.name().to_string(), r);
1803        }
1804
1805        let mut skipped_out: Vec<String> = Vec::new();
1806        let mut relocatable_out: Vec<RelocatableArtifact> = Vec::new();
1807        for part in &mut classified {
1808            if let ShellSubcommand::Install { kind: _, packages } = part {
1809                let mut choco_pkgs: Vec<String> = Vec::new();
1810                for pkg in packages.iter() {
1811                    match lookup.get(pkg) {
1812                        // Relocatable: install into the rootfs layer, drop
1813                        // from the choco line entirely.
1814                        Some(
1815                            ResolvedWindowsPackage::DirectRelease {
1816                                name,
1817                                url,
1818                                asset_name,
1819                            }
1820                            | ResolvedWindowsPackage::RelocatableArchive {
1821                                name,
1822                                url,
1823                                asset_name,
1824                            },
1825                        ) => {
1826                            relocatable_out.push(RelocatableArtifact {
1827                                name: name.clone(),
1828                                url: url.clone(),
1829                                asset_name: asset_name.clone(),
1830                            });
1831                        }
1832                        // No relocatable artifact, but a Chocolatey package
1833                        // exists — keep emitting `choco install`.
1834                        Some(ResolvedWindowsPackage::ChocoFallback { choco_name, .. }) => {
1835                            choco_pkgs.push(choco_name.clone());
1836                        }
1837                        // Linux-only — silently omit.
1838                        Some(ResolvedWindowsPackage::Skip { .. }) => skipped_out.push(pkg.clone()),
1839                        // Neither a relocatable artifact nor a choco mapping —
1840                        // surface a precise diagnostic instead of a silently
1841                        // broken image.
1842                        None => {
1843                            return Err(crate::error::BuildError::ChocoResolutionFailed {
1844                                package: pkg.clone(),
1845                                source_distro: source_distro.to_string(),
1846                            });
1847                        }
1848                    }
1849                }
1850                // An Install whose package list is now empty (all relocatable
1851                // or all skipped) is dropped by `rejoin_subcommands`.
1852                *packages = choco_pkgs;
1853            }
1854        }
1855
1856        Ok(TranslatedRun {
1857            command_line: wrap_in_cmd(&rejoin_subcommands(&classified)),
1858            skipped_packages: skipped_out,
1859            relocatable: relocatable_out,
1860        })
1861    }
1862}
1863
1864/// Escape a string for use in JSON
1865#[cfg(test)]
1866mod tests {
1867    use super::*;
1868    use crate::dockerfile::RunInstruction;
1869
1870    #[test]
1871    fn test_from_image() {
1872        let cmd = BuildahCommand::from_image("alpine:3.18");
1873        assert_eq!(cmd.program, "buildah");
1874        assert_eq!(cmd.args, vec!["from", "alpine:3.18"]);
1875    }
1876
1877    #[test]
1878    fn test_build_flag_map() {
1879        use crate::builder::{BuildOptions, PullBaseMode};
1880        use std::collections::BTreeMap;
1881        use std::path::Path;
1882
1883        let options = BuildOptions {
1884            tags: vec!["app:latest".into(), "app:1.0".into()],
1885            target: Some("runtime".into()),
1886            no_cache: true,
1887            layers: true,
1888            squash: true,
1889            format: Some("docker".into()),
1890            platform: Some("linux/amd64,linux/arm64".into()),
1891            host_network: true,
1892            pull: PullBaseMode::Always,
1893            cache_from: Some("type=registry,ref=ex/cache".into()),
1894            cache_to: Some("type=registry,ref=ex/cache".into()),
1895            cache_ttl: Some(std::time::Duration::from_secs(3600)),
1896            ..BuildOptions::default()
1897        };
1898        let mut args = BTreeMap::new();
1899        args.insert("VERSION".to_string(), "1.0".to_string());
1900        args.insert("CHANNEL".to_string(), "stable".to_string());
1901
1902        let cmd = BuildahCommand::build(
1903            Path::new("/ctx/.zlayer-rendered-Dockerfile"),
1904            Path::new("/ctx"),
1905            &options,
1906            &args,
1907            &["default".to_string()],
1908            &[],
1909        );
1910        let a = &cmd.args;
1911
1912        assert_eq!(a[0], "build");
1913        // -f <rendered path>
1914        let f_idx = a.iter().position(|x| x == "-f").expect("-f present");
1915        assert_eq!(a[f_idx + 1], "/ctx/.zlayer-rendered-Dockerfile");
1916        // tags
1917        let tag_count = a.iter().filter(|x| x.as_str() == "--tag").count();
1918        assert_eq!(tag_count, 2);
1919        // target
1920        let t_idx = a.iter().position(|x| x == "--target").expect("--target");
1921        assert_eq!(a[t_idx + 1], "runtime");
1922        assert!(a.iter().any(|x| x == "--no-cache"));
1923        assert!(a.iter().any(|x| x == "--layers"));
1924        assert!(a.iter().any(|x| x == "--squash"));
1925        // format
1926        let fmt_idx = a.iter().position(|x| x == "--format").expect("--format");
1927        assert_eq!(a[fmt_idx + 1], "docker");
1928        // platform
1929        let p_idx = a
1930            .iter()
1931            .position(|x| x == "--platform")
1932            .expect("--platform");
1933        assert_eq!(a[p_idx + 1], "linux/amd64,linux/arm64");
1934        // build args (sorted: CHANNEL before VERSION)
1935        let ba: Vec<&String> = a
1936            .iter()
1937            .enumerate()
1938            .filter(|(i, x)| x.as_str() == "--build-arg" && *i + 1 < a.len())
1939            .map(|(i, _)| &a[i + 1])
1940            .collect();
1941        assert_eq!(ba, vec!["CHANNEL=stable", "VERSION=1.0"]);
1942        assert!(a.iter().any(|x| x == "--network=host"));
1943        assert!(a.iter().any(|x| x == "--pull=always"));
1944        // cache flags
1945        let cf = a
1946            .iter()
1947            .position(|x| x == "--cache-from")
1948            .expect("cache-from");
1949        assert_eq!(a[cf + 1], "type=registry,ref=ex/cache");
1950        let ct = a.iter().position(|x| x == "--cache-to").expect("cache-to");
1951        assert_eq!(a[ct + 1], "type=registry,ref=ex/cache");
1952        let cttl = a
1953            .iter()
1954            .position(|x| x == "--cache-ttl")
1955            .expect("cache-ttl");
1956        assert_eq!(a[cttl + 1], "3600s");
1957        // ssh
1958        let ssh = a.iter().position(|x| x == "--ssh").expect("--ssh");
1959        assert_eq!(a[ssh + 1], "default");
1960        // context is the LAST positional.
1961        assert_eq!(a.last().map(String::as_str), Some("/ctx"));
1962    }
1963
1964    #[test]
1965    fn test_build_omits_layers_when_false() {
1966        use crate::builder::BuildOptions;
1967        use std::collections::BTreeMap;
1968        use std::path::Path;
1969
1970        let options = BuildOptions {
1971            layers: false,
1972            ..BuildOptions::default()
1973        };
1974        let cmd = BuildahCommand::build(
1975            Path::new("/ctx/Dockerfile"),
1976            Path::new("/ctx"),
1977            &options,
1978            &BTreeMap::new(),
1979            &[],
1980            &[],
1981        );
1982        assert!(!cmd.args.iter().any(|x| x == "--layers"));
1983        // Default pull policy is Newer → --pull=ifnewer.
1984        assert!(cmd.args.iter().any(|x| x == "--pull=ifnewer"));
1985    }
1986
1987    #[test]
1988    fn test_pull_no_policy() {
1989        let cmd = BuildahCommand::pull("ghcr.io/astral-sh/uv:0.5.0", None);
1990        assert_eq!(cmd.program, "buildah");
1991        assert_eq!(cmd.args, vec!["pull", "ghcr.io/astral-sh/uv:0.5.0"]);
1992    }
1993
1994    #[test]
1995    fn test_pull_with_policy() {
1996        let cmd = BuildahCommand::pull("ghcr.io/astral-sh/uv:0.5.0", Some("newer"));
1997        assert_eq!(
1998            cmd.args,
1999            vec!["pull", "--policy", "newer", "ghcr.io/astral-sh/uv:0.5.0"]
2000        );
2001    }
2002
2003    #[test]
2004    fn test_run_shell() {
2005        let cmd = BuildahCommand::run_shell("container-1", "apt-get update");
2006        assert_eq!(
2007            cmd.args,
2008            vec![
2009                "run",
2010                "container-1",
2011                "--",
2012                "/bin/sh",
2013                "-c",
2014                "apt-get update"
2015            ]
2016        );
2017    }
2018
2019    #[test]
2020    fn test_run_exec() {
2021        let args = vec!["echo".to_string(), "hello".to_string()];
2022        let cmd = BuildahCommand::run_exec("container-1", &args);
2023        assert_eq!(cmd.args, vec!["run", "container-1", "--", "echo", "hello"]);
2024    }
2025
2026    #[test]
2027    fn test_copy() {
2028        let sources = vec!["src/".to_string(), "Cargo.toml".to_string()];
2029        let cmd = BuildahCommand::copy("container-1", &sources, "/app/");
2030        assert_eq!(
2031            cmd.args,
2032            vec!["copy", "container-1", "src/", "Cargo.toml", "/app/"]
2033        );
2034    }
2035
2036    #[test]
2037    fn test_copy_from() {
2038        let sources = vec!["/app".to_string()];
2039        let cmd = BuildahCommand::copy_from("container-1", "builder", &sources, "/app");
2040        assert_eq!(
2041            cmd.args,
2042            vec!["copy", "--from", "builder", "container-1", "/app", "/app"]
2043        );
2044    }
2045
2046    #[test]
2047    fn test_copy_from_external_image_reference_is_preserved() {
2048        // `COPY --from=ghcr.io/astral-sh/uv:0.5.0 /uv /usr/local/bin/uv`
2049        // — when the buildah backend hands an external image reference to
2050        // the translator, the registry-qualified ref must reach buildah
2051        // verbatim. Buildah resolves it against the local image store; the
2052        // backend is responsible for pulling the image first.
2053        use crate::dockerfile::CopyInstruction;
2054
2055        let copy = CopyInstruction {
2056            sources: vec!["/uv".to_string()],
2057            destination: "/usr/local/bin/uv".to_string(),
2058            from: Some("ghcr.io/astral-sh/uv:0.5.0".to_string()),
2059            chown: None,
2060            chmod: None,
2061            link: false,
2062            exclude: Vec::new(),
2063        };
2064        let instruction = Instruction::Copy(copy);
2065        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
2066
2067        assert_eq!(
2068            cmds.len(),
2069            1,
2070            "COPY translates to a single buildah copy command"
2071        );
2072        assert_eq!(
2073            cmds[0].args,
2074            vec![
2075                "copy",
2076                "--from",
2077                "ghcr.io/astral-sh/uv:0.5.0",
2078                "container-1",
2079                "/uv",
2080                "/usr/local/bin/uv",
2081            ],
2082            "external image reference must be passed through to buildah unchanged",
2083        );
2084    }
2085
2086    #[test]
2087    fn test_config_env() {
2088        let cmd = BuildahCommand::config_env("container-1", "PATH", "/usr/local/bin");
2089        assert_eq!(
2090            cmd.args,
2091            vec!["config", "--env", "PATH=/usr/local/bin", "container-1"]
2092        );
2093    }
2094
2095    #[test]
2096    fn test_config_workdir() {
2097        let cmd = BuildahCommand::config_workdir("container-1", "/app");
2098        assert_eq!(
2099            cmd.args,
2100            vec!["config", "--workingdir", "/app", "container-1"]
2101        );
2102    }
2103
2104    #[test]
2105    fn test_config_entrypoint_exec() {
2106        let args = vec!["/app".to_string(), "--config".to_string()];
2107        let cmd = BuildahCommand::config_entrypoint_exec("container-1", &args);
2108        assert!(cmd.args.contains(&"--entrypoint".to_string()));
2109        assert!(cmd
2110            .args
2111            .iter()
2112            .any(|a| a.contains('[') && a.contains("/app")));
2113    }
2114
2115    #[test]
2116    fn test_commit() {
2117        let cmd = BuildahCommand::commit("container-1", "myimage:latest");
2118        assert_eq!(cmd.args, vec!["commit", "container-1", "myimage:latest"]);
2119    }
2120
2121    #[test]
2122    fn test_to_command_string() {
2123        let cmd = BuildahCommand::config_env("container-1", "VAR", "value with spaces");
2124        let s = cmd.to_command_string();
2125        assert!(s.starts_with("buildah config"));
2126        assert!(s.contains("VAR=value with spaces"));
2127    }
2128
2129    #[test]
2130    fn test_from_instruction_run() {
2131        let instruction = Instruction::Run(RunInstruction {
2132            command: ShellOrExec::Shell("echo hello".to_string()),
2133            mounts: vec![],
2134            network: None,
2135            security: None,
2136            env: HashMap::new(),
2137        });
2138
2139        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
2140        assert_eq!(cmds.len(), 1);
2141        assert!(cmds[0].args.contains(&"run".to_string()));
2142    }
2143
2144    #[test]
2145    fn test_from_instruction_workdir_creates_and_configures() {
2146        // WORKDIR must both create the dir in the rootfs (like Docker) AND
2147        // update image metadata. Emitting only `config --workingdir` leaves
2148        // containers chdir-ing to a missing directory at init time.
2149        let instruction = Instruction::Workdir("/workspace".to_string());
2150        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
2151
2152        assert_eq!(cmds.len(), 2, "WORKDIR should emit mkdir + config");
2153
2154        let run_args = &cmds[0].args;
2155        assert_eq!(run_args[0], "run");
2156        assert_eq!(run_args[1], "container-1");
2157        assert_eq!(run_args[2], "--");
2158        assert_eq!(run_args[3], "mkdir");
2159        assert_eq!(run_args[4], "-p");
2160        assert_eq!(run_args[5], "/workspace");
2161
2162        assert_eq!(
2163            cmds[1].args,
2164            vec!["config", "--workingdir", "/workspace", "container-1"]
2165        );
2166    }
2167
2168    #[test]
2169    fn test_from_instruction_env_multiple() {
2170        let mut vars = HashMap::new();
2171        vars.insert("FOO".to_string(), "bar".to_string());
2172        vars.insert("BAZ".to_string(), "qux".to_string());
2173
2174        let instruction = Instruction::Env(EnvInstruction { vars });
2175        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
2176
2177        // Should produce two config commands (one per env var)
2178        assert_eq!(cmds.len(), 2);
2179        for cmd in &cmds {
2180            assert!(cmd.args.contains(&"config".to_string()));
2181            assert!(cmd.args.contains(&"--env".to_string()));
2182        }
2183    }
2184
2185    #[test]
2186    fn test_escape_json_string() {
2187        assert_eq!(escape_json_string("hello"), "hello");
2188        assert_eq!(escape_json_string("hello \"world\""), "hello \\\"world\\\"");
2189        assert_eq!(escape_json_string("line1\nline2"), "line1\\nline2");
2190    }
2191
2192    #[test]
2193    fn test_run_with_mounts_cache() {
2194        use crate::dockerfile::{CacheSharing, RunMount};
2195
2196        let run = RunInstruction {
2197            command: ShellOrExec::Shell("apt-get update".to_string()),
2198            mounts: vec![RunMount::Cache {
2199                target: "/var/cache/apt".to_string(),
2200                id: Some("apt-cache".to_string()),
2201                sharing: CacheSharing::Shared,
2202                readonly: false,
2203            }],
2204            network: None,
2205            security: None,
2206            env: HashMap::new(),
2207        };
2208
2209        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
2210
2211        // Verify --mount comes BEFORE container ID
2212        let mount_idx = cmd
2213            .args
2214            .iter()
2215            .position(|a| a.starts_with("--mount="))
2216            .expect("should have --mount arg");
2217        let container_idx = cmd
2218            .args
2219            .iter()
2220            .position(|a| a == "container-1")
2221            .expect("should have container id");
2222
2223        assert!(
2224            mount_idx < container_idx,
2225            "--mount should come before container ID"
2226        );
2227
2228        // Verify mount argument content
2229        assert!(cmd.args[mount_idx].contains("type=cache"));
2230        assert!(cmd.args[mount_idx].contains("target=/var/cache/apt"));
2231        assert!(cmd.args[mount_idx].contains("id=apt-cache"));
2232        assert!(cmd.args[mount_idx].contains("sharing=shared"));
2233    }
2234
2235    #[test]
2236    fn test_run_with_multiple_mounts() {
2237        use crate::dockerfile::{CacheSharing, RunMount};
2238
2239        let run = RunInstruction {
2240            command: ShellOrExec::Shell("cargo build".to_string()),
2241            mounts: vec![
2242                RunMount::Cache {
2243                    target: "/usr/local/cargo/registry".to_string(),
2244                    id: Some("cargo-registry".to_string()),
2245                    sharing: CacheSharing::Shared,
2246                    readonly: false,
2247                },
2248                RunMount::Cache {
2249                    target: "/app/target".to_string(),
2250                    id: Some("cargo-target".to_string()),
2251                    sharing: CacheSharing::Locked,
2252                    readonly: false,
2253                },
2254            ],
2255            network: None,
2256            security: None,
2257            env: HashMap::new(),
2258        };
2259
2260        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
2261
2262        // Count --mount arguments
2263        let mount_count = cmd
2264            .args
2265            .iter()
2266            .filter(|a| a.starts_with("--mount="))
2267            .count();
2268        assert_eq!(mount_count, 2, "should have 2 mount arguments");
2269
2270        // Verify all mounts come before container ID
2271        let container_idx = cmd
2272            .args
2273            .iter()
2274            .position(|a| a == "container-1")
2275            .expect("should have container id");
2276
2277        for (idx, arg) in cmd.args.iter().enumerate() {
2278            if arg.starts_with("--mount=") {
2279                assert!(
2280                    idx < container_idx,
2281                    "--mount at index {idx} should come before container ID at {container_idx}",
2282                );
2283            }
2284        }
2285    }
2286
2287    #[test]
2288    fn test_from_instruction_run_with_mounts() {
2289        use crate::dockerfile::{CacheSharing, RunMount};
2290
2291        let instruction = Instruction::Run(RunInstruction {
2292            command: ShellOrExec::Shell("npm install".to_string()),
2293            mounts: vec![RunMount::Cache {
2294                target: "/root/.npm".to_string(),
2295                id: Some("npm-cache".to_string()),
2296                sharing: CacheSharing::Shared,
2297                readonly: false,
2298            }],
2299            network: None,
2300            security: None,
2301            env: HashMap::new(),
2302        });
2303
2304        let cmds = BuildahCommand::from_instruction("container-1", &instruction);
2305        assert_eq!(cmds.len(), 1);
2306
2307        let cmd = &cmds[0];
2308        assert!(
2309            cmd.args.iter().any(|a| a.starts_with("--mount=")),
2310            "should include --mount argument"
2311        );
2312    }
2313
2314    #[test]
2315    fn test_run_with_mounts_exec_form() {
2316        use crate::dockerfile::{CacheSharing, RunMount};
2317
2318        let run = RunInstruction {
2319            command: ShellOrExec::Exec(vec![
2320                "pip".to_string(),
2321                "install".to_string(),
2322                "-r".to_string(),
2323                "requirements.txt".to_string(),
2324            ]),
2325            mounts: vec![RunMount::Cache {
2326                target: "/root/.cache/pip".to_string(),
2327                id: Some("pip-cache".to_string()),
2328                sharing: CacheSharing::Shared,
2329                readonly: false,
2330            }],
2331            network: None,
2332            security: None,
2333            env: HashMap::new(),
2334        };
2335
2336        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
2337
2338        // Should have mount, container, --, and then the exec args
2339        assert!(cmd.args.contains(&"--".to_string()));
2340        assert!(cmd.args.contains(&"pip".to_string()));
2341        assert!(cmd.args.contains(&"install".to_string()));
2342    }
2343
2344    #[test]
2345    fn test_run_with_mounts_emits_env_flags_sorted() {
2346        // RunInstruction with `env` must produce `--env=K=V` args BEFORE the
2347        // container ID, sorted by key for determinism. Env is intentionally
2348        // scoped to this single buildah-run invocation (not baked into the
2349        // image config via `buildah config --env`).
2350        let mut env = HashMap::new();
2351        env.insert("B".to_string(), "2".to_string());
2352        env.insert("A".to_string(), "1".to_string());
2353
2354        let run = RunInstruction {
2355            command: ShellOrExec::Shell("env".to_string()),
2356            mounts: vec![],
2357            network: None,
2358            security: None,
2359            env,
2360        };
2361
2362        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
2363
2364        // Confirm both --env flags appear, in sorted (A then B) order.
2365        let env_positions: Vec<(usize, &String)> = cmd
2366            .args
2367            .iter()
2368            .enumerate()
2369            .filter(|(_, a)| a.starts_with("--env="))
2370            .collect();
2371        assert_eq!(
2372            env_positions.len(),
2373            2,
2374            "expected 2 --env args, got {env_positions:?}"
2375        );
2376        assert_eq!(env_positions[0].1, "--env=A=1");
2377        assert_eq!(env_positions[1].1, "--env=B=2");
2378
2379        // Both --env flags must come BEFORE the container ID.
2380        let container_idx = cmd
2381            .args
2382            .iter()
2383            .position(|a| a == "container-1")
2384            .expect("container ID present");
2385        for (idx, _) in &env_positions {
2386            assert!(
2387                *idx < container_idx,
2388                "--env at {idx} must precede container ID at {container_idx}"
2389            );
2390        }
2391
2392        // And BEFORE the `--` separator.
2393        let sep_idx = cmd
2394            .args
2395            .iter()
2396            .position(|a| a == "--")
2397            .expect("-- separator present");
2398        for (idx, _) in &env_positions {
2399            assert!(
2400                *idx < sep_idx,
2401                "--env at {idx} must precede `--` at {sep_idx}"
2402            );
2403        }
2404    }
2405
2406    #[test]
2407    fn test_translator_routes_env_only_run_through_mounts_path() {
2408        // A RunInstruction with env but no mounts must still flow through
2409        // run_with_mounts_shell so the --env= flags get emitted. The plain
2410        // run_shell / run_exec factories don't know about env.
2411        let mut env = HashMap::new();
2412        env.insert("FOO".to_string(), "bar".to_string());
2413
2414        let run = RunInstruction {
2415            command: ShellOrExec::Shell("echo $FOO".to_string()),
2416            mounts: vec![],
2417            network: None,
2418            security: None,
2419            env,
2420        };
2421
2422        let cmds = DockerfileTranslator::new(ImageOs::Linux)
2423            .translate("container-1", &Instruction::Run(run));
2424        assert_eq!(cmds.len(), 1);
2425
2426        let cmd = &cmds[0];
2427        assert!(
2428            cmd.args.iter().any(|a| a == "--env=FOO=bar"),
2429            "expected --env=FOO=bar in args: {:?}",
2430            cmd.args
2431        );
2432    }
2433
2434    // ---------------------------------------------------------------------
2435    // Host-network plumbing
2436    //
2437    // These tests pin the `--host-network` CLI flag's end-to-end behavior:
2438    // a `RunInstruction` with `network: Some(RunNetwork::Host)` MUST emit
2439    // `--net=host` BEFORE the container ID on the buildah command, and the
2440    // translator-level `with_host_network(true)` MUST force-set host
2441    // networking on every RUN regardless of any per-instruction value.
2442    // ---------------------------------------------------------------------
2443
2444    #[test]
2445    fn test_run_with_mounts_emits_net_host_before_container() {
2446        let run = RunInstruction {
2447            command: ShellOrExec::Shell("apt-get update".to_string()),
2448            mounts: vec![],
2449            network: Some(crate::dockerfile::RunNetwork::Host),
2450            security: None,
2451            env: HashMap::new(),
2452        };
2453
2454        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
2455
2456        let net_idx = cmd
2457            .args
2458            .iter()
2459            .position(|a| a == "--net=host")
2460            .expect("expected --net=host arg");
2461        let container_idx = cmd
2462            .args
2463            .iter()
2464            .position(|a| a == "container-1")
2465            .expect("container id present");
2466        let sep_idx = cmd
2467            .args
2468            .iter()
2469            .position(|a| a == "--")
2470            .expect("-- separator present");
2471        assert!(
2472            net_idx < container_idx,
2473            "--net=host (idx {net_idx}) must precede container ID (idx {container_idx})"
2474        );
2475        assert!(
2476            net_idx < sep_idx,
2477            "--net=host (idx {net_idx}) must precede `--` (idx {sep_idx})"
2478        );
2479    }
2480
2481    #[test]
2482    fn test_run_with_mounts_emits_net_none() {
2483        let run = RunInstruction {
2484            command: ShellOrExec::Shell("hostname".to_string()),
2485            mounts: vec![],
2486            network: Some(crate::dockerfile::RunNetwork::None),
2487            security: None,
2488            env: HashMap::new(),
2489        };
2490
2491        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
2492        assert!(
2493            cmd.args.iter().any(|a| a == "--net=none"),
2494            "expected --net=none in args, got: {:?}",
2495            cmd.args
2496        );
2497    }
2498
2499    #[test]
2500    fn test_run_with_mounts_default_network_omits_net_flag() {
2501        let run = RunInstruction {
2502            command: ShellOrExec::Shell("true".to_string()),
2503            mounts: vec![],
2504            network: Some(crate::dockerfile::RunNetwork::Default),
2505            security: None,
2506            env: HashMap::new(),
2507        };
2508
2509        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
2510        assert!(
2511            !cmd.args.iter().any(|a| a.starts_with("--net")),
2512            "RunNetwork::Default must NOT emit any --net flag, got: {:?}",
2513            cmd.args
2514        );
2515    }
2516
2517    #[test]
2518    fn test_run_with_mounts_no_network_field_omits_net_flag() {
2519        let run = RunInstruction {
2520            command: ShellOrExec::Shell("true".to_string()),
2521            mounts: vec![],
2522            network: None,
2523            security: None,
2524            env: HashMap::new(),
2525        };
2526
2527        let cmd = BuildahCommand::run_with_mounts("container-1", &run);
2528        assert!(
2529            !cmd.args.iter().any(|a| a.starts_with("--net")),
2530            "network=None must NOT emit any --net flag, got: {:?}",
2531            cmd.args
2532        );
2533    }
2534
2535    #[test]
2536    fn test_translator_host_network_forces_net_host_on_run_with_none_network() {
2537        // Load-bearing assertion for the `zlayer --host-network` CLI flag:
2538        // host_network=true must emit --net=host even when the Dockerfile
2539        // RUN does NOT specify a per-instruction --network.
2540        let run = RunInstruction {
2541            command: ShellOrExec::Shell("apt-get update".to_string()),
2542            mounts: vec![],
2543            network: None,
2544            security: None,
2545            env: HashMap::new(),
2546        };
2547
2548        let cmds = DockerfileTranslator::new(ImageOs::Linux)
2549            .with_host_network(true)
2550            .translate("c1", &Instruction::Run(run));
2551
2552        assert_eq!(cmds.len(), 1, "expected exactly one buildah command");
2553        let cmd = &cmds[0];
2554        assert!(
2555            cmd.args.iter().any(|a| a == "--net=host"),
2556            "expected --net=host in args (host_network=true should force it even when run.network is None), got: {:?}",
2557            cmd.args
2558        );
2559    }
2560
2561    #[test]
2562    fn test_translator_host_network_overrides_per_instruction_network_none() {
2563        // `RUN --network=none` + CLI `--host-network` -> host wins.
2564        let run = RunInstruction {
2565            command: ShellOrExec::Shell("apt-get install -y curl".to_string()),
2566            mounts: vec![],
2567            network: Some(crate::dockerfile::RunNetwork::None),
2568            security: None,
2569            env: HashMap::new(),
2570        };
2571
2572        let cmds = DockerfileTranslator::new(ImageOs::Linux)
2573            .with_host_network(true)
2574            .translate("c1", &Instruction::Run(run));
2575
2576        assert_eq!(cmds.len(), 1);
2577        let cmd = &cmds[0];
2578        assert!(
2579            cmd.args.iter().any(|a| a == "--net=host"),
2580            "host_network=true must override RunNetwork::None, got: {:?}",
2581            cmd.args
2582        );
2583        assert!(
2584            !cmd.args.iter().any(|a| a == "--net=none"),
2585            "host_network=true must REPLACE (not append to) RunNetwork::None, got: {:?}",
2586            cmd.args
2587        );
2588    }
2589
2590    #[test]
2591    fn test_translator_host_network_routes_bare_run_through_mounts_path() {
2592        // Bare RUN (no mounts, env, or network) must still flow through
2593        // run_with_mounts_shell when host_network=true — the simple
2594        // factories don't emit --net=host.
2595        let run = RunInstruction {
2596            command: ShellOrExec::Shell("echo hi".to_string()),
2597            mounts: vec![],
2598            network: None,
2599            security: None,
2600            env: HashMap::new(),
2601        };
2602
2603        let cmds = DockerfileTranslator::new(ImageOs::Linux)
2604            .with_host_network(true)
2605            .translate("c1", &Instruction::Run(run));
2606
2607        assert_eq!(cmds.len(), 1);
2608        let cmd = &cmds[0];
2609        assert!(
2610            cmd.args.iter().any(|a| a == "--net=host"),
2611            "bare RUN with host_network=true must emit --net=host, got: {:?}",
2612            cmd.args
2613        );
2614    }
2615
2616    #[test]
2617    fn test_translator_host_network_routes_env_only_run_with_net_host() {
2618        // env-only RUN + host_network=true must produce BOTH --env=... and
2619        // --net=host, both before the container ID.
2620        let mut env = HashMap::new();
2621        env.insert("FOO".to_string(), "bar".to_string());
2622
2623        let run = RunInstruction {
2624            command: ShellOrExec::Shell("echo $FOO".to_string()),
2625            mounts: vec![],
2626            network: None,
2627            security: None,
2628            env,
2629        };
2630
2631        let cmds = DockerfileTranslator::new(ImageOs::Linux)
2632            .with_host_network(true)
2633            .translate("c1", &Instruction::Run(run));
2634
2635        assert_eq!(cmds.len(), 1);
2636        let cmd = &cmds[0];
2637
2638        let env_idx = cmd
2639            .args
2640            .iter()
2641            .position(|a| a == "--env=FOO=bar")
2642            .expect("--env=FOO=bar present");
2643        let net_idx = cmd
2644            .args
2645            .iter()
2646            .position(|a| a == "--net=host")
2647            .expect("--net=host present");
2648        let container_idx = cmd
2649            .args
2650            .iter()
2651            .position(|a| a == "c1")
2652            .expect("container id present");
2653        assert!(env_idx < container_idx);
2654        assert!(net_idx < container_idx);
2655    }
2656
2657    #[test]
2658    fn test_translator_host_network_routes_mount_only_run_with_net_host() {
2659        use crate::dockerfile::{CacheSharing, RunMount};
2660
2661        let run = RunInstruction {
2662            command: ShellOrExec::Shell("npm install".to_string()),
2663            mounts: vec![RunMount::Cache {
2664                target: "/root/.npm".to_string(),
2665                id: Some("npm-cache".to_string()),
2666                sharing: CacheSharing::Shared,
2667                readonly: false,
2668            }],
2669            network: None,
2670            security: None,
2671            env: HashMap::new(),
2672        };
2673
2674        let cmds = DockerfileTranslator::new(ImageOs::Linux)
2675            .with_host_network(true)
2676            .translate("c1", &Instruction::Run(run));
2677
2678        assert_eq!(cmds.len(), 1);
2679        let cmd = &cmds[0];
2680        assert!(
2681            cmd.args.iter().any(|a| a.starts_with("--mount=")),
2682            "--mount must be present"
2683        );
2684        assert!(
2685            cmd.args.iter().any(|a| a == "--net=host"),
2686            "--net=host must be present alongside --mount when host_network=true"
2687        );
2688    }
2689
2690    #[test]
2691    fn test_translator_host_network_default_off_does_not_emit_net_flag() {
2692        // Sanity: default translator (host_network not set) must not emit
2693        // --net=... on a vanilla RUN. We're only adding a flag, never
2694        // silently changing existing behavior.
2695        let run = RunInstruction {
2696            command: ShellOrExec::Shell("true".to_string()),
2697            mounts: vec![],
2698            network: None,
2699            security: None,
2700            env: HashMap::new(),
2701        };
2702
2703        let cmds =
2704            DockerfileTranslator::new(ImageOs::Linux).translate("c1", &Instruction::Run(run));
2705        assert_eq!(cmds.len(), 1);
2706        let cmd = &cmds[0];
2707        assert!(
2708            !cmd.args.iter().any(|a| a.starts_with("--net")),
2709            "default translator (host_network=false) must NOT emit --net flag, got: {:?}",
2710            cmd.args
2711        );
2712    }
2713
2714    #[test]
2715    fn test_manifest_create() {
2716        let cmd = BuildahCommand::manifest_create("myapp:latest");
2717        assert_eq!(cmd.program, "buildah");
2718        assert_eq!(cmd.args, vec!["manifest", "create", "myapp:latest"]);
2719    }
2720
2721    #[test]
2722    fn test_manifest_add() {
2723        let cmd = BuildahCommand::manifest_add("myapp:latest", "myapp-amd64:latest");
2724        assert_eq!(
2725            cmd.args,
2726            vec!["manifest", "add", "myapp:latest", "myapp-amd64:latest"]
2727        );
2728    }
2729
2730    #[test]
2731    fn test_manifest_push() {
2732        let cmd =
2733            BuildahCommand::manifest_push("myapp:latest", "docker://registry.example.com/myapp");
2734        assert_eq!(
2735            cmd.args,
2736            vec![
2737                "manifest",
2738                "push",
2739                "--all",
2740                "myapp:latest",
2741                "docker://registry.example.com/myapp"
2742            ]
2743        );
2744    }
2745
2746    #[test]
2747    fn test_manifest_rm() {
2748        let cmd = BuildahCommand::manifest_rm("myapp:latest");
2749        assert_eq!(cmd.args, vec!["manifest", "rm", "myapp:latest"]);
2750    }
2751
2752    #[test]
2753    fn test_rmi_force() {
2754        // The `-f` flag must precede the image ref so the idempotent
2755        // manifest-create cleanup can evict a plain image left by a partial run.
2756        let cmd = BuildahCommand::rmi_force("myapp:latest");
2757        assert_eq!(cmd.program, "buildah");
2758        assert_eq!(cmd.args, vec!["rmi", "-f", "myapp:latest"]);
2759    }
2760
2761    #[test]
2762    fn test_push_with_creds_orders_creds_before_image() {
2763        // buildah rejects options that appear after the positional image, so
2764        // `--creds` MUST precede the image ref.
2765        let cmd = BuildahCommand::push_with_creds("forge.example.com/app:ci", Some("user:token"));
2766        assert_eq!(
2767            cmd.args,
2768            vec!["push", "--creds", "user:token", "forge.example.com/app:ci",]
2769        );
2770        let creds_idx = cmd.args.iter().position(|a| a == "--creds").unwrap();
2771        let image_idx = cmd
2772            .args
2773            .iter()
2774            .position(|a| a == "forge.example.com/app:ci")
2775            .unwrap();
2776        assert!(creds_idx < image_idx, "--creds must precede the image ref");
2777    }
2778
2779    #[test]
2780    fn test_push_with_creds_none_is_plain_push() {
2781        let cmd = BuildahCommand::push_with_creds("forge.example.com/app:ci", None);
2782        assert_eq!(cmd.args, vec!["push", "forge.example.com/app:ci"]);
2783    }
2784
2785    #[test]
2786    fn test_manifest_push_with_creds_orders_flags_before_positionals() {
2787        let cmd = BuildahCommand::manifest_push_with_creds(
2788            "myapp:latest",
2789            "docker://forge.example.com/myapp:ci",
2790            Some("user:token"),
2791        );
2792        assert_eq!(
2793            cmd.args,
2794            vec![
2795                "manifest",
2796                "push",
2797                "--all",
2798                "--creds",
2799                "user:token",
2800                "myapp:latest",
2801                "docker://forge.example.com/myapp:ci",
2802            ]
2803        );
2804        let creds_idx = cmd.args.iter().position(|a| a == "--creds").unwrap();
2805        let list_idx = cmd.args.iter().position(|a| a == "myapp:latest").unwrap();
2806        assert!(
2807            creds_idx < list_idx,
2808            "--creds must precede the manifest list name"
2809        );
2810    }
2811
2812    #[test]
2813    fn test_manifest_push_with_creds_none_matches_plain() {
2814        let cmd = BuildahCommand::manifest_push_with_creds(
2815            "myapp:latest",
2816            "docker://forge.example.com/myapp:ci",
2817            None,
2818        );
2819        assert_eq!(
2820            cmd.args,
2821            vec![
2822                "manifest",
2823                "push",
2824                "--all",
2825                "myapp:latest",
2826                "docker://forge.example.com/myapp:ci",
2827            ]
2828        );
2829    }
2830
2831    // -------------------------------------------------------------------------
2832    // L-3: OS-aware translation tests
2833    // -------------------------------------------------------------------------
2834
2835    #[test]
2836    fn test_run_shell_for_os_linux() {
2837        let cmd = BuildahCommand::run_shell_for_os("c1", "echo hello", ImageOs::Linux);
2838        assert_eq!(
2839            cmd.args,
2840            vec!["run", "c1", "--", "/bin/sh", "-c", "echo hello"]
2841        );
2842    }
2843
2844    #[test]
2845    fn test_run_shell_for_os_windows() {
2846        let cmd = BuildahCommand::run_shell_for_os("c1", "echo hello", ImageOs::Windows);
2847        assert_eq!(
2848            cmd.args,
2849            vec!["run", "c1", "--", "cmd.exe", "/S", "/C", "echo hello"]
2850        );
2851    }
2852
2853    #[test]
2854    fn test_run_shell_for_os_darwin_matches_linux() {
2855        // Darwin (macOS) images are POSIX: the OS default shell — and the
2856        // resulting `buildah run` shell-form command — must be byte-identical
2857        // to Linux (`/bin/sh -c`), never the Windows `cmd.exe` form.
2858        let darwin = BuildahCommand::run_shell_for_os("c1", "echo hello", ImageOs::Darwin);
2859        let linux = BuildahCommand::run_shell_for_os("c1", "echo hello", ImageOs::Linux);
2860        assert_eq!(darwin.args, linux.args);
2861        assert_eq!(
2862            darwin.args,
2863            vec!["run", "c1", "--", "/bin/sh", "-c", "echo hello"]
2864        );
2865
2866        // The translator's active shell for a Darwin target also matches Linux.
2867        assert_eq!(
2868            DockerfileTranslator::new(ImageOs::Darwin).active_shell(),
2869            DockerfileTranslator::new(ImageOs::Linux).active_shell(),
2870        );
2871    }
2872
2873    #[test]
2874    fn test_run_shell_custom_powershell() {
2875        let shell = ["powershell", "-Command"];
2876        let cmd = BuildahCommand::run_shell_custom("c1", shell, "Get-Process");
2877        assert_eq!(
2878            cmd.args,
2879            vec!["run", "c1", "--", "powershell", "-Command", "Get-Process"]
2880        );
2881    }
2882
2883    #[test]
2884    fn test_translator_linux_run_shell_default() {
2885        let mut t = DockerfileTranslator::new(ImageOs::Linux);
2886        let instr = Instruction::Run(RunInstruction::shell("apt-get update"));
2887        let cmds = t.translate("c1", &instr);
2888        assert_eq!(cmds.len(), 1);
2889        assert_eq!(
2890            cmds[0].args,
2891            vec!["run", "c1", "--", "/bin/sh", "-c", "apt-get update"]
2892        );
2893    }
2894
2895    #[test]
2896    fn test_translator_windows_run_shell_default() {
2897        let mut t = DockerfileTranslator::new(ImageOs::Windows);
2898        let instr = Instruction::Run(RunInstruction::shell("dir C:\\"));
2899        let cmds = t.translate("c1", &instr);
2900        assert_eq!(cmds.len(), 1);
2901        assert_eq!(
2902            cmds[0].args,
2903            vec!["run", "c1", "--", "cmd.exe", "/S", "/C", "dir C:\\"]
2904        );
2905    }
2906
2907    #[test]
2908    fn test_translator_shell_override_linux_bash() {
2909        // SHELL ["/bin/bash", "-lc"] then RUN cmd → uses bash
2910        let mut t = DockerfileTranslator::new(ImageOs::Linux);
2911
2912        let shell_instr = Instruction::Shell(vec!["/bin/bash".to_string(), "-lc".to_string()]);
2913        let shell_cmds = t.translate("c1", &shell_instr);
2914        // SHELL emits the metadata config --shell
2915        assert_eq!(shell_cmds.len(), 1);
2916        assert!(shell_cmds[0].args.contains(&"--shell".to_string()));
2917
2918        let run_instr = Instruction::Run(RunInstruction::shell("set -e; echo $SHELL"));
2919        let run_cmds = t.translate("c1", &run_instr);
2920        assert_eq!(run_cmds.len(), 1);
2921        assert_eq!(
2922            run_cmds[0].args,
2923            vec!["run", "c1", "--", "/bin/bash", "-lc", "set -e; echo $SHELL"]
2924        );
2925    }
2926
2927    #[test]
2928    fn test_translator_shell_override_windows_powershell() {
2929        // SHELL ["powershell", "-Command"] then RUN cmd on Windows → uses
2930        // powershell, not the default cmd.exe /S /C.
2931        let mut t = DockerfileTranslator::new(ImageOs::Windows);
2932
2933        let shell_instr =
2934            Instruction::Shell(vec!["powershell".to_string(), "-Command".to_string()]);
2935        t.translate("c1", &shell_instr);
2936
2937        let run_instr = Instruction::Run(RunInstruction::shell("Get-Process"));
2938        let run_cmds = t.translate("c1", &run_instr);
2939        assert_eq!(run_cmds.len(), 1);
2940        assert_eq!(
2941            run_cmds[0].args,
2942            vec!["run", "c1", "--", "powershell", "-Command", "Get-Process"]
2943        );
2944    }
2945
2946    #[test]
2947    fn test_translator_shell_override_persists_across_runs() {
2948        // Two RUNs after a single SHELL should both use the overridden shell.
2949        let mut t = DockerfileTranslator::new(ImageOs::Linux);
2950        t.translate(
2951            "c1",
2952            &Instruction::Shell(vec!["/bin/bash".to_string(), "-c".to_string()]),
2953        );
2954
2955        for _ in 0..2 {
2956            let cmds = t.translate("c1", &Instruction::Run(RunInstruction::shell("echo hi")));
2957            assert_eq!(
2958                cmds[0].args,
2959                vec!["run", "c1", "--", "/bin/bash", "-c", "echo hi"]
2960            );
2961        }
2962    }
2963
2964    #[test]
2965    fn test_translator_exec_form_ignores_shell_override() {
2966        // Exec-form RUN must not be wrapped with the shell prefix, even
2967        // when a SHELL override is active — matches Docker semantics.
2968        let mut t = DockerfileTranslator::new(ImageOs::Windows);
2969        t.translate(
2970            "c1",
2971            &Instruction::Shell(vec!["powershell".to_string(), "-Command".to_string()]),
2972        );
2973
2974        let run = Instruction::Run(RunInstruction::exec(vec![
2975            "myapp.exe".to_string(),
2976            "--flag".to_string(),
2977        ]));
2978        let cmds = t.translate("c1", &run);
2979        assert_eq!(cmds[0].args, vec!["run", "c1", "--", "myapp.exe", "--flag"]);
2980    }
2981
2982    #[test]
2983    fn test_translator_workdir_linux() {
2984        let mut t = DockerfileTranslator::new(ImageOs::Linux);
2985        let cmds = t.translate("c1", &Instruction::Workdir("/app".to_string()));
2986        assert_eq!(cmds.len(), 2);
2987        assert_eq!(cmds[0].args, vec!["run", "c1", "--", "mkdir", "-p", "/app"]);
2988        assert_eq!(cmds[1].args, vec!["config", "--workingdir", "/app", "c1"]);
2989    }
2990
2991    #[test]
2992    fn test_translator_workdir_linux_with_empty_src_dir() {
2993        // When configured with an empty source directory (the production
2994        // BuildahBackend path), WORKDIR translates to `buildah copy
2995        // <empty>/. <dir>` instead of `buildah run -- mkdir -p <dir>`.
2996        // That's the shell-free path that works on distroless / scratch /
2997        // any base lacking `/bin/sh`.
2998        let empty = std::path::PathBuf::from("/tmp/zlayer-empty-test");
2999        let mut t = DockerfileTranslator::new(ImageOs::Linux).with_empty_src_dir(empty);
3000        let cmds = t.translate("c1", &Instruction::Workdir("/app".to_string()));
3001        assert_eq!(cmds.len(), 2);
3002        assert_eq!(
3003            cmds[0].args,
3004            vec!["copy", "c1", "/tmp/zlayer-empty-test/.", "/app"],
3005            "WORKDIR with empty_src_dir must emit `buildah copy <empty>/. <dir>` to materialize the dir without running a shell",
3006        );
3007        assert_eq!(cmds[1].args, vec!["config", "--workingdir", "/app", "c1"]);
3008    }
3009
3010    #[test]
3011    fn test_translator_workdir_linux_with_empty_src_dir_ignores_host_network() {
3012        // `buildah copy` never executes anything inside the container, so
3013        // `--net=host` is irrelevant to it. The host_network flag should
3014        // affect RUN translations only, not the COPY-based WORKDIR path.
3015        let empty = std::path::PathBuf::from("/var/tmp/empty");
3016        let mut t = DockerfileTranslator::new(ImageOs::Linux)
3017            .with_host_network(true)
3018            .with_empty_src_dir(empty);
3019        let cmds = t.translate("c1", &Instruction::Workdir("/app".to_string()));
3020        assert_eq!(cmds.len(), 2);
3021        assert!(
3022            !cmds[0].args.iter().any(|a| a.starts_with("--net")),
3023            "buildah copy must never carry --net flags, got: {:?}",
3024            cmds[0].args
3025        );
3026        assert_eq!(cmds[0].args[0], "copy");
3027        assert_eq!(cmds[1].args, vec!["config", "--workingdir", "/app", "c1"]);
3028    }
3029
3030    #[test]
3031    fn test_translator_workdir_windows() {
3032        let mut t = DockerfileTranslator::new(ImageOs::Windows);
3033        let cmds = t.translate("c1", &Instruction::Workdir("C:\\app".to_string()));
3034        assert_eq!(cmds.len(), 2);
3035        // Pre-mkdir guarded with `if not exist` so a repeated WORKDIR in the
3036        // same Dockerfile doesn't cause Windows mkdir to error on existence.
3037        assert_eq!(
3038            cmds[0].args,
3039            vec![
3040                "run",
3041                "c1",
3042                "--",
3043                "cmd.exe",
3044                "/S",
3045                "/C",
3046                r#"if not exist "C:\app" mkdir "C:\app""#
3047            ]
3048        );
3049        assert_eq!(
3050            cmds[1].args,
3051            vec!["config", "--workingdir", "C:\\app", "c1"]
3052        );
3053    }
3054
3055    #[test]
3056    fn test_translator_workdir_host_network_linux_emits_net_host() {
3057        // Regression: when the translator is constructed with
3058        // `with_host_network(true)` (i.e. the user passed `--host-network`),
3059        // WORKDIR's `mkdir -p` MUST carry `--net=host` just like every
3060        // RUN does. Before the fix, only `Instruction::Run` consulted
3061        // `self.host_network`, so a `WORKDIR /app` would route through
3062        // buildah's default rootless networking → netavark → die on the
3063        // very first instruction of stage 1 if the host's netavark
3064        // config was broken.
3065        let mut t = DockerfileTranslator::new(ImageOs::Linux).with_host_network(true);
3066        let cmds = t.translate("c1", &Instruction::Workdir("/app".to_string()));
3067        assert_eq!(cmds.len(), 2);
3068        assert_eq!(
3069            cmds[0].args,
3070            vec!["run", "--net=host", "c1", "--", "mkdir", "-p", "/app"],
3071            "WORKDIR mkdir with host_network=true must emit --net=host BEFORE the container ID",
3072        );
3073        // The `config --workingdir` metadata write is unaffected by
3074        // host_network — `buildah config` doesn't run a container.
3075        assert_eq!(cmds[1].args, vec!["config", "--workingdir", "/app", "c1"]);
3076    }
3077
3078    #[test]
3079    fn test_translator_workdir_no_host_network_omits_net_flag() {
3080        // Pin the default-path behavior: without `--host-network`,
3081        // WORKDIR's mkdir must NOT carry any `--net` flag. Mirrors the
3082        // existing `test_translator_workdir_linux` but makes the
3083        // host_network=false branch explicit so a future refactor that
3084        // accidentally always emits `--net=host` is caught immediately.
3085        let mut t = DockerfileTranslator::new(ImageOs::Linux).with_host_network(false);
3086        let cmds = t.translate("c1", &Instruction::Workdir("/app".to_string()));
3087        assert_eq!(cmds.len(), 2);
3088        assert!(
3089            !cmds[0].args.iter().any(|a| a.starts_with("--net")),
3090            "WORKDIR with host_network=false must NOT emit any --net flag, got: {:?}",
3091            cmds[0].args
3092        );
3093        assert_eq!(cmds[0].args, vec!["run", "c1", "--", "mkdir", "-p", "/app"]);
3094    }
3095
3096    #[test]
3097    fn test_translator_workdir_host_network_windows_emits_net_host() {
3098        // Symmetric coverage for the Windows guarded-mkdir path. Windows
3099        // builds dispatch through the HCS backend in practice (not buildah),
3100        // so this is belt-and-suspenders: if someone ever wires the buildah
3101        // translator into a Windows build, host_network must still be
3102        // honored on the guarded `if not exist ... mkdir` invocation.
3103        let mut t = DockerfileTranslator::new(ImageOs::Windows).with_host_network(true);
3104        let cmds = t.translate("c1", &Instruction::Workdir("C:\\app".to_string()));
3105        assert_eq!(cmds.len(), 2);
3106        let net_idx = cmds[0]
3107            .args
3108            .iter()
3109            .position(|a| a == "--net=host")
3110            .expect("expected --net=host on Windows WORKDIR with host_network=true");
3111        let container_idx = cmds[0]
3112            .args
3113            .iter()
3114            .position(|a| a == "c1")
3115            .expect("container ID present");
3116        let sep_idx = cmds[0]
3117            .args
3118            .iter()
3119            .position(|a| a == "--")
3120            .expect("`--` separator present");
3121        assert!(
3122            net_idx < container_idx && container_idx < sep_idx,
3123            "argument order must be: run --net=host <container> -- ... (got {:?})",
3124            cmds[0].args
3125        );
3126    }
3127
3128    #[test]
3129    fn test_translator_workdir_windows_path_with_spaces() {
3130        // Paths containing spaces (e.g. `C:\Program Files\app`) must be quoted
3131        // for cmd.exe, which is why the mkdir command we emit wraps the path
3132        // in double-quotes.
3133        let mut t = DockerfileTranslator::new(ImageOs::Windows);
3134        let cmds = t.translate(
3135            "c1",
3136            &Instruction::Workdir("C:\\Program Files\\app".to_string()),
3137        );
3138        assert_eq!(cmds.len(), 2);
3139        let mkdir_cmd = &cmds[0].args[6];
3140        assert_eq!(
3141            mkdir_cmd,
3142            r#"if not exist "C:\Program Files\app" mkdir "C:\Program Files\app""#
3143        );
3144    }
3145
3146    #[test]
3147    fn test_from_instruction_preserves_linux_byte_identical_output() {
3148        // Backward-compat guarantee: the legacy `from_instruction` entrypoint
3149        // must emit the exact same byte-for-byte commands as it did before
3150        // the translator refactor. The existing Linux callers (buildah backend)
3151        // rely on this.
3152        let run = Instruction::Run(RunInstruction::shell("echo hello"));
3153        let legacy = BuildahCommand::from_instruction("c1", &run);
3154        let via_translator = DockerfileTranslator::new(ImageOs::Linux).translate("c1", &run);
3155        assert_eq!(legacy.len(), via_translator.len());
3156        for (a, b) in legacy.iter().zip(via_translator.iter()) {
3157            assert_eq!(a.args, b.args);
3158            assert_eq!(a.program, b.program);
3159        }
3160
3161        // WORKDIR must still emit mkdir -p + config --workingdir
3162        let workdir = Instruction::Workdir("/workspace".to_string());
3163        let legacy = BuildahCommand::from_instruction("c1", &workdir);
3164        assert_eq!(legacy.len(), 2);
3165        assert_eq!(
3166            legacy[0].args,
3167            vec!["run", "c1", "--", "mkdir", "-p", "/workspace"]
3168        );
3169        assert_eq!(
3170            legacy[1].args,
3171            vec!["config", "--workingdir", "/workspace", "c1"]
3172        );
3173    }
3174
3175    #[test]
3176    fn test_translator_active_shell_reflects_override() {
3177        let mut t = DockerfileTranslator::new(ImageOs::Linux);
3178        assert_eq!(t.active_shell(), vec!["/bin/sh", "-c"]);
3179
3180        t.set_shell_override(vec!["/bin/bash".to_string(), "-lc".to_string()]);
3181        assert_eq!(t.active_shell(), vec!["/bin/bash", "-lc"]);
3182    }
3183
3184    #[test]
3185    fn test_translator_target_os_accessor() {
3186        assert_eq!(
3187            DockerfileTranslator::new(ImageOs::Linux).target_os(),
3188            ImageOs::Linux
3189        );
3190        assert_eq!(
3191            DockerfileTranslator::new(ImageOs::Windows).target_os(),
3192            ImageOs::Windows
3193        );
3194    }
3195
3196    #[test]
3197    fn test_translator_windows_run_with_mounts_uses_cmd_exe() {
3198        use crate::dockerfile::{CacheSharing, RunMount};
3199
3200        let mut t = DockerfileTranslator::new(ImageOs::Windows);
3201        let run = RunInstruction {
3202            command: ShellOrExec::Shell("echo cached".to_string()),
3203            mounts: vec![RunMount::Cache {
3204                target: "C:\\cache".to_string(),
3205                id: Some("win-cache".to_string()),
3206                sharing: CacheSharing::Shared,
3207                readonly: false,
3208            }],
3209            network: None,
3210            security: None,
3211            env: HashMap::new(),
3212        };
3213
3214        let cmds = t.translate("c1", &Instruction::Run(run));
3215        assert_eq!(cmds.len(), 1);
3216
3217        // --mount= must precede container ID
3218        let mount_idx = cmds[0]
3219            .args
3220            .iter()
3221            .position(|a| a.starts_with("--mount="))
3222            .expect("mount arg present");
3223        let container_idx = cmds[0]
3224            .args
3225            .iter()
3226            .position(|a| a == "c1")
3227            .expect("container ID present");
3228        assert!(mount_idx < container_idx);
3229
3230        // Shell form must use cmd.exe /S /C, not /bin/sh -c
3231        assert!(cmds[0].args.iter().any(|a| a == "cmd.exe"));
3232        assert!(cmds[0].args.iter().any(|a| a == "/S"));
3233        assert!(cmds[0].args.iter().any(|a| a == "/C"));
3234        assert!(!cmds[0].args.iter().any(|a| a == "/bin/sh"));
3235    }
3236
3237    // -----------------------------------------------------------------
3238    // apt → choco translation (moved from `windows_builder::tests`)
3239    // -----------------------------------------------------------------
3240
3241    use crate::windows_image_resolver::{ChocoMapMetadata, ChocoMapShard};
3242
3243    /// Tests that mutate `XDG_CACHE_HOME` / `LOCALAPPDATA` must run
3244    /// serially or the resolver picks up another test's fixture dir
3245    /// because `cargo test` runs unit tests in parallel by default.
3246    static CACHE_ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
3247
3248    /// Write a single-shard package map under `cache_root` so the
3249    /// resolver's disk cache hits without going through the network.
3250    fn write_shard_fixture(
3251        cache_root: &std::path::Path,
3252        distro: &str,
3253        shard: &str,
3254        mappings: &[(&str, &str)],
3255    ) {
3256        let fixture = ChocoMapShard {
3257            metadata: ChocoMapMetadata {
3258                generated_at: "2026-05-21T00:00:00Z".to_string(),
3259                source: "chocolatey.org".to_string(),
3260                distro: distro.to_string(),
3261                shard: shard.to_string(),
3262                total_mappings: mappings.len() as u64,
3263            },
3264            mappings: mappings
3265                .iter()
3266                .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
3267                .collect(),
3268        };
3269        let shard_dir = cache_root.join("package-maps-choco-v1").join(distro);
3270        std::fs::create_dir_all(&shard_dir).unwrap();
3271        std::fs::write(
3272            shard_dir.join(format!("{shard}.json")),
3273            serde_json::to_string(&fixture).unwrap(),
3274        )
3275        .unwrap();
3276    }
3277
3278    /// Override the platform cache dir for the duration of a test by
3279    /// pointing `XDG_CACHE_HOME` (Linux/macOS) and `LOCALAPPDATA`
3280    /// (Windows) at a fresh tempdir. Returns the mutex guard (so the
3281    /// env is held exclusively for the test's lifetime), the tempdir
3282    /// (kept alive until drop), and the cache root.
3283    fn redirect_cache_dir() -> (
3284        std::sync::MutexGuard<'static, ()>,
3285        tempfile::TempDir,
3286        std::path::PathBuf,
3287    ) {
3288        // `lock()` may report poisoning if a prior test panicked while
3289        // holding it; the env vars themselves are safe to re-set so we
3290        // unwrap-or-into-inner to keep the test useful even after a
3291        // sibling failure.
3292        let guard = CACHE_ENV_GUARD
3293            .lock()
3294            .unwrap_or_else(std::sync::PoisonError::into_inner);
3295        let tmp = tempfile::tempdir().unwrap();
3296        let cache_root = tmp.path().to_path_buf();
3297        // `dirs::cache_dir()` ignores `XDG_CACHE_HOME` on macOS/Windows, so set
3298        // the explicit override the resolver honors on every platform. The
3299        // XDG/LOCALAPPDATA sets are kept for any other code paths that read the
3300        // platform cache dir directly.
3301        std::env::set_var("ZLAYER_PACKAGE_MAP_CACHE_DIR", &cache_root);
3302        std::env::set_var("XDG_CACHE_HOME", &cache_root);
3303        std::env::set_var("LOCALAPPDATA", &cache_root);
3304        // These translator tests seed offline shard fixtures and must not
3305        // touch the network; disable the relocatable-artifact discovery GET
3306        // so resolution stays purely on the cached Chocolatey shards.
3307        std::env::set_var("ZLAYER_WINDOWS_DISCOVER_DISABLE", "1");
3308        (guard, tmp, cache_root)
3309    }
3310
3311    fn block_on<F: std::future::Future>(fut: F) -> F::Output {
3312        tokio::runtime::Builder::new_current_thread()
3313            .enable_all()
3314            .build()
3315            .expect("runtime")
3316            .block_on(fut)
3317    }
3318
3319    #[test]
3320    fn detect_apt_install_in_run() {
3321        let parts = split_shell_subcommands("apt-get update && apt-get install -y curl git");
3322        assert_eq!(parts.len(), 2);
3323        assert!(is_package_manager_sync(&parts[0]));
3324        let detected = detect_install_in_subcommand(&parts[1])
3325            .expect("install sub-command must be recognised");
3326        assert_eq!(detected.0, DetectedPmKind::Apt);
3327        assert_eq!(detected.1, vec!["curl".to_string(), "git".to_string()]);
3328    }
3329
3330    #[test]
3331    fn detect_yum_install_in_run() {
3332        let detected = detect_install_in_subcommand("yum install -y httpd")
3333            .expect("yum install -y httpd must be recognised");
3334        assert_eq!(detected.0, DetectedPmKind::YumOrDnf);
3335        assert_eq!(detected.1, vec!["httpd".to_string()]);
3336
3337        let detected = detect_install_in_subcommand("dnf install -y nginx php-fpm")
3338            .expect("dnf install -y must be recognised");
3339        assert_eq!(detected.0, DetectedPmKind::YumOrDnf);
3340        assert_eq!(detected.1, vec!["nginx".to_string(), "php-fpm".to_string()]);
3341    }
3342
3343    #[test]
3344    fn detect_apk_install_in_run() {
3345        let detected = detect_install_in_subcommand("apk add --no-cache nodejs npm")
3346            .expect("apk add must be recognised");
3347        assert_eq!(detected.0, DetectedPmKind::Apk);
3348        assert_eq!(detected.1, vec!["nodejs".to_string(), "npm".to_string()]);
3349    }
3350
3351    #[test]
3352    fn detect_no_install_returns_none() {
3353        assert!(detect_install_in_subcommand("echo hello").is_none());
3354        assert!(detect_install_in_subcommand("ls /tmp").is_none());
3355        assert!(detect_install_in_subcommand("apt-getinstall -y curl").is_none());
3356        assert!(detect_install_in_subcommand("apt-get install -y").is_none());
3357        let parts = split_shell_subcommands("echo hello && ls /tmp");
3358        assert_eq!(parts.len(), 2);
3359        for p in &parts {
3360            assert!(detect_install_in_subcommand(p).is_none());
3361            assert!(!is_package_manager_sync(p));
3362        }
3363    }
3364
3365    #[test]
3366    fn split_shell_subcommands_honours_and_and_semicolon() {
3367        let parts = split_shell_subcommands("a && b ; c");
3368        assert_eq!(
3369            parts,
3370            vec!["a".to_string(), "b".to_string(), "c".to_string()]
3371        );
3372    }
3373
3374    #[test]
3375    fn split_shell_subcommands_drops_empty_segments() {
3376        let parts = split_shell_subcommands(" && a && ; b ;");
3377        assert_eq!(parts, vec!["a".to_string(), "b".to_string()]);
3378    }
3379
3380    #[test]
3381    fn is_package_manager_sync_matches_common_variants() {
3382        assert!(is_package_manager_sync("apt-get update"));
3383        assert!(is_package_manager_sync("apt update"));
3384        assert!(is_package_manager_sync("apk update"));
3385        assert!(is_package_manager_sync("yum check-update"));
3386        assert!(is_package_manager_sync("dnf makecache"));
3387        assert!(is_package_manager_sync("sudo apt-get update"));
3388        assert!(!is_package_manager_sync("apt-get install -y curl"));
3389        assert!(!is_package_manager_sync("echo hello"));
3390    }
3391
3392    #[test]
3393    fn rejoin_emits_choco_install_for_install_subcommand() {
3394        let parts = vec![
3395            ShellSubcommand::Verbatim("echo before".to_string()),
3396            ShellSubcommand::PackageManagerSync,
3397            ShellSubcommand::Install {
3398                kind: DetectedPmKind::Apt,
3399                packages: vec!["curl".to_string(), "git".to_string()],
3400            },
3401            ShellSubcommand::Verbatim("echo after".to_string()),
3402        ];
3403        let out = rejoin_subcommands(&parts);
3404        assert_eq!(
3405            out,
3406            "echo before && choco install -y curl git && echo after"
3407        );
3408    }
3409
3410    #[test]
3411    fn wrap_in_cmd_escapes_embedded_quotes() {
3412        let wrapped = wrap_in_cmd(r#"echo "hello""#);
3413        assert!(wrapped.starts_with("cmd /c \""));
3414        assert!(wrapped.contains(r#"\"hello\""#));
3415        assert!(wrapped.ends_with('"'));
3416    }
3417
3418    #[test]
3419    fn translate_run_apt_to_choco_with_in_memory_shard() {
3420        // Build the shape the resolver writes to disk so the cache-hit
3421        // path runs without any network.
3422        let (_guard, _tmp, cache_root) = redirect_cache_dir();
3423        write_shard_fixture(
3424            &cache_root,
3425            "debian-12",
3426            "c",
3427            &[("curl", "curl"), ("linux-headers-generic", "__skip__")],
3428        );
3429        write_shard_fixture(
3430            &cache_root,
3431            "debian-12",
3432            "l",
3433            &[("linux-headers-generic", "__skip__")],
3434        );
3435
3436        let translator = DockerfileTranslator::new(ImageOs::Windows);
3437        let TranslatedRun {
3438            command_line: rewritten,
3439            skipped_packages: skipped,
3440            ..
3441        } = block_on(translator.translate_shell_command(
3442            "apt-get install -y curl linux-headers-generic",
3443            "debian-12",
3444            None,
3445        ))
3446        .expect("translate succeeds when every package resolves");
3447        assert!(
3448            rewritten.contains("choco install -y curl"),
3449            "rewritten command must include curl: {rewritten}"
3450        );
3451        assert!(
3452            !rewritten.contains("linux-headers-generic"),
3453            "skipped package must NOT appear in rewritten command: {rewritten}"
3454        );
3455        assert_eq!(skipped, vec!["linux-headers-generic".to_string()]);
3456    }
3457
3458    // -----------------------------------------------------------------
3459    // Provisioned-toolchain skip behaviour (new in the move to
3460    // `DockerfileTranslator`).
3461    // -----------------------------------------------------------------
3462
3463    #[test]
3464    fn translate_shell_command_skips_provisioned_toolchain() {
3465        // With a Go toolchain provisioned, an `apt-get install -y golang
3466        // git` must drop `golang` from the choco install and only emit
3467        // `git`. We seed the `g` shard with a `git → git` mapping so the
3468        // resolver hits without network.
3469        let (_guard, _tmp, cache_root) = redirect_cache_dir();
3470        write_shard_fixture(&cache_root, "debian-12", "g", &[("git", "git")]);
3471
3472        let translator = DockerfileTranslator::new(ImageOs::Windows);
3473        let TranslatedRun {
3474            command_line: rewritten,
3475            skipped_packages: skipped,
3476            ..
3477        } = block_on(translator.translate_shell_command(
3478            "apt-get install -y golang git",
3479            "debian-12",
3480            Some("go"),
3481        ))
3482        .expect("translate succeeds when remaining package resolves");
3483        assert!(
3484            !rewritten.contains("golang"),
3485            "provisioned-toolchain package must NOT appear in choco install: {rewritten}"
3486        );
3487        assert!(
3488            rewritten.contains("choco install -y git"),
3489            "non-toolchain package must still be installed: {rewritten}"
3490        );
3491        assert!(
3492            skipped.is_empty(),
3493            "toolchain drops are not reported as resolver-skipped: {skipped:?}"
3494        );
3495    }
3496
3497    #[test]
3498    fn translate_shell_command_keeps_unrelated_pkg_with_toolchain() {
3499        // With a Go toolchain provisioned, an `apt-get install -y curl`
3500        // must still produce `choco install -y curl` — the toolchain
3501        // skip is only for packages that match the language.
3502        let (_guard, _tmp, cache_root) = redirect_cache_dir();
3503        write_shard_fixture(&cache_root, "debian-12", "c", &[("curl", "curl")]);
3504
3505        let translator = DockerfileTranslator::new(ImageOs::Windows);
3506        let TranslatedRun {
3507            command_line: rewritten,
3508            skipped_packages: skipped,
3509            ..
3510        } = block_on(translator.translate_shell_command(
3511            "apt-get install -y curl",
3512            "debian-12",
3513            Some("go"),
3514        ))
3515        .expect("translate succeeds");
3516        assert!(
3517            rewritten.contains("choco install -y curl"),
3518            "curl must still be installed: {rewritten}"
3519        );
3520        assert!(skipped.is_empty(), "no resolver-skipped: {skipped:?}");
3521    }
3522
3523    #[test]
3524    fn translate_run_command_linux_is_passthrough() {
3525        // On Linux the translator must pass shell- and exec-form RUNs
3526        // through verbatim; no apt→choco rewriting happens because the
3527        // Linux backend hands the command straight to `/bin/sh -c`.
3528        let translator = DockerfileTranslator::new(ImageOs::Linux);
3529        let TranslatedRun {
3530            command_line: shell_out,
3531            skipped_packages: skipped,
3532            ..
3533        } = block_on(translator.translate_run_command(
3534            &ShellOrExec::Shell("apt-get install -y curl".to_string()),
3535            "debian-12",
3536            None,
3537        ))
3538        .expect("Linux passthrough never fails");
3539        assert_eq!(shell_out, "apt-get install -y curl");
3540        assert!(skipped.is_empty());
3541
3542        let TranslatedRun {
3543            command_line: exec_out,
3544            skipped_packages: skipped,
3545            ..
3546        } = block_on(translator.translate_run_command(
3547            &ShellOrExec::Exec(vec!["echo".to_string(), "hi".to_string()]),
3548            "debian-12",
3549            None,
3550        ))
3551        .expect("Linux passthrough never fails");
3552        assert_eq!(exec_out, "echo hi");
3553        assert!(skipped.is_empty());
3554    }
3555
3556    #[test]
3557    fn translate_run_command_windows_exec_is_passthrough() {
3558        // Exec-form on Windows joins with spaces and skips choco
3559        // detection — the caller has already chosen the absolute path
3560        // to the binary they want to invoke.
3561        let translator = DockerfileTranslator::new(ImageOs::Windows);
3562        let TranslatedRun {
3563            command_line: out,
3564            skipped_packages: skipped,
3565            ..
3566        } = block_on(translator.translate_run_command(
3567            &ShellOrExec::Exec(vec![
3568                "C:\\app\\bin\\srv.exe".to_string(),
3569                "--port".to_string(),
3570                "80".to_string(),
3571            ]),
3572            "debian-12",
3573            None,
3574        ))
3575        .expect("exec-form passthrough never fails");
3576        assert_eq!(out, "C:\\app\\bin\\srv.exe --port 80");
3577        assert!(skipped.is_empty());
3578    }
3579
3580    #[test]
3581    fn translate_shell_command_no_toolchain_installs_all() {
3582        // With no toolchain provisioned, `apt-get install -y golang git`
3583        // installs both packages via Chocolatey. `golang` resolves to
3584        // `golang` in the seeded shard (Chocolatey actually ships a
3585        // `golang` package, but the precise mapping is irrelevant here —
3586        // what matters is that it isn't dropped).
3587        let (_guard, _tmp, cache_root) = redirect_cache_dir();
3588        write_shard_fixture(
3589            &cache_root,
3590            "debian-12",
3591            "g",
3592            &[("golang", "golang"), ("git", "git")],
3593        );
3594
3595        let translator = DockerfileTranslator::new(ImageOs::Windows);
3596        let TranslatedRun {
3597            command_line: rewritten,
3598            skipped_packages: skipped,
3599            ..
3600        } = block_on(translator.translate_shell_command(
3601            "apt-get install -y golang git",
3602            "debian-12",
3603            None,
3604        ))
3605        .expect("translate succeeds");
3606        assert!(
3607            rewritten.contains("choco install -y golang git"),
3608            "both packages must be installed: {rewritten}"
3609        );
3610        assert!(skipped.is_empty(), "no resolver-skipped: {skipped:?}");
3611    }
3612}