mkwim 0.1.0

Create a bootable Windows installation image from an ISO
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2026 Jarkko Sakkinen

//! Read a Windows installation ISO without mounting it.
//!
//! [`isomage`] parses ISO 9660 (with Joliet/Rock Ridge) and UDF disc images
//! directly, returning an in-memory directory tree whose file entries carry
//! byte ranges into the image. Files are then streamed onto the target filesystem
//! with [`cat_node`], so the ISO is never loop-mounted and no archiver is
//! spawned.

use std::fs::{self, File};
use std::io::{Seek, Write};
use std::path::Path;

use anyhow::{Context, Result};
use isomage::{TreeNode, cat_node, detect_and_parse_filesystem, udf::parse_udf};

/// Open and parse an ISO image, returning the underlying file handle and the
/// parsed directory tree. UDF is preferred because Windows media commonly has
/// a minimal ISO 9660 bridge containing only `README.TXT`. The handle must be
/// kept around so file data can be streamed from it later.
pub fn open(path: &Path) -> Result<(File, TreeNode)> {
    let mut file = File::open(path).with_context(|| format!("open ISO {}", path.display()))?;
    let root = if let Ok(root) = parse_udf(&mut file) {
        root
    } else {
        file.rewind()
            .with_context(|| format!("rewind ISO {}", path.display()))?;
        detect_and_parse_filesystem(&mut file, &path.to_string_lossy())
            .map_err(|e| anyhow::anyhow!("parse ISO {}: {e}", path.display()))?
    };
    Ok((file, root))
}

/// Locate `sources/install.wim` in the tree, tolerating case differences in the
/// directory/file names used by different Windows media.
pub fn find_install_wim(root: &TreeNode) -> Option<&TreeNode> {
    if let Some(node) = root.find_node("sources/install.wim") {
        return Some(node);
    }
    let sources = root
        .children
        .iter()
        .find(|c| c.is_directory && c.name.eq_ignore_ascii_case("sources"))?;
    sources
        .children
        .iter()
        .find(|c| !c.is_directory && c.name.eq_ignore_ascii_case("install.wim"))
}

/// Return the total size of all files in an ISO tree.
pub fn total_file_size(node: &TreeNode) -> Result<u64> {
    if node.is_directory {
        node.children.iter().try_fold(0_u64, |size, child| {
            let child_size = total_file_size(child)?;
            size.checked_add(child_size)
                .ok_or_else(|| anyhow::anyhow!("ISO file size exceeds u64"))
        })
    } else {
        Ok(node.size)
    }
}

/// Stream a single file node from `iso` into `writer`.
pub fn cat(iso: &mut File, node: &TreeNode, writer: &mut impl Write) -> Result<()> {
    cat_node(iso, node, writer).map_err(|e| anyhow::anyhow!("stream file from ISO: {e}"))
}

/// Recursively copy the ISO subtree rooted at `node` (relative path `rel`)
/// into `dest_root`. `skip` receives the POSIX-style relative path of each
/// entry (e.g. `sources/install.wim`) and may return `true` to omit it; the
/// skipped entry is then handled specially by the caller. `rep` is updated
/// with the path of the entry currently being copied.
pub fn copy_tree(
    iso: &mut File,
    node: &TreeNode,
    rel: &str,
    dest_root: &Path,
    skip: &dyn Fn(&str) -> bool,
    rep: &crate::progress::Reporter,
) -> Result<()> {
    if !rel.is_empty() {
        rep.set_message(rel);
        if skip(rel) {
            tracing::debug!("skipping {rel} (handled separately)");
            return Ok(());
        }
    }

    let dest = if rel.is_empty() {
        dest_root.to_path_buf()
    } else {
        dest_root.join(rel)
    };

    if node.is_directory {
        fs::create_dir_all(&dest).with_context(|| format!("mkdir {}", dest.display()))?;
        for child in &node.children {
            let child_rel = if rel.is_empty() {
                child.name.clone()
            } else {
                format!("{rel}/{}", child.name)
            };
            copy_tree(iso, child, &child_rel, dest_root, skip, rep)?;
        }
    } else {
        if let Some(parent) = dest.parent() {
            fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
        }
        let mut out = File::create(&dest).with_context(|| format!("create {}", dest.display()))?;
        cat(iso, node, &mut out)?;
        out.sync_all()
            .with_context(|| format!("sync {}", dest.display()))?;
        tracing::debug!("copied {} ({} bytes)", rel, node.size);
    }

    Ok(())
}

/// Recursively copy an ISO subtree into a FAT filesystem directory.
pub fn copy_tree_to_fat(
    iso: &mut File,
    node: &TreeNode,
    rel: &str,
    dest_root: &crate::disk::FatDir<'_>,
    skip: &dyn Fn(&str) -> bool,
    rep: &crate::progress::Reporter,
) -> Result<()> {
    if !rel.is_empty() {
        rep.set_message(rel);
        if skip(rel) {
            tracing::debug!("skipping {rel} (handled separately)");
            return Ok(());
        }
    }

    if node.is_directory {
        if !rel.is_empty() {
            dest_root
                .create_dir(rel)
                .with_context(|| format!("mkdir {rel} in image"))?;
        }
        for child in &node.children {
            let child_rel = if rel.is_empty() {
                child.name.clone()
            } else {
                format!("{rel}/{}", child.name)
            };
            copy_tree_to_fat(iso, child, &child_rel, dest_root, skip, rep)?;
        }
    } else {
        let mut out = crate::disk::create_image_file(dest_root, rel)
            .with_context(|| format!("create {rel} in image"))?;
        cat(iso, node, &mut out)?;
        out.flush()
            .with_context(|| format!("flush {rel} in image"))?;
        tracing::debug!("copied {} ({} bytes)", rel, node.size);
    }

    Ok(())
}