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

nordisk/holger

estate/holger-live/src/lib.rs

//! **Holger's live slot** (L20, 2026-10-18): a Holger box — main, twin, or the front — takes a
//! live update into an app slot of its own, the lifecycle step Gunnar's boxes take, in Holger's
//! own code. The decisions are korp's (`korp_installer::live`: the A/B slots, the verification
//! legs, the ONE setting, the confirm, the rollback); the wire is the shared nordisk-pairing
//! live door (`/live/running`, `/live/stage`, the installer's own certificate only):
//!
//! * [`LiveSlot::running`] — what this box runs: its BASE, the running slot's app pin, the slot
//!   word (`a`, `b trial`, `b booted`), and its own off switch.
//! * [`LiveSlot::stage`] — a new signed app verified against this box's enrolled root, rollback
//!   floor and BASE, written to the slot NOT running, the setting last (`<slot> trial`); then the
//!   box is asked to stop gracefully and re-exec korp-start into it ([`staged`] tells the PID-1
//!   stop to re-exec instead of powering off).
//! * [`LiveSlot::confirm_step`] — the new app confirms its slot once it has served for
//!   [`CONFIRM_AFTER`]; a slot still not serving at [`CONFIRM_DEADLINE`] is rolled back (the
//!   re-exec: korp-start finds it `booted`, never confirmed, and boots the other slot).
//!
//! A box whose BASE moved, whose live switch is off, or whose stage is refused, is updated from
//! its keep medium instead (the front reports `needs-keep` for itself, `done:keep` for the
//! others): the keep path stays the fallback.

/// A first-boot wait, said by name once a minute (test3-holger-rs 2026-10-22).
pub mod waits;

/// The trial confirm as jera jobs (feature `jobs`).
#[cfg(feature = "jobs")]
pub mod trial;

/// A box's own background duty (a watch, a retry) as jera jobs (feature `jobs`, A28 M18).
#[cfg(feature = "jobs")]
pub mod duty;

use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

use korp_installer::live::{self as korp, Esp, Trial};
pub use korp_installer::live::{Policy, Slot};
pub use nordisk_pairing::live::{Declared, Running, Stage};

/// The trial's deadlines and its step are korp's (lane B3): ONE rule for every product.
pub use korp_installer::live::{confirm_plan, ConfirmStep, CONFIRM_AFTER, CONFIRM_DEADLINE};
/// How long the graceful stop of a live update has before the box re-execs anyway.
pub const STOP_GRACE: Duration = Duration::from_secs(30);

/// Writes one slot's partition (the real partition on a box, a buffer in a test).
pub type SlotWriter = Box<dyn Fn(Slot, &[u8]) -> Result<(), String> + Send + Sync>;
/// Asks the box to stop gracefully and re-exec korp-start (nothing in a test).
pub type Restart = Box<dyn Fn() + Send + Sync>;

/// A live update staged in this process: the PID-1 stop ends in a re-exec ([`after_stop`]).
static STAGED: AtomicBool = AtomicBool::new(false);

/// Whether a live update waits for the stop.
pub fn staged() -> bool {
    STAGED.load(Ordering::SeqCst)
}

/// What a clean stop ends in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AfterStop {
    /// The ordinary end of an appliance.
    PowerOff,
    /// A live update is staged: re-exec korp-start.
    ReExec,
}

/// Pure: **after the graceful stop** — a staged live update re-execs, everything else powers off.
pub fn after_stop(staged: bool) -> AfterStop {
    if staged {
        AfterStop::ReExec
    } else {
        AfterStop::PowerOff
    }
}

/// **A box's live slot**: its ESP (where korp keeps the setting and each slot's records), its
/// live policy, how a slot's partition is written, and how the box restarts into it.
pub struct LiveSlot {
    pub esp: Esp,
    pub policy: Policy,
    write_slot: SlotWriter,
    restart: Restart,
    /// Where korp-start lays the booted slot's release declaration (`/run/korp/release`; a
    /// bench box's own directory).
    pub release_dir: PathBuf,
}

impl LiveSlot {
    pub fn new(esp: Esp, policy: Policy, write_slot: SlotWriter, restart: Restart) -> LiveSlot {
        LiveSlot {
            esp,
            policy,
            write_slot,
            restart,
            release_dir: PathBuf::from(korp_installer::release::RUN_DIR),
        }
    }

    fn korp_file(&self, rel: &str) -> PathBuf {
        self.esp.root.join("korp").join(rel)
    }

    /// **What this box runs** (`GET /live/running`).
    pub fn running(&self) -> Result<Running, String> {
        let s = self.esp.read_setting()?;
        let pin_at = self.esp.records(s.active).pin;
        let pin = std::fs::read_to_string(&pin_at).map_err(|e| {
            format!(
                "[live-running] the running slot's pin {}: {e}",
                pin_at.display()
            )
        })?;
        Ok(Running {
            base: korp::running_base(&self.esp)?,
            app: pin.trim().to_string(),
            slot: korp::render_setting(&s).trim().to_string(),
            live_update: self.policy.live_update,
        })
    }

