Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

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 `&quot;` 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('&', "&amp;")
            .replace('<', "&lt;")
            .replace('>', "&gt;")
            .replace('"', "&quot;");
        assert_eq!(twin, "A &amp; B &lt;C&gt; &quot;D&quot; 'E'");
        assert!(
            wxs.contains(&format!("Name=\"{twin}\"")),
            "escaped exactly like the twin: {wxs}"
        );
        // Apostrophe stays literal (not &apos;) — the `xml` contract, not `xml_attr`.
        assert!(
            !wxs.contains("&apos;"),
            "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}"
            );
        }
    }
}