#![cfg(target_os = "windows")]
use std::io;
use std::path::{Path, PathBuf};
use base64::Engine as _;
use oci_client::secrets::RegistryAuth;
use zlayer_hcs::schema::Layer;
use crate::windows::backuptar;
use crate::windows::layer;
use crate::windows::wclayer::{self, LayerChain};
#[derive(Debug, Clone)]
pub struct ResolvedLayerDescriptor {
pub digest: String,
pub media_type: String,
pub size: i64,
pub urls: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct UnpackedImage {
pub chain: LayerChain,
pub root: PathBuf,
}
pub async fn unpack_windows_image(
puller: &zlayer_registry::client::ImagePuller,
image: &str,
auth: &RegistryAuth,
layers: &[ResolvedLayerDescriptor],
dest_root: &Path,
) -> io::Result<UnpackedImage> {
layer::enable_backup_restore_privileges()?;
std::fs::create_dir_all(dest_root)?;
let mut chain_so_far: Vec<Layer> = Vec::with_capacity(layers.len());
for (layer_idx, desc) in layers.iter().enumerate() {
let layer_id = new_layer_id();
let layer_path = dest_root.join(&layer_id);
std::fs::create_dir_all(&layer_path)?;
let bytes = puller
.pull_blob_with_urls(image, &desc.digest, auth, &desc.urls, Some(desc.size))
.await
.map_err(|e| io::Error::other(format!("pull blob {}: {e}", desc.digest)))?;
verify_digest(&bytes, &desc.digest)?;
let raw = decompress(&bytes, &desc.media_type)?;
let is_base_layer = chain_so_far.is_empty();
if is_base_layer {
extract_tar_as_base_layer(&raw, &layer_path)?;
wclayer::process_base_layer(&layer_path).map_err(|e| {
io::Error::other(format!(
"ProcessBaseLayer(layer={layer_idx} digest={} dest={}): {e}",
desc.digest,
layer_path.display()
))
})?;
let uvm_dir = layer_path.join("UtilityVM");
if uvm_dir.join("Files").is_dir() {
wclayer::process_utility_vm_image(&uvm_dir).map_err(|e| {
io::Error::other(format!(
"ProcessUtilityVMImage(layer={layer_idx} digest={} dest={}): {e}",
desc.digest,
uvm_dir.display()
))
})?;
}
} else {
let staging_path = dest_root.join(format!("{layer_id}.staging"));
std::fs::create_dir_all(&staging_path)?;
let parent_chain = build_parent_chain(&chain_so_far);
let pending_links =
extract_tar_as_diff_layer(&raw, &staging_path, parent_chain.0.as_slice())?;
wclayer::import_layer(&layer_path, &staging_path, &parent_chain).map_err(|e| {
io::Error::other(format!(
"HcsImportLayer(layer={layer_idx} digest={} dest={}): {e}",
desc.digest,
layer_path.display()
))
})?;
for link in &pending_links {
let dest_link = layer_path.join(&link.link_rel);
let dest_target = layer_path.join(&link.target_rel);
if let Err(e) = wclayer::link_relative(&layer_path, &dest_target, &dest_link) {
return Err(io::Error::other(format!(
"post-import hard_link({} -> {}) in layer {}: {e} \
(target resolved from {} during tar walk)",
dest_link.display(),
dest_target.display(),
layer_path.display(),
link.target_origin,
)));
}
}
let _ = std::fs::remove_dir_all(&staging_path);
}
chain_so_far.push(Layer {
id: wclayer::layer_id_for_path(&layer_path)?,
path: layer_path.to_string_lossy().into_owned(),
});
}
let mut final_chain = chain_so_far;
final_chain.reverse();
Ok(UnpackedImage {
chain: LayerChain::new(final_chain),
root: dest_root.to_path_buf(),
})
}
fn build_parent_chain(base_first: &[Layer]) -> LayerChain {
let parents: Vec<Layer> = base_first.iter().rev().cloned().collect();
LayerChain::new(parents)
}
fn new_layer_id() -> String {
uuid::Uuid::new_v4().to_string()
}
fn decompress(bytes: &[u8], media_type: &str) -> io::Result<Vec<u8>> {
use std::io::Read as _;
let mt = media_type.to_ascii_lowercase();
if mt.ends_with("+gzip") || mt.ends_with(".tar.gzip") {
let mut d = flate2::read::GzDecoder::new(bytes);
let mut out = Vec::new();
d.read_to_end(&mut out)?;
Ok(out)
} else if mt.ends_with("+zstd") || mt.ends_with(".tar.zstd") {
let mut d = zstd::stream::read::Decoder::new(bytes)?;
let mut out = Vec::new();
d.read_to_end(&mut out)?;
Ok(out)
} else {
Ok(bytes.to_vec())
}
}
fn verify_digest(bytes: &[u8], expected: &str) -> io::Result<()> {
use sha2::{Digest, Sha256};
let expected_hex = expected.trim_start_matches("sha256:");
let mut hasher = Sha256::new();
hasher.update(bytes);
let got = hex::encode(hasher.finalize());
if !got.eq_ignore_ascii_case(expected_hex) {
return Err(io::Error::other(format!(
"blob digest mismatch: expected sha256:{expected_hex}, got sha256:{got}"
)));
}
Ok(())
}
#[derive(Debug, Clone)]
pub(crate) struct PendingLink {
pub link_rel: PathBuf,
pub target_rel: PathBuf,
pub target_origin: TargetOrigin,
}
#[derive(Debug, Clone)]
pub(crate) enum TargetOrigin {
CurrentLayer,
ParentLayer(PathBuf),
}
impl std::fmt::Display for TargetOrigin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CurrentLayer => f.write_str("current layer's staging dir"),
Self::ParentLayer(p) => write!(f, "parent layer {}", p.display()),
}
}
}
#[allow(clippy::too_many_lines)]
fn extract_tar_as_diff_layer(
tar_bytes: &[u8],
layer_path: &Path,
parents: &[Layer],
) -> io::Result<Vec<PendingLink>> {
use std::collections::HashSet;
let mut archive = tar::Archive::new(tar_bytes);
let mut pending_links: Vec<PendingLink> = Vec::new();
let mut added_files: HashSet<String> = HashSet::new();
let mut pending_tombstones: Vec<String> = Vec::new();
let mut raw_tombstones_written = false;
for entry in archive.entries()? {
let mut entry = entry?;
let rel_path = entry.path()?.into_owned();
let entry_type = entry.header().entry_type();
if entry_type.is_dir() {
let dest = layer_path.join(&rel_path);
create_long_path_dir_all(&dest)?;
write_wcidirs_sidecar(&mut entry, &rel_path, &dest)?;
continue;
}
if let Some(basename) = rel_path.file_name().and_then(|s| s.to_str()) {
if let Some(stripped) = basename.strip_prefix(".wh.") {
let sibling: PathBuf = match rel_path.parent() {
Some(p) if !p.as_os_str().is_empty() => p.join(stripped),
_ => PathBuf::from(stripped),
};
let normalised: String = sibling
.to_string_lossy()
.replace('\\', "/")
.trim_start_matches('/')
.to_string();
if !normalised.is_empty() {
pending_tombstones.push(normalised);
}
continue;
}
}
let dest = layer_path.join(&rel_path);
if let Some(parent) = dest.parent() {
create_long_path_dir_all(parent)?;
}
if entry_type == tar::EntryType::Link {
let link_target = entry.link_name()?.ok_or_else(|| {
io::Error::other(format!(
"tar hardlink entry missing link_name: {}",
rel_path.display()
))
})?;
let target_rel = link_target.into_owned();
let target_key = path_to_forward_slash(&target_rel);
let link_key = path_to_forward_slash(&rel_path);
let origin = if added_files.contains(&target_key) {
TargetOrigin::CurrentLayer
} else {
let mut found: Option<PathBuf> = None;
for parent in parents {
let parent_root = PathBuf::from(&parent.path);
let candidate = parent_root.join(&target_rel);
if candidate.exists() {
found = Some(parent_root);
break;
}
}
match found {
Some(root) => TargetOrigin::ParentLayer(root),
None => {
return Err(io::Error::other(format!(
"hardlink {} -> {}: target not found in this \
layer's staging dir nor in any of the {} parent \
layer(s)",
link_key,
target_key,
parents.len(),
)));
}
}
};
added_files.insert(link_key.clone());
pending_links.push(PendingLink {
link_rel: rel_path.clone(),
target_rel: target_rel.clone(),
target_origin: origin,
});
continue;
}
let rel_str = rel_path.to_string_lossy();
let is_files_payload = rel_str.starts_with("Files/") || rel_str.starts_with("Files\\");
if is_files_payload {
backuptar::write_oci_entry_to_backup_stream(&mut entry, &dest)?;
added_files.insert(path_to_forward_slash(&rel_path));
} else {
let mut f = layer::create_long_path_file(&dest)?;
std::io::copy(&mut entry, &mut f)?;
if rel_str == "tombstones.txt" || rel_str == "tombstones" {
raw_tombstones_written = true;
}
added_files.insert(path_to_forward_slash(&rel_path));
}
}
if !pending_tombstones.is_empty() {
let tombstones_path = layer_path.join("tombstones.txt");
let mut existing: Vec<String> = Vec::new();
if raw_tombstones_written && tombstones_path.exists() {
let bytes = std::fs::read(&tombstones_path)?;
for line in bytes.split(|&b| b == b'\n') {
if line.is_empty() {
continue;
}
existing.push(String::from_utf8_lossy(line).trim().to_string());
}
}
let mut seen: HashSet<String> = existing.iter().cloned().collect();
let mut all_lines = existing;
for line in pending_tombstones {
if seen.insert(line.clone()) {
all_lines.push(line);
}
}
let body = all_lines.join("\n") + "\n";
let mut f = layer::create_long_path_file(&tombstones_path)?;
std::io::Write::write_all(&mut f, body.as_bytes())?;
}
Ok(pending_links)
}
fn path_to_forward_slash(p: &Path) -> String {
p.to_string_lossy()
.replace('\\', "/")
.trim_start_matches('/')
.to_string()
}
fn extract_tar_as_base_layer(tar_bytes: &[u8], layer_path: &Path) -> io::Result<()> {
let mut archive = tar::Archive::new(tar_bytes);
let mut pending_links: Vec<(PathBuf, PathBuf)> = Vec::new();
for entry in archive.entries()? {
let mut entry = entry?;
let rel_path = entry.path()?.into_owned();
let entry_type = entry.header().entry_type();
if entry_type.is_dir() {
let dest = layer_path.join(&rel_path);
create_long_path_dir_all(&dest)?;
continue;
}
if let Some(basename) = rel_path.file_name().and_then(|s| s.to_str()) {
if basename.starts_with(".wh.") {
return Err(io::Error::other(format!(
"base layer cannot have tombstones (got whiteout entry {})",
rel_path.display()
)));
}
}
let dest = layer_path.join(&rel_path);
if let Some(parent) = dest.parent() {
create_long_path_dir_all(parent)?;
}
if entry_type == tar::EntryType::Link {
let link_target = entry.link_name()?.ok_or_else(|| {
io::Error::other(format!(
"tar hardlink entry missing link_name: {}",
rel_path.display()
))
})?;
pending_links.push((rel_path.clone(), link_target.into_owned()));
continue;
}
let rel_str = rel_path.to_string_lossy();
let is_files_payload = rel_str.starts_with("Files/")
|| rel_str.starts_with("Files\\")
|| rel_str.starts_with("UtilityVM/")
|| rel_str.starts_with("UtilityVM\\");
if is_files_payload {
backuptar::write_oci_entry_as_base_layer(&mut entry, &dest)?;
} else {
let mut f = layer::create_long_path_file(&dest)?;
std::io::copy(&mut entry, &mut f)?;
}
}
for (link_rel, target_rel) in pending_links {
let link_abs = layer_path.join(&link_rel);
let target_abs = layer_path.join(&target_rel);
if let Some(parent) = link_abs.parent() {
create_long_path_dir_all(parent)?;
}
if let Err(e) = std::fs::hard_link(&target_abs, &link_abs) {
return Err(io::Error::other(format!(
"hard_link({} -> {}): {e}",
link_abs.display(),
target_abs.display()
)));
}
}
Ok(())
}
fn write_wcidirs_sidecar<R: std::io::Read>(
entry: &mut tar::Entry<'_, R>,
rel_path: &Path,
dest: &Path,
) -> io::Result<()> {
let rel_norm = rel_path.to_string_lossy().replace('\\', "/");
let is_uvm =
rel_norm == "UtilityVM" || rel_norm == "UtilityVM/" || rel_norm.starts_with("UtilityVM/");
if is_uvm {
return Ok(());
}
let mut attrs: u32 = 0x0000_0010;
let mut reparse: Option<Vec<u8>> = None;
if let Some(pax) = entry.pax_extensions()? {
let engine = base64::engine::general_purpose::STANDARD;
for ext in pax {
let ext = ext?;
match ext.key().unwrap_or("") {
"MSWINDOWS.fileattr" => {
if let Some(parsed) = std::str::from_utf8(ext.value_bytes())
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
{
attrs = parsed;
}
}
"MSWINDOWS.reparse" => {
reparse = Some(engine.decode(ext.value_bytes()).map_err(|e| {
io::Error::other(format!("PAX MSWINDOWS.reparse base64 decode: {e}"))
})?);
}
_ => {}
}
}
}
let dirname = dest.file_name().ok_or_else(|| {
io::Error::other(format!(
"tar dir entry has no file_name: {}",
rel_path.display()
))
})?;
let mut marker_name = dirname.to_os_string();
marker_name.push(".$wcidirs$");
let marker_path = match dest.parent() {
Some(p) => p.join(&marker_name),
None => PathBuf::from(&marker_name),
};
let mut f = layer::create_long_path_file(&marker_path)?;
std::io::Write::write_all(&mut f, &attrs.to_le_bytes())?;
if let Some(rp) = reparse {
backuptar::write_stream_header(&mut f, backuptar::BACKUP_REPARSE_DATA, 0, rp.len() as u64)?;
std::io::Write::write_all(&mut f, &rp)?;
}
Ok(())
}
fn create_long_path_dir_all(dir: &Path) -> io::Result<()> {
if dir.as_os_str().is_empty() {
return Ok(());
}
match std::fs::create_dir_all(dir) {
Ok(()) => Ok(()),
Err(e) => {
let mut to_create: Vec<&Path> = dir.ancestors().collect();
to_create.reverse();
for component in to_create {
if component.as_os_str().is_empty() {
continue;
}
if component.is_dir() {
continue;
}
layer::create_long_path_dir(component).map_err(|inner| {
io::Error::other(format!(
"create_dir_all fallback for {} (initial {e}): {inner}",
component.display()
))
})?;
}
Ok(())
}
}
}
#[derive(Debug, Clone)]
pub struct UvmBootFiles {
pub uvm_layer_dir: std::path::PathBuf,
pub os_files_dir: std::path::PathBuf,
pub system_template_vhdx: std::path::PathBuf,
pub boot_rel_path: &'static str,
}
pub fn locate_uvm_boot_files(
chain: &crate::windows::wclayer::LayerChain,
) -> std::io::Result<UvmBootFiles> {
const BOOT_REL: &str = r"\EFI\Microsoft\Boot\bootmgfw.efi";
for layer in chain.0.iter().rev() {
let layer_dir = std::path::PathBuf::from(&layer.path);
let uvm_dir = layer_dir.join("UtilityVM");
let files_dir = uvm_dir.join("Files");
let bootmgfw = files_dir
.join("EFI")
.join("Microsoft")
.join("Boot")
.join("bootmgfw.efi");
if bootmgfw.is_file() {
let template = uvm_dir.join("SystemTemplate.vhdx");
if !template.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"layer {:?} has UtilityVM\\Files but is missing SystemTemplate.vhdx — \
image's UVM payload is incomplete",
layer.path
),
));
}
return Ok(UvmBootFiles {
uvm_layer_dir: uvm_dir,
os_files_dir: files_dir,
system_template_vhdx: template,
boot_rel_path: BOOT_REL,
});
}
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no layer in chain contains a UtilityVM payload \
(image is not a Hyper-V-bootable Windows base image)",
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn digest_verify_accepts_matching_hash() {
let bytes = b"hello world";
let digest = "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
verify_digest(bytes, digest).expect("should match");
}
#[test]
fn digest_verify_rejects_mismatch() {
let err = verify_digest(
b"hello world",
"sha256:0000000000000000000000000000000000000000000000000000000000000000",
)
.expect_err("should reject");
assert!(err.to_string().contains("digest mismatch"));
}
#[test]
fn digest_verify_is_case_insensitive() {
let bytes = b"hello world";
let upper = "sha256:B94D27B9934D3E08A52E52D7DA7DABFAC484EFE37A5380EE9088F7ACE2EFCDE9";
verify_digest(bytes, upper).expect("should match");
}
#[test]
fn decompress_passthrough_for_unknown_media_type() {
let out = decompress(b"not compressed", "application/octet-stream").expect("ok");
assert_eq!(out, b"not compressed");
}
#[test]
fn decompress_gzip_roundtrip() {
use std::io::Write as _;
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(b"hello tar").unwrap();
let compressed = gz.finish().unwrap();
let out = decompress(&compressed, "application/vnd.oci.image.layer.v1.tar+gzip")
.expect("decompress");
assert_eq!(out, b"hello tar");
}
#[test]
fn build_parent_chain_reverses_base_first_to_child_first() {
let base_first = vec![
Layer {
id: "base".into(),
path: r"C:\l\base".into(),
},
Layer {
id: "mid".into(),
path: r"C:\l\mid".into(),
},
Layer {
id: "top".into(),
path: r"C:\l\top".into(),
},
];
let chain = build_parent_chain(&base_first);
assert_eq!(chain.0.len(), 3);
assert_eq!(chain.0[0].id, "top");
assert_eq!(chain.0[1].id, "mid");
assert_eq!(chain.0[2].id, "base");
}
#[test]
fn build_parent_chain_handles_empty() {
let chain = build_parent_chain(&[]);
assert!(chain.0.is_empty());
}
#[test]
fn new_layer_id_is_unique_and_uuid_shaped() {
let a = new_layer_id();
let b = new_layer_id();
assert_ne!(a, b);
assert_eq!(a.len(), 36); }
}