Files
OpenPXE/crates/iso-store/src/windows.rs
T
2026-05-21 02:13:08 -04:00

264 lines
9.8 KiB
Rust

//! Windows ISO post-processing. Patches `boot.wim` (image index 2, WinPE)
//! with two plain-text files so the client hits our SMB share and runs
//! Windows Setup from there.
//!
//! Credit: the *technique* (not the code) is adapted from Bootimus
//! (Apache-2.0, https://github.com/garybowers/bootimus). We reimplement in
//! Rust and shell out to `wimlib-imagex` at container runtime because
//! there is no maintained pure-Rust wimlib binding.
//!
//! What we inject — and why these are safe:
//!
//! * `Windows/System32/winpeshl.ini`: a plain INI that WinPE reads at
//! startup and uses to launch `startnet.cmd` instead of the default
//! interactive shell. No driver, no executable, no signed code.
//!
//! * `Windows/System32/startnet.cmd`: a batch file that runs `wpeinit`,
//! waits for a DHCP lease, then `net use Z: \\<server>\<share> /user:guest`
//! and invokes `Z:\setup.exe`. Everything the client executes is stock
//! Microsoft-signed WinPE + `setup.exe`. We add zero native code to
//! the client's boot path. The trust store is untouched.
//!
//! What we *do not* inject:
//! * No `.sys` drivers, signed or otherwise.
//! * No `.cer`, no registry hive edits, no `bcdedit` changes.
//! * No `bypass*` Windows 11 tweaks (operators who want those can use an
//! unattend.xml; they will never be injected silently by us).
use std::path::{Path, PathBuf};
use std::process::Command;
/// Public identifier of whether/how Windows patching ran for an ISO.
/// Stored on `IsoMeta` so the UI can show a clear "SMB ready" indicator.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WinPatchState {
/// Not a Windows ISO, nothing to do.
NotApplicable,
/// Windows ISO detected but Windows support is disabled in settings.
DisabledBySettings,
/// wimlib-imagex isn't on PATH — operator needs to install the runtime
/// dependency before Windows ISOs can be patched.
WimlibMissing,
/// Patching succeeded; the ISO's boot.wim was rewritten in-place.
Patched { smb_host: String, smb_share: String },
/// wimlib returned an error.
Failed { reason: String },
}
pub struct WimPatcher {
pub smb_host: String,
pub smb_share: String,
}
impl WimPatcher {
#[must_use]
pub fn new(smb_host: String, smb_share: String) -> Self {
Self {
smb_host,
smb_share,
}
}
/// Apply WinPE patches to `boot.wim` inside `extracted_iso_dir`. Returns
/// a state enum — never panics. Designed to be safely re-runnable; each
/// call rebuilds image 2 from scratch via `wimlib-imagex update`.
pub fn patch(&self, extracted_iso_dir: &Path) -> WinPatchState {
if !wimlib_present() {
return WinPatchState::WimlibMissing;
}
let boot_wim = extracted_iso_dir.join("sources").join("boot.wim");
if !boot_wim.exists() {
// Not a standard Windows install ISO layout.
return WinPatchState::NotApplicable;
}
let work = match tempfile::tempdir() {
Ok(d) => d,
Err(e) => {
return WinPatchState::Failed {
reason: format!("tempdir: {e}"),
}
}
};
// Stage the two files we want present at /Windows/System32/.
let staging = work.path().join("stage/Windows/System32");
if let Err(e) = std::fs::create_dir_all(&staging) {
return WinPatchState::Failed {
reason: format!("staging mkdir: {e}"),
};
}
if let Err(e) = std::fs::write(staging.join("winpeshl.ini"), WINPESHL_INI) {
return WinPatchState::Failed {
reason: format!("write winpeshl.ini: {e}"),
};
}
let startnet = render_startnet(&self.smb_host, &self.smb_share);
if let Err(e) = std::fs::write(staging.join("startnet.cmd"), startnet) {
return WinPatchState::Failed {
reason: format!("write startnet.cmd: {e}"),
};
}
// Build a wimlib update command file:
// add <stage>/Windows/System32 /Windows/System32
let update_file = work.path().join("update.cmd");
let update_cmd = format!("add \"{}\" \"/Windows/System32\"\n", staging.display());
if let Err(e) = std::fs::write(&update_file, update_cmd) {
return WinPatchState::Failed {
reason: format!("write update.cmd: {e}"),
};
}
// Run wimlib-imagex update against image index 2 (WinPE).
let output = Command::new("wimlib-imagex")
.arg("update")
.arg(&boot_wim)
.arg("2")
.arg("--rebuild")
.arg("--command-file")
.arg(&update_file)
.output();
match output {
Ok(o) if o.status.success() => WinPatchState::Patched {
smb_host: self.smb_host.clone(),
smb_share: self.smb_share.clone(),
},
Ok(o) => WinPatchState::Failed {
reason: format!(
"wimlib-imagex update failed (exit {:?}): {}",
o.status.code(),
String::from_utf8_lossy(&o.stderr)
),
},
Err(e) => WinPatchState::Failed {
reason: format!("spawn wimlib-imagex: {e}"),
},
}
}
}
fn wimlib_present() -> bool {
which("wimlib-imagex").is_some()
}
fn which(cmd: &str) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&paths) {
let p = dir.join(cmd);
if p.is_file() {
return Some(p);
}
}
None
}
/// The winpeshl.ini contents. This file tells WinPE "don't run cmd.exe
/// interactively; run startnet.cmd and exit when it returns".
const WINPESHL_INI: &str = "[LaunchApps]\r\n\
\"%SYSTEMROOT%\\system32\\startnet.cmd\"\r\n";
/// Render startnet.cmd. The script:
/// 1. Loads WinPE networking (`wpeinit`) and renews DHCP.
/// 2. Waits until the SMB server is reachable.
/// 3. Maps the install share to Z: as guest.
/// 4. Runs setup.exe from the share.
///
/// Uses CRLF line endings because WinPE cmd.exe requires them for .cmd files
/// created on unix hosts.
fn render_startnet(host: &str, share: &str) -> String {
use std::fmt::Write as _;
let host = host.trim();
let share = share.trim_matches('/');
let mut s = String::new();
// Windows-style CRLF; consumed verbatim by cmd.exe inside WinPE.
// Bootimus v0.1.58 lesson: surface `net use` errors instead of
// tight-looping on a blind retry. We retry but log every miss.
s.push_str("@echo off\r\n");
s.push_str("echo OpenPXE WinPE bootstrap\r\n");
s.push_str("wpeinit\r\n");
// v0.1.58: explicitly start Workstation before mapping the share —
// `net use` otherwise lazily inits SMB-client and races wpeinit.
s.push_str("net start Workstation >nul 2>&1\r\n");
s.push_str("ipconfig /renew\r\n");
writeln!(s, "echo Waiting for SMB server {host} to be reachable...\r").unwrap();
writeln!(
s,
":waitsmb\r\nping -n 1 -w 500 {host} >nul && goto havenet\r\n\
timeout /t 2 /nobreak >nul\r\ngoto waitsmb\r"
)
.unwrap();
s.push_str(":havenet\r\n");
writeln!(
s,
"echo Mapping install media from \\\\{host}\\{share}...\r"
)
.unwrap();
writeln!(
s,
":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\n\
timeout /t 3 /nobreak >nul\r\ngoto mapshare\r"
)
.unwrap();
s.push_str(":mapped\r\n");
s.push_str("echo Starting Windows Setup\r\n");
s.push_str("Z:\\setup.exe\r\n");
s.push_str("echo Setup exited; dropping to cmd for diagnosis\r\n");
s.push_str("cmd\r\n");
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn startnet_has_crlf_and_no_testsigning() {
let s = render_startnet("10.0.0.5", "win11");
assert!(s.contains("\r\n"));
// Hard guard: must never include trust-store or driver-policy mutations.
assert!(!s.to_lowercase().contains("bcdedit"));
assert!(!s.to_lowercase().contains("testsigning"));
assert!(!s.to_lowercase().contains("certutil"));
assert!(s.contains("net use Z:"));
assert!(s.contains("setup.exe"));
}
#[test]
fn startnet_primes_workstation_and_surfaces_mapping_errors() {
let s = render_startnet("10.0.0.5", "win11");
assert!(
s.contains("net start Workstation"),
"WinPE should explicitly start the SMB client before net use:\n{s}"
);
let net_use_line = s
.lines()
.find(|line| line.contains("net use Z:"))
.expect("net use line");
assert!(
!net_use_line.contains(">nul"),
"net use errors must remain visible in WinPE console: {net_use_line}"
);
}
#[test]
fn patcher_reports_wimlib_missing_gracefully() {
// We don't assume wimlib is present in CI; this checks the missing
// branch is the noisy-but-survivable one we expect.
let patcher = WimPatcher::new("10.0.0.5".into(), "win11".into());
let dir = tempfile::tempdir().unwrap();
// Construct a fake "sources/boot.wim".
std::fs::create_dir_all(dir.path().join("sources")).unwrap();
std::fs::write(dir.path().join("sources/boot.wim"), b"placeholder").unwrap();
let result = patcher.patch(dir.path());
// Depending on whether wimlib is installed on the runner, we get
// either WimlibMissing or Failed(...). Both mean "no silent
// success with trust-store mutation" — that's the invariant.
assert!(matches!(
result,
WinPatchState::WimlibMissing | WinPatchState::Failed { .. }
));
}
}