    /// **A new app** (`POST /live/stage`): verified against this box's enrolled root, floor and
    /// BASE, written to the slot not running, the setting last; then the box is asked to stop
    /// gracefully and re-exec. Refused by name (korp's words) when any leg fails: nothing moves.
    pub fn stage(&self, st: &Stage) -> Result<String, String> {
        self.stage_declared(st, &Declared::default())
    }

    /// **A new app with what its release declares** (lane B5, korp `stage_declared`): the
    /// release declaration verified for THIS app and laid beside its pin (`[release-sig]`
    /// refuses the whole stage), the release's env held against this box's cmdline
    /// (`[live-env-moved]`), and a stage that carries no declaration refused while this box
    /// carries one (`[live-release-absent]`: the box keeps its app and its release, the front
    /// keep-boots it). Then the graceful re-exec.
    pub fn stage_declared(&self, st: &Stage, d: &Declared) -> Result<String, String> {
        let pubkey = std::fs::read(self.korp_file("root.pub"))
            .ok()
            .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok());
        let floor = self.floor().map_err(|e| format!("[live-floor] {e:#}"))?;
        let base = korp::running_base(&self.esp)?;
        let p = korp::LivePayload {
            archive: &st.archive,
            pin_hex: &st.pin_hex,
            signature: Some(&st.signature),
            declared_base: Some(&st.base),
        };
        let decl = korp::Declaration {
            release: d
                .release
                .as_ref()
                .map(|(b, s)| (b.as_slice(), (!s.is_empty()).then_some(s.as_slice()))),
            env: d.env.as_deref(),
        };
        let mut write = |slot: Slot, bytes: &[u8]| (self.write_slot)(slot, bytes);
        let staged = korp::stage_declared(
            &self.esp,
            &p,
            &decl,
            pubkey.as_ref(),
            floor,
            &base,
            self.policy,
            &mut write,
        )?;
        for line in &staged.said {
            eprintln!("holger-live: {line}");
        }
        STAGED.store(true, Ordering::SeqCst);
        (self.restart)();
        Ok(format!(
            "staged into slot {:?} ({} bytes, app {}; {}); stopping gracefully, then re-exec",
            staged.slot,
            st.archive.len(),
            st.pin_hex,
            staged.said.join("; ")
        ))
    }

    /// **The rollback floor a stage is held against** (lane B3): on a korp box the store
    /// korp-start reads (`rollback::floor_for_live`: the chip's witness when there is one),
    /// so a TPM box's stage is never refused by a store that cannot read a witness.
    fn floor(&self) -> Result<u64, String> {
        #[cfg(target_os = "linux")]
        let f = korp_installer::rollback::floor_for_live(&self.esp.root, self.esp.remount);
        #[cfg(not(target_os = "linux"))]
        let f = korp_installer::rollback::FloorStore::floor(
            &mut korp_installer::rollback::FloorFile::at(self.korp_file("rollback.floor")),
        );
        f.map_err(|e| format!("{e:#}"))
    }

    /// Whether the running slot is on trial (booted once, not yet confirmed).
    pub fn on_trial(&self) -> bool {
        self.esp
            .read_setting()
            .map(|s| s.trial == Trial::Booted)
            .unwrap_or(false)
    }

    /// **One look of the trial watcher**: on a slot on trial, confirm it once the app has served
    /// for [`CONFIRM_AFTER`]; [`ConfirmStep::RollBack`] tells the caller to re-exec.
    pub fn confirm_step(
        &self,
        up: Duration,
        serving_since: Option<Duration>,
    ) -> Result<ConfirmStep, String> {
        let step = confirm_plan(self.on_trial(), up, serving_since);
        if step == ConfirmStep::Confirm {
            korp::confirm_on(&self.esp)?;
        }
        Ok(step)
    }

    /// **The bench's korp-start** (feature `bench`): what korp-start does with the setting when
    /// the box re-execs — a staged slot is marked booted, a booted one never confirmed is rolled
    /// back. Answers korp-start's sentence.
    #[cfg(any(test, feature = "bench"))]
    pub fn boot_as_korp_start(&self) -> Result<String, String> {
        let plan = korp::plan_boot(&self.esp, self.policy.live_update)?;
        if let Some(next) = plan.write {
            self.esp.write(&[(
                PathBuf::from("korp").join(korp::SETTING_FILE),
                korp::render_setting(&next).into_bytes(),
            )])?;
        }
        // The booted slot's release declaration, as korp-start lays it (lane B5): verified for
        // the slot's app, under `release_dir`; none, or one that does not hold, lays nothing.
        let pin = std::fs::read_to_string(&plan.records.pin)
            .map_err(|e| format!("[live-running] {}: {e}", plan.records.pin.display()))?;
        let root = korp_installer::loader::parse_blake3_hex(pin.trim())
            .map_err(|e| format!("[live-pin] {e:#}"))?;
        let pubkey = std::fs::read(self.korp_file("root.pub"))
            .ok()
            .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok());
        let files = korp::release_of(
            &plan.records,
            &root,
            pubkey.as_ref(),
            self.policy.require_signed,
        )
        .ok()
        .flatten()
        .unwrap_or_default();
        korp_installer::release::expose(&files, &self.release_dir)
            .map_err(|e| format!("[release-expose] {e}"))?;
        Ok(plan.said)
    }
}

