use std::collections::{BTreeMap, HashMap};
use std::path::{Component, Path, PathBuf};
use chrono::{DateTime, Utc};
use sha2::{Digest, Sha256};
use crate::dockerfile::{
AddInstruction, CopyInstruction, Dockerfile, DockerfileFromTarget, EnvInstruction,
ExposeInstruction, ExposeProtocol, HealthcheckInstruction, Instruction, RunInstruction,
ShellOrExec,
};
use crate::error::{BuildError, Result};
use zlayer_registry::RegistryAuth;
#[derive(Debug, Clone)]
pub struct WindowsBuildConfig {
pub cache_dir: PathBuf,
pub registry_auth: RegistryAuth,
pub platform: String,
pub os_version_override: Option<String>,
pub scratch_size_gb: u64,
}
impl WindowsBuildConfig {
#[must_use]
pub const fn default_platform() -> &'static str {
"windows/amd64"
}
#[must_use]
pub const fn default_scratch_size_gb() -> u64 {
20
}
}
#[derive(Debug, Clone)]
pub struct BuildContext {
pub context_dir: PathBuf,
pub dockerfile_path: PathBuf,
pub build_args: HashMap<String, String>,
pub tag: String,
pub ltsc: Option<String>,
}
#[derive(Debug, Clone)]
pub struct LayerRef {
pub digest: String,
pub media_type: String,
pub size: i64,
pub urls: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct WindowsLayerEntry {
pub layer_id: String,
pub layer_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct BaseImageManifest {
pub image_ref: String,
pub os: String,
pub os_version: Option<String>,
pub arch: String,
pub config_blob: Vec<u8>,
}
#[derive(Debug, Clone, Default)]
pub struct OciImageConfig {
pub working_dir: Option<String>,
pub env: Vec<String>,
pub entrypoint: Option<Vec<String>>,
pub cmd: Option<Vec<String>>,
pub user: Option<String>,
pub exposed_ports: BTreeMap<String, serde_json::Value>,
pub volumes: BTreeMap<String, serde_json::Value>,
pub labels: BTreeMap<String, String>,
pub stop_signal: Option<String>,
pub healthcheck: Option<OciHealthcheck>,
pub shell: Option<Vec<String>>,
pub on_build: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct OciHealthcheck {
pub test: Vec<String>,
pub interval: Option<String>,
pub timeout: Option<String>,
pub retries: Option<u32>,
pub start_period: Option<String>,
}
impl OciHealthcheck {
#[must_use]
pub fn disabled() -> Self {
Self {
test: vec!["NONE".to_string()],
interval: None,
timeout: None,
retries: None,
start_period: None,
}
}
#[must_use]
pub fn is_disabled(&self) -> bool {
self.test == ["NONE"]
}
}
#[derive(Debug, Clone)]
pub struct ExecutedInstruction {
pub source_line: String,
pub produced_layer: bool,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug)]
pub struct BuildSkeleton {
pub parsed_dockerfile: Dockerfile,
pub base_layers: Vec<LayerRef>,
pub base_manifest: BaseImageManifest,
pub working_layer_chain_dir: PathBuf,
pub working_chain: Vec<WindowsLayerEntry>,
pub image_config: OciImageConfig,
pub instruction_log: Vec<ExecutedInstruction>,
pub provisioned_toolchain_language: Option<String>,
}
#[derive(Debug, Clone)]
pub struct EmittedLayer {
pub media_type: String,
pub digest: String,
pub size: u64,
pub diff_id: String,
pub local_path: PathBuf,
pub urls: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct BuiltImage {
pub tag: String,
pub image_config_blob: Vec<u8>,
pub image_config_digest: String,
pub manifest_blob: Vec<u8>,
pub manifest_digest: String,
pub layers: Vec<EmittedLayer>,
}
pub struct WindowsBuilder {
config: WindowsBuildConfig,
}
impl WindowsBuilder {
#[must_use]
pub fn new(config: WindowsBuildConfig) -> Self {
Self { config }
}
#[must_use]
pub fn config(&self) -> &WindowsBuildConfig {
&self.config
}
pub async fn build_skeleton(&self, ctx: &BuildContext) -> Result<BuildSkeleton> {
let dockerfile_path = if ctx.dockerfile_path.is_absolute() {
ctx.dockerfile_path.clone()
} else {
ctx.context_dir.join(&ctx.dockerfile_path)
};
let dockerfile_text =
std::fs::read_to_string(&dockerfile_path).map_err(|e| BuildError::ContextRead {
path: dockerfile_path.clone(),
source: e,
})?;
let parsed = Dockerfile::parse(&dockerfile_text)?;
self.build_skeleton_with_parsed(parsed, ctx).await
}
pub async fn build_skeleton_with_parsed(
&self,
parsed: Dockerfile,
ctx: &BuildContext,
) -> Result<BuildSkeleton> {
if parsed.stages.is_empty() {
return Err(BuildError::InvalidInstruction {
instruction: "FROM".to_string(),
reason: "Dockerfile has no FROM instruction".to_string(),
});
}
if parsed.stages.len() > 1 {
return Err(BuildError::NotSupported {
operation: format!(
"multi-stage WCOW builds ({} stages) — Phase 4 task 4.A skeleton supports \
a single stage; cross-stage COPY arrives in Phase 4 task 4.C",
parsed.stages.len()
),
});
}
let stage = &parsed.stages[0];
let mut base_image_ref = match &stage.base_image {
DockerfileFromTarget::Image(r) => r.to_string(),
DockerfileFromTarget::Stage(name) => {
return Err(BuildError::stage_not_found(name));
}
DockerfileFromTarget::Scratch => {
return Err(BuildError::InvalidInstruction {
instruction: "FROM scratch".to_string(),
reason: "WCOW builds require a Windows base image (HCS cannot run a process \
without a kernel + cmd.exe). Use \
mcr.microsoft.com/windows/nanoserver:... or .../servercore:..."
.to_string(),
});
}
};
let ltsc = ctx.ltsc.as_deref().unwrap_or("ltsc2022");
if let Some(rewritten) =
crate::windows_image_resolver::rewrite_image_for_windows(&base_image_ref, ltsc)
{
tracing::debug!(
"rewrote FROM {} -> {} (ltsc={})",
base_image_ref,
rewritten,
ltsc
);
base_image_ref = rewritten;
}
let build_id = new_build_id();
let build_dir = self.config.cache_dir.join(&build_id);
std::fs::create_dir_all(&build_dir).map_err(|e| BuildError::ContextRead {
path: build_dir.clone(),
source: e,
})?;
let working_layer_chain_dir = build_dir.join("unpacked");
let (base_layers, base_manifest, working_chain) =
pull_and_materialise_base(&base_image_ref, &self.config, &working_layer_chain_dir)
.await?;
let instruction_log = vec![ExecutedInstruction {
source_line: format!("FROM {base_image_ref}"),
produced_layer: true,
timestamp: Utc::now(),
}];
Ok(BuildSkeleton {
parsed_dockerfile: parsed,
base_layers,
base_manifest,
working_layer_chain_dir,
working_chain,
image_config: OciImageConfig::default(),
instruction_log,
provisioned_toolchain_language: None,
})
}
pub async fn execute_instruction(
&self,
skeleton: &mut BuildSkeleton,
ctx: &BuildContext,
instruction: &Instruction,
) -> Result<()> {
let step_index = skeleton
.parsed_dockerfile
.stages
.first()
.and_then(|s| s.instructions.iter().position(|i| i == instruction))
.unwrap_or(0);
let result = match instruction {
Instruction::Run(run) => self.execute_run_step(skeleton, run, step_index).await,
Instruction::Copy(copy) => self.apply_copy(skeleton, ctx, copy, step_index).await,
Instruction::Add(add) => self.apply_add(skeleton, ctx, add, step_index).await,
Instruction::Env(env) => {
apply_env(&mut skeleton.image_config, env);
Ok(())
}
Instruction::Workdir(path) => {
apply_workdir(&mut skeleton.image_config, path);
Ok(())
}
Instruction::Entrypoint(cmd) => {
apply_entrypoint(&mut skeleton.image_config, cmd);
Ok(())
}
Instruction::Cmd(cmd) => {
apply_cmd(&mut skeleton.image_config, cmd);
Ok(())
}
Instruction::User(user) => {
skeleton.image_config.user = Some(user.clone());
Ok(())
}
Instruction::Expose(expose) => {
apply_expose(&mut skeleton.image_config, expose);
Ok(())
}
Instruction::Volume(paths) => {
for p in paths {
skeleton
.image_config
.volumes
.insert(p.clone(), serde_json::json!({}));
}
Ok(())
}
Instruction::Label(labels) => {
for (k, v) in labels {
skeleton.image_config.labels.insert(k.clone(), v.clone());
}
Ok(())
}
Instruction::Arg(_) => Ok(()),
Instruction::Shell(tokens) => {
skeleton.image_config.shell = Some(tokens.clone());
Ok(())
}
Instruction::Stopsignal(sig) => {
skeleton.image_config.stop_signal = Some(sig.clone());
Ok(())
}
Instruction::Healthcheck(hc) => {
apply_healthcheck(&mut skeleton.image_config, hc);
Ok(())
}
Instruction::Onbuild(boxed) => {
skeleton
.image_config
.on_build
.push(format_onbuild_trigger(boxed));
Ok(())
}
};
if result.is_ok() {
if !matches!(instruction, Instruction::Arg(_)) {
skeleton.instruction_log.push(ExecutedInstruction {
source_line: format_instruction_source_line(instruction),
produced_layer: instruction.creates_layer(),
timestamp: Utc::now(),
});
}
}
result
}
async fn apply_copy(
&self,
skeleton: &mut BuildSkeleton,
ctx: &BuildContext,
copy: &CopyInstruction,
step_index: usize,
) -> Result<()> {
if let Some(stage) = ©.from {
return Err(BuildError::NotSupported {
operation: format!(
"multi-stage COPY --from='{stage}' lands in a later task — the WCOW skeleton \
supports single-stage builds only"
),
});
}
if let Some(owner) = ©.chown {
tracing::info!(
step_index = step_index,
chown = %owner,
"COPY --chown is a no-op on WCOW (Windows containers do not honour Unix-style \
uid:gid ownership the same way)"
);
}
let resolved_sources = resolve_copy_sources(ctx, ©.sources)?;
apply_filesystem_writes(
self.config(),
skeleton,
step_index,
&resolved_sources,
©.destination,
false,
&[],
)
.await
}
async fn apply_add(
&self,
skeleton: &mut BuildSkeleton,
ctx: &BuildContext,
add: &AddInstruction,
step_index: usize,
) -> Result<()> {
if let Some(owner) = &add.chown {
tracing::info!(
step_index = step_index,
chown = %owner,
"ADD --chown is a no-op on WCOW"
);
}
let mut local_sources: Vec<String> = Vec::new();
let mut url_sources: Vec<String> = Vec::new();
for src in &add.sources {
if is_http_url(src) {
url_sources.push(src.clone());
} else {
local_sources.push(src.clone());
}
}
let resolved_locals = resolve_copy_sources(ctx, &local_sources)?;
let mut downloads: Vec<DownloadedFile> = Vec::with_capacity(url_sources.len());
for url in &url_sources {
let download = download_url(url).await?;
downloads.push(download);
}
apply_filesystem_writes(
self.config(),
skeleton,
step_index,
&resolved_locals,
&add.destination,
true,
&downloads,
)
.await
}
async fn execute_run_step(
&self,
skeleton: &mut BuildSkeleton,
run: &RunInstruction,
step_index: usize,
) -> Result<()> {
execute_run_step_impl(&self.config, skeleton, run, step_index).await
}
pub async fn emit_image(&self, skeleton: &BuildSkeleton, tag: &str) -> Result<BuiltImage> {
emit_image_impl(self.config(), skeleton, tag).await
}
pub async fn push(&self, image: &BuiltImage, tag: &str) -> Result<()> {
let target = RegistryPushTarget::new();
self.push_with(image, tag, &target).await
}
pub async fn push_with(
&self,
image: &BuiltImage,
tag: &str,
target: &dyn PushTarget,
) -> Result<()> {
push_impl(image, tag, &self.config.registry_auth, target).await
}
pub async fn build_and_push(&self, ctx: &BuildContext) -> Result<()> {
let mut skeleton = self.build_skeleton(ctx).await?;
let instructions: Vec<Instruction> = skeleton
.parsed_dockerfile
.stages
.first()
.map(|s| s.instructions.clone())
.unwrap_or_default();
for instr in &instructions {
self.execute_instruction(&mut skeleton, ctx, instr).await?;
}
let built = self.emit_image(&skeleton, &ctx.tag).await?;
self.push(&built, &ctx.tag).await
}
#[allow(clippy::too_many_lines)]
pub async fn build_image_for_backend(
&self,
context: &Path,
dockerfile: &Dockerfile,
options: &crate::builder::BuildOptions,
event_tx: Option<&std::sync::mpsc::Sender<crate::tui::BuildEvent>>,
) -> std::result::Result<crate::builder::BuiltImage, BuildError> {
use crate::tui::BuildEvent;
fn send_event(tx: Option<&std::sync::mpsc::Sender<BuildEvent>>, ev: BuildEvent) {
if let Some(tx) = tx {
let _ = tx.send(ev);
}
}
let started = std::time::Instant::now();
if dockerfile.stages.len() != 1 {
return Err(BuildError::NotSupported {
operation: format!(
"multi-stage Windows builds ({} stages) — HCS backend supports a single \
stage in the first iteration; track the follow-up at TODO(L-4-followup)",
dockerfile.stages.len()
),
});
}
let stage = &dockerfile.stages[0];
let base_ref = match &stage.base_image {
DockerfileFromTarget::Image(r) => r.to_string(),
DockerfileFromTarget::Stage(name) => {
return Err(BuildError::stage_not_found(name));
}
DockerfileFromTarget::Scratch => {
return Err(BuildError::InvalidInstruction {
instruction: "FROM scratch".to_string(),
reason: "HCS builder requires a Windows base image — `scratch` cannot run \
HCS processes (no OS kernel, no cmd.exe). Use \
`mcr.microsoft.com/windows/nanoserver:...` or `.../servercore:...`."
.to_string(),
});
}
};
let primary_tag = options.tags.first().cloned().unwrap_or_default();
let ctx = BuildContext {
context_dir: context.to_path_buf(),
dockerfile_path: PathBuf::new(),
build_args: options.build_args.clone(),
tag: primary_tag,
ltsc: options.windows_ltsc.clone(),
};
send_event(
event_tx,
BuildEvent::BuildStarted {
total_stages: 1,
total_instructions: stage.instructions.len(),
},
);
send_event(
event_tx,
BuildEvent::StageStarted {
index: 0,
name: stage.name.clone(),
base_image: base_ref.clone(),
},
);
let mut skeleton = self
.build_skeleton_with_parsed(dockerfile.clone(), &ctx)
.await?;
#[cfg(target_os = "windows")]
{
if let Some(spec) =
crate::windows_toolchain::detect_toolchain(&skeleton.base_manifest.image_ref)
{
let already_injected = spec.env.keys().any(|k| {
let prefix = format!("{k}=");
skeleton
.image_config
.env
.iter()
.any(|e| e.starts_with(&prefix))
});
if !already_injected {
for (k, v) in &spec.env {
skeleton.image_config.env.push(format!("{k}={v}"));
}
let existing_path = skeleton
.image_config
.env
.iter()
.find(|e| e.starts_with("PATH="))
.map(|e| e[5..].to_string());
skeleton
.image_config
.env
.retain(|e| !e.starts_with("PATH="));
let prefix = spec.path_dirs.join(";");
let new_path = match existing_path {
Some(p) if !p.is_empty() => format!("PATH={prefix};{p}"),
_ => format!("PATH={prefix}"),
};
skeleton.image_config.env.push(new_path);
}
skeleton.provisioned_toolchain_language = Some(spec.language.clone());
}
}
#[cfg(not(target_os = "windows"))]
{
}
for (idx, instruction) in stage.instructions.iter().enumerate() {
send_event(
event_tx,
BuildEvent::InstructionStarted {
stage: 0,
index: idx,
instruction: format!("{instruction:?}"),
},
);
match self
.execute_instruction(&mut skeleton, &ctx, instruction)
.await
{
Ok(()) => {
send_event(
event_tx,
BuildEvent::InstructionComplete {
stage: 0,
index: idx,
cached: false,
},
);
}
Err(e) => {
send_event(
event_tx,
BuildEvent::BuildFailed {
error: e.to_string(),
},
);
return Err(e);
}
}
}
send_event(event_tx, BuildEvent::StageComplete { index: 0 });
let built = self.emit_image(&skeleton, &ctx.tag).await?;
if options.push {
self.push(&built, &ctx.tag).await?;
}
#[allow(clippy::cast_possible_truncation)]
let elapsed_ms = started.elapsed().as_millis() as u64;
let image_id = built.manifest_digest.clone();
let mut tags = options.tags.clone();
if tags.is_empty() {
tags.push(format!("zlayer-windows-build:{}", &image_id));
}
let total_size: u64 = built
.layers
.iter()
.map(|l| l.size)
.sum::<u64>()
.saturating_add(built.image_config_blob.len() as u64)
.saturating_add(built.manifest_blob.len() as u64);
let layer_count = built.layers.len();
send_event(
event_tx,
BuildEvent::BuildComplete {
image_id: image_id.clone(),
},
);
Ok(crate::builder::BuiltImage {
image_id,
tags,
layer_count,
size: total_size,
build_time_ms: elapsed_ms,
is_manifest: false,
})
}
}
#[async_trait::async_trait]
pub trait PushTarget: Send + Sync {
async fn upload_blob(
&self,
reference: &str,
digest: &str,
media_type: &str,
data: Vec<u8>,
auth: &RegistryAuth,
) -> std::result::Result<(), String>;
async fn put_manifest(
&self,
reference: &str,
bytes: Vec<u8>,
content_type: &str,
auth: &RegistryAuth,
) -> std::result::Result<(), String>;
}
pub struct RegistryPushTarget {
puller: zlayer_registry::ImagePuller,
}
impl RegistryPushTarget {
#[must_use]
pub fn new() -> Self {
let cache = zlayer_registry::BlobCache::new()
.expect("in-memory BlobCache::new() is infallible in the current impl");
Self {
puller: zlayer_registry::ImagePuller::new(cache),
}
}
}
impl Default for RegistryPushTarget {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl PushTarget for RegistryPushTarget {
async fn upload_blob(
&self,
reference: &str,
digest: &str,
media_type: &str,
data: Vec<u8>,
auth: &RegistryAuth,
) -> std::result::Result<(), String> {
self.puller
.push_blob(reference, digest, &data, media_type, auth)
.await
.map_err(|e| e.to_string())
}
async fn put_manifest(
&self,
reference: &str,
bytes: Vec<u8>,
content_type: &str,
auth: &RegistryAuth,
) -> std::result::Result<(), String> {
self.puller
.push_manifest_blob(reference, bytes, content_type, auth)
.await
.map(|_digest| ())
.map_err(|e| e.to_string())
}
}
async fn push_impl(
image: &BuiltImage,
tag: &str,
auth: &RegistryAuth,
target: &dyn PushTarget,
) -> Result<()> {
for layer in &image.layers {
if layer.urls.is_some() {
tracing::debug!(
tag = %tag,
digest = %layer.digest,
"skipping foreign layer blob upload (urls[] preserved on manifest)"
);
continue;
}
let data = if layer.local_path.as_os_str().is_empty() {
return Err(BuildError::PushFailed {
tag: tag.to_string(),
reason: format!(
"non-foreign layer {} has no local_path (emit_image must populate it)",
layer.digest
),
});
} else {
tokio::fs::read(&layer.local_path)
.await
.map_err(|e| BuildError::PushFailed {
tag: tag.to_string(),
reason: format!(
"failed to read layer blob {} from {}: {e}",
layer.digest,
layer.local_path.display()
),
})?
};
target
.upload_blob(tag, &layer.digest, &layer.media_type, data, auth)
.await
.map_err(|reason| BuildError::BlobUploadFailed {
digest: layer.digest.clone(),
tag: tag.to_string(),
reason,
})?;
}
target
.upload_blob(
tag,
&image.image_config_digest,
OCI_IMAGE_CONFIG_MEDIA_TYPE,
image.image_config_blob.clone(),
auth,
)
.await
.map_err(|reason| BuildError::BlobUploadFailed {
digest: image.image_config_digest.clone(),
tag: tag.to_string(),
reason,
})?;
target
.put_manifest(
tag,
image.manifest_blob.clone(),
OCI_IMAGE_MANIFEST_MEDIA_TYPE,
auth,
)
.await
.map_err(|reason| BuildError::ManifestPutFailed {
tag: tag.to_string(),
reason,
})?;
Ok(())
}
fn format_instruction_source_line(instr: &Instruction) -> String {
match instr {
Instruction::Run(run) => match &run.command {
ShellOrExec::Shell(s) => format!("RUN {s}"),
ShellOrExec::Exec(args) => format!(
"RUN {}",
serde_json::to_string(args).unwrap_or_else(|_| "[]".to_string())
),
},
Instruction::Copy(c) => {
let from = c
.from
.as_deref()
.map(|f| format!("--from={f} "))
.unwrap_or_default();
format!("COPY {from}{} {}", c.sources.join(" "), c.destination)
}
Instruction::Add(a) => format!("ADD {} {}", a.sources.join(" "), a.destination),
Instruction::Env(e) => {
let mut keys: Vec<&String> = e.vars.keys().collect();
keys.sort();
let body = keys
.iter()
.map(|k| format!("{}={}", k, e.vars[*k]))
.collect::<Vec<_>>()
.join(" ");
format!("ENV {body}")
}
Instruction::Workdir(p) => format!("WORKDIR {p}"),
Instruction::Expose(e) => {
let proto = match e.protocol {
ExposeProtocol::Tcp => "tcp",
ExposeProtocol::Udp => "udp",
};
format!("EXPOSE {}/{proto}", e.port)
}
Instruction::Label(labels) => {
let mut keys: Vec<&String> = labels.keys().collect();
keys.sort();
let body = keys
.iter()
.map(|k| format!("{}={}", k, labels[*k]))
.collect::<Vec<_>>()
.join(" ");
format!("LABEL {body}")
}
Instruction::User(u) => format!("USER {u}"),
Instruction::Entrypoint(c) => match c {
ShellOrExec::Shell(s) => format!("ENTRYPOINT {s}"),
ShellOrExec::Exec(args) => format!(
"ENTRYPOINT {}",
serde_json::to_string(args).unwrap_or_else(|_| "[]".to_string())
),
},
Instruction::Cmd(c) => match c {
ShellOrExec::Shell(s) => format!("CMD {s}"),
ShellOrExec::Exec(args) => format!(
"CMD {}",
serde_json::to_string(args).unwrap_or_else(|_| "[]".to_string())
),
},
Instruction::Volume(paths) => format!("VOLUME {}", paths.join(" ")),
Instruction::Shell(tokens) => format!(
"SHELL {}",
serde_json::to_string(tokens).unwrap_or_else(|_| "[]".to_string())
),
Instruction::Arg(a) => match &a.default {
Some(d) => format!("ARG {}={d}", a.name),
None => format!("ARG {}", a.name),
},
Instruction::Stopsignal(s) => format!("STOPSIGNAL {s}"),
Instruction::Healthcheck(_) => "HEALTHCHECK".to_string(),
Instruction::Onbuild(inner) => {
format!("ONBUILD {}", format_instruction_source_line(inner))
}
}
}
pub(crate) fn apply_workdir(cfg: &mut OciImageConfig, path: &str) {
let trimmed = path.trim();
if trimmed.is_empty() {
return;
}
let is_absolute = is_absolute_windows_or_unix(trimmed);
let resolved = if is_absolute {
trimmed.to_string()
} else if let Some(prev) = cfg.working_dir.as_deref() {
join_windows_path(prev, trimmed)
} else {
trimmed.to_string()
};
cfg.working_dir = Some(resolved);
}
pub(crate) fn apply_env(cfg: &mut OciImageConfig, env: &EnvInstruction) {
let mut keys: Vec<&String> = env.vars.keys().collect();
keys.sort();
for key in keys {
let value = &env.vars[key];
let entry = format!("{key}={value}");
cfg.env
.retain(|e| e.split_once('=').is_none_or(|(k, _)| k != key.as_str()));
cfg.env.push(entry);
}
}
pub(crate) fn apply_entrypoint(cfg: &mut OciImageConfig, cmd: &ShellOrExec) {
cfg.entrypoint = Some(shell_or_exec_to_vec(cmd));
cfg.cmd = None;
}
pub(crate) fn apply_cmd(cfg: &mut OciImageConfig, cmd: &ShellOrExec) {
cfg.cmd = Some(shell_or_exec_to_vec(cmd));
}
pub(crate) fn apply_expose(cfg: &mut OciImageConfig, expose: &ExposeInstruction) {
let proto = match expose.protocol {
ExposeProtocol::Tcp => "tcp",
ExposeProtocol::Udp => "udp",
};
let key = format!("{}/{}", expose.port, proto);
cfg.exposed_ports.insert(key, serde_json::json!({}));
}
pub(crate) fn apply_healthcheck(cfg: &mut OciImageConfig, hc: &HealthcheckInstruction) {
match hc {
HealthcheckInstruction::None => {
cfg.healthcheck = Some(OciHealthcheck::disabled());
}
HealthcheckInstruction::Check {
command,
interval,
timeout,
start_period,
retries,
..
} => {
let test = match command {
ShellOrExec::Shell(s) => vec!["CMD-SHELL".to_string(), s.clone()],
ShellOrExec::Exec(args) => {
let mut v = Vec::with_capacity(args.len() + 1);
v.push("CMD".to_string());
v.extend(args.iter().cloned());
v
}
};
cfg.healthcheck = Some(OciHealthcheck {
test,
interval: interval.map(duration_to_oci_string),
timeout: timeout.map(duration_to_oci_string),
retries: *retries,
start_period: start_period.map(duration_to_oci_string),
});
}
}
}
fn shell_or_exec_to_vec(cmd: &ShellOrExec) -> Vec<String> {
match cmd {
ShellOrExec::Shell(body) => {
vec!["cmd".to_string(), "/c".to_string(), body.clone()]
}
ShellOrExec::Exec(args) => args.clone(),
}
}
fn duration_to_oci_string(d: std::time::Duration) -> String {
let total_ms = d.as_millis();
if total_ms == 0 {
return "0s".to_string();
}
if total_ms % 1000 != 0 {
return format!("{total_ms}ms");
}
let secs = d.as_secs();
if secs % 60 != 0 {
return format!("{secs}s");
}
let mins = secs / 60;
if mins % 60 != 0 {
return format!("{mins}m");
}
format!("{}h", mins / 60)
}
fn format_onbuild_trigger(instr: &Instruction) -> String {
match instr {
Instruction::Run(run) => match &run.command {
ShellOrExec::Shell(s) => format!("RUN {s}"),
ShellOrExec::Exec(args) => format!(
"RUN {}",
serde_json::to_string(args).unwrap_or_else(|_| "[]".to_string())
),
},
Instruction::Copy(c) => format!("COPY {} {}", c.sources.join(" "), c.destination),
Instruction::Add(a) => format!("ADD {} {}", a.sources.join(" "), a.destination),
Instruction::Env(e) => {
let mut keys: Vec<&String> = e.vars.keys().collect();
keys.sort();
let body = keys
.iter()
.map(|k| format!("{}={}", k, e.vars[*k]))
.collect::<Vec<_>>()
.join(" ");
format!("ENV {body}")
}
Instruction::Workdir(p) => format!("WORKDIR {p}"),
Instruction::User(u) => format!("USER {u}"),
Instruction::Cmd(c) => match c {
ShellOrExec::Shell(s) => format!("CMD {s}"),
ShellOrExec::Exec(args) => format!(
"CMD {}",
serde_json::to_string(args).unwrap_or_else(|_| "[]".to_string())
),
},
Instruction::Entrypoint(c) => match c {
ShellOrExec::Shell(s) => format!("ENTRYPOINT {s}"),
ShellOrExec::Exec(args) => format!(
"ENTRYPOINT {}",
serde_json::to_string(args).unwrap_or_else(|_| "[]".to_string())
),
},
other => other.name().to_string(),
}
}
fn is_absolute_windows_or_unix(p: &str) -> bool {
if p.starts_with('/') || p.starts_with('\\') {
return true;
}
let bytes = p.as_bytes();
if bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'\\' || bytes[2] == b'/')
{
return true;
}
false
}
fn join_windows_path(base: &str, suffix: &str) -> String {
let mut joined = base.trim_end_matches(['\\', '/']).to_string();
joined.push('\\');
joined.push_str(suffix.trim_start_matches(['\\', '/']));
joined
}
#[derive(Debug, Clone)]
struct ResolvedSource {
relative: String,
absolute: PathBuf,
}
struct DownloadedFile {
path: PathBuf,
basename: String,
url: String,
_guard: tempfile::TempDir,
}
fn is_http_url(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
lower.starts_with("http://") || lower.starts_with("https://")
}
fn resolve_copy_sources(ctx: &BuildContext, srcs: &[String]) -> Result<Vec<ResolvedSource>> {
let mut out = Vec::with_capacity(srcs.len());
for src in srcs {
if path_contains_parent_dir(src) {
return Err(BuildError::PathTraversal { src: src.clone() });
}
let absolute = ctx.context_dir.join(src);
out.push(ResolvedSource {
relative: src.clone(),
absolute,
});
}
Ok(out)
}
fn path_contains_parent_dir(src: &str) -> bool {
let normalised = src.replace('\\', "/");
Path::new(&normalised)
.components()
.any(|c| matches!(c, Component::ParentDir))
}
async fn download_url(url: &str) -> Result<DownloadedFile> {
let response = reqwest::get(url)
.await
.map_err(|e| BuildError::HttpFetchFailed {
url: url.to_string(),
source: e,
})?
.error_for_status()
.map_err(|e| BuildError::HttpFetchFailed {
url: url.to_string(),
source: e,
})?;
let bytes = response
.bytes()
.await
.map_err(|e| BuildError::HttpFetchFailed {
url: url.to_string(),
source: e,
})?;
let basename = url
.rsplit('/')
.next()
.and_then(|s| s.split('?').next())
.filter(|s| !s.is_empty())
.unwrap_or("download")
.to_string();
let guard = tempfile::tempdir().map_err(BuildError::IoError)?;
let path = guard.path().join(&basename);
tokio::fs::write(&path, &bytes)
.await
.map_err(BuildError::IoError)?;
Ok(DownloadedFile {
path,
basename,
url: url.to_string(),
_guard: guard,
})
}
#[allow(clippy::case_sensitive_file_extension_comparisons)]
fn is_tarball_path(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower.ends_with(".tar")
|| lower.ends_with(".tar.gz")
|| lower.ends_with(".tgz")
|| lower.ends_with(".tar.bz2")
|| lower.ends_with(".tar.xz")
}
fn prepare_scratch_for_writes(
config: &WindowsBuildConfig,
skeleton: &BuildSkeleton,
step_index: usize,
) -> Result<(PathBuf, PathBuf)> {
let _ = config; let scratch_id = format!("copy-add-{step_index}-{}", uuid::Uuid::new_v4());
let scratch_dir = skeleton.working_layer_chain_dir.join(&scratch_id);
let files_root = scratch_dir.join("Files");
std::fs::create_dir_all(&files_root).map_err(BuildError::IoError)?;
Ok((scratch_dir, files_root))
}
fn dest_under_files_root(dest: &str) -> PathBuf {
let mut s = dest.replace('\\', "/");
if s.len() >= 2 && s.as_bytes()[0].is_ascii_alphabetic() && s.as_bytes()[1] == b':' {
s = s[2..].to_string();
}
let trimmed = s.trim_start_matches('/');
PathBuf::from(trimmed)
}
fn destination_is_directory(dest: &str, source_count: usize) -> bool {
source_count > 1 || dest.ends_with('/') || dest.ends_with('\\')
}
fn copy_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
let meta = std::fs::metadata(src)?;
if meta.is_dir() {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let child_src = entry.path();
let child_dst = dst.join(entry.file_name());
copy_recursive(&child_src, &child_dst)?;
}
Ok(())
} else {
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(src, dst).map(|_| ())
}
}
fn extract_tarball(archive_path: &Path, dest_dir: &Path) -> Result<()> {
use std::fs::File;
use std::io::BufReader;
std::fs::create_dir_all(dest_dir).map_err(BuildError::IoError)?;
let file = File::open(archive_path).map_err(BuildError::IoError)?;
let reader = BufReader::new(file);
let lower = archive_path.to_string_lossy().to_ascii_lowercase();
#[allow(clippy::case_sensitive_file_extension_comparisons)]
let mut archive: tar::Archive<Box<dyn std::io::Read>> = if lower.ends_with(".tar.gz")
|| lower.ends_with(".tgz")
{
tar::Archive::new(Box::new(flate2::read::GzDecoder::new(reader)) as Box<dyn std::io::Read>)
} else if lower.ends_with(".tar.bz2") {
tar::Archive::new(Box::new(bzip2::read::BzDecoder::new(reader)) as Box<dyn std::io::Read>)
} else if lower.ends_with(".tar.xz") {
tar::Archive::new(Box::new(xz2::read::XzDecoder::new(reader)) as Box<dyn std::io::Read>)
} else {
tar::Archive::new(Box::new(reader) as Box<dyn std::io::Read>)
};
for entry in archive
.entries()
.map_err(|e| BuildError::TarExtractFailed { source: e })?
{
let mut entry = entry.map_err(|e| BuildError::TarExtractFailed { source: e })?;
let entry_path = entry
.path()
.map_err(|e| BuildError::TarExtractFailed { source: e })?
.into_owned();
if entry_path
.components()
.any(|c| matches!(c, Component::ParentDir))
{
return Err(BuildError::TarExtractFailed {
source: std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"tarball entry '{}' contains '..' — refusing to extract",
entry_path.display()
),
),
});
}
entry
.unpack_in(dest_dir)
.map_err(|e| BuildError::TarExtractFailed { source: e })?;
}
Ok(())
}
#[allow(clippy::similar_names)] async fn apply_filesystem_writes(
config: &WindowsBuildConfig,
skeleton: &mut BuildSkeleton,
step_index: usize,
locals: &[ResolvedSource],
dest: &str,
extract_archives: bool,
downloads: &[DownloadedFile],
) -> Result<()> {
let total_sources = locals.len() + downloads.len();
if total_sources == 0 {
return Ok(());
}
let dest_is_dir = destination_is_directory(dest, total_sources);
let (scratch_dir, files_root) = prepare_scratch_for_writes(config, skeleton, step_index)?;
let dest_rel = dest_under_files_root(dest);
let dest_abs_in_layer = files_root.join(&dest_rel);
for src in locals {
let meta = std::fs::metadata(&src.absolute).map_err(|e| BuildError::ContextRead {
path: src.absolute.clone(),
source: e,
})?;
if extract_archives && meta.is_file() && is_tarball_path(&src.relative) {
extract_tarball(&src.absolute, &dest_abs_in_layer)?;
} else if meta.is_dir() {
std::fs::create_dir_all(&dest_abs_in_layer).map_err(BuildError::IoError)?;
for entry in std::fs::read_dir(&src.absolute).map_err(BuildError::IoError)? {
let entry = entry.map_err(BuildError::IoError)?;
let child_dst = dest_abs_in_layer.join(entry.file_name());
copy_recursive(&entry.path(), &child_dst).map_err(BuildError::IoError)?;
}
} else if dest_is_dir {
std::fs::create_dir_all(&dest_abs_in_layer).map_err(BuildError::IoError)?;
let basename = src
.absolute
.file_name()
.map(std::ffi::OsStr::to_os_string)
.ok_or_else(|| BuildError::ContextRead {
path: src.absolute.clone(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"COPY/ADD source '{}' has no file name",
src.absolute.display()
),
),
})?;
let dst = dest_abs_in_layer.join(basename);
std::fs::copy(&src.absolute, &dst).map_err(BuildError::IoError)?;
} else {
if let Some(parent) = dest_abs_in_layer.parent() {
std::fs::create_dir_all(parent).map_err(BuildError::IoError)?;
}
std::fs::copy(&src.absolute, &dest_abs_in_layer).map_err(BuildError::IoError)?;
}
}
for download in downloads {
let is_tar = is_tarball_path(&download.basename);
if extract_archives && is_tar {
extract_tarball(&download.path, &dest_abs_in_layer)?;
} else if dest_is_dir {
std::fs::create_dir_all(&dest_abs_in_layer).map_err(BuildError::IoError)?;
let dst = dest_abs_in_layer.join(&download.basename);
std::fs::copy(&download.path, &dst).map_err(BuildError::IoError)?;
} else {
if let Some(parent) = dest_abs_in_layer.parent() {
std::fs::create_dir_all(parent).map_err(BuildError::IoError)?;
}
std::fs::copy(&download.path, &dest_abs_in_layer).map_err(BuildError::IoError)?;
}
tracing::debug!(
step_index = step_index,
url = %download.url,
dest = %dest,
"ADD URL download materialised"
);
}
commit_scratch_as_layer(skeleton, step_index, &scratch_dir).await
}
#[allow(clippy::unused_async)]
#[cfg(target_os = "windows")]
async fn commit_scratch_as_layer(
skeleton: &mut BuildSkeleton,
step_index: usize,
scratch_dir: &Path,
) -> Result<()> {
use flate2::write::GzEncoder;
use flate2::Compression;
use sha2::{Digest, Sha256};
use zlayer_agent::windows::wclayer::{self, LayerChain};
use zlayer_hcs::schema::Layer as HcsLayer;
let parent_chain: LayerChain = LayerChain::new(
skeleton
.working_chain
.iter()
.rev()
.map(|e| HcsLayer {
id: e.layer_id.clone(),
path: e.layer_path.to_string_lossy().into_owned(),
})
.collect(),
);
let tar_bytes =
tar_export_folder(scratch_dir).map_err(|e| BuildError::LayerExportFailed { source: e })?;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
std::io::Write::write_all(&mut encoder, &tar_bytes)
.map_err(|e| BuildError::LayerExportFailed { source: e })?;
let compressed = encoder
.finish()
.map_err(|e| BuildError::LayerExportFailed { source: e })?;
let digest = format!("sha256:{}", hex::encode(Sha256::digest(&compressed)));
#[allow(clippy::cast_possible_wrap)]
let size = compressed.len() as i64;
let new_layer_id = uuid::Uuid::new_v4().to_string();
let new_layer_path = skeleton.working_layer_chain_dir.join(&new_layer_id);
std::fs::create_dir_all(&new_layer_path)
.map_err(|e| BuildError::LayerExportFailed { source: e })?;
zlayer_agent::windows::layer::enable_backup_restore_privileges()
.map_err(BuildError::IoError)?;
wclayer::import_layer(&new_layer_path, scratch_dir, &parent_chain)
.map_err(|e| BuildError::LayerExportFailed { source: e })?;
if let Err(e) = std::fs::remove_dir_all(scratch_dir) {
tracing::warn!(
scratch_dir = %scratch_dir.display(),
step_index = step_index,
error = %e,
"failed to remove COPY/ADD scratch dir after import"
);
}
skeleton.base_layers.push(LayerRef {
digest,
media_type: OCI_WINDOWS_LAYER_MEDIA_TYPE.to_string(),
size,
urls: Vec::new(),
});
skeleton.working_chain.push(WindowsLayerEntry {
layer_id: new_layer_id,
layer_path: new_layer_path,
});
Ok(())
}
#[cfg(not(target_os = "windows"))]
#[allow(clippy::unused_async)]
async fn commit_scratch_as_layer(
_skeleton: &mut BuildSkeleton,
_step_index: usize,
_scratch_dir: &Path,
) -> Result<()> {
Ok(())
}
fn new_build_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("wcow-{nanos:032x}")
}
#[cfg(target_os = "windows")]
async fn pull_and_materialise_base(
base_image_ref: &str,
config: &WindowsBuildConfig,
working_layer_chain_dir: &std::path::Path,
) -> Result<(Vec<LayerRef>, BaseImageManifest, Vec<WindowsLayerEntry>)> {
use zlayer_agent::windows::unpacker::{self, ResolvedLayerDescriptor};
std::fs::create_dir_all(working_layer_chain_dir).map_err(BuildError::IoError)?;
let target = parse_platform_string(&config.platform)?;
let target = match config.os_version_override.as_ref() {
Some(v) => target.with_os_version(v.clone()),
None => target,
};
let cache_type = zlayer_registry::CacheType::from_env()
.map_err(|e| BuildError::registry_error(format!("WCOW blob cache from env: {e}")))?;
let blob_cache = cache_type
.build()
.await
.map_err(|e| BuildError::registry_error(format!("open WCOW blob cache: {e}")))?;
let puller = zlayer_registry::ImagePuller::with_platform(blob_cache, target);
let (manifest, _digest) = puller
.pull_manifest(base_image_ref, &config.registry_auth)
.await
.map_err(|e| BuildError::registry_error(format!("pull manifest {base_image_ref}: {e}")))?;
let config_blob = puller
.pull_blob(
base_image_ref,
&manifest.config.digest,
&config.registry_auth,
)
.await
.unwrap_or_default();
let os_version: Option<String> = serde_json::from_slice::<serde_json::Value>(&config_blob)
.ok()
.as_ref()
.and_then(|v| v.get("os.version"))
.and_then(serde_json::Value::as_str)
.map(ToString::to_string);
let descriptors: Vec<ResolvedLayerDescriptor> = manifest
.layers
.iter()
.map(|layer| ResolvedLayerDescriptor {
digest: layer.digest.clone(),
media_type: layer.media_type.clone(),
size: layer.size,
urls: layer.urls.clone().unwrap_or_default(),
})
.collect();
let unpacked = unpacker::unpack_windows_image(
&puller,
base_image_ref,
&config.registry_auth,
&descriptors,
working_layer_chain_dir,
)
.await
.map_err(BuildError::IoError)?;
let layer_refs: Vec<LayerRef> = descriptors
.iter()
.map(|d| LayerRef {
digest: d.digest.clone(),
media_type: d.media_type.clone(),
size: d.size,
urls: d.urls.clone(),
})
.collect();
let mut working_chain: Vec<WindowsLayerEntry> = unpacked
.chain
.0
.iter()
.rev()
.map(|layer| WindowsLayerEntry {
layer_id: layer.id.clone(),
layer_path: PathBuf::from(&layer.path),
})
.collect();
if working_chain.is_empty() {
return Err(BuildError::registry_error(format!(
"no parent layers were materialised from {base_image_ref} — \
the base image must contribute at least one layer"
)));
}
debug_assert_eq!(layer_refs.len(), working_chain.len());
working_chain.truncate(layer_refs.len());
Ok((
layer_refs,
BaseImageManifest {
image_ref: base_image_ref.to_string(),
os: "windows".to_string(),
os_version,
arch: "amd64".to_string(),
config_blob,
},
working_chain,
))
}
#[cfg(not(target_os = "windows"))]
#[allow(clippy::unused_async)]
async fn pull_and_materialise_base(
_base_image_ref: &str,
_config: &WindowsBuildConfig,
_working_layer_chain_dir: &std::path::Path,
) -> Result<(Vec<LayerRef>, BaseImageManifest, Vec<WindowsLayerEntry>)> {
Err(BuildError::NotSupported {
operation: "WindowsBuilder::build_skeleton requires target_os = \"windows\" — \
HcsImportLayer / wclayer::* APIs are not available on this host"
.to_string(),
})
}
#[cfg(target_os = "windows")]
fn parse_platform_string(platform: &str) -> Result<zlayer_spec::TargetPlatform> {
let (os_str, arch_str) = platform.split_once('/').ok_or_else(|| {
BuildError::invalid_instruction(
"platform",
format!("expected `<os>/<arch>` (e.g. windows/amd64), got `{platform}`"),
)
})?;
let os = zlayer_spec::OsKind::from_oci_str(os_str).ok_or_else(|| {
BuildError::invalid_instruction("platform.os", format!("unrecognised OS `{os_str}`"))
})?;
let arch = match arch_str {
"amd64" | "x86_64" => zlayer_spec::ArchKind::Amd64,
"arm64" | "aarch64" => zlayer_spec::ArchKind::Arm64,
other => {
return Err(BuildError::invalid_instruction(
"platform.arch",
format!("unrecognised arch `{other}`"),
));
}
};
Ok(zlayer_spec::TargetPlatform::new(os, arch))
}
#[cfg(target_os = "windows")]
const OCI_WINDOWS_LAYER_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar+gzip";
#[cfg(target_os = "windows")]
const RUN_STEP_TERMINATION_GRACE_SECS: u64 = 10 * 60;
#[allow(clippy::too_many_lines)]
#[cfg(target_os = "windows")]
async fn execute_run_step_impl(
_config: &WindowsBuildConfig,
skeleton: &mut BuildSkeleton,
run: &RunInstruction,
step_index: usize,
) -> Result<()> {
use std::time::{Duration, Instant};
use flate2::write::GzEncoder;
use flate2::Compression;
use sha2::{Digest, Sha256};
use tracing::{debug, info, warn};
use zlayer_agent::windows::scratch as agent_scratch;
use zlayer_agent::windows::wclayer::{self, LayerChain};
use zlayer_hcs::process::ComputeProcess;
use zlayer_hcs::schema::{
ComputeSystem as HcsSystemDoc, Container, Layer as HcsLayer, ProcessParameters,
ProcessStatus, SchemaVersion, Storage,
};
use zlayer_hcs::system::ComputeSystem;
if skeleton.working_chain.is_empty() {
return Err(BuildError::LayerCreate {
message: "RUN attempted with an empty working layer chain — \
the base image must materialise at least one layer"
.to_string(),
});
}
let parent_chain: LayerChain = LayerChain::new(
skeleton
.working_chain
.iter()
.rev()
.map(|e| HcsLayer {
id: e.layer_id.clone(),
path: e.layer_path.to_string_lossy().into_owned(),
})
.collect(),
);
let source_distro = derive_source_distro(&skeleton.base_manifest);
let (command_line, skipped_packages) = translate_run_command(
&run.command,
&source_distro,
skeleton.provisioned_toolchain_language.as_deref(),
)
.await?;
for skipped in &skipped_packages {
info!(
step_index = step_index,
package = %skipped,
"skipping Linux-only package with no Chocolatey equivalent"
);
}
debug!(step_index = step_index, command = %command_line, "RUN");
let scratch_id = format!("scratch-{}", uuid::Uuid::new_v4());
let scratch_dir = skeleton.working_layer_chain_dir.join(&scratch_id);
zlayer_agent::windows::layer::enable_backup_restore_privileges()
.map_err(BuildError::IoError)?;
let scratch_layer = agent_scratch::create(&scratch_dir, &parent_chain).map_err(|e| {
BuildError::LayerCreate {
message: format!("scratch layer create at {}: {e}", scratch_dir.display()),
}
})?;
let hcs_id = format!("zlayer-build-run-{}", uuid::Uuid::new_v4());
let parents_for_doc: Vec<HcsLayer> = parent_chain.0.clone();
let doc = HcsSystemDoc {
owner: "zlayer-builder".to_string(),
schema_version: SchemaVersion::default(),
hosting_system_id: String::new(),
container: Some(Container {
guest_os: Some(zlayer_hcs::schema::GuestOs {
host_name: Some("zlayer-build".to_string()),
}),
storage: Some(Storage {
layers: parents_for_doc,
path: Some(scratch_layer.layer_path().to_string_lossy().into_owned()),
}),
networking: None,
mapped_directories: Vec::new(),
mapped_pipes: Vec::new(),
processor: None,
memory: None,
}),
virtual_machine: None,
should_terminate_on_last_handle_closed: Some(true),
};
let doc_json = serde_json::to_string(&doc).map_err(|e| BuildError::LayerCreate {
message: format!("serialize HCS compute-system doc: {e}"),
})?;
let system = ComputeSystem::create(&hcs_id, &doc_json)
.await
.map_err(|e| BuildError::LayerCreate {
message: format!("HcsCreateComputeSystem({hcs_id}): {e}"),
})?;
system
.start("")
.await
.map_err(|e| BuildError::LayerCreate {
message: format!("HcsStartComputeSystem({hcs_id}): {e}"),
})?;
let params = ProcessParameters {
command_line: command_line.clone(),
working_directory: String::new(),
environment: BTreeMap::default(),
emulate_console: Some(false),
create_std_in_pipe: Some(false),
create_std_out_pipe: Some(true),
create_std_err_pipe: Some(true),
console_size: None,
user: None,
};
let params_json = serde_json::to_string(¶ms).map_err(|e| BuildError::LayerCreate {
message: format!("serialize ProcessParameters: {e}"),
})?;
let exec_result = async {
let process = ComputeProcess::create(system.raw(), ¶ms_json)
.await
.map_err(|e| BuildError::LayerCreate {
message: format!("HcsCreateProcess: {e}"),
})?;
info!(step_index = step_index, command = %command_line, "build RUN process started");
let started = Instant::now();
let poll_interval = Duration::from_millis(250);
let timeout = Duration::from_secs(RUN_STEP_TERMINATION_GRACE_SECS);
loop {
let props_json = process
.properties(r#"{"PropertyTypes":["ProcessStatus"]}"#)
.await
.map_err(|e| BuildError::LayerCreate {
message: format!("HcsGetProcessProperties: {e}"),
})?;
if let Ok(status) = serde_json::from_str::<ProcessStatus>(&props_json) {
if let Some(code) = status.exit_code {
if code == 0 {
return Ok(());
}
return Err(BuildError::RunStepFailed {
step_index,
#[allow(clippy::cast_possible_wrap)]
exit_code: code as i32,
stderr_tail: format!(
"(stdio capture not yet wired) command: {command_line}"
),
});
}
}
if started.elapsed() >= timeout {
let _ = process.terminate("").await;
return Err(BuildError::RunStepFailed {
step_index,
exit_code: 124,
stderr_tail: format!(
"RUN timed out after {RUN_STEP_TERMINATION_GRACE_SECS}s: {command_line}"
),
});
}
tokio::time::sleep(poll_interval).await;
}
}
.await;
if let Err(e) = system.terminate("").await {
warn!(
hcs_id = %hcs_id,
error = %e,
"HcsTerminateComputeSystem failed during RUN cleanup"
);
}
exec_result?;
let export_dir = skeleton
.working_layer_chain_dir
.join(format!("export-{scratch_id}"));
if export_dir.exists() {
std::fs::remove_dir_all(&export_dir)
.map_err(|e| BuildError::LayerExportFailed { source: e })?;
}
std::fs::create_dir_all(&export_dir)
.map_err(|e| BuildError::LayerExportFailed { source: e })?;
wclayer::export_layer(scratch_layer.layer_path(), &export_dir, &parent_chain, "{}")
.map_err(|e| BuildError::LayerExportFailed { source: e })?;
let tar_bytes =
tar_export_folder(&export_dir).map_err(|e| BuildError::LayerExportFailed { source: e })?;
let _diff_id = format!("sha256:{}", hex::encode(Sha256::digest(&tar_bytes)));
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
std::io::Write::write_all(&mut encoder, &tar_bytes)
.map_err(|e| BuildError::LayerExportFailed { source: e })?;
let compressed = encoder
.finish()
.map_err(|e| BuildError::LayerExportFailed { source: e })?;
let digest = format!("sha256:{}", hex::encode(Sha256::digest(&compressed)));
#[allow(clippy::cast_possible_wrap)]
let size = compressed.len() as i64;
if let Err(e) = scratch_layer.detach_and_destroy() {
warn!(
scratch_id = %scratch_id,
error = %e,
"scratch teardown failed after RUN export; continuing"
);
}
let new_layer_id = uuid::Uuid::new_v4().to_string();
let new_layer_path = skeleton.working_layer_chain_dir.join(&new_layer_id);
std::fs::create_dir_all(&new_layer_path)
.map_err(|e| BuildError::LayerExportFailed { source: e })?;
wclayer::import_layer(&new_layer_path, &export_dir, &parent_chain)
.map_err(|e| BuildError::LayerExportFailed { source: e })?;
if let Err(e) = std::fs::remove_dir_all(&export_dir) {
warn!(
export_dir = %export_dir.display(),
error = %e,
"failed to remove RUN export folder after import"
);
}
skeleton.base_layers.push(LayerRef {
digest,
media_type: OCI_WINDOWS_LAYER_MEDIA_TYPE.to_string(),
size,
urls: Vec::new(),
});
skeleton.working_chain.push(WindowsLayerEntry {
layer_id: new_layer_id,
layer_path: new_layer_path,
});
Ok(())
}
#[cfg(not(target_os = "windows"))]
#[allow(clippy::unused_async)]
async fn execute_run_step_impl(
_config: &WindowsBuildConfig,
_skeleton: &mut BuildSkeleton,
_run: &RunInstruction,
_step_index: usize,
) -> Result<()> {
Err(BuildError::NotSupported {
operation: "WindowsBuilder::execute_instruction RUN requires target_os = \"windows\" — \
the HCS compute-system + wclayer::export_layer APIs are not available on this host"
.to_string(),
})
}
#[cfg(target_os = "windows")]
fn tar_export_folder(folder: &std::path::Path) -> std::io::Result<Vec<u8>> {
use std::io::Write as _;
let mut builder = tar::Builder::new(Vec::new());
append_dir_contents(&mut builder, folder, std::path::Path::new(""))?;
builder.finish()?;
builder
.into_inner()
.map_err(|e| std::io::Error::other(format!("tar finalize: {e}")))
.inspect(|_w| {
let _ = std::io::sink().flush();
})
}
#[cfg(target_os = "windows")]
fn append_dir_contents<W: std::io::Write>(
builder: &mut tar::Builder<W>,
dir: &std::path::Path,
tar_rel: &std::path::Path,
) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
let entry_tar_path = tar_rel.join(&name);
let meta = entry.metadata()?;
if meta.is_dir() {
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Directory);
header.set_size(0);
header.set_mode(0o755);
header.set_mtime(0);
header.set_path(format!(
"{}/",
entry_tar_path.to_string_lossy().replace('\\', "/")
))?;
header.set_cksum();
builder.append(&header, std::io::empty())?;
append_dir_contents(builder, &path, &entry_tar_path)?;
} else {
let data = std::fs::read(&path)?;
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_mtime(0);
header.set_path(entry_tar_path.to_string_lossy().replace('\\', "/"))?;
header.set_cksum();
builder.append(&header, data.as_slice())?;
}
}
Ok(())
}
#[cfg(target_os = "windows")]
async fn translate_run_command(
cmd: &ShellOrExec,
source_distro: &str,
provisioned_toolchain_language: Option<&str>,
) -> Result<(String, Vec<String>)> {
let translator = crate::buildah::DockerfileTranslator::new(crate::backend::ImageOs::Windows);
translator
.translate_run_command(cmd, source_distro, provisioned_toolchain_language)
.await
}
#[cfg(any(target_os = "windows", test))]
fn derive_source_distro(base: &BaseImageManifest) -> String {
let ref_str = base.image_ref.as_str();
let (repo, tag) = match ref_str.rsplit_once(':') {
Some((r, t)) if !t.contains('/') => (r, t),
_ => (ref_str, "latest"),
};
let short_repo = repo.rsplit('/').next().unwrap_or(repo);
match short_repo.to_ascii_lowercase().as_str() {
"debian" => format!("debian-{tag}"),
"ubuntu" => format!("ubuntu-{tag}"),
"alpine" => format!("alpine-{tag}"),
"fedora" => format!("fedora-{tag}"),
"centos" | "centos-stream" => format!("centos-{tag}"),
"rocky" | "rockylinux" => format!("rocky-{tag}"),
"almalinux" => format!("alma-{tag}"),
"rhel" | "ubi8" | "ubi9" => format!("rhel-{tag}"),
other => {
tracing::warn!(
image_ref = %ref_str,
short_repo = %other,
"could not derive Chocolatey source distro from base image; defaulting to debian-12"
);
"debian-12".to_string()
}
}
}
pub(crate) const FOREIGN_WINDOWS_LAYER_MEDIA_TYPE: &str =
"application/vnd.docker.image.rootfs.foreign.diff.tar.gzip";
pub(crate) const OCI_TAR_GZIP_LAYER_MEDIA_TYPE: &str =
"application/vnd.oci.image.layer.v1.tar+gzip";
pub(crate) const OCI_IMAGE_CONFIG_MEDIA_TYPE: &str = "application/vnd.oci.image.config.v1+json";
pub(crate) const OCI_IMAGE_MANIFEST_MEDIA_TYPE: &str = "application/vnd.oci.image.manifest.v1+json";
pub(crate) fn compute_sha256_hex(blob: &[u8]) -> String {
format!("sha256:{}", hex::encode(Sha256::digest(blob)))
}
fn base_diff_ids(config_blob: &[u8]) -> Vec<String> {
if config_blob.is_empty() {
return Vec::new();
}
let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(config_blob) else {
return Vec::new();
};
parsed
.get("rootfs")
.and_then(|r| r.get("diff_ids"))
.and_then(|d| d.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(ToString::to_string))
.collect()
})
.unwrap_or_default()
}
fn base_config_os_version(config_blob: &[u8]) -> Option<String> {
if config_blob.is_empty() {
return None;
}
let parsed = serde_json::from_slice::<serde_json::Value>(config_blob).ok()?;
parsed
.get("os.version")
.and_then(|v| v.as_str())
.map(ToString::to_string)
}
fn resolve_os_version(
config: &WindowsBuildConfig,
base_manifest: &BaseImageManifest,
) -> Result<String> {
if let Some(v) = config.os_version_override.as_ref() {
if !v.trim().is_empty() {
return Ok(v.clone());
}
}
if let Some(v) = base_manifest.os_version.as_ref() {
if !v.trim().is_empty() {
return Ok(v.clone());
}
}
if let Some(v) = base_config_os_version(&base_manifest.config_blob) {
if !v.trim().is_empty() {
return Ok(v);
}
}
Err(BuildError::OsVersionUnresolved)
}
fn arch_for_config(base_manifest: &BaseImageManifest) -> String {
match base_manifest.arch.as_str() {
"x86_64" => "amd64".to_string(),
"aarch64" => "arm64".to_string(),
other => other.to_string(),
}
}
fn iso8601(ts: DateTime<Utc>) -> String {
ts.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true)
}
#[allow(clippy::too_many_lines)]
fn build_image_config_config_object(cfg: &OciImageConfig) -> serde_json::Value {
let mut obj = serde_json::Map::new();
if let Some(wd) = &cfg.working_dir {
obj.insert(
"WorkingDir".to_string(),
serde_json::Value::String(wd.clone()),
);
}
if !cfg.env.is_empty() {
obj.insert(
"Env".to_string(),
serde_json::Value::Array(
cfg.env
.iter()
.map(|e| serde_json::Value::String(e.clone()))
.collect(),
),
);
}
if let Some(ep) = &cfg.entrypoint {
obj.insert(
"Entrypoint".to_string(),
serde_json::Value::Array(
ep.iter()
.map(|s| serde_json::Value::String(s.clone()))
.collect(),
),
);
}
if let Some(c) = &cfg.cmd {
obj.insert(
"Cmd".to_string(),
serde_json::Value::Array(
c.iter()
.map(|s| serde_json::Value::String(s.clone()))
.collect(),
),
);
}
if let Some(u) = &cfg.user {
obj.insert("User".to_string(), serde_json::Value::String(u.clone()));
}
if !cfg.exposed_ports.is_empty() {
let mut m = serde_json::Map::new();
for (k, v) in &cfg.exposed_ports {
m.insert(k.clone(), v.clone());
}
obj.insert("ExposedPorts".to_string(), serde_json::Value::Object(m));
}
if !cfg.volumes.is_empty() {
let mut m = serde_json::Map::new();
for (k, v) in &cfg.volumes {
m.insert(k.clone(), v.clone());
}
obj.insert("Volumes".to_string(), serde_json::Value::Object(m));
}
if !cfg.labels.is_empty() {
let mut m = serde_json::Map::new();
for (k, v) in &cfg.labels {
m.insert(k.clone(), serde_json::Value::String(v.clone()));
}
obj.insert("Labels".to_string(), serde_json::Value::Object(m));
}
if let Some(s) = &cfg.stop_signal {
obj.insert(
"StopSignal".to_string(),
serde_json::Value::String(s.clone()),
);
}
if let Some(hc) = &cfg.healthcheck {
let mut hm = serde_json::Map::new();
hm.insert(
"Test".to_string(),
serde_json::Value::Array(
hc.test
.iter()
.map(|s| serde_json::Value::String(s.clone()))
.collect(),
),
);
if let Some(iv) = &hc.interval {
hm.insert(
"Interval".to_string(),
serde_json::Value::String(iv.clone()),
);
}
if let Some(to) = &hc.timeout {
hm.insert("Timeout".to_string(), serde_json::Value::String(to.clone()));
}
if let Some(sp) = &hc.start_period {
hm.insert(
"StartPeriod".to_string(),
serde_json::Value::String(sp.clone()),
);
}
if let Some(r) = hc.retries {
hm.insert("Retries".to_string(), serde_json::Value::Number(r.into()));
}
obj.insert("Healthcheck".to_string(), serde_json::Value::Object(hm));
}
if let Some(sh) = &cfg.shell {
obj.insert(
"Shell".to_string(),
serde_json::Value::Array(
sh.iter()
.map(|s| serde_json::Value::String(s.clone()))
.collect(),
),
);
}
if !cfg.on_build.is_empty() {
obj.insert(
"OnBuild".to_string(),
serde_json::Value::Array(
cfg.on_build
.iter()
.map(|s| serde_json::Value::String(s.clone()))
.collect(),
),
);
}
serde_json::Value::Object(obj)
}
fn build_image_config_blob(
skeleton: &BuildSkeleton,
os_version: &str,
layers: &[EmittedLayer],
architecture: &str,
) -> Result<Vec<u8>> {
let mut root = serde_json::Map::new();
root.insert(
"architecture".to_string(),
serde_json::Value::String(architecture.to_string()),
);
root.insert(
"os".to_string(),
serde_json::Value::String("windows".to_string()),
);
root.insert(
"os.version".to_string(),
serde_json::Value::String(os_version.to_string()),
);
root.insert(
"config".to_string(),
build_image_config_config_object(&skeleton.image_config),
);
let diff_ids: Vec<serde_json::Value> = layers
.iter()
.map(|l| serde_json::Value::String(l.diff_id.clone()))
.collect();
let mut rootfs = serde_json::Map::new();
rootfs.insert(
"type".to_string(),
serde_json::Value::String("layers".to_string()),
);
rootfs.insert("diff_ids".to_string(), serde_json::Value::Array(diff_ids));
root.insert("rootfs".to_string(), serde_json::Value::Object(rootfs));
let history: Vec<serde_json::Value> = skeleton
.instruction_log
.iter()
.map(|entry| {
let mut h = serde_json::Map::new();
h.insert(
"created".to_string(),
serde_json::Value::String(iso8601(entry.timestamp)),
);
h.insert(
"created_by".to_string(),
serde_json::Value::String(entry.source_line.clone()),
);
if !entry.produced_layer {
h.insert("empty_layer".to_string(), serde_json::Value::Bool(true));
}
serde_json::Value::Object(h)
})
.collect();
root.insert("history".to_string(), serde_json::Value::Array(history));
serde_json::to_vec(&serde_json::Value::Object(root))
.map_err(|e| BuildError::SerializeManifestFailed { source: e })
}
fn build_manifest_blob(
image_config_digest: &str,
image_config_size: u64,
layers: &[EmittedLayer],
) -> Result<Vec<u8>> {
let mut root = serde_json::Map::new();
root.insert(
"schemaVersion".to_string(),
serde_json::Value::Number(2.into()),
);
root.insert(
"mediaType".to_string(),
serde_json::Value::String(OCI_IMAGE_MANIFEST_MEDIA_TYPE.to_string()),
);
let mut cfg = serde_json::Map::new();
cfg.insert(
"mediaType".to_string(),
serde_json::Value::String(OCI_IMAGE_CONFIG_MEDIA_TYPE.to_string()),
);
cfg.insert(
"digest".to_string(),
serde_json::Value::String(image_config_digest.to_string()),
);
cfg.insert(
"size".to_string(),
serde_json::Value::Number(image_config_size.into()),
);
root.insert("config".to_string(), serde_json::Value::Object(cfg));
let layer_descriptors: Vec<serde_json::Value> = layers
.iter()
.map(|l| {
let mut m = serde_json::Map::new();
m.insert(
"mediaType".to_string(),
serde_json::Value::String(l.media_type.clone()),
);
m.insert(
"digest".to_string(),
serde_json::Value::String(l.digest.clone()),
);
m.insert("size".to_string(), serde_json::Value::Number(l.size.into()));
if let Some(urls) = &l.urls {
if !urls.is_empty() {
m.insert(
"urls".to_string(),
serde_json::Value::Array(
urls.iter()
.map(|u| serde_json::Value::String(u.clone()))
.collect(),
),
);
}
}
serde_json::Value::Object(m)
})
.collect();
root.insert(
"layers".to_string(),
serde_json::Value::Array(layer_descriptors),
);
serde_json::to_vec(&serde_json::Value::Object(root))
.map_err(|e| BuildError::SerializeManifestFailed { source: e })
}
fn build_emitted_layers(skeleton: &BuildSkeleton) -> Vec<EmittedLayer> {
let base_diff_ids = base_diff_ids(&skeleton.base_manifest.config_blob);
let mut layers = Vec::with_capacity(skeleton.base_layers.len());
for (idx, layer_ref) in skeleton.base_layers.iter().enumerate() {
let is_foreign = layer_ref.media_type == FOREIGN_WINDOWS_LAYER_MEDIA_TYPE
|| layer_ref.media_type.contains("foreign.diff.tar.gzip");
let media_type = if is_foreign {
FOREIGN_WINDOWS_LAYER_MEDIA_TYPE.to_string()
} else {
OCI_TAR_GZIP_LAYER_MEDIA_TYPE.to_string()
};
let diff_id = if is_foreign {
base_diff_ids
.get(idx)
.cloned()
.unwrap_or_else(|| layer_ref.digest.clone())
} else {
layer_ref.digest.clone()
};
let local_path = skeleton
.working_chain
.get(idx)
.map(|e| e.layer_path.clone())
.unwrap_or_default();
let urls = if is_foreign && !layer_ref.urls.is_empty() {
Some(layer_ref.urls.clone())
} else {
None
};
#[allow(clippy::cast_sign_loss)]
let size = layer_ref.size.max(0) as u64;
layers.push(EmittedLayer {
media_type,
digest: layer_ref.digest.clone(),
size,
diff_id,
local_path,
urls,
});
}
layers
}
#[allow(clippy::unused_async)]
pub(crate) async fn emit_image_impl(
config: &WindowsBuildConfig,
skeleton: &BuildSkeleton,
tag: &str,
) -> Result<BuiltImage> {
let os_version = resolve_os_version(config, &skeleton.base_manifest)?;
let architecture = arch_for_config(&skeleton.base_manifest);
let layers = build_emitted_layers(skeleton);
let image_config_blob = build_image_config_blob(skeleton, &os_version, &layers, &architecture)?;
let image_config_digest = compute_sha256_hex(&image_config_blob);
#[allow(clippy::cast_possible_truncation)]
let image_config_size = image_config_blob.len() as u64;
let manifest_blob = build_manifest_blob(&image_config_digest, image_config_size, &layers)?;
let manifest_digest = compute_sha256_hex(&manifest_blob);
Ok(BuiltImage {
tag: tag.to_string(),
image_config_blob,
image_config_digest,
manifest_blob,
manifest_digest,
layers,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dockerfile::{AddInstruction, CopyInstruction, EnvInstruction, ShellOrExec};
fn dummy_config() -> WindowsBuildConfig {
WindowsBuildConfig {
cache_dir: std::env::temp_dir().join("zlayer-wcow-skeleton-tests"),
registry_auth: RegistryAuth::Anonymous,
platform: WindowsBuildConfig::default_platform().to_string(),
os_version_override: None,
scratch_size_gb: 0,
}
}
fn dummy_skeleton() -> BuildSkeleton {
let parsed = Dockerfile::parse("FROM mcr.microsoft.com/windows/nanoserver:ltsc2022\n")
.expect("parse fixture");
BuildSkeleton {
parsed_dockerfile: parsed,
base_layers: Vec::new(),
base_manifest: BaseImageManifest {
image_ref: "mcr.microsoft.com/windows/nanoserver:ltsc2022".into(),
os: "windows".into(),
os_version: None,
arch: "amd64".into(),
config_blob: Vec::new(),
},
working_layer_chain_dir: std::env::temp_dir().join("zlayer-wcow-skeleton-tests/x"),
working_chain: Vec::new(),
image_config: OciImageConfig::default(),
instruction_log: vec![ExecutedInstruction {
source_line: "FROM mcr.microsoft.com/windows/nanoserver:ltsc2022".to_string(),
produced_layer: true,
timestamp: Utc::now(),
}],
provisioned_toolchain_language: None,
}
}
fn ctx_and_skeleton_in_tempdir() -> (BuildContext, BuildSkeleton, tempfile::TempDir) {
let tmp = tempfile::tempdir().expect("tmpdir");
let context_dir = tmp.path().join("context");
std::fs::create_dir_all(&context_dir).expect("mk context");
let chain_dir = tmp.path().join("chain");
std::fs::create_dir_all(&chain_dir).expect("mk chain");
let ctx = BuildContext {
context_dir,
dockerfile_path: PathBuf::from("Dockerfile"),
build_args: HashMap::new(),
tag: "zlayer-wcow-test:latest".to_string(),
ltsc: None,
};
let mut skel = dummy_skeleton();
skel.working_layer_chain_dir = chain_dir;
(ctx, skel, tmp)
}
#[test]
fn new_smoke() {
let cfg = dummy_config();
let builder = WindowsBuilder::new(cfg.clone());
assert_eq!(builder.config().platform, cfg.platform);
assert!(builder
.config()
.cache_dir
.ends_with("zlayer-wcow-skeleton-tests"));
assert_eq!(
WindowsBuildConfig::default_platform(),
"windows/amd64",
"default platform string drift would silently break MCR base resolution"
);
}
#[tokio::test]
async fn build_skeleton_with_simple_dockerfile_parses_one_stage() {
let parsed = Dockerfile::parse("FROM mcr.microsoft.com/windows/nanoserver:ltsc2022\n")
.expect("parse the simplest possible WCOW Dockerfile");
assert_eq!(
parsed.stages.len(),
1,
"single-stage WCOW Dockerfile must parse to exactly one stage"
);
let stage = &parsed.stages[0];
match &stage.base_image {
DockerfileFromTarget::Image(r) => {
assert!(
r.to_string()
.contains("mcr.microsoft.com/windows/nanoserver"),
"image ref round-trip lost the registry prefix: {r}"
);
}
other => panic!("expected Image FROM target, got {other:?}"),
}
}
#[tokio::test]
async fn execute_instruction_copy_from_multi_stage_is_unsupported() {
let builder = WindowsBuilder::new(dummy_config());
let (ctx, mut skel, _guard) = ctx_and_skeleton_in_tempdir();
let copy = Instruction::Copy(
CopyInstruction::new(vec!["app.exe".to_string()], "C:\\app\\app.exe".to_string())
.from_stage("builder"),
);
let err = builder
.execute_instruction(&mut skel, &ctx, ©)
.await
.expect_err("multi-stage COPY --from must surface NotSupported");
assert!(
matches!(err, BuildError::NotSupported { ref operation } if operation.contains("multi-stage")),
"COPY --from error must explain multi-stage gap, got: {err}"
);
}
#[tokio::test]
async fn execute_instruction_env_records_kv() {
let builder = WindowsBuilder::new(dummy_config());
let (ctx, mut skel, _guard) = ctx_and_skeleton_in_tempdir();
let mut vars = HashMap::new();
vars.insert("APP_HOME".to_string(), "C:\\app".to_string());
let env = Instruction::Env(EnvInstruction { vars });
builder
.execute_instruction(&mut skel, &ctx, &env)
.await
.expect("ENV must succeed and accumulate into image_config");
assert_eq!(skel.image_config.env, vec!["APP_HOME=C:\\app".to_string()]);
}
#[tokio::test]
async fn execute_instruction_workdir_and_entrypoint_mutate_config() {
let builder = WindowsBuilder::new(dummy_config());
let (ctx, mut skel, _guard) = ctx_and_skeleton_in_tempdir();
builder
.execute_instruction(
&mut skel,
&ctx,
&Instruction::Workdir("C:\\app".to_string()),
)
.await
.expect("WORKDIR must succeed");
assert_eq!(skel.image_config.working_dir.as_deref(), Some("C:\\app"));
builder
.execute_instruction(
&mut skel,
&ctx,
&Instruction::Entrypoint(ShellOrExec::Exec(vec!["C:\\app\\app.exe".to_string()])),
)
.await
.expect("ENTRYPOINT must succeed");
assert_eq!(
skel.image_config.entrypoint.as_deref(),
Some(["C:\\app\\app.exe".to_string()].as_slice())
);
}
#[test]
fn apply_workdir_relative_resolves_against_previous() {
let mut cfg = OciImageConfig::default();
apply_workdir(&mut cfg, "C:\\app");
apply_workdir(&mut cfg, "sub");
assert_eq!(cfg.working_dir.as_deref(), Some("C:\\app\\sub"));
apply_workdir(&mut cfg, "D:\\other");
assert_eq!(cfg.working_dir.as_deref(), Some("D:\\other"));
apply_workdir(&mut cfg, "/data");
assert_eq!(cfg.working_dir.as_deref(), Some("/data"));
}
#[test]
fn apply_env_replaces_existing_key() {
let mut cfg = OciImageConfig::default();
let mut vars = HashMap::new();
vars.insert("FOO".to_string(), "1".to_string());
apply_env(&mut cfg, &EnvInstruction { vars });
let mut vars2 = HashMap::new();
vars2.insert("FOO".to_string(), "2".to_string());
vars2.insert("BAR".to_string(), "baz".to_string());
apply_env(&mut cfg, &EnvInstruction { vars: vars2 });
assert!(cfg.env.contains(&"FOO=2".to_string()), "{:?}", cfg.env);
assert!(cfg.env.contains(&"BAR=baz".to_string()), "{:?}", cfg.env);
assert!(!cfg.env.contains(&"FOO=1".to_string()), "{:?}", cfg.env);
let foo_count = cfg.env.iter().filter(|e| e.starts_with("FOO=")).count();
assert_eq!(foo_count, 1, "ENV must enforce single KEY: {:?}", cfg.env);
}
#[test]
fn apply_entrypoint_resets_cmd_per_spec() {
let mut cfg = OciImageConfig::default();
apply_cmd(&mut cfg, &ShellOrExec::Exec(vec!["bash".to_string()]));
assert!(cfg.cmd.is_some());
apply_entrypoint(
&mut cfg,
&ShellOrExec::Exec(vec!["C:\\app\\app.exe".to_string()]),
);
assert_eq!(
cfg.entrypoint.as_deref(),
Some(["C:\\app\\app.exe".to_string()].as_slice())
);
assert!(
cfg.cmd.is_none(),
"ENTRYPOINT must reset CMD per Dockerfile spec"
);
}
#[test]
fn apply_expose_accumulates_ports() {
let mut cfg = OciImageConfig::default();
apply_expose(&mut cfg, &ExposeInstruction::tcp(80));
apply_expose(&mut cfg, &ExposeInstruction::tcp(443));
apply_expose(&mut cfg, &ExposeInstruction::udp(53));
assert!(cfg.exposed_ports.contains_key("80/tcp"));
assert!(cfg.exposed_ports.contains_key("443/tcp"));
assert!(cfg.exposed_ports.contains_key("53/udp"));
assert_eq!(cfg.exposed_ports.len(), 3);
}
#[test]
fn apply_label_last_value_wins() {
let mut cfg = OciImageConfig::default();
cfg.labels
.insert("maintainer".to_string(), "alice".to_string());
cfg.labels
.insert("maintainer".to_string(), "bob".to_string());
assert_eq!(
cfg.labels.get("maintainer").map(String::as_str),
Some("bob")
);
}
#[test]
fn apply_healthcheck_disabled_and_check_round_trip() {
let mut cfg = OciImageConfig::default();
apply_healthcheck(&mut cfg, &HealthcheckInstruction::None);
let hc = cfg
.healthcheck
.as_ref()
.expect("HEALTHCHECK NONE must populate config");
assert!(hc.is_disabled());
let cmd = HealthcheckInstruction::Check {
command: ShellOrExec::Shell("curl -f http://localhost/".to_string()),
interval: Some(std::time::Duration::from_secs(30)),
timeout: Some(std::time::Duration::from_secs(5)),
start_period: None,
start_interval: None,
retries: Some(3),
};
apply_healthcheck(&mut cfg, &cmd);
let hc2 = cfg.healthcheck.as_ref().expect("healthcheck populated");
assert_eq!(
hc2.test,
vec![
"CMD-SHELL".to_string(),
"curl -f http://localhost/".to_string()
]
);
assert_eq!(hc2.interval.as_deref(), Some("30s"));
assert_eq!(hc2.timeout.as_deref(), Some("5s"));
assert_eq!(hc2.retries, Some(3));
}
fn locate_scratch_files(chain_dir: &std::path::Path) -> PathBuf {
for entry in std::fs::read_dir(chain_dir).expect("read chain dir") {
let entry = entry.expect("read dir entry");
let path = entry.path();
if path.is_dir()
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|s| s.starts_with("copy-add-"))
{
return path.join("Files");
}
}
panic!("no copy-add-* scratch dir under {}", chain_dir.display());
}
#[tokio::test]
#[cfg_attr(
windows,
ignore = "exercises the off-Windows COPY materialization path (commit is a no-op \
off-Windows). On Windows execute_instruction commits via real HcsImportLayer, \
which needs a base layer present; that path is covered by the layer e2e."
)]
async fn apply_copy_simple_file_writes_to_scratch() {
let builder = WindowsBuilder::new(dummy_config());
let (ctx, mut skel, _guard) = ctx_and_skeleton_in_tempdir();
std::fs::write(ctx.context_dir.join("hello.txt"), b"hello").unwrap();
let copy = Instruction::Copy(CopyInstruction::new(
vec!["hello.txt".to_string()],
"C:\\app\\hello.txt".to_string(),
));
builder
.execute_instruction(&mut skel, &ctx, ©)
.await
.expect("COPY of a simple file must succeed off-Windows");
let files = locate_scratch_files(&skel.working_layer_chain_dir);
let copied = files.join("app").join("hello.txt");
assert!(copied.is_file(), "expected file at {}", copied.display());
assert_eq!(std::fs::read(&copied).unwrap(), b"hello");
}
#[tokio::test]
async fn apply_copy_rejects_parent_dir_traversal() {
let builder = WindowsBuilder::new(dummy_config());
let (ctx, mut skel, _guard) = ctx_and_skeleton_in_tempdir();
let copy = Instruction::Copy(CopyInstruction::new(
vec!["../secrets".to_string()],
"C:\\".to_string(),
));
let err = builder
.execute_instruction(&mut skel, &ctx, ©)
.await
.expect_err("COPY with `..` must be rejected");
assert!(
matches!(err, BuildError::PathTraversal { ref src } if src == "../secrets"),
"expected PathTraversal, got: {err}"
);
}
#[tokio::test]
#[cfg_attr(
windows,
ignore = "exercises the off-Windows COPY materialization path (commit is a no-op \
off-Windows). On Windows execute_instruction commits via real HcsImportLayer, \
which needs a base layer present; that path is covered by the layer e2e."
)]
async fn apply_copy_directory_recursive() {
let builder = WindowsBuilder::new(dummy_config());
let (ctx, mut skel, _guard) = ctx_and_skeleton_in_tempdir();
let src_dir = ctx.context_dir.join("payload");
std::fs::create_dir_all(src_dir.join("nested")).unwrap();
std::fs::write(src_dir.join("a.txt"), b"A").unwrap();
std::fs::write(src_dir.join("nested").join("b.txt"), b"B").unwrap();
let copy = Instruction::Copy(CopyInstruction::new(
vec!["payload".to_string()],
"C:\\opt\\payload\\".to_string(),
));
builder
.execute_instruction(&mut skel, &ctx, ©)
.await
.expect("recursive COPY must succeed");
let files = locate_scratch_files(&skel.working_layer_chain_dir);
assert!(files.join("opt/payload/a.txt").is_file());
assert!(files.join("opt/payload/nested/b.txt").is_file());
}
#[tokio::test]
#[cfg_attr(
windows,
ignore = "exercises the off-Windows ADD tarball-extract materialization path (commit is \
a no-op off-Windows). On Windows execute_instruction commits via real \
HcsImportLayer, which needs a base layer present; covered by the layer e2e."
)]
async fn apply_add_tarball_extracts() {
use flate2::write::GzEncoder;
use flate2::Compression;
let builder = WindowsBuilder::new(dummy_config());
let (ctx, mut skel, _guard) = ctx_and_skeleton_in_tempdir();
let tar_bytes = {
let mut tar_builder = tar::Builder::new(Vec::new());
let payload = b"INSIDE\n";
let mut header = tar::Header::new_gnu();
header.set_size(payload.len() as u64);
header.set_mode(0o644);
header.set_mtime(0);
header.set_path("inside.txt").unwrap();
header.set_cksum();
tar_builder.append(&header, payload.as_ref()).unwrap();
tar_builder.finish().unwrap();
tar_builder.into_inner().unwrap()
};
let mut gz = GzEncoder::new(Vec::new(), Compression::default());
std::io::Write::write_all(&mut gz, &tar_bytes).unwrap();
let gz_bytes = gz.finish().unwrap();
std::fs::write(ctx.context_dir.join("payload.tar.gz"), gz_bytes).unwrap();
let add = Instruction::Add(AddInstruction::new(
vec!["payload.tar.gz".to_string()],
"C:\\opt\\extracted\\".to_string(),
));
builder
.execute_instruction(&mut skel, &ctx, &add)
.await
.expect("ADD must extract a tarball");
let files = locate_scratch_files(&skel.working_layer_chain_dir);
let extracted = files.join("opt/extracted/inside.txt");
assert!(extracted.is_file(), "expected {}", extracted.display());
assert_eq!(std::fs::read(&extracted).unwrap(), b"INSIDE\n");
}
#[tokio::test]
#[ignore = "live network — exercises ADD URL fetch against example.com"]
async fn apply_add_http_url_downloads() {
let builder = WindowsBuilder::new(dummy_config());
let (ctx, mut skel, _guard) = ctx_and_skeleton_in_tempdir();
let add = Instruction::Add(AddInstruction::new(
vec!["https://example.com/".to_string()],
"C:\\downloads\\".to_string(),
));
builder
.execute_instruction(&mut skel, &ctx, &add)
.await
.expect("ADD URL must succeed when the network is reachable");
let files = locate_scratch_files(&skel.working_layer_chain_dir);
assert!(
files.join("downloads").is_dir(),
"expected downloads/ dir under {}",
files.display()
);
}
#[test]
fn path_traversal_detection_flavours() {
assert!(path_contains_parent_dir("../etc"));
assert!(path_contains_parent_dir("foo/../bar"));
assert!(path_contains_parent_dir("foo\\..\\bar"));
assert!(!path_contains_parent_dir("foo/bar"));
assert!(!path_contains_parent_dir("foo..bar")); }
#[test]
fn dest_under_files_root_strips_drive() {
assert_eq!(
dest_under_files_root("C:\\app\\bin"),
PathBuf::from("app/bin")
);
assert_eq!(
dest_under_files_root("/etc/passwd"),
PathBuf::from("etc/passwd")
);
assert_eq!(
dest_under_files_root("relative/x"),
PathBuf::from("relative/x")
);
}
#[test]
fn duration_to_oci_string_shapes() {
use std::time::Duration;
assert_eq!(duration_to_oci_string(Duration::from_secs(30)), "30s");
assert_eq!(duration_to_oci_string(Duration::from_secs(90)), "90s");
assert_eq!(duration_to_oci_string(Duration::from_secs(60)), "1m");
assert_eq!(duration_to_oci_string(Duration::from_millis(500)), "500ms");
assert_eq!(duration_to_oci_string(Duration::from_secs(3600)), "1h");
}
#[test]
fn derive_source_distro_known_bases() {
let mk = |image_ref: &str| BaseImageManifest {
image_ref: image_ref.to_string(),
os: "windows".into(),
os_version: None,
arch: "amd64".into(),
config_blob: Vec::new(),
};
assert_eq!(derive_source_distro(&mk("debian:12")), "debian-12");
assert_eq!(
derive_source_distro(&mk("docker.io/library/ubuntu:22.04")),
"ubuntu-22.04"
);
assert_eq!(derive_source_distro(&mk("alpine:3.19")), "alpine-3.19");
assert_eq!(
derive_source_distro(&mk("mcr.microsoft.com/windows/nanoserver:ltsc2022")),
"debian-12"
);
}
#[tokio::test]
#[ignore = "requires Windows host with Hyper-V + mcr.microsoft.com/windows/nanoserver:ltsc2022 base image"]
async fn run_step_emits_new_layer_on_windows_host() {
let cache_dir = std::env::temp_dir().join("zlayer-wcow-run-e2e");
std::fs::create_dir_all(&cache_dir).expect("create cache_dir");
let cfg = WindowsBuildConfig {
cache_dir,
registry_auth: RegistryAuth::Anonymous,
platform: WindowsBuildConfig::default_platform().to_string(),
os_version_override: None,
scratch_size_gb: WindowsBuildConfig::default_scratch_size_gb(),
};
let builder = WindowsBuilder::new(cfg);
let ctx_dir = tempfile::tempdir().expect("tmpdir");
let dockerfile_path = ctx_dir.path().join("Dockerfile");
std::fs::write(
&dockerfile_path,
b"FROM mcr.microsoft.com/windows/nanoserver:ltsc2022\nRUN cmd /c echo hello > C:\\hello.txt\n",
)
.expect("write Dockerfile");
let ctx = BuildContext {
context_dir: ctx_dir.path().to_path_buf(),
dockerfile_path: PathBuf::from("Dockerfile"),
build_args: HashMap::new(),
tag: "zlayer-wcow-run-e2e:test".to_string(),
ltsc: None,
};
let mut skeleton = builder
.build_skeleton(&ctx)
.await
.expect("build_skeleton must succeed against the real MCR base image");
let base_layer_count = skeleton.base_layers.len();
let working_chain_count = skeleton.working_chain.len();
assert!(
base_layer_count >= 1,
"expected at least one base layer materialised"
);
let stage = &skeleton.parsed_dockerfile.stages[0].clone();
let run_instr = stage
.instructions
.iter()
.find(|i| matches!(i, Instruction::Run(_)))
.cloned()
.expect("Dockerfile fixture has a RUN");
builder
.execute_instruction(&mut skeleton, &ctx, &run_instr)
.await
.expect("RUN cmd /c echo hello must succeed on a Windows host");
assert_eq!(
skeleton.base_layers.len(),
base_layer_count + 1,
"RUN must append exactly one descriptor to base_layers"
);
assert_eq!(
skeleton.working_chain.len(),
working_chain_count + 1,
"RUN must append exactly one on-disk layer entry to working_chain"
);
}
fn skeleton_with_foreign_base() -> BuildSkeleton {
let parsed =
Dockerfile::parse("FROM mcr.microsoft.com/windows/nanoserver:ltsc2022\n").unwrap();
let base_config_blob = serde_json::json!({
"architecture": "amd64",
"os": "windows",
"os.version": "10.0.20348.2227",
"rootfs": {
"type": "layers",
"diff_ids": ["sha256:base0000000000000000000000000000000000000000000000000000000000"],
},
"config": {},
})
.to_string()
.into_bytes();
BuildSkeleton {
parsed_dockerfile: parsed,
base_layers: vec![LayerRef {
digest: "sha256:basecompressed00000000000000000000000000000000000000000000000000"
.to_string(),
media_type: FOREIGN_WINDOWS_LAYER_MEDIA_TYPE.to_string(),
size: 12345,
urls: vec![
"https://mcr.microsoft.com/v2/windows/nanoserver/blobs/sha256:base".to_string(),
],
}],
base_manifest: BaseImageManifest {
image_ref: "mcr.microsoft.com/windows/nanoserver:ltsc2022".into(),
os: "windows".into(),
os_version: Some("10.0.20348.2227".to_string()),
arch: "amd64".into(),
config_blob: base_config_blob,
},
working_layer_chain_dir: std::env::temp_dir().join("zlayer-wcow-emit-tests/x"),
working_chain: vec![WindowsLayerEntry {
layer_id: "base".to_string(),
layer_path: PathBuf::from("/nonexistent/base"),
}],
image_config: OciImageConfig::default(),
instruction_log: vec![ExecutedInstruction {
source_line: "FROM mcr.microsoft.com/windows/nanoserver:ltsc2022".to_string(),
produced_layer: true,
timestamp: Utc::now(),
}],
provisioned_toolchain_language: None,
}
}
#[tokio::test]
async fn emit_image_simple_base_only() {
let cfg = dummy_config();
let skel = skeleton_with_foreign_base();
let built = emit_image_impl(&cfg, &skel, "myimage:test")
.await
.expect("emit must succeed for a foreign-base-only skeleton");
let manifest: serde_json::Value = serde_json::from_slice(&built.manifest_blob).unwrap();
assert_eq!(manifest["schemaVersion"], 2);
assert_eq!(
manifest["mediaType"], OCI_IMAGE_MANIFEST_MEDIA_TYPE,
"manifest mediaType must be the OCI image manifest type"
);
let layers = manifest["layers"].as_array().expect("layers array");
assert_eq!(
layers.len(),
1,
"base-only skeleton emits exactly one layer"
);
let l0 = &layers[0];
assert_eq!(l0["mediaType"], FOREIGN_WINDOWS_LAYER_MEDIA_TYPE);
let urls = l0["urls"].as_array().expect("foreign layer carries urls");
assert_eq!(
urls[0].as_str().unwrap(),
"https://mcr.microsoft.com/v2/windows/nanoserver/blobs/sha256:base"
);
let ic: serde_json::Value = serde_json::from_slice(&built.image_config_blob).unwrap();
assert_eq!(ic["os"], "windows");
assert_eq!(ic["os.version"], "10.0.20348.2227");
assert_eq!(ic["architecture"], "amd64");
let history = ic["history"].as_array().expect("history array");
assert_eq!(
history.len(),
1,
"FROM-only skeleton emits one history entry"
);
assert!(history[0]["created_by"]
.as_str()
.unwrap()
.starts_with("FROM mcr.microsoft.com/windows/nanoserver:ltsc2022"));
assert!(
history[0].get("empty_layer").is_none(),
"FROM produced a layer, so empty_layer must be omitted (or false)"
);
let diff_ids = ic["rootfs"]["diff_ids"].as_array().expect("diff_ids array");
assert_eq!(diff_ids.len(), 1);
assert_eq!(
diff_ids[0].as_str().unwrap(),
"sha256:base0000000000000000000000000000000000000000000000000000000000"
);
assert_eq!(
manifest["config"]["digest"].as_str().unwrap(),
built.image_config_digest
);
assert_eq!(
manifest["config"]["size"].as_u64().unwrap(),
built.image_config_blob.len() as u64
);
let recomputed = compute_sha256_hex(&built.manifest_blob);
assert_eq!(recomputed, built.manifest_digest);
assert_eq!(built.tag, "myimage:test");
}
#[tokio::test]
async fn emit_image_with_run_step() {
let cfg = dummy_config();
let mut skel = skeleton_with_foreign_base();
skel.base_layers.push(LayerRef {
digest: "sha256:run111111111111111111111111111111111111111111111111111111111111".into(),
media_type: OCI_TAR_GZIP_LAYER_MEDIA_TYPE.to_string(),
size: 9999,
urls: Vec::new(),
});
skel.working_chain.push(WindowsLayerEntry {
layer_id: "run1".to_string(),
layer_path: PathBuf::from("/nonexistent/run1"),
});
skel.instruction_log.push(ExecutedInstruction {
source_line: "RUN choco install -y curl".to_string(),
produced_layer: true,
timestamp: Utc::now(),
});
let built = emit_image_impl(&cfg, &skel, "myimage:run").await.unwrap();
let manifest: serde_json::Value = serde_json::from_slice(&built.manifest_blob).unwrap();
let layers = manifest["layers"].as_array().unwrap();
assert_eq!(layers.len(), 2, "FROM + RUN produces two layer descriptors");
assert_eq!(layers[0]["mediaType"], FOREIGN_WINDOWS_LAYER_MEDIA_TYPE);
assert_eq!(layers[1]["mediaType"], OCI_TAR_GZIP_LAYER_MEDIA_TYPE);
assert!(
layers[1].get("urls").is_none(),
"non-foreign layer must NOT carry urls[]"
);
let ic: serde_json::Value = serde_json::from_slice(&built.image_config_blob).unwrap();
let history = ic["history"].as_array().unwrap();
assert_eq!(history.len(), 2);
assert_eq!(
history[1]["created_by"].as_str().unwrap(),
"RUN choco install -y curl"
);
}
#[tokio::test]
async fn emit_image_with_config_only_instructions() {
let cfg = dummy_config();
let mut skel = skeleton_with_foreign_base();
skel.instruction_log.push(ExecutedInstruction {
source_line: "ENV FOO=bar".to_string(),
produced_layer: false,
timestamp: Utc::now(),
});
skel.instruction_log.push(ExecutedInstruction {
source_line: "WORKDIR C:\\app".to_string(),
produced_layer: false,
timestamp: Utc::now(),
});
skel.image_config.env.push("FOO=bar".to_string());
skel.image_config.working_dir = Some("C:\\app".to_string());
let built = emit_image_impl(&cfg, &skel, "myimage:cfg").await.unwrap();
let manifest: serde_json::Value = serde_json::from_slice(&built.manifest_blob).unwrap();
let layers = manifest["layers"].as_array().unwrap();
assert_eq!(
layers.len(),
1,
"config-only instructions must NOT add layer descriptors"
);
let ic: serde_json::Value = serde_json::from_slice(&built.image_config_blob).unwrap();
let history = ic["history"].as_array().unwrap();
assert_eq!(
history.len(),
3,
"FROM + ENV + WORKDIR produces three history entries"
);
assert!(history[0].get("empty_layer").is_none());
assert_eq!(history[1]["empty_layer"], true);
assert_eq!(history[2]["empty_layer"], true);
assert_eq!(ic["config"]["WorkingDir"], "C:\\app");
assert_eq!(ic["config"]["Env"][0], "FOO=bar");
}
#[test]
fn compute_sha256_known_input() {
assert_eq!(
compute_sha256_hex(b"hello"),
"sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[tokio::test]
async fn foreign_layer_carries_urls_through_manifest() {
let cfg = dummy_config();
let skel = skeleton_with_foreign_base();
let built = emit_image_impl(&cfg, &skel, "myimage:foreign")
.await
.unwrap();
let foreign = &built.layers[0];
assert_eq!(foreign.media_type, FOREIGN_WINDOWS_LAYER_MEDIA_TYPE);
let urls = foreign
.urls
.as_ref()
.expect("foreign layer must carry an urls[] vector through BuiltImage");
assert!(
!urls.is_empty(),
"urls[] must be non-empty on a foreign layer"
);
assert!(urls[0].starts_with("https://mcr.microsoft.com/"));
let manifest: serde_json::Value = serde_json::from_slice(&built.manifest_blob).unwrap();
let l0 = &manifest["layers"][0];
let on_wire_urls = l0["urls"].as_array().expect("wire form must carry urls[]");
assert!(!on_wire_urls.is_empty());
}
#[tokio::test]
async fn emit_image_errors_when_os_version_unresolved() {
let cfg = dummy_config();
let mut skel = skeleton_with_foreign_base();
skel.base_manifest.os_version = None;
skel.base_manifest.config_blob = serde_json::json!({
"architecture": "amd64",
"os": "windows",
"rootfs": {
"type": "layers",
"diff_ids": ["sha256:base"],
},
"config": {},
})
.to_string()
.into_bytes();
let err = emit_image_impl(&cfg, &skel, "myimage:err")
.await
.expect_err("emit must error without an os.version");
assert!(
matches!(err, BuildError::OsVersionUnresolved),
"expected OsVersionUnresolved, got: {err}"
);
}
use std::sync::Mutex;
#[derive(Debug, Default, Clone)]
struct PushRecord {
uploaded_blob_digests: Vec<String>,
manifest_put: Option<(String, Vec<u8>, String)>,
}
struct RecordingPushTarget {
inner: Mutex<PushRecord>,
fail_on_digest: Option<String>,
}
impl RecordingPushTarget {
fn new() -> Self {
Self {
inner: Mutex::new(PushRecord::default()),
fail_on_digest: None,
}
}
fn with_failure(digest: impl Into<String>) -> Self {
Self {
inner: Mutex::new(PushRecord::default()),
fail_on_digest: Some(digest.into()),
}
}
fn snapshot(&self) -> PushRecord {
self.inner.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl PushTarget for RecordingPushTarget {
async fn upload_blob(
&self,
_reference: &str,
digest: &str,
_media_type: &str,
_data: Vec<u8>,
_auth: &RegistryAuth,
) -> std::result::Result<(), String> {
if let Some(fail) = &self.fail_on_digest {
if fail == digest {
return Err(format!("simulated upload failure for {digest}"));
}
}
self.inner
.lock()
.unwrap()
.uploaded_blob_digests
.push(digest.to_string());
Ok(())
}
async fn put_manifest(
&self,
reference: &str,
bytes: Vec<u8>,
content_type: &str,
_auth: &RegistryAuth,
) -> std::result::Result<(), String> {
self.inner.lock().unwrap().manifest_put =
Some((reference.to_string(), bytes, content_type.to_string()));
Ok(())
}
}
fn built_image_fixture() -> (BuiltImage, tempfile::TempDir) {
let tmp = tempfile::tempdir().expect("tmpdir");
let oci_blob_path = tmp.path().join("oci-layer.tar.gz");
std::fs::write(&oci_blob_path, b"fake-oci-layer-bytes").expect("write fake blob");
let layers = vec![
EmittedLayer {
media_type: FOREIGN_WINDOWS_LAYER_MEDIA_TYPE.to_string(),
digest: "sha256:foreign00000000000000000000000000000000000000000000000000000000"
.to_string(),
size: 12345,
diff_id: "sha256:foreigndiff0000000000000000000000000000000000000000000000000000"
.to_string(),
local_path: PathBuf::new(),
urls: Some(vec![
"https://mcr.microsoft.com/v2/windows/nanoserver/blobs/sha256:foreign"
.to_string(),
]),
},
EmittedLayer {
media_type: OCI_TAR_GZIP_LAYER_MEDIA_TYPE.to_string(),
digest: "sha256:oci11111111111111111111111111111111111111111111111111111111111111"
.to_string(),
size: 20,
diff_id: "sha256:ocidiff111111111111111111111111111111111111111111111111111111111"
.to_string(),
local_path: oci_blob_path,
urls: None,
},
];
let manifest_blob =
build_manifest_blob("sha256:config0000", 42, &layers).expect("manifest blob");
let manifest_digest = compute_sha256_hex(&manifest_blob);
let built = BuiltImage {
tag: "ghcr.io/zorpxinc/zlayer-test:wcow-0.1".to_string(),
image_config_blob: b"{\"fake\":\"config\"}".to_vec(),
image_config_digest: "sha256:config0000".to_string(),
manifest_blob,
manifest_digest,
layers,
};
(built, tmp)
}
#[tokio::test]
async fn push_skips_foreign_layers() {
let (built, _tmp) = built_image_fixture();
let target = RecordingPushTarget::new();
push_impl(&built, &built.tag, &RegistryAuth::Anonymous, &target)
.await
.expect("push must succeed against a noop target");
let rec = target.snapshot();
assert!(
!rec.uploaded_blob_digests
.iter()
.any(|d| d.contains("foreign")),
"foreign layer was uploaded but must be skipped; uploads = {:?}",
rec.uploaded_blob_digests
);
let oci_uploads = rec
.uploaded_blob_digests
.iter()
.filter(|d| d.starts_with("sha256:oci"))
.count();
assert_eq!(
oci_uploads, 1,
"OCI layer must be uploaded exactly once; uploads = {:?}",
rec.uploaded_blob_digests
);
assert!(
rec.uploaded_blob_digests
.iter()
.any(|d| d == "sha256:config0000"),
"image config blob must be uploaded; uploads = {:?}",
rec.uploaded_blob_digests
);
let (tag, _bytes, ct) = rec.manifest_put.as_ref().expect("manifest PUT recorded");
assert_eq!(tag, &built.tag);
assert_eq!(ct, OCI_IMAGE_MANIFEST_MEDIA_TYPE);
}
#[tokio::test]
async fn push_manifest_preserves_foreign_urls() {
let (built, _tmp) = built_image_fixture();
let target = RecordingPushTarget::new();
push_impl(&built, &built.tag, &RegistryAuth::Anonymous, &target)
.await
.expect("push must succeed");
let rec = target.snapshot();
let (_tag, bytes, _ct) = rec.manifest_put.expect("manifest PUT recorded");
assert_eq!(
bytes, built.manifest_blob,
"manifest PUT bytes must be byte-identical to BuiltImage::manifest_blob"
);
let manifest: serde_json::Value =
serde_json::from_slice(&bytes).expect("PUT bytes must be valid JSON");
let layer0 = &manifest["layers"][0];
assert_eq!(layer0["mediaType"], FOREIGN_WINDOWS_LAYER_MEDIA_TYPE);
let urls = layer0["urls"]
.as_array()
.expect("foreign layer urls[] must survive the PUT");
assert_eq!(urls.len(), 1);
assert_eq!(
urls[0].as_str().unwrap(),
"https://mcr.microsoft.com/v2/windows/nanoserver/blobs/sha256:foreign"
);
assert!(
manifest["layers"][1].get("urls").is_none(),
"non-foreign layer must not carry a urls[] array on the wire"
);
}
#[tokio::test]
async fn push_failure_surfaces_typed_error() {
let (built, _tmp) = built_image_fixture();
let oci_digest = built.layers[1].digest.clone();
let target = RecordingPushTarget::with_failure(&oci_digest);
let err = push_impl(&built, &built.tag, &RegistryAuth::Anonymous, &target)
.await
.expect_err("push must fail when upload_blob errors");
match err {
BuildError::BlobUploadFailed { digest, tag, .. } => {
assert_eq!(digest, oci_digest);
assert_eq!(tag, built.tag);
}
other => panic!("expected BuildError::BlobUploadFailed, got: {other}"),
}
}
#[tokio::test]
#[ignore = "live network: requires GHCR creds + mcr.microsoft.com/windows/nanoserver:ltsc2022 base + Windows host"]
async fn build_and_push_e2e() {
let cfg = dummy_config();
let builder = WindowsBuilder::new(cfg);
let tmp = tempfile::tempdir().expect("tmpdir");
let ctx_dir = tmp.path().join("ctx");
std::fs::create_dir_all(&ctx_dir).expect("mk ctx");
std::fs::write(
ctx_dir.join("Dockerfile"),
"FROM mcr.microsoft.com/windows/nanoserver:ltsc2022\n",
)
.expect("write dockerfile");
let ctx = BuildContext {
context_dir: ctx_dir,
dockerfile_path: PathBuf::from("Dockerfile"),
build_args: HashMap::new(),
tag: "ghcr.io/zorpxinc/zlayer-wcow-e2e:latest".to_string(),
ltsc: None,
};
builder
.build_and_push(&ctx)
.await
.expect("live build_and_push");
}
#[tokio::test]
async fn build_image_for_backend_rejects_multi_stage_and_emits_no_events_on_early_error() {
let cfg = dummy_config();
let builder = WindowsBuilder::new(cfg);
let dockerfile = Dockerfile::parse(
"FROM mcr.microsoft.com/windows/nanoserver:ltsc2022 AS one\n\
FROM mcr.microsoft.com/windows/nanoserver:ltsc2022 AS two\n",
)
.expect("two-stage parse");
let options = crate::builder::BuildOptions {
tags: vec!["test:latest".to_string()],
..Default::default()
};
let (tx, rx) = std::sync::mpsc::channel::<crate::tui::BuildEvent>();
let context = std::env::temp_dir();
let result = builder
.build_image_for_backend(&context, &dockerfile, &options, Some(&tx))
.await;
match result {
Err(BuildError::NotSupported { operation }) => {
assert!(
operation.contains("multi-stage Windows builds"),
"expected multi-stage NotSupported, got: {operation}"
);
}
other => panic!("expected NotSupported, got: {other:?}"),
}
drop(tx);
let received: Vec<_> = rx.try_iter().collect();
assert!(
received.is_empty(),
"no events should be emitted when multi-stage gate fires; got {received:?}"
);
}
#[tokio::test]
async fn build_image_for_backend_emits_started_then_failed_off_windows() {
let cfg = dummy_config();
let builder = WindowsBuilder::new(cfg);
let dockerfile =
Dockerfile::parse("FROM mcr.microsoft.com/windows/nanoserver:ltsc2022\nRUN echo hi\n")
.expect("one-stage parse");
let options = crate::builder::BuildOptions {
tags: vec!["smoke:latest".to_string()],
..Default::default()
};
let (tx, rx) = std::sync::mpsc::channel::<crate::tui::BuildEvent>();
let context = std::env::temp_dir();
let result = builder
.build_image_for_backend(&context, &dockerfile, &options, Some(&tx))
.await;
drop(tx);
let events: Vec<_> = rx.try_iter().collect();
assert!(
result.is_err(),
"smoke test must fail because base materialisation cannot succeed in the unit-test env"
);
assert!(
events
.iter()
.any(|e| matches!(e, crate::tui::BuildEvent::BuildStarted { .. })),
"BuildStarted must fire before base materialisation; got events = {events:?}"
);
assert!(
events
.iter()
.any(|e| matches!(e, crate::tui::BuildEvent::StageStarted { .. })),
"StageStarted must fire before base materialisation; got events = {events:?}"
);
}
}