Name update
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
//! 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 {
|
||||
let mut s = String::new();
|
||||
let host = host.trim();
|
||||
let share = share.trim_matches('/');
|
||||
s.push_str("@echo off\r\n");
|
||||
s.push_str("echo PXEForge WinPE bootstrap\r\n");
|
||||
s.push_str("wpeinit\r\n");
|
||||
s.push_str("ipconfig /renew\r\n");
|
||||
s.push_str(&format!("echo Waiting for SMB server {host} to be reachable...\r\n"));
|
||||
s.push_str(&format!(":waitsmb\r\nping -n 1 -w 500 {host} >nul && goto havenet\r\ntimeout /t 2 /nobreak >nul\r\ngoto waitsmb\r\n"));
|
||||
s.push_str(":havenet\r\n");
|
||||
s.push_str(&format!("echo Mapping install media from \\\\{host}\\{share}...\r\n"));
|
||||
s.push_str(&format!(":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\ntimeout /t 3 /nobreak >nul\r\ngoto mapshare\r\n"));
|
||||
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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user