/// **The box's own slot on a korp appliance** (Linux): `/boot` as korp-start mounted it, the
/// policy from the baked cmdline, the slot partitions, and the graceful stop then re-exec.
#[cfg(target_os = "linux")]
pub mod appliance {
    use super::*;

    /// This box's live policy: `korp.live_update`, `require_signed` as korp-start decided it.
    pub fn policy() -> Policy {
        let cl = korp_installer::cmdline::effective();
        let signed = korp_installer::cmdline::decide(
            &cl,
            std::env::var(korp_installer::cmdline::REQUIRE_SIGNED_ENV)
                .ok()
                .as_deref(),
        )
        .require_signed;
        Policy {
            live_update: cl.live_update_on(),
            require_signed: signed,
        }
    }

    /// The slot of this box.
    pub fn slot() -> LiveSlot {
        LiveSlot::new(
            Esp::boot(),
            policy(),
            Box::new(korp::write_slot_partition),
            Box::new(stop_then_reexec),
        )
    }

    /// SIGTERM to PID 1 itself a second from now (after the stage's answer left): the ordinary
    /// graceful stop, which ends in [`reexec_now`] because [`staged`] is set. A stop that has
    /// not reached its end within [`STOP_GRACE`] is re-exec'd from here (synced first).
    fn stop_then_reexec() {
        // A28 M18, stays a thread: it IS the stop — two sleeps, a signal and the re-exec; nothing outlives it.
        let _ = std::thread::Builder::new()
            .name("live-stop".into())
            .spawn(|| {
                std::thread::sleep(Duration::from_secs(1));
                let _ = nix::sys::signal::kill(
                    nix::unistd::Pid::this(),
                    nix::sys::signal::Signal::SIGTERM,
                );
                std::thread::sleep(STOP_GRACE);
                let e = reexec_now();
                eprintln!("holger-live: the re-exec after the grace FAILED: {e}");
            });
    }

    /// **The re-exec**: synced, then korp-start (`/init`) again. Only returns when it failed.
    pub fn reexec_now() -> String {
        nix::unistd::sync();
        let argv = [std::ffi::CString::new("/init").expect("no NUL")];
        let envp: Vec<std::ffi::CString> = std::env::vars()
            .filter_map(|(k, v)| std::ffi::CString::new(format!("{k}={v}")).ok())
            .collect();
        match korp::reexec(std::path::Path::new("/init"), &argv, &envp) {
            Ok(never) => match never {},
            Err(e) => e,
        }
    }

    /// **The end of PID 1's stop**: a staged live update re-execs; otherwise `power_off` runs.
    pub fn end_of_stop<R>(power_off: impl FnOnce() -> R) -> R {
        if after_stop(staged()) == AfterStop::ReExec {
            let e = reexec_now();
            eprintln!("holger-live: the live update's re-exec FAILED ({e}); powering off");
        }
        power_off()
    }

    /// Whether this process runs on a korp ESP at all (a dev run has none).
    pub fn present() -> bool {
        Esp::boot().root.join("korp").is_dir()
    }
}

/// **A box's ESP on the bench** (feature `bench`): a korp ESP in a directory — a kernel, a
/// start-app, the baked cmdline, the enrolled root key and slot A's app — so the live update runs
/// korp's own legs with no PID 1. The slots' partitions are a record of what was written; the
/// re-exec is [`LiveSlot::boot_as_korp_start`].
#[cfg(feature = "bench")]
pub mod bench {
    use super::*;
    use std::sync::{Arc, Mutex};

    /// The root key a bench box enrols (`seed` byte repeated).
    pub fn root_key(seed: u8) -> ed25519_dalek::SigningKey {
        ed25519_dalek::SigningKey::from_bytes(&[seed; 32])
    }

