227 lines
9.0 KiB
Rust
227 lines
9.0 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 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 { .. }
|
|
));
|
|
}
|
|
}
|