src/msi.rs
//! AIRGAP7 (Windows) / INSTALL-2 — Windows MSI builder.
//!
//! Design (`install-system.md` "Windows MSI builder"): absorb the `msi-agent`
//! Perl toolchain (`tools/*.pl`) into Rust. The `.msi` is produced by **WiX
//! 4.0.6** running under Wine in a podman container — a large external
//! integration. So [`WixBuilder`](crate::msi::WixBuilder) is a SEAM (driving podman/WiX is out of scope
//! for the offline core), but the **GUID-stability** logic — the one piece that
//! is pure, deterministic, and security-relevant — is implemented for real:
//! [`guid_v5`](crate::msi::guid_v5) generates UUID-v5 (SHA-1, namespaced) component GUIDs exactly as
//! `tools/build.pl` does, so MSI upgrades stay stable.
use serde::{Deserialize, Serialize};
// The canonical four-entity XML/SVG escaper (`& < > "`), consumed from the ONE
// shared `nornir-escape` leaf. Replaces the byte-identical local `xml_escape`
// twin that used to live here (dup-audit B2, move=delete). `xml` returns a
// `Cow<str>` — Display-compatible, so every `format!`/`push_str` call site is
// unchanged in behaviour and the rendered WXS is byte-for-byte identical.
use nornir_escape::xml as xml_escape;
/// `tools/build.pl`'s namespace for component GUID derivation.
pub const GUID_NAMESPACE: &str = "7f5b3f9e-3a2a-4e96-a5c3-4a2b9f7e6a10";
/// Windows service start type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ServiceStart {
Auto,
Manual,
Disabled,
}
/// The Windows service definition (LocalSystem delayed-auto, env block).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WindowsServiceSpec {
pub account: String,
pub start: ServiceStart,
pub delayed: bool,
pub env_block: Vec<(String, String)>,
}
/// A file harvested into the MSI (source path → install Id).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WixFile {
pub source: String,
pub id: String,
}
/// The Windows product to build into a `.msi` (the `msi-agent` Sorcerer shape).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WindowsComponentSpec {
pub name: String,
pub display_name: String,
pub version: String,
/// Stable per-product upgrade code (never changes across versions).
pub upgrade_code: String,
pub install_dir: String,
pub service: WindowsServiceSpec,
pub files: Vec<WixFile>,
}
/// Render the WiX v4 `Product.wxs` source for `spec`. Each file gets a stable
/// component GUID via [`guid_v5`] (MSI-upgrade stability), the product/upgrade
/// codes are injected, and the service install is emitted. Pure → fully testable
/// without Wine/podman.
pub fn render_wxs(spec: &WindowsComponentSpec) -> String {
let mut s = String::new();
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
s.push_str("<Wix xmlns=\"http://wixtoolset.org/schemas/v4/wxs\">\n");
s.push_str(&format!(
" <Package Name=\"{name}\" Version=\"{ver}\" Manufacturer=\"X14\" UpgradeCode=\"{up}\">\n",
name = xml_escape(&spec.display_name),
ver = xml_escape(&spec.version),
up = spec.upgrade_code,
));
s.push_str(
" <MajorUpgrade DowngradeErrorMessage=\"A newer version is already installed.\" />\n",
);
s.push_str(" <MediaTemplate EmbedCab=\"yes\" />\n");
s.push_str(&format!(
" <StandardDirectory Id=\"ProgramFiles64Folder\">\n <Directory Id=\"INSTALLDIR\" Name=\"{}\">\n",
xml_escape(&spec.install_dir)
));
// One component per harvested file, each with a deterministic GUID.
for (i, f) in spec.files.iter().enumerate() {
let guid = component_guid(&format!("{}.{}", spec.name, f.id));
s.push_str(&format!(
" <Component Id=\"cmp{i}\" Guid=\"{{{guid}}}\">\n <File Id=\"{id}\" Source=\"{src}\" KeyPath=\"yes\" />\n",
id = xml_escape(&f.id),
src = xml_escape(&f.source),
));
// Attach the Windows service to the first component.
if i == 0 {
s.push_str(&render_service(spec));
}
s.push_str(" </Component>\n");
}
s.push_str(" </Directory>\n </StandardDirectory>\n");
s.push_str(" <Feature Id=\"Main\" Title=\"Main\" Level=\"1\">\n");
for i in 0..spec.files.len() {
s.push_str(&format!(" <ComponentRef Id=\"cmp{i}\" />\n"));
}
s.push_str(" </Feature>\n </Package>\n</Wix>\n");
s
}
fn render_service(spec: &WindowsComponentSpec) -> String {
// The MSI path passes `None` recovery ⇒ byte-identical to the historical output.
render_service_install(&spec.name, &spec.display_name, &spec.service, None)
}
/// Render the WiX `<ServiceInstall>` + `<ServiceControl>` pair for a Windows SCM
/// service from a [`WindowsServiceSpec`] — the ONE Windows service-XML projection,
/// consumed by BOTH the MSI builder ([`render_wxs`]) and the install-tree
/// [`crate::service::WindowsServiceRenderer`] (L5: shared, never twinned). Pure →
/// fully testable without Wine/podman.
///
/// `restart_delay` optionally appends a `util:ServiceConfig` **crash-recovery** block
/// (first/second/third failure → `restart` after `<delay>` seconds); `None` emits no
/// recovery element — the MSI path passes `None`, so its rendered WXS is unchanged.
pub fn render_service_install(
name: &str,
display_name: &str,
svc: &WindowsServiceSpec,
restart_delay: Option<u32>,
) -> String {
let start = match svc.start {
ServiceStart::Auto => "auto",
ServiceStart::Manual => "demand",
ServiceStart::Disabled => "disabled",
};
let mut s = format!(
" <ServiceInstall Id=\"svc\" Name=\"{name}\" DisplayName=\"{disp}\" Type=\"ownProcess\" Start=\"{start}\" ErrorControl=\"normal\" Account=\"{acct}\"",
name = xml_escape(name),
disp = xml_escape(display_name),
acct = xml_escape(&svc.account),
);
if svc.delayed {
s.push_str(" DelayedAutoStart=\"yes\"");
}
s.push_str(">\n");
if !svc.env_block.is_empty() {
let env: Vec<String> = svc
.env_block
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect();
s.push_str(&format!(
" <ServiceConfig xmlns=\"http://wixtoolset.org/schemas/v4/wxs/util\" />\n <!-- env: {} -->\n",
xml_escape(&env.join(";"))
));
}
// Crash-recovery: first/second/third failure → restart after `delay` s. Set only
// by the install-tree renderer (RestartPolicy::OnFailure/Always); `None` keeps the
// MSI path byte-identical.
if let Some(delay) = restart_delay {
s.push_str(&format!(
" <ServiceConfig xmlns=\"http://wixtoolset.org/schemas/v4/wxs/util\" FirstFailureActionType=\"restart\" SecondFailureActionType=\"restart\" ThirdFailureActionType=\"restart\" RestartServiceDelayInSeconds=\"{delay}\" ResetPeriodInDays=\"1\" />\n"
));
}
s.push_str(" </ServiceInstall>\n");
s.push_str(&format!(
" <ServiceControl Id=\"svcctl\" Name=\"{name}\" Start=\"install\" Stop=\"both\" Remove=\"uninstall\" Wait=\"yes\" />\n",
name = xml_escape(name)
));
s
}
/// Builds MSIs via **WiX v4 under Wine in podman**. The WXS rendering + the
/// build-command construction are real; the actual `podman run` needs the
/// pre-built WiX-in-Wine image on the staging host, so [`WixBuilder::build`] is
/// honest (clear error) when podman/the image is absent.
#[derive(Debug, Clone)]
pub struct WixBuilder {
/// Pre-built WiX-in-Wine container image (e.g. "wix4-bundlev2").
pub image: String,
}
impl WixBuilder {
pub fn new(image: impl Into<String>) -> Self {
WixBuilder {
image: image.into(),
}
}
/// Build `spec` into `out` (a real `.msi`). Stages the harvested files +
/// renders `Product.wxs` into a temp workdir, then runs `wix build` under
/// Wine through draupnir's ONE OCI engine (zero-shell — the constellation
/// law; the old `podman run` subprocess was removed). The staging + WXS
/// render is real; the container run needs the `msi-oci` feature + the WiX
/// image (a staging-host concern) and errors clearly if absent — it never
/// fakes an MSI.
pub fn build(&self, spec: &WindowsComponentSpec, out: &std::path::Path) -> anyhow::Result<()> {
// 1. Stage workdir + render the WXS the build will compile.
let work =
tempfile::tempdir().map_err(|e| anyhow::anyhow!("create MSI build workdir: {e}"))?;
let wxs = work.path().join("Product.wxs");
std::fs::write(&wxs, render_wxs(spec))
.map_err(|e| anyhow::anyhow!("write {wxs:?}: {e}"))?;
// Harvest: copy each source file into the workdir (so the mount is
// self-contained). Missing sources are a real, surfaced error.
for f in &spec.files {
let src = std::path::Path::new(&f.source);
if src.exists() {
let dst = work
.path()
.join(src.file_name().unwrap_or(std::ffi::OsStr::new(&f.id)));
std::fs::copy(src, &dst).map_err(|e| anyhow::anyhow!("harvest {src:?}: {e}"))?;
}
}
// 2. Run WiX under Wine to build the MSI. Honest if the engine/image is absent.
#[cfg(feature = "msi-oci")]
{
// Route through draupnir's ONE OCI engine (zero-shell) — no `podman run`.
// Bind the staged workdir at /work; the container writes /work/out.msi,
// which (via the bind) lands at `work/out.msi` on the host.
use draupnir::{
BootSpec,
container::{ContainerBoot, RunOptions},
};
let spec_boot = BootSpec::container("skidbladnir-wix-msi", &self.image).with_cmd([
"wine",
"wix.exe",
"build",
"/work/Product.wxs",
"-o",
"/work/out.msi",
]);
let binds = vec![format!("{}:/work", work.path().display())];
let outcome = ContainerBoot::new()
.run_to_completion_with_binds(&spec_boot, &RunOptions::default(), &binds)
.map_err(|e| {
anyhow::anyhow!(
"MSI build via the OCI engine failed (image `{}`); \
WXS rendered + staged at {:?}: {e}",
self.image,
work.path()
)
})?;
if outcome.exit_code != Some(0) {
anyhow::bail!(
"WiX build (OCI/Wine) exited {:?}; stderr: {}",
outcome.exit_code,
outcome.stderr.join("\n")
);
}
}
// 3. Move the produced MSI to `out`. (Only reachable on the OCI path — the
// feature-off arm below bails before ever producing an artifact.)
#[cfg(feature = "msi-oci")]
{
let produced = work.path().join("out.msi");
if !produced.exists() {
anyhow::bail!("WiX build reported success but produced no out.msi");
}
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::copy(&produced, out)
.map_err(|e| anyhow::anyhow!("copy built MSI to {out:?}: {e}"))?;
Ok(())
}
#[cfg(not(feature = "msi-oci"))]
{
// No OCI engine compiled in (`--no-default-features` / feature off).
// The constellation zero-shell law forbids the old `podman run`
// subprocess, so bail HONESTLY (never fake an MSI, never shell out) and
// point at the pure-Rust route. The WXS+stage step above was real.
let _ = out; // no artifact is produced without an engine
anyhow::bail!(
"MSI build needs the zero-shell OCI engine (`--features msi-oci` / `oci`) \
+ the `{}` WiX-in-Wine image (a staging-host concern); WXS rendered + \
staged at {:?} but no container engine is compiled in (the podman \
subprocess path was removed under the zero-shell law)",
self.image,
work.path()
)
}
}
}
/// Generate a UUID-v5 (SHA-1, namespaced) component GUID for `component_id` —
/// the deterministic, collision-free GUID `tools/build.pl` produces, ported to
/// Rust. Same namespace + same key ⇒ same GUID across builds (MSI stability).
pub fn guid_v5(namespace: &str, component_id: &str) -> String {
let ns = parse_uuid(namespace).expect("valid namespace UUID");
let mut input = Vec::with_capacity(16 + component_id.len());
input.extend_from_slice(&ns);
input.extend_from_slice(component_id.to_lowercase().as_bytes());
let hash = sha1(&input);
let mut b = [0u8; 16];
b.copy_from_slice(&hash[..16]);
// Set version (5) and variant (RFC 4122) bits — the UUID-v5 contract.
b[6] = (b[6] & 0x0f) | 0x50;
b[8] = (b[8] & 0x3f) | 0x80;
format_uuid(&b)
}
/// Convenience: a component GUID under the `msi-agent` namespace.
pub fn component_guid(component_id: &str) -> String {
guid_v5(GUID_NAMESPACE, component_id)
}
// ── UUID parse/format ──────────────────────────────────────────────
fn parse_uuid(s: &str) -> Option<[u8; 16]> {
let hex: String = s.chars().filter(|c| *c != '-').collect();
if hex.len() != 32 {
return None;
}
let mut out = [0u8; 16];
for i in 0..16 {
out[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
}
Some(out)
}
fn format_uuid(b: &[u8; 16]) -> String {
let h: String = b.iter().map(|x| format!("{x:02x}")).collect();
format!(
"{}-{}-{}-{}-{}",
&h[0..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..32]
)
}
// ── minimal SHA-1 (UUID v5 needs SHA-1; sha2 doesn't provide it; self-contained) ──
fn sha1(data: &[u8]) -> [u8; 20] {
let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
let ml = (data.len() as u64) * 8;
let mut msg = data.to_vec();
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend_from_slice(&ml.to_be_bytes());
for chunk in msg.chunks(64) {
let mut w = [0u32; 80];
for i in 0..16 {
w[i] = u32::from_be_bytes([
chunk[i * 4],
chunk[i * 4 + 1],
chunk[i * 4 + 2],
chunk[i * 4 + 3],
]);
}
for i in 16..80 {
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
}
let (mut a, mut b, mut c, mut d, mut e) = (h[0], h[1], h[2], h[3], h[4]);
for (i, &wi) in w.iter().enumerate() {
let (f, k) = match i {
0..=19 => ((b & c) | ((!b) & d), 0x5A827999u32),
20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
_ => (b ^ c ^ d, 0xCA62C1D6),
};
let tmp = a
.rotate_left(5)
.wrapping_add(f)
.wrapping_add(e)
.wrapping_add(k)
.wrapping_add(wi);
e = d;
d = c;
c = b.rotate_left(30);
b = a;
a = tmp;
}
h[0] = h[0].wrapping_add(a);
h[1] = h[1].wrapping_add(b);
h[2] = h[2].wrapping_add(c);
h[3] = h[3].wrapping_add(d);
h[4] = h[4].wrapping_add(e);
}
let mut out = [0u8; 20];
for i in 0..5 {
out[i * 4..i * 4 + 4].copy_from_slice(&h[i].to_be_bytes());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// LAW 1 (inject-assert): UUID-v5 GUID is deterministic, well-formed (version
/// nibble 5, RFC-4122 variant), case-insensitive on the key, and distinct for
/// distinct components — the MSI-stability contract `tools/build.pl` enforces.
#[test]
fn guid_v5_is_deterministic_and_well_formed() {
let a = component_guid("SorcererService");
let b = component_guid("SorcererService");
assert_eq!(a, b, "same id → same GUID (stability)");
// Case-insensitive key (build.pl lowercases).
assert_eq!(component_guid("sorcererservice"), a);
// Distinct components → distinct GUIDs.
assert_ne!(component_guid("KafkaConnect"), a);
// Well-formed: 8-4-4-4-12, version 5, variant 8/9/a/b.
let parts: Vec<&str> = a.split('-').collect();
assert_eq!(
parts.iter().map(|p| p.len()).collect::<Vec<_>>(),
vec![8, 4, 4, 4, 12]
);
assert_eq!(&parts[2][0..1], "5", "UUID version 5");
assert!(
matches!(&parts[3][0..1], "8" | "9" | "a" | "b"),
"RFC-4122 variant"
);
}
/// Cross-check the SHA-1 against a known vector ("abc") so the GUID core is
/// provably correct, not just internally consistent.
#[test]
fn sha1_matches_known_vector() {
let h = sha1(b"abc");
let hex: String = h.iter().map(|x| format!("{x:02x}")).collect();
assert_eq!(hex, "a9993e364706816aba3e25717850c26c9cd0d89d");
}
fn sample_spec() -> WindowsComponentSpec {
WindowsComponentSpec {
name: "Sorcerer".into(),
display_name: "X14 Sorcerer CDC".into(),
version: "1.1.29.0".into(),
upgrade_code: component_guid("Sorcerer.UpgradeCode"),
install_dir: "X14\\Sorcerer".into(),
service: WindowsServiceSpec {
account: "LocalSystem".into(),
start: ServiceStart::Auto,
delayed: true,
env_block: vec![("RUST_LOG".into(), "info".into())],
},
files: vec![
WixFile {
source: "sorcerer.exe".into(),
id: "SorcererExe".into(),
},
WixFile {
source: "config.toml".into(),
id: "ConfigToml".into(),
},
],
}
}
/// LAW 1 (inject-assert): the rendered WiX sources carry the injected
/// product/upgrade codes, a deterministic per-file component GUID (UUID-v5),
/// the service install, and one ComponentRef per file — the WXS WiX compiles.
#[test]
fn render_wxs_emits_real_wix_sources() {
let spec = sample_spec();
let wxs = render_wxs(&spec);
// Package identity (escaped display name + upgrade code).
assert!(wxs.contains("Name=\"X14 Sorcerer CDC\""));
assert!(wxs.contains(&format!("UpgradeCode=\"{}\"", spec.upgrade_code)));
assert!(wxs.contains("Version=\"1.1.29.0\""));
// A deterministic component GUID per file (matches guid_v5).
let g0 = component_guid("Sorcerer.SorcererExe");
assert!(
wxs.contains(&format!("Guid=\"{{{g0}}}\"")),
"stable per-file GUID"
);
// Both files + their ComponentRefs.
assert!(wxs.contains("Source=\"sorcerer.exe\""));
assert!(wxs.contains("Source=\"config.toml\""));
assert!(wxs.contains("<ComponentRef Id=\"cmp0\" />"));
assert!(wxs.contains("<ComponentRef Id=\"cmp1\" />"));
// The service install (delayed-auto LocalSystem) + control.
assert!(wxs.contains("<ServiceInstall"));
assert!(wxs.contains("Account=\"LocalSystem\""));
assert!(wxs.contains("DelayedAutoStart=\"yes\""));
assert!(wxs.contains("Start=\"auto\""));
assert!(wxs.contains("<ServiceControl"));
}
/// dup-audit B2: the WXS escaping now flows through the shared
/// `nornir_escape::xml`, and its output is byte-for-byte what the deleted
/// local `xml_escape` twin produced — the four canonical entities, with the
/// apostrophe left literal (WiX attributes are double-quoted). Red-when-broken:
/// swap the escaper for one that diverges (e.g. drops `"` or escapes `'`)
/// and this fails.
#[test]
fn wxs_escaping_matches_deleted_twin_byte_for_byte() {
let mut spec = sample_spec();
spec.display_name = "A & B <C> \"D\" 'E'".into();
let wxs = render_wxs(&spec);
// The old twin: & < > " escaped; ' untouched.
let twin = spec
.display_name
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """);
assert_eq!(twin, "A & B <C> "D" 'E'");
assert!(
wxs.contains(&format!("Name=\"{twin}\"")),
"escaped exactly like the twin: {wxs}"
);
// Apostrophe stays literal (not ') — the `xml` contract, not `xml_attr`.
assert!(
!wxs.contains("'"),
"double-quoted WiX attrs keep ' literal"
);
}
/// The full pipeline: render + stage happen for real; the build then errors
/// HONESTLY (clear message, no faked MSI, no shell-out) whichever engine
/// state applies, and the staged WXS was real.
#[test]
fn build_pipeline_is_real_and_honest() {
let spec = sample_spec();
let tmp = tempfile::tempdir().unwrap();
let res = WixBuilder::new("wix4-bundlev2").build(&spec, &tmp.path().join("out.msi"));
// Feature-off: honest zero-shell bail pointing at the OCI engine feature.
#[cfg(not(feature = "msi-oci"))]
{
let err = res.unwrap_err().to_string();
assert!(
err.contains("msi-oci"),
"honest: needs the OCI engine feature: {err}"
);
assert!(
err.contains("WXS rendered"),
"but the WXS+stage step was real: {err}"
);
}
// OCI path: with no reachable engine/image it errors honestly (never a fake MSI).
#[cfg(feature = "msi-oci")]
if let Err(e) = &res {
assert!(
e.to_string().contains("WXS rendered"),
"the WXS+stage step was real: {e}"
);
}
}
}