    /// `archive`'s pin (hex) and its signature as a release signs it: a VERSIONED record under
    /// the root key of `seed`.
    pub fn sign(archive: &[u8], seed: u8) -> (String, Vec<u8>) {
        use ed25519_dalek::Signer as _;
        use korp_installer::rollback::{PayloadVersion, RootSignature};
        let pin = blake3::hash(archive);
        let raw = root_key(seed)
            .sign(&korp_installer::rollback::signed_record(3, &pin).expect("a legal record"))
            .to_bytes();
        (
            pin.to_hex().to_string(),
            RootSignature {
                version: PayloadVersion::Versioned(3),
                signature: raw,
            }
            .to_blob(),
        )
    }

    /// **A release declaration** `(bundle, signature)` for `archive`, signed as the composer
    /// signs it under the root key of `seed` (lane B5).
    pub fn sign_release(
        files: &[(String, Vec<u8>)],
        archive: &[u8],
        seed: u8,
    ) -> (Vec<u8>, Vec<u8>) {
        let key = korp_installer::rootkey::RootSigningKey::from_seed(&[seed; 32], "bench");
        assert_eq!(
            key.public_bytes(),
            root_key(seed).verifying_key().to_bytes(),
            "one root key"
        );
        let (bundle, sig) = korp_installer::release::sign_bundle(files, archive, Some(&key))
            .expect("a legal declaration")
            .expect("files declared");
        (bundle, sig.expect("signed"))
    }

    /// A release declaration's bundle of `files`, unsigned (a test's tampered one).
    pub fn encode_release(files: &[(String, Vec<u8>)]) -> Vec<u8> {
        korp_installer::release::encode(files).expect("a legal declaration")
    }

    /// A box laid on the bench: its slot, and what its slot partitions were written.
    pub struct BenchBox {
        pub slot: LiveSlot,
        pub written: Arc<Mutex<Vec<(Slot, Vec<u8>)>>>,
    }

    impl BenchBox {
        /// **Lay a box** at `dir`: kernel `kernel` (a different kernel is a different BASE), the
        /// root key of `seed` enrolled, slot A running `app`, live update on and signed.
        pub fn lay(
            dir: &std::path::Path,
            kernel: &str,
            seed: u8,
            app: &[u8],
        ) -> Result<BenchBox, String> {
            let w = |rel: &str, b: &[u8]| {
                let p = dir.join(rel);
                std::fs::create_dir_all(p.parent().expect("a parent"))
                    .and_then(|_| std::fs::write(&p, b))
                    .map_err(|e| format!("{}: {e}", p.display()))
            };
            w("vmlinuz", kernel.as_bytes())?;
            w("korp-start", b"korp-start")?;
            w(
                "korp/cmdline",
                b"korp.live_update=on korp.require_signed=1 korp.entry=holger",
            )?;
            w("korp/root.pub", &root_key(seed).verifying_key().to_bytes())?;
            w("korp/app.blake3", blake3::hash(app).to_hex().as_bytes())?;
            let written: Arc<Mutex<Vec<(Slot, Vec<u8>)>>> = Arc::default();
            let into = Arc::clone(&written);
            let slot = LiveSlot::new(
                Esp {
                    root: dir.to_path_buf(),
                    remount: false,
                },
                Policy {
                    live_update: true,
                    require_signed: true,
                },
                Box::new(move |s, b| {
                    into.lock()
                        .map_err(|e| e.to_string())?
                        .push((s, b.to_vec()));
                    Ok(())
                }),
                Box::new(|| {}),
            );
            let mut slot = slot;
            slot.release_dir = dir.join("run/korp/release");
            Ok(BenchBox { slot, written })
        }

        /// [`BenchBox::lay`] with slot A carrying the release declaration `files`, signed for
        /// `app` under the root key of `seed` (lane B5).
        pub fn lay_declared(
            dir: &std::path::Path,
            kernel: &str,
            seed: u8,
            app: &[u8],
            files: &[(String, Vec<u8>)],
        ) -> Result<BenchBox, String> {
            let b = Self::lay(dir, kernel, seed, app)?;
            let (bundle, sig) = sign_release(files, app, seed);
            let r = b.slot.esp.records(Slot::A);
            std::fs::write(&r.release, bundle)
                .and_then(|_| std::fs::write(&r.release_sig, sig))
                .map_err(|e| e.to_string())?;
            Ok(b)
        }

        /// Where this box's korp-start lays its release declaration.
        pub fn release_dir(&self) -> &std::path::Path {
            &self.slot.release_dir
        }

        /// This box's BASE.
        pub fn base(&self) -> String {
            korp::running_base(&self.slot.esp).unwrap_or_default()
        }

        /// A stage of `archive` for this box's BASE, signed under the root key of `seed`.
        pub fn stage_of(&self, archive: &[u8], seed: u8) -> Stage {
            let (pin_hex, signature) = sign(archive, seed);
            Stage {
                pin_hex,
                signature,
                base: self.base(),
                archive: archive.to_vec(),
            }
        }
    }
}