mod executor;
mod install;
pub use executor::*;
#[cfg(unix)]
pub use install::buildd as buildd_install;
#[cfg(unix)]
pub use install::buildd::{ensure_buildd_sidecar, InstallOutcome as SidecarInstallOutcome};
pub use install::{
current_platform, install_instructions, is_platform_supported, BuildahInstallation,
BuildahInstaller, InstallError,
};
use crate::backend::ImageOs;
use crate::dockerfile::{
AddInstruction, CopyInstruction, EnvInstruction, ExposeInstruction, HealthcheckInstruction,
Instruction, RunInstruction, RunNetwork, ShellOrExec,
};
use std::collections::HashMap;
const LINUX_DEFAULT_SHELL: &[&str] = &["/bin/sh", "-c"];
const WINDOWS_DEFAULT_SHELL: &[&str] = &["cmd.exe", "/S", "/C"];
fn default_shell_for(os: ImageOs) -> Vec<String> {
let raw: &[&str] = match os {
ImageOs::Linux => LINUX_DEFAULT_SHELL,
ImageOs::Windows => WINDOWS_DEFAULT_SHELL,
};
raw.iter().map(|s| (*s).to_string()).collect()
}
#[derive(Debug, Clone)]
pub struct BuildahCommand {
pub program: String,
pub args: Vec<String>,
pub env: HashMap<String, String>,
}
impl BuildahCommand {
#[must_use]
pub fn new(subcommand: &str) -> Self {
Self {
program: "buildah".to_string(),
args: vec![subcommand.to_string()],
env: HashMap::new(),
}
}
#[must_use]
pub fn arg(mut self, arg: impl Into<String>) -> Self {
self.args.push(arg.into());
self
}
#[must_use]
pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.args.extend(args.into_iter().map(Into::into));
self
}
#[must_use]
pub fn arg_opt(self, flag: &str, value: Option<impl Into<String>>) -> Self {
if let Some(v) = value {
self.arg(flag).arg(v)
} else {
self
}
}
#[must_use]
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env.insert(key.into(), value.into());
self
}
#[must_use]
pub fn to_command_string(&self) -> String {
let mut parts = vec![self.program.clone()];
parts.extend(self.args.iter().map(|a| {
if a.contains(' ') || a.contains('"') {
format!("\"{}\"", a.replace('"', "\\\""))
} else {
a.clone()
}
}));
parts.join(" ")
}
#[must_use]
pub fn from_image(image: &str) -> Self {
Self::new("from").arg(image)
}
#[must_use]
pub fn from_image_named(image: &str, name: &str) -> Self {
Self::new("from").arg("--name").arg(name).arg(image)
}
#[must_use]
pub fn from_scratch() -> Self {
Self::new("from").arg("scratch")
}
#[must_use]
pub fn rm(container: &str) -> Self {
Self::new("rm").arg(container)
}
#[must_use]
pub fn commit(container: &str, image_name: &str) -> Self {
Self::new("commit").arg(container).arg(image_name)
}
#[must_use]
pub fn commit_with_opts(
container: &str,
image_name: &str,
format: Option<&str>,
squash: bool,
) -> Self {
let mut cmd = Self::new("commit");
if let Some(fmt) = format {
cmd = cmd.arg("--format").arg(fmt);
}
if squash {
cmd = cmd.arg("--squash");
}
cmd.arg(container).arg(image_name)
}
#[must_use]
pub fn tag(image: &str, new_name: &str) -> Self {
Self::new("tag").arg(image).arg(new_name)
}
#[must_use]
pub fn rmi(image: &str) -> Self {
Self::new("rmi").arg(image)
}
#[must_use]
pub fn pull(image: &str, policy: Option<&str>) -> Self {
let mut cmd = Self::new("pull");
if let Some(p) = policy {
cmd = cmd.arg("--policy").arg(p);
}
cmd.arg(image)
}
#[must_use]
pub fn push(image: &str) -> Self {
Self::new("push").arg(image)
}
#[must_use]
pub fn push_to(image: &str, destination: &str) -> Self {
Self::new("push").arg(image).arg(destination)
}
#[must_use]
pub fn inspect(name: &str) -> Self {
Self::new("inspect").arg(name)
}
#[must_use]
pub fn inspect_format(name: &str, format: &str) -> Self {
Self::new("inspect").arg("--format").arg(format).arg(name)
}
#[must_use]
pub fn images() -> Self {
Self::new("images")
}
#[must_use]
pub fn containers() -> Self {
Self::new("containers")
}
#[must_use]
pub fn run_shell(container: &str, command: &str) -> Self {
Self::run_shell_custom(container, LINUX_DEFAULT_SHELL, command)
}
#[must_use]
pub fn run_shell_custom(
container: &str,
shell: impl IntoIterator<Item = impl AsRef<str>>,
command: &str,
) -> Self {
Self::run_shell_custom_with_net(container, shell, command, None)
}
#[must_use]
pub fn run_shell_custom_with_net(
container: &str,
shell: impl IntoIterator<Item = impl AsRef<str>>,
command: &str,
net: Option<RunNetwork>,
) -> Self {
let mut cmd = Self::new("run");
if let Some(mode) = net {
match mode {
RunNetwork::Host => cmd = cmd.arg("--net=host"),
RunNetwork::None => cmd = cmd.arg("--net=none"),
RunNetwork::Default => {}
}
}
cmd = cmd.arg(container).arg("--");
for s in shell {
cmd = cmd.arg(s.as_ref().to_string());
}
cmd.arg(command)
}
#[must_use]
pub fn run_shell_for_os(container: &str, command: &str, os: ImageOs) -> Self {
let shell = default_shell_for(os);
Self::run_shell_custom(container, &shell, command)
}
#[must_use]
pub fn run_exec(container: &str, args: &[String]) -> Self {
Self::run_exec_with_net(container, args, None)
}
#[must_use]
pub fn run_exec_with_net(container: &str, args: &[String], net: Option<RunNetwork>) -> Self {
let mut cmd = Self::new("run");
if let Some(mode) = net {
match mode {
RunNetwork::Host => cmd = cmd.arg("--net=host"),
RunNetwork::None => cmd = cmd.arg("--net=none"),
RunNetwork::Default => {}
}
}
cmd = cmd.arg(container).arg("--");
for arg in args {
cmd = cmd.arg(arg);
}
cmd
}
#[must_use]
pub fn run(container: &str, command: &ShellOrExec) -> Self {
match command {
ShellOrExec::Shell(s) => Self::run_shell(container, s),
ShellOrExec::Exec(args) => Self::run_exec(container, args),
}
}
#[must_use]
pub fn run_with_mounts(container: &str, run: &RunInstruction) -> Self {
Self::run_with_mounts_shell(container, run, LINUX_DEFAULT_SHELL)
}
#[must_use]
pub fn run_with_mounts_shell(
container: &str,
run: &RunInstruction,
shell: impl IntoIterator<Item = impl AsRef<str>>,
) -> Self {
let mut cmd = Self::new("run");
for mount in &run.mounts {
cmd = cmd.arg(format!("--mount={}", mount.to_buildah_arg()));
}
let mut env_keys: Vec<&String> = run.env.keys().collect();
env_keys.sort();
for key in env_keys {
if let Some(value) = run.env.get(key) {
cmd = cmd.arg(format!("--env={key}={value}"));
}
}
if let Some(net) = run.network {
match net {
RunNetwork::Host => {
cmd = cmd.arg("--net=host");
}
RunNetwork::None => {
cmd = cmd.arg("--net=none");
}
RunNetwork::Default => {}
}
}
cmd = cmd.arg(container).arg("--");
match &run.command {
ShellOrExec::Shell(s) => {
for part in shell {
cmd = cmd.arg(part.as_ref().to_string());
}
cmd.arg(s)
}
ShellOrExec::Exec(args) => {
for arg in args {
cmd = cmd.arg(arg);
}
cmd
}
}
}
#[must_use]
pub fn copy(container: &str, sources: &[String], dest: &str) -> Self {
let mut cmd = Self::new("copy").arg(container);
for src in sources {
cmd = cmd.arg(src);
}
cmd.arg(dest)
}
#[must_use]
pub fn copy_from(container: &str, from: &str, sources: &[String], dest: &str) -> Self {
let mut cmd = Self::new("copy").arg("--from").arg(from).arg(container);
for src in sources {
cmd = cmd.arg(src);
}
cmd.arg(dest)
}
#[must_use]
pub fn copy_instruction(container: &str, copy: &CopyInstruction) -> Self {
let mut cmd = Self::new("copy");
if let Some(ref from) = copy.from {
cmd = cmd.arg("--from").arg(from);
}
if let Some(ref chown) = copy.chown {
cmd = cmd.arg("--chown").arg(chown);
}
if let Some(ref chmod) = copy.chmod {
cmd = cmd.arg("--chmod").arg(chmod);
}
cmd = cmd.arg(container);
for src in ©.sources {
cmd = cmd.arg(src);
}
cmd.arg(©.destination)
}
#[must_use]
pub fn add(container: &str, sources: &[String], dest: &str) -> Self {
let mut cmd = Self::new("add").arg(container);
for src in sources {
cmd = cmd.arg(src);
}
cmd.arg(dest)
}
#[must_use]
pub fn add_instruction(container: &str, add: &AddInstruction) -> Self {
let mut cmd = Self::new("add");
if let Some(ref chown) = add.chown {
cmd = cmd.arg("--chown").arg(chown);
}
if let Some(ref chmod) = add.chmod {
cmd = cmd.arg("--chmod").arg(chmod);
}
cmd = cmd.arg(container);
for src in &add.sources {
cmd = cmd.arg(src);
}
cmd.arg(&add.destination)
}
#[must_use]
pub fn config_env(container: &str, key: &str, value: &str) -> Self {
Self::new("config")
.arg("--env")
.arg(format!("{key}={value}"))
.arg(container)
}
#[must_use]
pub fn config_envs(container: &str, env: &EnvInstruction) -> Vec<Self> {
env.vars
.iter()
.map(|(k, v)| Self::config_env(container, k, v))
.collect()
}
#[must_use]
pub fn config_workdir(container: &str, dir: &str) -> Self {
Self::new("config")
.arg("--workingdir")
.arg(dir)
.arg(container)
}
#[must_use]
pub fn config_expose(container: &str, expose: &ExposeInstruction) -> Self {
let port_spec = format!(
"{}/{}",
expose.port,
match expose.protocol {
crate::dockerfile::ExposeProtocol::Tcp => "tcp",
crate::dockerfile::ExposeProtocol::Udp => "udp",
}
);
Self::new("config")
.arg("--port")
.arg(port_spec)
.arg(container)
}
#[must_use]
pub fn config_entrypoint_shell(container: &str, command: &str) -> Self {
Self::new("config")
.arg("--entrypoint")
.arg(format!(
"[\"/bin/sh\", \"-c\", \"{}\"]",
escape_json_string(command)
))
.arg(container)
}
#[must_use]
pub fn config_entrypoint_exec(container: &str, args: &[String]) -> Self {
let json_array = format!(
"[{}]",
args.iter()
.map(|a| format!("\"{}\"", escape_json_string(a)))
.collect::<Vec<_>>()
.join(", ")
);
Self::new("config")
.arg("--entrypoint")
.arg(json_array)
.arg(container)
}
#[must_use]
pub fn config_entrypoint(container: &str, command: &ShellOrExec) -> Self {
match command {
ShellOrExec::Shell(s) => Self::config_entrypoint_shell(container, s),
ShellOrExec::Exec(args) => Self::config_entrypoint_exec(container, args),
}
}
#[must_use]
pub fn config_cmd_shell(container: &str, command: &str) -> Self {
Self::new("config")
.arg("--cmd")
.arg(format!("/bin/sh -c \"{}\"", escape_json_string(command)))
.arg(container)
}
#[must_use]
pub fn config_cmd_exec(container: &str, args: &[String]) -> Self {
let json_array = format!(
"[{}]",
args.iter()
.map(|a| format!("\"{}\"", escape_json_string(a)))
.collect::<Vec<_>>()
.join(", ")
);
Self::new("config")
.arg("--cmd")
.arg(json_array)
.arg(container)
}
#[must_use]
pub fn config_cmd(container: &str, command: &ShellOrExec) -> Self {
match command {
ShellOrExec::Shell(s) => Self::config_cmd_shell(container, s),
ShellOrExec::Exec(args) => Self::config_cmd_exec(container, args),
}
}
#[must_use]
pub fn config_user(container: &str, user: &str) -> Self {
Self::new("config").arg("--user").arg(user).arg(container)
}
#[must_use]
pub fn config_label(container: &str, key: &str, value: &str) -> Self {
Self::new("config")
.arg("--label")
.arg(format!("{key}={value}"))
.arg(container)
}
#[must_use]
pub fn config_labels(container: &str, labels: &HashMap<String, String>) -> Vec<Self> {
labels
.iter()
.map(|(k, v)| Self::config_label(container, k, v))
.collect()
}
#[must_use]
pub fn config_volume(container: &str, path: &str) -> Self {
Self::new("config").arg("--volume").arg(path).arg(container)
}
#[must_use]
pub fn config_stopsignal(container: &str, signal: &str) -> Self {
Self::new("config")
.arg("--stop-signal")
.arg(signal)
.arg(container)
}
#[must_use]
pub fn config_shell(container: &str, shell: &[String]) -> Self {
let json_array = format!(
"[{}]",
shell
.iter()
.map(|a| format!("\"{}\"", escape_json_string(a)))
.collect::<Vec<_>>()
.join(", ")
);
Self::new("config")
.arg("--shell")
.arg(json_array)
.arg(container)
}
#[must_use]
pub fn config_healthcheck(container: &str, healthcheck: &HealthcheckInstruction) -> Self {
match healthcheck {
HealthcheckInstruction::None => Self::new("config")
.arg("--healthcheck")
.arg("NONE")
.arg(container),
HealthcheckInstruction::Check {
command,
interval,
timeout,
start_period,
retries,
..
} => {
let mut cmd = Self::new("config");
let cmd_str = match command {
ShellOrExec::Shell(s) => format!("CMD {s}"),
ShellOrExec::Exec(args) => {
format!(
"CMD [{}]",
args.iter()
.map(|a| format!("\"{}\"", escape_json_string(a)))
.collect::<Vec<_>>()
.join(", ")
)
}
};
cmd = cmd.arg("--healthcheck").arg(cmd_str);
if let Some(i) = interval {
cmd = cmd
.arg("--healthcheck-interval")
.arg(format!("{}s", i.as_secs()));
}
if let Some(t) = timeout {
cmd = cmd
.arg("--healthcheck-timeout")
.arg(format!("{}s", t.as_secs()));
}
if let Some(sp) = start_period {
cmd = cmd
.arg("--healthcheck-start-period")
.arg(format!("{}s", sp.as_secs()));
}
if let Some(r) = retries {
cmd = cmd.arg("--healthcheck-retries").arg(r.to_string());
}
cmd.arg(container)
}
}
}
#[must_use]
pub fn manifest_create(name: &str) -> Self {
Self::new("manifest").arg("create").arg(name)
}
#[must_use]
pub fn manifest_add(list: &str, image: &str) -> Self {
Self::new("manifest").arg("add").arg(list).arg(image)
}
#[must_use]
pub fn manifest_push(list: &str, destination: &str) -> Self {
Self::new("manifest")
.arg("push")
.arg("--all")
.arg(list)
.arg(destination)
}
#[must_use]
pub fn manifest_rm(list: &str) -> Self {
Self::new("manifest").arg("rm").arg(list)
}
#[must_use]
pub fn from_instruction(container: &str, instruction: &Instruction) -> Vec<Self> {
DockerfileTranslator::new(ImageOs::Linux).translate(container, instruction)
}
}
#[derive(Debug, Clone)]
pub struct DockerfileTranslator {
target_os: ImageOs,
shell_override: Option<Vec<String>>,
host_network: bool,
}
impl DockerfileTranslator {
#[must_use]
pub fn new(target_os: ImageOs) -> Self {
Self {
target_os,
shell_override: None,
host_network: false,
}
}
#[must_use]
pub fn with_host_network(mut self, on: bool) -> Self {
self.host_network = on;
self
}
#[must_use]
pub fn target_os(&self) -> ImageOs {
self.target_os
}
#[must_use]
pub fn active_shell(&self) -> Vec<String> {
match &self.shell_override {
Some(s) => s.clone(),
None => default_shell_for(self.target_os),
}
}
pub fn set_shell_override(&mut self, shell: Vec<String>) {
self.shell_override = Some(shell);
}
#[allow(clippy::too_many_lines)]
pub fn translate(&mut self, container: &str, instruction: &Instruction) -> Vec<BuildahCommand> {
match instruction {
Instruction::Run(run) => {
let shell = self.active_shell();
let effective_run: std::borrow::Cow<'_, RunInstruction> = if self.host_network {
let mut owned = run.clone();
owned.network = Some(RunNetwork::Host);
std::borrow::Cow::Owned(owned)
} else {
std::borrow::Cow::Borrowed(run)
};
let run_ref: &RunInstruction = &effective_run;
let needs_pre_container_flags = !run_ref.mounts.is_empty()
|| !run_ref.env.is_empty()
|| run_ref.network.is_some();
if needs_pre_container_flags {
vec![BuildahCommand::run_with_mounts_shell(
container, run_ref, &shell,
)]
} else {
match &run_ref.command {
ShellOrExec::Shell(s) => {
vec![BuildahCommand::run_shell_custom(container, &shell, s)]
}
ShellOrExec::Exec(args) => vec![BuildahCommand::run_exec(container, args)],
}
}
}
Instruction::Copy(copy) => {
vec![BuildahCommand::copy_instruction(container, copy)]
}
Instruction::Add(add) => {
vec![BuildahCommand::add_instruction(container, add)]
}
Instruction::Env(env) => BuildahCommand::config_envs(container, env),
Instruction::Workdir(dir) => self.translate_workdir(container, dir),
Instruction::Expose(expose) => {
vec![BuildahCommand::config_expose(container, expose)]
}
Instruction::Label(labels) => BuildahCommand::config_labels(container, labels),
Instruction::User(user) => {
vec![BuildahCommand::config_user(container, user)]
}
Instruction::Entrypoint(cmd) => {
vec![BuildahCommand::config_entrypoint(container, cmd)]
}
Instruction::Cmd(cmd) => {
vec![BuildahCommand::config_cmd(container, cmd)]
}
Instruction::Volume(paths) => paths
.iter()
.map(|p| BuildahCommand::config_volume(container, p))
.collect(),
Instruction::Shell(shell) => {
self.set_shell_override(shell.clone());
vec![BuildahCommand::config_shell(container, shell)]
}
Instruction::Arg(_) => {
vec![]
}
Instruction::Stopsignal(signal) => {
vec![BuildahCommand::config_stopsignal(container, signal)]
}
Instruction::Healthcheck(hc) => {
vec![BuildahCommand::config_healthcheck(container, hc)]
}
Instruction::Onbuild(_) => {
tracing::warn!("ONBUILD instruction not supported in buildah conversion");
vec![]
}
}
}
fn translate_workdir(&self, container: &str, dir: &str) -> Vec<BuildahCommand> {
let net = self.host_network.then_some(RunNetwork::Host);
match self.target_os {
ImageOs::Linux => {
vec![
BuildahCommand::run_exec_with_net(
container,
&["mkdir".to_string(), "-p".to_string(), dir.to_string()],
net,
),
BuildahCommand::config_workdir(container, dir),
]
}
ImageOs::Windows => {
let guarded = format!(r#"if not exist "{dir}" mkdir "{dir}""#);
vec![
BuildahCommand::run_shell_custom_with_net(
container,
WINDOWS_DEFAULT_SHELL,
&guarded,
net,
),
BuildahCommand::config_workdir(container, dir),
]
}
}
}
}
#[cfg(any(target_os = "windows", test))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DetectedPmKind {
Apt,
Apk,
YumOrDnf,
}
#[cfg(any(target_os = "windows", test))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ShellSubcommand {
Verbatim(String),
PackageManagerSync,
Install {
kind: DetectedPmKind,
packages: Vec<String>,
},
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn detect_install_in_subcommand(
subcommand: &str,
) -> Option<(DetectedPmKind, Vec<String>)> {
let tokens: Vec<&str> = subcommand.split_whitespace().collect();
if tokens.is_empty() {
return None;
}
let (kind, after_verb_idx) = match tokens[0] {
"sudo" if tokens.len() >= 2 => detect_pm_verb(&tokens[1..]).map(|(k, n)| (k, n + 1))?,
_ => detect_pm_verb(&tokens)?,
};
let args = &tokens[after_verb_idx..];
let mut packages = Vec::new();
for arg in args {
if arg.starts_with('-') {
continue;
}
packages.push((*arg).to_string());
}
if packages.is_empty() {
return None;
}
Some((kind, packages))
}
#[cfg(any(target_os = "windows", test))]
fn detect_pm_verb(tokens: &[&str]) -> Option<(DetectedPmKind, usize)> {
match (tokens.first().copied(), tokens.get(1).copied()) {
(Some("apt-get" | "apt"), Some("install")) => Some((DetectedPmKind::Apt, 2)),
(Some("apk"), Some("add")) => Some((DetectedPmKind::Apk, 2)),
(Some("yum" | "dnf"), Some("install")) => Some((DetectedPmKind::YumOrDnf, 2)),
_ => None,
}
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn is_package_manager_sync(subcommand: &str) -> bool {
let tokens: Vec<&str> = subcommand.split_whitespace().collect();
let stripped: &[&str] = if tokens.first().copied() == Some("sudo") {
&tokens[1..]
} else {
&tokens
};
matches!(
(stripped.first().copied(), stripped.get(1).copied()),
(Some("apt-get" | "apt" | "apk"), Some("update"))
| (
Some("yum" | "dnf"),
Some("check-update" | "update" | "makecache")
)
)
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn split_shell_subcommands(raw: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut chars = raw.chars().peekable();
while let Some(c) = chars.next() {
match c {
'&' if chars.peek() == Some(&'&') => {
chars.next();
if !current.trim().is_empty() {
out.push(current.trim().to_string());
}
current.clear();
}
';' => {
if !current.trim().is_empty() {
out.push(current.trim().to_string());
}
current.clear();
}
other => current.push(other),
}
}
if !current.trim().is_empty() {
out.push(current.trim().to_string());
}
out
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn rejoin_subcommands(parts: &[ShellSubcommand]) -> String {
let mut emitted: Vec<String> = Vec::new();
for part in parts {
match part {
ShellSubcommand::Verbatim(s) => emitted.push(s.clone()),
ShellSubcommand::PackageManagerSync => {
}
ShellSubcommand::Install { packages, .. } => {
if packages.is_empty() {
continue;
}
let mut joined = String::from("choco install -y");
for pkg in packages {
joined.push(' ');
joined.push_str(pkg);
}
emitted.push(joined);
}
}
}
emitted.join(" && ")
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn wrap_in_cmd(body: &str) -> String {
if body.is_empty() {
return "cmd /c \"\"".to_string();
}
let escaped = body.replace('"', "\\\"");
format!("cmd /c \"{escaped}\"")
}
#[cfg(any(target_os = "windows", test))]
fn package_matches_toolchain(linux_pkg: &str, toolchain_language: &str) -> bool {
let pkg = linux_pkg.to_ascii_lowercase();
match toolchain_language.to_ascii_lowercase().as_str() {
"go" => matches!(pkg.as_str(), "golang" | "go"),
"node" => matches!(pkg.as_str(), "nodejs" | "node"),
"python" => matches!(pkg.as_str(), "python3" | "python"),
"rust" => matches!(pkg.as_str(), "rust" | "rustc" | "cargo"),
"deno" => pkg == "deno",
"bun" => pkg == "bun",
_ => false,
}
}
#[cfg(any(target_os = "windows", test))]
impl DockerfileTranslator {
pub(crate) async fn translate_run_command(
&self,
cmd: &ShellOrExec,
source_distro: &str,
provisioned_toolchain_language: Option<&str>,
) -> Result<(String, Vec<String>), crate::error::BuildError> {
match self.target_os {
ImageOs::Linux => match cmd {
ShellOrExec::Shell(s) => Ok((s.clone(), Vec::new())),
ShellOrExec::Exec(args) => Ok((args.join(" "), Vec::new())),
},
ImageOs::Windows => match cmd {
ShellOrExec::Exec(args) => Ok((args.join(" "), Vec::new())),
ShellOrExec::Shell(raw) => {
self.translate_shell_command(raw, source_distro, provisioned_toolchain_language)
.await
}
},
}
}
pub(crate) async fn translate_shell_command(
&self,
raw: &str,
source_distro: &str,
provisioned_toolchain_language: Option<&str>,
) -> Result<(String, Vec<String>), crate::error::BuildError> {
if matches!(self.target_os, ImageOs::Linux) {
return Ok((raw.to_string(), Vec::new()));
}
let subcommands = split_shell_subcommands(raw);
if subcommands.is_empty() {
return Ok((wrap_in_cmd(""), Vec::new()));
}
let mut classified: Vec<ShellSubcommand> = Vec::with_capacity(subcommands.len());
let mut all_packages: Vec<String> = Vec::new();
for sub in &subcommands {
if is_package_manager_sync(sub) {
classified.push(ShellSubcommand::PackageManagerSync);
continue;
}
if let Some((kind, mut packages)) = detect_install_in_subcommand(sub) {
if let Some(lang) = provisioned_toolchain_language {
packages.retain(|p| !package_matches_toolchain(p, lang));
}
if packages.is_empty() {
continue;
}
all_packages.extend(packages.iter().cloned());
classified.push(ShellSubcommand::Install { kind, packages });
continue;
}
classified.push(ShellSubcommand::Verbatim(sub.clone()));
}
if all_packages.is_empty() {
let rejoined = rejoin_subcommands(&classified);
return Ok((wrap_in_cmd(&rejoined), Vec::new()));
}
let resolved = crate::windows_image_resolver::resolve_chocolatey_packages(
&all_packages,
source_distro,
)
.await?;
let mut lookup: HashMap<String, (Option<String>, bool)> = HashMap::new();
for (linux, choco, skipped) in resolved {
lookup.insert(linux, (choco, skipped));
}
let mut skipped_out: Vec<String> = Vec::new();
for part in &mut classified {
if let ShellSubcommand::Install { kind: _, packages } = part {
let mut rewritten: Vec<String> = Vec::new();
for pkg in packages.iter() {
match lookup.get(pkg) {
Some((Some(choco), false)) => rewritten.push(choco.clone()),
Some((_, true)) => skipped_out.push(pkg.clone()),
Some((None, false)) | None => {
return Err(crate::error::BuildError::ChocoResolutionFailed {
package: pkg.clone(),
source_distro: source_distro.to_string(),
});
}
}
}
*packages = rewritten;
}
}
Ok((wrap_in_cmd(&rejoin_subcommands(&classified)), skipped_out))
}
}
fn escape_json_string(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dockerfile::RunInstruction;
#[test]
fn test_from_image() {
let cmd = BuildahCommand::from_image("alpine:3.18");
assert_eq!(cmd.program, "buildah");
assert_eq!(cmd.args, vec!["from", "alpine:3.18"]);
}
#[test]
fn test_pull_no_policy() {
let cmd = BuildahCommand::pull("ghcr.io/astral-sh/uv:0.5.0", None);
assert_eq!(cmd.program, "buildah");
assert_eq!(cmd.args, vec!["pull", "ghcr.io/astral-sh/uv:0.5.0"]);
}
#[test]
fn test_pull_with_policy() {
let cmd = BuildahCommand::pull("ghcr.io/astral-sh/uv:0.5.0", Some("newer"));
assert_eq!(
cmd.args,
vec!["pull", "--policy", "newer", "ghcr.io/astral-sh/uv:0.5.0"]
);
}
#[test]
fn test_run_shell() {
let cmd = BuildahCommand::run_shell("container-1", "apt-get update");
assert_eq!(
cmd.args,
vec![
"run",
"container-1",
"--",
"/bin/sh",
"-c",
"apt-get update"
]
);
}
#[test]
fn test_run_exec() {
let args = vec!["echo".to_string(), "hello".to_string()];
let cmd = BuildahCommand::run_exec("container-1", &args);
assert_eq!(cmd.args, vec!["run", "container-1", "--", "echo", "hello"]);
}
#[test]
fn test_copy() {
let sources = vec!["src/".to_string(), "Cargo.toml".to_string()];
let cmd = BuildahCommand::copy("container-1", &sources, "/app/");
assert_eq!(
cmd.args,
vec!["copy", "container-1", "src/", "Cargo.toml", "/app/"]
);
}
#[test]
fn test_copy_from() {
let sources = vec!["/app".to_string()];
let cmd = BuildahCommand::copy_from("container-1", "builder", &sources, "/app");
assert_eq!(
cmd.args,
vec!["copy", "--from", "builder", "container-1", "/app", "/app"]
);
}
#[test]
fn test_copy_from_external_image_reference_is_preserved() {
use crate::dockerfile::CopyInstruction;
let copy = CopyInstruction {
sources: vec!["/uv".to_string()],
destination: "/usr/local/bin/uv".to_string(),
from: Some("ghcr.io/astral-sh/uv:0.5.0".to_string()),
chown: None,
chmod: None,
link: false,
exclude: Vec::new(),
};
let instruction = Instruction::Copy(copy);
let cmds = BuildahCommand::from_instruction("container-1", &instruction);
assert_eq!(
cmds.len(),
1,
"COPY translates to a single buildah copy command"
);
assert_eq!(
cmds[0].args,
vec![
"copy",
"--from",
"ghcr.io/astral-sh/uv:0.5.0",
"container-1",
"/uv",
"/usr/local/bin/uv",
],
"external image reference must be passed through to buildah unchanged",
);
}
#[test]
fn test_config_env() {
let cmd = BuildahCommand::config_env("container-1", "PATH", "/usr/local/bin");
assert_eq!(
cmd.args,
vec!["config", "--env", "PATH=/usr/local/bin", "container-1"]
);
}
#[test]
fn test_config_workdir() {
let cmd = BuildahCommand::config_workdir("container-1", "/app");
assert_eq!(
cmd.args,
vec!["config", "--workingdir", "/app", "container-1"]
);
}
#[test]
fn test_config_entrypoint_exec() {
let args = vec!["/app".to_string(), "--config".to_string()];
let cmd = BuildahCommand::config_entrypoint_exec("container-1", &args);
assert!(cmd.args.contains(&"--entrypoint".to_string()));
assert!(cmd
.args
.iter()
.any(|a| a.contains('[') && a.contains("/app")));
}
#[test]
fn test_commit() {
let cmd = BuildahCommand::commit("container-1", "myimage:latest");
assert_eq!(cmd.args, vec!["commit", "container-1", "myimage:latest"]);
}
#[test]
fn test_to_command_string() {
let cmd = BuildahCommand::config_env("container-1", "VAR", "value with spaces");
let s = cmd.to_command_string();
assert!(s.starts_with("buildah config"));
assert!(s.contains("VAR=value with spaces"));
}
#[test]
fn test_from_instruction_run() {
let instruction = Instruction::Run(RunInstruction {
command: ShellOrExec::Shell("echo hello".to_string()),
mounts: vec![],
network: None,
security: None,
env: HashMap::new(),
});
let cmds = BuildahCommand::from_instruction("container-1", &instruction);
assert_eq!(cmds.len(), 1);
assert!(cmds[0].args.contains(&"run".to_string()));
}
#[test]
fn test_from_instruction_workdir_creates_and_configures() {
let instruction = Instruction::Workdir("/workspace".to_string());
let cmds = BuildahCommand::from_instruction("container-1", &instruction);
assert_eq!(cmds.len(), 2, "WORKDIR should emit mkdir + config");
let run_args = &cmds[0].args;
assert_eq!(run_args[0], "run");
assert_eq!(run_args[1], "container-1");
assert_eq!(run_args[2], "--");
assert_eq!(run_args[3], "mkdir");
assert_eq!(run_args[4], "-p");
assert_eq!(run_args[5], "/workspace");
assert_eq!(
cmds[1].args,
vec!["config", "--workingdir", "/workspace", "container-1"]
);
}
#[test]
fn test_from_instruction_env_multiple() {
let mut vars = HashMap::new();
vars.insert("FOO".to_string(), "bar".to_string());
vars.insert("BAZ".to_string(), "qux".to_string());
let instruction = Instruction::Env(EnvInstruction { vars });
let cmds = BuildahCommand::from_instruction("container-1", &instruction);
assert_eq!(cmds.len(), 2);
for cmd in &cmds {
assert!(cmd.args.contains(&"config".to_string()));
assert!(cmd.args.contains(&"--env".to_string()));
}
}
#[test]
fn test_escape_json_string() {
assert_eq!(escape_json_string("hello"), "hello");
assert_eq!(escape_json_string("hello \"world\""), "hello \\\"world\\\"");
assert_eq!(escape_json_string("line1\nline2"), "line1\\nline2");
}
#[test]
fn test_run_with_mounts_cache() {
use crate::dockerfile::{CacheSharing, RunMount};
let run = RunInstruction {
command: ShellOrExec::Shell("apt-get update".to_string()),
mounts: vec![RunMount::Cache {
target: "/var/cache/apt".to_string(),
id: Some("apt-cache".to_string()),
sharing: CacheSharing::Shared,
readonly: false,
}],
network: None,
security: None,
env: HashMap::new(),
};
let cmd = BuildahCommand::run_with_mounts("container-1", &run);
let mount_idx = cmd
.args
.iter()
.position(|a| a.starts_with("--mount="))
.expect("should have --mount arg");
let container_idx = cmd
.args
.iter()
.position(|a| a == "container-1")
.expect("should have container id");
assert!(
mount_idx < container_idx,
"--mount should come before container ID"
);
assert!(cmd.args[mount_idx].contains("type=cache"));
assert!(cmd.args[mount_idx].contains("target=/var/cache/apt"));
assert!(cmd.args[mount_idx].contains("id=apt-cache"));
assert!(cmd.args[mount_idx].contains("sharing=shared"));
}
#[test]
fn test_run_with_multiple_mounts() {
use crate::dockerfile::{CacheSharing, RunMount};
let run = RunInstruction {
command: ShellOrExec::Shell("cargo build".to_string()),
mounts: vec![
RunMount::Cache {
target: "/usr/local/cargo/registry".to_string(),
id: Some("cargo-registry".to_string()),
sharing: CacheSharing::Shared,
readonly: false,
},
RunMount::Cache {
target: "/app/target".to_string(),
id: Some("cargo-target".to_string()),
sharing: CacheSharing::Locked,
readonly: false,
},
],
network: None,
security: None,
env: HashMap::new(),
};
let cmd = BuildahCommand::run_with_mounts("container-1", &run);
let mount_count = cmd
.args
.iter()
.filter(|a| a.starts_with("--mount="))
.count();
assert_eq!(mount_count, 2, "should have 2 mount arguments");
let container_idx = cmd
.args
.iter()
.position(|a| a == "container-1")
.expect("should have container id");
for (idx, arg) in cmd.args.iter().enumerate() {
if arg.starts_with("--mount=") {
assert!(
idx < container_idx,
"--mount at index {idx} should come before container ID at {container_idx}",
);
}
}
}
#[test]
fn test_from_instruction_run_with_mounts() {
use crate::dockerfile::{CacheSharing, RunMount};
let instruction = Instruction::Run(RunInstruction {
command: ShellOrExec::Shell("npm install".to_string()),
mounts: vec![RunMount::Cache {
target: "/root/.npm".to_string(),
id: Some("npm-cache".to_string()),
sharing: CacheSharing::Shared,
readonly: false,
}],
network: None,
security: None,
env: HashMap::new(),
});
let cmds = BuildahCommand::from_instruction("container-1", &instruction);
assert_eq!(cmds.len(), 1);
let cmd = &cmds[0];
assert!(
cmd.args.iter().any(|a| a.starts_with("--mount=")),
"should include --mount argument"
);
}
#[test]
fn test_run_with_mounts_exec_form() {
use crate::dockerfile::{CacheSharing, RunMount};
let run = RunInstruction {
command: ShellOrExec::Exec(vec![
"pip".to_string(),
"install".to_string(),
"-r".to_string(),
"requirements.txt".to_string(),
]),
mounts: vec![RunMount::Cache {
target: "/root/.cache/pip".to_string(),
id: Some("pip-cache".to_string()),
sharing: CacheSharing::Shared,
readonly: false,
}],
network: None,
security: None,
env: HashMap::new(),
};
let cmd = BuildahCommand::run_with_mounts("container-1", &run);
assert!(cmd.args.contains(&"--".to_string()));
assert!(cmd.args.contains(&"pip".to_string()));
assert!(cmd.args.contains(&"install".to_string()));
}
#[test]
fn test_run_with_mounts_emits_env_flags_sorted() {
let mut env = HashMap::new();
env.insert("B".to_string(), "2".to_string());
env.insert("A".to_string(), "1".to_string());
let run = RunInstruction {
command: ShellOrExec::Shell("env".to_string()),
mounts: vec![],
network: None,
security: None,
env,
};
let cmd = BuildahCommand::run_with_mounts("container-1", &run);
let env_positions: Vec<(usize, &String)> = cmd
.args
.iter()
.enumerate()
.filter(|(_, a)| a.starts_with("--env="))
.collect();
assert_eq!(
env_positions.len(),
2,
"expected 2 --env args, got {env_positions:?}"
);
assert_eq!(env_positions[0].1, "--env=A=1");
assert_eq!(env_positions[1].1, "--env=B=2");
let container_idx = cmd
.args
.iter()
.position(|a| a == "container-1")
.expect("container ID present");
for (idx, _) in &env_positions {
assert!(
*idx < container_idx,
"--env at {idx} must precede container ID at {container_idx}"
);
}
let sep_idx = cmd
.args
.iter()
.position(|a| a == "--")
.expect("-- separator present");
for (idx, _) in &env_positions {
assert!(
*idx < sep_idx,
"--env at {idx} must precede `--` at {sep_idx}"
);
}
}
#[test]
fn test_translator_routes_env_only_run_through_mounts_path() {
let mut env = HashMap::new();
env.insert("FOO".to_string(), "bar".to_string());
let run = RunInstruction {
command: ShellOrExec::Shell("echo $FOO".to_string()),
mounts: vec![],
network: None,
security: None,
env,
};
let cmds = DockerfileTranslator::new(ImageOs::Linux)
.translate("container-1", &Instruction::Run(run));
assert_eq!(cmds.len(), 1);
let cmd = &cmds[0];
assert!(
cmd.args.iter().any(|a| a == "--env=FOO=bar"),
"expected --env=FOO=bar in args: {:?}",
cmd.args
);
}
#[test]
fn test_run_with_mounts_emits_net_host_before_container() {
let run = RunInstruction {
command: ShellOrExec::Shell("apt-get update".to_string()),
mounts: vec![],
network: Some(crate::dockerfile::RunNetwork::Host),
security: None,
env: HashMap::new(),
};
let cmd = BuildahCommand::run_with_mounts("container-1", &run);
let net_idx = cmd
.args
.iter()
.position(|a| a == "--net=host")
.expect("expected --net=host arg");
let container_idx = cmd
.args
.iter()
.position(|a| a == "container-1")
.expect("container id present");
let sep_idx = cmd
.args
.iter()
.position(|a| a == "--")
.expect("-- separator present");
assert!(
net_idx < container_idx,
"--net=host (idx {net_idx}) must precede container ID (idx {container_idx})"
);
assert!(
net_idx < sep_idx,
"--net=host (idx {net_idx}) must precede `--` (idx {sep_idx})"
);
}
#[test]
fn test_run_with_mounts_emits_net_none() {
let run = RunInstruction {
command: ShellOrExec::Shell("hostname".to_string()),
mounts: vec![],
network: Some(crate::dockerfile::RunNetwork::None),
security: None,
env: HashMap::new(),
};
let cmd = BuildahCommand::run_with_mounts("container-1", &run);
assert!(
cmd.args.iter().any(|a| a == "--net=none"),
"expected --net=none in args, got: {:?}",
cmd.args
);
}
#[test]
fn test_run_with_mounts_default_network_omits_net_flag() {
let run = RunInstruction {
command: ShellOrExec::Shell("true".to_string()),
mounts: vec![],
network: Some(crate::dockerfile::RunNetwork::Default),
security: None,
env: HashMap::new(),
};
let cmd = BuildahCommand::run_with_mounts("container-1", &run);
assert!(
!cmd.args.iter().any(|a| a.starts_with("--net")),
"RunNetwork::Default must NOT emit any --net flag, got: {:?}",
cmd.args
);
}
#[test]
fn test_run_with_mounts_no_network_field_omits_net_flag() {
let run = RunInstruction {
command: ShellOrExec::Shell("true".to_string()),
mounts: vec![],
network: None,
security: None,
env: HashMap::new(),
};
let cmd = BuildahCommand::run_with_mounts("container-1", &run);
assert!(
!cmd.args.iter().any(|a| a.starts_with("--net")),
"network=None must NOT emit any --net flag, got: {:?}",
cmd.args
);
}
#[test]
fn test_translator_host_network_forces_net_host_on_run_with_none_network() {
let run = RunInstruction {
command: ShellOrExec::Shell("apt-get update".to_string()),
mounts: vec![],
network: None,
security: None,
env: HashMap::new(),
};
let cmds = DockerfileTranslator::new(ImageOs::Linux)
.with_host_network(true)
.translate("c1", &Instruction::Run(run));
assert_eq!(cmds.len(), 1, "expected exactly one buildah command");
let cmd = &cmds[0];
assert!(
cmd.args.iter().any(|a| a == "--net=host"),
"expected --net=host in args (host_network=true should force it even when run.network is None), got: {:?}",
cmd.args
);
}
#[test]
fn test_translator_host_network_overrides_per_instruction_network_none() {
let run = RunInstruction {
command: ShellOrExec::Shell("apt-get install -y curl".to_string()),
mounts: vec![],
network: Some(crate::dockerfile::RunNetwork::None),
security: None,
env: HashMap::new(),
};
let cmds = DockerfileTranslator::new(ImageOs::Linux)
.with_host_network(true)
.translate("c1", &Instruction::Run(run));
assert_eq!(cmds.len(), 1);
let cmd = &cmds[0];
assert!(
cmd.args.iter().any(|a| a == "--net=host"),
"host_network=true must override RunNetwork::None, got: {:?}",
cmd.args
);
assert!(
!cmd.args.iter().any(|a| a == "--net=none"),
"host_network=true must REPLACE (not append to) RunNetwork::None, got: {:?}",
cmd.args
);
}
#[test]
fn test_translator_host_network_routes_bare_run_through_mounts_path() {
let run = RunInstruction {
command: ShellOrExec::Shell("echo hi".to_string()),
mounts: vec![],
network: None,
security: None,
env: HashMap::new(),
};
let cmds = DockerfileTranslator::new(ImageOs::Linux)
.with_host_network(true)
.translate("c1", &Instruction::Run(run));
assert_eq!(cmds.len(), 1);
let cmd = &cmds[0];
assert!(
cmd.args.iter().any(|a| a == "--net=host"),
"bare RUN with host_network=true must emit --net=host, got: {:?}",
cmd.args
);
}
#[test]
fn test_translator_host_network_routes_env_only_run_with_net_host() {
let mut env = HashMap::new();
env.insert("FOO".to_string(), "bar".to_string());
let run = RunInstruction {
command: ShellOrExec::Shell("echo $FOO".to_string()),
mounts: vec![],
network: None,
security: None,
env,
};
let cmds = DockerfileTranslator::new(ImageOs::Linux)
.with_host_network(true)
.translate("c1", &Instruction::Run(run));
assert_eq!(cmds.len(), 1);
let cmd = &cmds[0];
let env_idx = cmd
.args
.iter()
.position(|a| a == "--env=FOO=bar")
.expect("--env=FOO=bar present");
let net_idx = cmd
.args
.iter()
.position(|a| a == "--net=host")
.expect("--net=host present");
let container_idx = cmd
.args
.iter()
.position(|a| a == "c1")
.expect("container id present");
assert!(env_idx < container_idx);
assert!(net_idx < container_idx);
}
#[test]
fn test_translator_host_network_routes_mount_only_run_with_net_host() {
use crate::dockerfile::{CacheSharing, RunMount};
let run = RunInstruction {
command: ShellOrExec::Shell("npm install".to_string()),
mounts: vec![RunMount::Cache {
target: "/root/.npm".to_string(),
id: Some("npm-cache".to_string()),
sharing: CacheSharing::Shared,
readonly: false,
}],
network: None,
security: None,
env: HashMap::new(),
};
let cmds = DockerfileTranslator::new(ImageOs::Linux)
.with_host_network(true)
.translate("c1", &Instruction::Run(run));
assert_eq!(cmds.len(), 1);
let cmd = &cmds[0];
assert!(
cmd.args.iter().any(|a| a.starts_with("--mount=")),
"--mount must be present"
);
assert!(
cmd.args.iter().any(|a| a == "--net=host"),
"--net=host must be present alongside --mount when host_network=true"
);
}
#[test]
fn test_translator_host_network_default_off_does_not_emit_net_flag() {
let run = RunInstruction {
command: ShellOrExec::Shell("true".to_string()),
mounts: vec![],
network: None,
security: None,
env: HashMap::new(),
};
let cmds =
DockerfileTranslator::new(ImageOs::Linux).translate("c1", &Instruction::Run(run));
assert_eq!(cmds.len(), 1);
let cmd = &cmds[0];
assert!(
!cmd.args.iter().any(|a| a.starts_with("--net")),
"default translator (host_network=false) must NOT emit --net flag, got: {:?}",
cmd.args
);
}
#[test]
fn test_manifest_create() {
let cmd = BuildahCommand::manifest_create("myapp:latest");
assert_eq!(cmd.program, "buildah");
assert_eq!(cmd.args, vec!["manifest", "create", "myapp:latest"]);
}
#[test]
fn test_manifest_add() {
let cmd = BuildahCommand::manifest_add("myapp:latest", "myapp-amd64:latest");
assert_eq!(
cmd.args,
vec!["manifest", "add", "myapp:latest", "myapp-amd64:latest"]
);
}
#[test]
fn test_manifest_push() {
let cmd =
BuildahCommand::manifest_push("myapp:latest", "docker://registry.example.com/myapp");
assert_eq!(
cmd.args,
vec![
"manifest",
"push",
"--all",
"myapp:latest",
"docker://registry.example.com/myapp"
]
);
}
#[test]
fn test_manifest_rm() {
let cmd = BuildahCommand::manifest_rm("myapp:latest");
assert_eq!(cmd.args, vec!["manifest", "rm", "myapp:latest"]);
}
#[test]
fn test_run_shell_for_os_linux() {
let cmd = BuildahCommand::run_shell_for_os("c1", "echo hello", ImageOs::Linux);
assert_eq!(
cmd.args,
vec!["run", "c1", "--", "/bin/sh", "-c", "echo hello"]
);
}
#[test]
fn test_run_shell_for_os_windows() {
let cmd = BuildahCommand::run_shell_for_os("c1", "echo hello", ImageOs::Windows);
assert_eq!(
cmd.args,
vec!["run", "c1", "--", "cmd.exe", "/S", "/C", "echo hello"]
);
}
#[test]
fn test_run_shell_custom_powershell() {
let shell = ["powershell", "-Command"];
let cmd = BuildahCommand::run_shell_custom("c1", shell, "Get-Process");
assert_eq!(
cmd.args,
vec!["run", "c1", "--", "powershell", "-Command", "Get-Process"]
);
}
#[test]
fn test_translator_linux_run_shell_default() {
let mut t = DockerfileTranslator::new(ImageOs::Linux);
let instr = Instruction::Run(RunInstruction::shell("apt-get update"));
let cmds = t.translate("c1", &instr);
assert_eq!(cmds.len(), 1);
assert_eq!(
cmds[0].args,
vec!["run", "c1", "--", "/bin/sh", "-c", "apt-get update"]
);
}
#[test]
fn test_translator_windows_run_shell_default() {
let mut t = DockerfileTranslator::new(ImageOs::Windows);
let instr = Instruction::Run(RunInstruction::shell("dir C:\\"));
let cmds = t.translate("c1", &instr);
assert_eq!(cmds.len(), 1);
assert_eq!(
cmds[0].args,
vec!["run", "c1", "--", "cmd.exe", "/S", "/C", "dir C:\\"]
);
}
#[test]
fn test_translator_shell_override_linux_bash() {
let mut t = DockerfileTranslator::new(ImageOs::Linux);
let shell_instr = Instruction::Shell(vec!["/bin/bash".to_string(), "-lc".to_string()]);
let shell_cmds = t.translate("c1", &shell_instr);
assert_eq!(shell_cmds.len(), 1);
assert!(shell_cmds[0].args.contains(&"--shell".to_string()));
let run_instr = Instruction::Run(RunInstruction::shell("set -e; echo $SHELL"));
let run_cmds = t.translate("c1", &run_instr);
assert_eq!(run_cmds.len(), 1);
assert_eq!(
run_cmds[0].args,
vec!["run", "c1", "--", "/bin/bash", "-lc", "set -e; echo $SHELL"]
);
}
#[test]
fn test_translator_shell_override_windows_powershell() {
let mut t = DockerfileTranslator::new(ImageOs::Windows);
let shell_instr =
Instruction::Shell(vec!["powershell".to_string(), "-Command".to_string()]);
t.translate("c1", &shell_instr);
let run_instr = Instruction::Run(RunInstruction::shell("Get-Process"));
let run_cmds = t.translate("c1", &run_instr);
assert_eq!(run_cmds.len(), 1);
assert_eq!(
run_cmds[0].args,
vec!["run", "c1", "--", "powershell", "-Command", "Get-Process"]
);
}
#[test]
fn test_translator_shell_override_persists_across_runs() {
let mut t = DockerfileTranslator::new(ImageOs::Linux);
t.translate(
"c1",
&Instruction::Shell(vec!["/bin/bash".to_string(), "-c".to_string()]),
);
for _ in 0..2 {
let cmds = t.translate("c1", &Instruction::Run(RunInstruction::shell("echo hi")));
assert_eq!(
cmds[0].args,
vec!["run", "c1", "--", "/bin/bash", "-c", "echo hi"]
);
}
}
#[test]
fn test_translator_exec_form_ignores_shell_override() {
let mut t = DockerfileTranslator::new(ImageOs::Windows);
t.translate(
"c1",
&Instruction::Shell(vec!["powershell".to_string(), "-Command".to_string()]),
);
let run = Instruction::Run(RunInstruction::exec(vec![
"myapp.exe".to_string(),
"--flag".to_string(),
]));
let cmds = t.translate("c1", &run);
assert_eq!(cmds[0].args, vec!["run", "c1", "--", "myapp.exe", "--flag"]);
}
#[test]
fn test_translator_workdir_linux() {
let mut t = DockerfileTranslator::new(ImageOs::Linux);
let cmds = t.translate("c1", &Instruction::Workdir("/app".to_string()));
assert_eq!(cmds.len(), 2);
assert_eq!(cmds[0].args, vec!["run", "c1", "--", "mkdir", "-p", "/app"]);
assert_eq!(cmds[1].args, vec!["config", "--workingdir", "/app", "c1"]);
}
#[test]
fn test_translator_workdir_windows() {
let mut t = DockerfileTranslator::new(ImageOs::Windows);
let cmds = t.translate("c1", &Instruction::Workdir("C:\\app".to_string()));
assert_eq!(cmds.len(), 2);
assert_eq!(
cmds[0].args,
vec![
"run",
"c1",
"--",
"cmd.exe",
"/S",
"/C",
r#"if not exist "C:\app" mkdir "C:\app""#
]
);
assert_eq!(
cmds[1].args,
vec!["config", "--workingdir", "C:\\app", "c1"]
);
}
#[test]
fn test_translator_workdir_host_network_linux_emits_net_host() {
let mut t = DockerfileTranslator::new(ImageOs::Linux).with_host_network(true);
let cmds = t.translate("c1", &Instruction::Workdir("/app".to_string()));
assert_eq!(cmds.len(), 2);
assert_eq!(
cmds[0].args,
vec!["run", "--net=host", "c1", "--", "mkdir", "-p", "/app"],
"WORKDIR mkdir with host_network=true must emit --net=host BEFORE the container ID",
);
assert_eq!(cmds[1].args, vec!["config", "--workingdir", "/app", "c1"]);
}
#[test]
fn test_translator_workdir_no_host_network_omits_net_flag() {
let mut t = DockerfileTranslator::new(ImageOs::Linux).with_host_network(false);
let cmds = t.translate("c1", &Instruction::Workdir("/app".to_string()));
assert_eq!(cmds.len(), 2);
assert!(
!cmds[0].args.iter().any(|a| a.starts_with("--net")),
"WORKDIR with host_network=false must NOT emit any --net flag, got: {:?}",
cmds[0].args
);
assert_eq!(cmds[0].args, vec!["run", "c1", "--", "mkdir", "-p", "/app"]);
}
#[test]
fn test_translator_workdir_host_network_windows_emits_net_host() {
let mut t = DockerfileTranslator::new(ImageOs::Windows).with_host_network(true);
let cmds = t.translate("c1", &Instruction::Workdir("C:\\app".to_string()));
assert_eq!(cmds.len(), 2);
let net_idx = cmds[0]
.args
.iter()
.position(|a| a == "--net=host")
.expect("expected --net=host on Windows WORKDIR with host_network=true");
let container_idx = cmds[0]
.args
.iter()
.position(|a| a == "c1")
.expect("container ID present");
let sep_idx = cmds[0]
.args
.iter()
.position(|a| a == "--")
.expect("`--` separator present");
assert!(
net_idx < container_idx && container_idx < sep_idx,
"argument order must be: run --net=host <container> -- ... (got {:?})",
cmds[0].args
);
}
#[test]
fn test_translator_workdir_windows_path_with_spaces() {
let mut t = DockerfileTranslator::new(ImageOs::Windows);
let cmds = t.translate(
"c1",
&Instruction::Workdir("C:\\Program Files\\app".to_string()),
);
assert_eq!(cmds.len(), 2);
let mkdir_cmd = &cmds[0].args[6];
assert_eq!(
mkdir_cmd,
r#"if not exist "C:\Program Files\app" mkdir "C:\Program Files\app""#
);
}
#[test]
fn test_from_instruction_preserves_linux_byte_identical_output() {
let run = Instruction::Run(RunInstruction::shell("echo hello"));
let legacy = BuildahCommand::from_instruction("c1", &run);
let via_translator = DockerfileTranslator::new(ImageOs::Linux).translate("c1", &run);
assert_eq!(legacy.len(), via_translator.len());
for (a, b) in legacy.iter().zip(via_translator.iter()) {
assert_eq!(a.args, b.args);
assert_eq!(a.program, b.program);
}
let workdir = Instruction::Workdir("/workspace".to_string());
let legacy = BuildahCommand::from_instruction("c1", &workdir);
assert_eq!(legacy.len(), 2);
assert_eq!(
legacy[0].args,
vec!["run", "c1", "--", "mkdir", "-p", "/workspace"]
);
assert_eq!(
legacy[1].args,
vec!["config", "--workingdir", "/workspace", "c1"]
);
}
#[test]
fn test_translator_active_shell_reflects_override() {
let mut t = DockerfileTranslator::new(ImageOs::Linux);
assert_eq!(t.active_shell(), vec!["/bin/sh", "-c"]);
t.set_shell_override(vec!["/bin/bash".to_string(), "-lc".to_string()]);
assert_eq!(t.active_shell(), vec!["/bin/bash", "-lc"]);
}
#[test]
fn test_translator_target_os_accessor() {
assert_eq!(
DockerfileTranslator::new(ImageOs::Linux).target_os(),
ImageOs::Linux
);
assert_eq!(
DockerfileTranslator::new(ImageOs::Windows).target_os(),
ImageOs::Windows
);
}
#[test]
fn test_translator_windows_run_with_mounts_uses_cmd_exe() {
use crate::dockerfile::{CacheSharing, RunMount};
let mut t = DockerfileTranslator::new(ImageOs::Windows);
let run = RunInstruction {
command: ShellOrExec::Shell("echo cached".to_string()),
mounts: vec![RunMount::Cache {
target: "C:\\cache".to_string(),
id: Some("win-cache".to_string()),
sharing: CacheSharing::Shared,
readonly: false,
}],
network: None,
security: None,
env: HashMap::new(),
};
let cmds = t.translate("c1", &Instruction::Run(run));
assert_eq!(cmds.len(), 1);
let mount_idx = cmds[0]
.args
.iter()
.position(|a| a.starts_with("--mount="))
.expect("mount arg present");
let container_idx = cmds[0]
.args
.iter()
.position(|a| a == "c1")
.expect("container ID present");
assert!(mount_idx < container_idx);
assert!(cmds[0].args.iter().any(|a| a == "cmd.exe"));
assert!(cmds[0].args.iter().any(|a| a == "/S"));
assert!(cmds[0].args.iter().any(|a| a == "/C"));
assert!(!cmds[0].args.iter().any(|a| a == "/bin/sh"));
}
use crate::windows_image_resolver::{ChocoMapMetadata, ChocoMapShard};
static CACHE_ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn write_shard_fixture(
cache_root: &std::path::Path,
distro: &str,
shard: &str,
mappings: &[(&str, &str)],
) {
let fixture = ChocoMapShard {
metadata: ChocoMapMetadata {
generated_at: "2026-05-21T00:00:00Z".to_string(),
source: "chocolatey.org".to_string(),
distro: distro.to_string(),
shard: shard.to_string(),
total_mappings: mappings.len() as u64,
},
mappings: mappings
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
};
let shard_dir = cache_root.join("package-maps-choco-v1").join(distro);
std::fs::create_dir_all(&shard_dir).unwrap();
std::fs::write(
shard_dir.join(format!("{shard}.json")),
serde_json::to_string(&fixture).unwrap(),
)
.unwrap();
}
fn redirect_cache_dir() -> (
std::sync::MutexGuard<'static, ()>,
tempfile::TempDir,
std::path::PathBuf,
) {
let guard = CACHE_ENV_GUARD
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = tempfile::tempdir().unwrap();
let cache_root = tmp.path().to_path_buf();
std::env::set_var("ZLAYER_PACKAGE_MAP_CACHE_DIR", &cache_root);
std::env::set_var("XDG_CACHE_HOME", &cache_root);
std::env::set_var("LOCALAPPDATA", &cache_root);
(guard, tmp, cache_root)
}
fn block_on<F: std::future::Future>(fut: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime")
.block_on(fut)
}
#[test]
fn detect_apt_install_in_run() {
let parts = split_shell_subcommands("apt-get update && apt-get install -y curl git");
assert_eq!(parts.len(), 2);
assert!(is_package_manager_sync(&parts[0]));
let detected = detect_install_in_subcommand(&parts[1])
.expect("install sub-command must be recognised");
assert_eq!(detected.0, DetectedPmKind::Apt);
assert_eq!(detected.1, vec!["curl".to_string(), "git".to_string()]);
}
#[test]
fn detect_yum_install_in_run() {
let detected = detect_install_in_subcommand("yum install -y httpd")
.expect("yum install -y httpd must be recognised");
assert_eq!(detected.0, DetectedPmKind::YumOrDnf);
assert_eq!(detected.1, vec!["httpd".to_string()]);
let detected = detect_install_in_subcommand("dnf install -y nginx php-fpm")
.expect("dnf install -y must be recognised");
assert_eq!(detected.0, DetectedPmKind::YumOrDnf);
assert_eq!(detected.1, vec!["nginx".to_string(), "php-fpm".to_string()]);
}
#[test]
fn detect_apk_install_in_run() {
let detected = detect_install_in_subcommand("apk add --no-cache nodejs npm")
.expect("apk add must be recognised");
assert_eq!(detected.0, DetectedPmKind::Apk);
assert_eq!(detected.1, vec!["nodejs".to_string(), "npm".to_string()]);
}
#[test]
fn detect_no_install_returns_none() {
assert!(detect_install_in_subcommand("echo hello").is_none());
assert!(detect_install_in_subcommand("ls /tmp").is_none());
assert!(detect_install_in_subcommand("apt-getinstall -y curl").is_none());
assert!(detect_install_in_subcommand("apt-get install -y").is_none());
let parts = split_shell_subcommands("echo hello && ls /tmp");
assert_eq!(parts.len(), 2);
for p in &parts {
assert!(detect_install_in_subcommand(p).is_none());
assert!(!is_package_manager_sync(p));
}
}
#[test]
fn split_shell_subcommands_honours_and_and_semicolon() {
let parts = split_shell_subcommands("a && b ; c");
assert_eq!(
parts,
vec!["a".to_string(), "b".to_string(), "c".to_string()]
);
}
#[test]
fn split_shell_subcommands_drops_empty_segments() {
let parts = split_shell_subcommands(" && a && ; b ;");
assert_eq!(parts, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn is_package_manager_sync_matches_common_variants() {
assert!(is_package_manager_sync("apt-get update"));
assert!(is_package_manager_sync("apt update"));
assert!(is_package_manager_sync("apk update"));
assert!(is_package_manager_sync("yum check-update"));
assert!(is_package_manager_sync("dnf makecache"));
assert!(is_package_manager_sync("sudo apt-get update"));
assert!(!is_package_manager_sync("apt-get install -y curl"));
assert!(!is_package_manager_sync("echo hello"));
}
#[test]
fn rejoin_emits_choco_install_for_install_subcommand() {
let parts = vec![
ShellSubcommand::Verbatim("echo before".to_string()),
ShellSubcommand::PackageManagerSync,
ShellSubcommand::Install {
kind: DetectedPmKind::Apt,
packages: vec!["curl".to_string(), "git".to_string()],
},
ShellSubcommand::Verbatim("echo after".to_string()),
];
let out = rejoin_subcommands(&parts);
assert_eq!(
out,
"echo before && choco install -y curl git && echo after"
);
}
#[test]
fn wrap_in_cmd_escapes_embedded_quotes() {
let wrapped = wrap_in_cmd(r#"echo "hello""#);
assert!(wrapped.starts_with("cmd /c \""));
assert!(wrapped.contains(r#"\"hello\""#));
assert!(wrapped.ends_with('"'));
}
#[test]
fn translate_run_apt_to_choco_with_in_memory_shard() {
let (_guard, _tmp, cache_root) = redirect_cache_dir();
write_shard_fixture(
&cache_root,
"debian-12",
"c",
&[("curl", "curl"), ("linux-headers-generic", "__skip__")],
);
write_shard_fixture(
&cache_root,
"debian-12",
"l",
&[("linux-headers-generic", "__skip__")],
);
let translator = DockerfileTranslator::new(ImageOs::Windows);
let (rewritten, skipped) = block_on(translator.translate_shell_command(
"apt-get install -y curl linux-headers-generic",
"debian-12",
None,
))
.expect("translate succeeds when every package resolves");
assert!(
rewritten.contains("choco install -y curl"),
"rewritten command must include curl: {rewritten}"
);
assert!(
!rewritten.contains("linux-headers-generic"),
"skipped package must NOT appear in rewritten command: {rewritten}"
);
assert_eq!(skipped, vec!["linux-headers-generic".to_string()]);
}
#[test]
fn translate_shell_command_skips_provisioned_toolchain() {
let (_guard, _tmp, cache_root) = redirect_cache_dir();
write_shard_fixture(&cache_root, "debian-12", "g", &[("git", "git")]);
let translator = DockerfileTranslator::new(ImageOs::Windows);
let (rewritten, skipped) = block_on(translator.translate_shell_command(
"apt-get install -y golang git",
"debian-12",
Some("go"),
))
.expect("translate succeeds when remaining package resolves");
assert!(
!rewritten.contains("golang"),
"provisioned-toolchain package must NOT appear in choco install: {rewritten}"
);
assert!(
rewritten.contains("choco install -y git"),
"non-toolchain package must still be installed: {rewritten}"
);
assert!(
skipped.is_empty(),
"toolchain drops are not reported as resolver-skipped: {skipped:?}"
);
}
#[test]
fn translate_shell_command_keeps_unrelated_pkg_with_toolchain() {
let (_guard, _tmp, cache_root) = redirect_cache_dir();
write_shard_fixture(&cache_root, "debian-12", "c", &[("curl", "curl")]);
let translator = DockerfileTranslator::new(ImageOs::Windows);
let (rewritten, skipped) = block_on(translator.translate_shell_command(
"apt-get install -y curl",
"debian-12",
Some("go"),
))
.expect("translate succeeds");
assert!(
rewritten.contains("choco install -y curl"),
"curl must still be installed: {rewritten}"
);
assert!(skipped.is_empty(), "no resolver-skipped: {skipped:?}");
}
#[test]
fn translate_run_command_linux_is_passthrough() {
let translator = DockerfileTranslator::new(ImageOs::Linux);
let (shell_out, skipped) = block_on(translator.translate_run_command(
&ShellOrExec::Shell("apt-get install -y curl".to_string()),
"debian-12",
None,
))
.expect("Linux passthrough never fails");
assert_eq!(shell_out, "apt-get install -y curl");
assert!(skipped.is_empty());
let (exec_out, skipped) = block_on(translator.translate_run_command(
&ShellOrExec::Exec(vec!["echo".to_string(), "hi".to_string()]),
"debian-12",
None,
))
.expect("Linux passthrough never fails");
assert_eq!(exec_out, "echo hi");
assert!(skipped.is_empty());
}
#[test]
fn translate_run_command_windows_exec_is_passthrough() {
let translator = DockerfileTranslator::new(ImageOs::Windows);
let (out, skipped) = block_on(translator.translate_run_command(
&ShellOrExec::Exec(vec![
"C:\\app\\bin\\srv.exe".to_string(),
"--port".to_string(),
"80".to_string(),
]),
"debian-12",
None,
))
.expect("exec-form passthrough never fails");
assert_eq!(out, "C:\\app\\bin\\srv.exe --port 80");
assert!(skipped.is_empty());
}
#[test]
fn translate_shell_command_no_toolchain_installs_all() {
let (_guard, _tmp, cache_root) = redirect_cache_dir();
write_shard_fixture(
&cache_root,
"debian-12",
"g",
&[("golang", "golang"), ("git", "git")],
);
let translator = DockerfileTranslator::new(ImageOs::Windows);
let (rewritten, skipped) = block_on(translator.translate_shell_command(
"apt-get install -y golang git",
"debian-12",
None,
))
.expect("translate succeeds");
assert!(
rewritten.contains("choco install -y golang git"),
"both packages must be installed: {rewritten}"
);
assert!(skipped.is_empty(), "no resolver-skipped: {skipped:?}");
}
}