ui-ota (0.3.20): the manifest's interface channel and the engine that swaps a signed dashboard bundle in

The signed manifest gains a "ui" object {version, sha256, size, url, min_engine, signature}; the entry's own Ed25519
signature (the release key, manifest::ui_sign_bytes) and the manifest's cover it. src/uiota.rs: the hourly check hands
the entry over; a bundle for this engine is downloaded, checked (size, sha256, signature), unpacked next to the current
one and swapped by an atomic pointer; the server serves the bundle's fixed file names in place of the embedded ones;
the first page load starts a 10 s wait for the page's health ping, and silence, a first-paint error, a missing
index.html or a renamed bundle rolls back to the embedded interface and marks the version bad for good. No "ui"
object retires the bundle (the kill switch). settings.ui_builtin and state.ui carry Settings > Interface.
igneum-ota-sign gains sign-ui and verify-ui. Tests: a good bundle applies, a bad signature is refused, a too-new
min_engine is ignored, a broken bundle rolls back, every bad manifest field fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 3417f1e1fa)
This commit is contained in:
igneum-labs 2026-10-07 10:28:03 +00:00
parent b6fb6f528d
commit 5f6b3ebff6
10 changed files with 756 additions and 3 deletions

View file

@ -7,6 +7,9 @@
//! igneum-ota-sign embedded prints the public key compiled into the app and its fingerprint
//! igneum-ota-sign fingerprint <public-key-file|hex>
//! igneum-ota-sign sha256 <file> the file's sha256 and size, for the manifest
//! igneum-ota-sign sign-ui <private-key-file> <version> <sha256> <min-engine> the interface entry's own signature
//! (src/manifest.rs ui_sign_bytes; tools/ui-ota/publish.mjs calls this), same key
//! igneum-ota-sign verify-ui <public-key-file|hex|embedded> <version> <sha256> <min-engine> <signature-hex>
//! igneum-ota-sign sign-jobs <private-key-file> <igneum-jobs.json> the remote-jobs file (src/jobs.rs), same key
//! igneum-ota-sign verify-jobs <public-key-file|hex> <igneum-jobs.json> <sig-file>
//! igneum-ota-sign envelope-jobs <public-key-file|hex> <igneum-jobs.json> <sig-file> prints igneum-jobs.signed.json:
@ -101,6 +104,26 @@ fn main() {
let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
println!("{sum} {size}");
}
Some("sign-ui") if args.len() == 5 => {
let seed = manifest::hex_decode(&read_key_arg(&args[1])).unwrap_or_else(|| die("private key is not hex"));
let seed: [u8; 32] = seed.try_into().unwrap_or_else(|_| die("private key is not 32 bytes"));
let sk = SigningKey::from_bytes(&seed);
if manifest::parse_version(&args[2]).is_none() || manifest::parse_version(&args[4]).is_none() {
die("the version and the engine floor are versions like 0.3.19 or 0.3.19.1");
}
if args[3].len() != 64 || !args[3].chars().all(|c| c.is_ascii_hexdigit()) {
die("the sha256 is 64 hex characters");
}
println!("{}", manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes(&args[2], &args[3], &args[4])).to_bytes()));
}
Some("verify-ui") if args.len() == 6 => {
let pk = if args[1] == "embedded" { manifest::OTA_PUBLIC_KEY_HEX.to_string() } else { read_key_arg(&args[1]) };
let e = manifest::UiEntry { version: args[2].clone(), sha256: args[3].to_ascii_lowercase(), size: 1, url: "https://x".into(), min_engine: args[4].clone(), signature: args[5].to_ascii_lowercase() };
match manifest::verify_ui_entry(&e, &pk) {
Ok(()) => println!("verifies"),
Err(e) => die(&e),
}
}
Some("sign-jobs") if args.len() == 3 => {
let seed = manifest::hex_decode(&read_key_arg(&args[1])).unwrap_or_else(|| die("private key is not hex"));
let seed: [u8; 32] = seed.try_into().unwrap_or_else(|_| die("private key is not 32 bytes"));

View file

@ -136,6 +136,10 @@ pub struct Settings {
/// the switch's words say what becomes public (the key ids, the blocks, the weight rank, the card model).
#[serde(default)]
pub profile_public: bool,
/// ui-ota (7 October 2026, src/uiota.rs): "Use the built-in interface": the embedded dashboard serves even when an
/// over-the-air interface bundle is active. Default off.
#[serde(default)]
pub ui_builtin: bool,
}
fn one() -> u32 {
@ -150,7 +154,7 @@ fn yes() -> bool {
impl Default for Settings {
fn default() -> Settings {
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false }
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false, ui_builtin: false }
}
}

View file

@ -98,6 +98,12 @@ pub enum Cmd {
RestartNode(String),
/// quit, with its source (the log names it: C35, 5 October 2026, two unexplained quits)
Quit(&'static str),
/// ui-ota (src/uiota.rs): the download thread's result; the server served a bundle's index.html (its version);
/// the page's health ping (None) or its first-paint error; Settings > Use the built-in interface
UiOta(crate::uiota::Event),
UiPageLoaded(String),
UiHealth(Option<String>),
UiBuiltin(bool),
}
pub struct Shared {
@ -110,6 +116,8 @@ pub struct Shared {
pub runtime: Runtime,
pub packaged: Packaged,
cmd_tx: Mutex<Sender<Cmd>>,
/// ui-ota: the folder the server serves the dashboard from (None = the files embedded in the binary)
ui_dir: Mutex<Option<PathBuf>>,
pub started: Instant,
engine_log: Mutex<Option<std::fs::File>>,
/// the app log's path: the one the uploader sends (main.rs names it; the engine must not guess the stamp)
@ -145,7 +153,7 @@ impl Shared {
st.mining.accepted_total = settings.accepted_total;
st.mining.fee_total = settings.fee_total;
st.address = address_state(&settings, &wallet_path);
st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust, profile_public: settings.profile_public };
st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust, profile_public: settings.profile_public, ui_builtin: settings.ui_builtin };
st.dev_fee = crate::state::DevFeeState { on: settings.dev_fee, percent: if settings.dev_fee { 1 } else { 0 }, address: String::new(), line: String::new() };
st.live_page = packaged.live_page.clone();
st.finality.message = "waiting for the miner".into();
@ -162,6 +170,7 @@ impl Shared {
runtime,
packaged,
cmd_tx: Mutex::new(cmd_tx),
ui_dir: Mutex::new(None),
started: Instant::now(),
engine_log: Mutex::new(engine_log),
log_path,
@ -197,6 +206,22 @@ impl Shared {
self.port.load(std::sync::atomic::Ordering::Relaxed)
}
/// A Shared over a scratch folder for the unit tests that need one (src/uiota.rs): no node, no window, a channel
/// nobody reads (send drops the command), the defaults everywhere else.
#[cfg(test)]
pub fn for_tests(dir: PathBuf) -> Arc<Shared> {
let _ = std::fs::create_dir_all(&dir);
let (tx, _rx) = channel();
let runtime = crate::config::Runtime { network: "devnet".into(), rpc_port: 0, p2p_port: 0, peers: vec![], unsynced_mining: false, devnet_suffix: None, node_dir: dir.join("node"), app_dir: dir.clone(), log_dir: dir.join("logs"), status_secs: 30, sweep_only: true, host: "test".into(), machine_id: "0123456789abcdef".into() };
Arc::new(Shared::new("t".into(), runtime, crate::config::Packaged::default(), Settings::default(), tx, None, dir.join("app.log")))
}
/// ui-ota: what the server serves the dashboard from; None = the embedded files
pub fn ui_dir(&self) -> Option<PathBuf> {
self.ui_dir.lock().unwrap().clone()
}
pub fn set_ui_dir(&self, d: Option<PathBuf>) {
*self.ui_dir.lock().unwrap() = d;
}
pub fn send(&self, c: Cmd) {
let _ = self.cmd_tx.lock().unwrap().send(c);
}
@ -615,6 +640,8 @@ pub struct Engine {
last_upload: Instant,
upload_thread: Option<std::thread::JoinHandle<()>>,
ota: crate::ota::Updater,
/// the interface over the air (src/uiota.rs)
uiota: crate::uiota::UiOta,
jobs: crate::jobrun::Jobs,
/// a remote job has the miners stopped; they restart when the runner lets go
job_hold: bool,
@ -704,6 +731,7 @@ impl Engine {
// labels never carry the hostname: two cloned PCs with the same COMPUTERNAME would sign with the same keys
let label_base = format!("{}-{}", if cfg!(target_os = "macos") { "mac" } else { "win" }, shared.runtime.id8());
let ota = crate::ota::Updater::new(&shared);
let uiota = crate::uiota::UiOta::new(&shared);
let jobs = crate::jobrun::Jobs::new(&shared);
Engine {
shared,
@ -744,6 +772,7 @@ impl Engine {
last_upload: now,
upload_thread: None,
ota,
uiota,
jobs,
job_hold: false,
stamp,
@ -977,6 +1006,10 @@ impl Engine {
}
}
Cmd::Ota(ev) => self.ota.event(&self.shared, ev),
Cmd::UiOta(ev) => self.uiota.event(&self.shared, ev),
Cmd::UiPageLoaded(v) => self.uiota.page_loaded(&v, Instant::now()),
Cmd::UiHealth(err) => self.uiota.health(&self.shared, err.as_deref()),
Cmd::UiBuiltin(on) => self.uiota.set_builtin(&self.shared, on),
Cmd::Job(ev) => {
if let Some(a) = self.jobs.event(&self.shared, ev) {
self.job_action(a);
@ -3325,6 +3358,10 @@ impl Engine {
self.apply_update();
}
}
if let Some(ui) = self.ota.take_ui_change() {
self.uiota.consider(&self.shared, ui.as_ref(), VERSION);
}
self.uiota.tick(&self.shared, Instant::now());
if let Some(p) = self.ota.take_override_change() {
self.shared.log(&format!("consensus override changed ({}); the node restarts with it at a safe moment", p.display()));
self.node_override_restart = true;

View file

@ -24,6 +24,7 @@ mod server;
mod state;
mod manifest;
mod ota;
mod uiota;
mod update;
mod jobs;
mod jobrun;

View file

@ -12,8 +12,13 @@
//! "platforms": { "mac": {"url","sha256","size","kind":"dmg"|"zip"}, "windows": {"url","sha256","size","kind":"inno-setup"} },
//! "min_supported_version": "0.3.0", "notes": "one line",
//! "consensus": { "activation_height": null|number, "deadline_note": "", "override": {...} },
//! "tuning": { "updated": "...", "cards": { "<card model>": { "variant": "u2", "race": true, "candidates": [..] } } }
//! "tuning": { "updated": "...", "cards": { "<card model>": { "variant": "u2", "race": true, "candidates": [..] } } },
//! "ui": { "version": "0.3.19.1", "sha256": "<64 hex>", "size": 412345, "url": "https://dl.../ui/igneum-ui-0.3.19.1.tar.gz",
//! "min_engine": "0.3.19", "signature": "<128 hex>" }
//! }
//! `ui` (7 October 2026, docs/plans/ui-ota.md) is the interface channel: a tar.gz of app/igneum-app/ui the engine serves
//! in place of its embedded copy once the hash and the entry's own Ed25519 signature (over `ui_sign_bytes`, the same
//! release key) check; the whole manifest's signature covers it too. Removing the object is the kill switch.
//! A platform that is missing is not updated (the Windows build lands later than the Mac one).
//! `tuning` (4 October 2026, docs/design/miner-tuning.md) is the fleet's per-card kernel tuning: the engine writes it
//! to <app data>/tuning.json and every GPU worker reads it at its next prepare (IGNEUM_TUNING_FILE).
@ -62,6 +67,34 @@ pub struct Manifest {
/// tuning: the per-card kernel tuning object (tools/tuning.mjs writes it, publish-manifest.sh --tuning carries
/// it), written as is to <app data>/tuning.json for the GPU workers; signed with the rest of the manifest.
pub tuning: Option<serde_json::Value>,
/// ui: the interface channel (src/uiota.rs); None = no over-the-air interface is published
pub ui: Option<UiEntry>,
}
/// One published interface bundle (the manifest's `ui` object).
#[derive(Clone, Debug, PartialEq, Default)]
pub struct UiEntry {
pub version: String,
pub sha256: String,
pub size: u64,
pub url: String,
/// the lowest engine version that may serve this bundle; a newer bundle for an older engine is ignored
pub min_engine: String,
/// Ed25519 over `ui_sign_bytes(version, sha256, min_engine)` by the release key, 128 hex
pub signature: String,
}
/// The bytes the interface entry's own signature covers: a fixed tag, then the version, the hash and the engine
/// floor, one per line. The url and the size are not covered: the hash is what the engine trusts after the download.
pub fn ui_sign_bytes(version: &str, sha256: &str, min_engine: &str) -> Vec<u8> {
format!("igneum-ui\n{version}\n{}\n{min_engine}\n", sha256.to_ascii_lowercase()).into_bytes()
}
/// The entry's own signature against the release key (the whole manifest's signature is checked before this).
pub fn verify_ui_entry(e: &UiEntry, pub_hex: &str) -> Result<(), String> {
let key = public_key(pub_hex)?;
let sig = hex_decode(&e.signature).and_then(|b| Signature::from_slice(&b).ok()).ok_or("interface signature is not 128 hex characters")?;
key.verify(&ui_sign_bytes(&e.version, &e.sha256, &e.min_engine), &sig).map_err(|_| "interface signature does not verify".to_string())
}
impl Manifest {
@ -173,6 +206,32 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
Some(t) if !t.is_null() => return Err("tuning must be an object with a cards object".into()),
_ => None,
},
ui: match v.get("ui") {
Some(u) if u.is_object() => {
let e = UiEntry { version: s(u, "version"), sha256: s(u, "sha256").to_ascii_lowercase(), size: u.get("size").and_then(|x| x.as_u64()).unwrap_or(0), url: s(u, "url"), min_engine: s(u, "min_engine"), signature: s(u, "signature").to_ascii_lowercase() };
if parse_version(&e.version).is_none() {
return Err(format!("ui: version '{}' is not a version", e.version));
}
if parse_version(&e.min_engine).is_none() {
return Err(format!("ui: min_engine '{}' is not a version", e.min_engine));
}
if !e.url.starts_with("https://") && !e.url.starts_with("http://127.0.0.1:") {
return Err("ui: the url is not https".into());
}
if e.sha256.len() != 64 || !e.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("ui: sha256 is not 64 hex characters".into());
}
if e.size == 0 {
return Err("ui: size is missing".into());
}
if e.signature.len() != 128 || !e.signature.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("ui: signature is not 128 hex characters".into());
}
Some(e)
}
Some(u) if !u.is_null() => return Err("ui must be an object".into()),
_ => None,
},
})
}
@ -409,6 +468,36 @@ mod tests {
assert_eq!(ours, theirs);
}
#[test]
fn the_ui_entry_parses_and_every_bad_field_fails() {
use ed25519_dalek::{Signer, SigningKey};
let sk = SigningKey::from_bytes(&[3u8; 32]);
let pk = hex_encode(sk.verifying_key().as_bytes());
let sha = "ab".repeat(32);
let sig = hex_encode(&sk.sign(&ui_sign_bytes("1.0.1", &sha, "0.3.19")).to_bytes());
let base = serde_json::json!({ "version": "0.3.19", "platforms": {}, "ui": { "version": "1.0.1", "sha256": sha, "size": 400000, "url": "https://dl.igneum.network/dl/t/ui/igneum-ui-1.0.1.tar.gz", "min_engine": "0.3.19", "signature": sig } });
let m = parse(&base.to_string()).unwrap();
let u = m.ui.clone().unwrap();
assert_eq!(u.version, "1.0.1");
assert_eq!(u.min_engine, "0.3.19");
assert!(verify_ui_entry(&u, &pk).is_ok());
let mut t = u.clone();
t.sha256 = "00".repeat(32);
assert!(verify_ui_entry(&t, &pk).is_err(), "a changed hash breaks the entry's signature");
let mut t = u.clone();
t.min_engine = "0.3.0".into();
assert!(verify_ui_entry(&t, &pk).is_err(), "a changed engine floor breaks it too");
assert!(verify_ui_entry(&u, &hex_encode(SigningKey::from_bytes(&[4u8; 32]).verifying_key().as_bytes())).is_err());
assert_eq!(parse(r#"{"version":"0.3.19","platforms":{}}"#).unwrap().ui, None, "no ui object: the kill switch");
for (k, v) in [("version", serde_json::json!("x")), ("min_engine", serde_json::json!("")), ("url", serde_json::json!("http://evil/x.tar.gz")), ("sha256", serde_json::json!("abc")), ("size", serde_json::json!(0)), ("signature", serde_json::json!("zz"))] {
let mut b = base.clone();
b["ui"][k] = v;
assert!(parse(&b.to_string()).is_err(), "ui.{k} bad must fail");
}
assert!(parse(r#"{"version":"0.3.19","platforms":{},"ui":"x"}"#).is_err());
assert_eq!(std::str::from_utf8(&ui_sign_bytes("1.0.1", "AB", "0.3.19")).unwrap(), "igneum-ui\n1.0.1\nab\n0.3.19\n");
}
#[test]
fn tuning_parses() {
let m = parse(r#"{"version":"0.3.4","platforms":{},"tuning":{"updated":"2026-10-04T20:00:00Z","cards":{"NVIDIA_GeForce_RTX_5090":{"variant":"u2-ldg","race":true,"candidates":["u2-ldg","ldg","base"]}}}}"#).unwrap();

View file

@ -133,6 +133,9 @@ pub struct Updater {
live_next: Instant,
live_busy: bool,
live_api: String,
/// ui-ota: the interface entry of the last verified manifest, handed to src/uiota.rs once per check
/// (Some(None) = the channel was withdrawn: the kill switch)
ui_change: Option<Option<manifest::UiEntry>>,
}
impl Updater {
@ -180,6 +183,7 @@ impl Updater {
live_next: now,
live_busy: false,
live_api: env("IGNEUM_APP_LIVE_API").unwrap_or_else(|| live_api_from(&shared.packaged.live_page)),
ui_change: None,
};
shared.log(&format!("update slot: minute {} of every hour (machine id {})", u.slot, shared.runtime.id8()));
#[cfg(windows)]
@ -275,6 +279,10 @@ impl Updater {
}
/// The tuning file, once per change (the engine only logs it: the workers read the file at their next prepare).
/// ui-ota: the interface entry of the last check, once (None until a check has run)
pub fn take_ui_change(&mut self) -> Option<Option<manifest::UiEntry>> {
self.ui_change.take()
}
pub fn take_tuning_change(&mut self) -> Option<PathBuf> {
self.tuning_changed.take()
}
@ -689,6 +697,7 @@ impl Updater {
self.min_supported = m.min_supported_version.clone();
self.write_override(shared, &m);
self.write_tuning(shared, &m);
self.ui_change = Some(m.ui.clone());
if let Some(e) = entry {
if changed {
self.file = None;

View file

@ -172,8 +172,26 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
return;
};
let rest = if rest.is_empty() { "/" } else { rest };
// ui-ota (src/uiota.rs): an active interface bundle serves its files in place of the embedded ones; the names are
// fixed (uiota::SERVED), nothing else is read from the folder; a file the bundle lacks falls back to the embedded one
if req.method == "GET" {
let rel = if rest == "/" { "index.html" } else { rest.trim_start_matches('/') };
if crate::uiota::SERVED.contains(&rel) {
if let Some(dir) = shared.ui_dir() {
if let Ok(bytes) = std::fs::read(dir.join(rel)) {
if rel == "index.html" {
let v = std::fs::read_to_string(dir.join("VERSION")).unwrap_or_default().trim().to_string();
shared.send(Cmd::UiPageLoaded(v));
}
respond(&mut stream, 200, crate::uiota::content_type(rel), &bytes, rel.starts_with("fonts/"));
return;
}
}
}
}
match (req.method.as_str(), rest) {
("GET", "/") | ("GET", "/index.html") => respond(&mut stream, 200, "text/html; charset=utf-8", INDEX.as_bytes(), false),
("GET", "/VERSION") => respond(&mut stream, 200, "text/plain; charset=utf-8", crate::uiota::EMBEDDED_VERSION.as_bytes(), false),
("GET", "/app.css") => respond(&mut stream, 200, "text/css; charset=utf-8", CSS.as_bytes(), false),
("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false),
("GET", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true),
@ -316,6 +334,17 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.send(Cmd::InstallUpdate);
Ok(json!({ "ok": true }))
}
"/api/ui/health" => {
// ui-ota: the page's first-paint ping (no error) or the error it caught before it (src/uiota.rs)
let err = body.get("error").and_then(|v| v.as_str()).filter(|e| !e.is_empty()).map(|e| e.to_string());
shared.send(Cmd::UiHealth(err));
Ok(json!({ "ok": true }))
}
"/api/ui/builtin" => {
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
shared.send(Cmd::UiBuiltin(on));
Ok(json!({ "ok": true }))
}
"/api/update/auto" => {
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
shared.send(Cmd::AutoUpdate(on));

View file

@ -363,6 +363,32 @@ pub struct SettingsState {
pub proof_verify_trust: bool,
/// miner-ui-5: the public address profile opt-in (settings.profile_public)
pub profile_public: bool,
/// ui-ota: "Use the built-in interface" (settings.ui_builtin)
pub ui_builtin: bool,
}
/// The interface over the air (src/uiota.rs): what serves, from where, since when; Settings > Interface.
#[derive(Clone, Serialize, Default)]
pub struct UiState {
/// the version compiled into this engine (ui/VERSION)
pub embedded_version: String,
/// the version the server serves now (the bundle's, or the embedded one)
pub active_version: String,
/// embedded | ota
pub source: String,
/// unix s the active bundle was swapped in (0 for the embedded interface)
pub installed_at: f64,
/// the page confirmed the active bundle with its health ping (always true for the embedded interface)
pub confirmed: bool,
/// the version the manifest publishes now ("" when the channel is withdrawn)
pub published_version: String,
pub busy: bool,
pub error: String,
/// versions refused here (never applied again)
pub bad: Vec<String>,
pub builtin: bool,
/// why the published version is not applied, when it is not
pub note: String,
}
/// One remote job this machine ran (the ledger entry), for the Settings history and the last-job strip.
@ -474,6 +500,7 @@ pub struct State {
pub settings: SettingsState,
pub dev_fee: DevFeeState,
pub update: UpdateState,
pub ui: UiState,
pub jobs: JobsState,
pub events: Vec<Event>,
pub uptime_s: u64,

533
app/igneum-app/src/uiota.rs Normal file
View file

@ -0,0 +1,533 @@
//! Interface over the air (docs/plans/ui-ota.md, 7 October 2026): the dashboard (app/igneum-app/ui, embedded in the
//! engine binary) can be replaced by a signed bundle from the manifest's `ui` channel without a new app version.
//!
//! The flow, driven from the updater's hourly manifest (src/ota.rs hands the parsed `ui` entry over):
//! decide: the entry is ignored when its min_engine is newer than this engine, when its version is the active
//! one or the embedded one, when it was marked bad before, or when the miner chose the built-in interface
//! -> download (curl to <app data>/ui/<version>.tar.gz, size and sha256 against the manifest, then the entry's own
//! Ed25519 signature with the release key compiled into src/manifest.rs; the manifest's signature covered it too)
//! -> unpack with the system tar into <app data>/ui/<version>.new, index.html, app.js and app.css must be there,
//! rename to <app data>/ui/<version>
//! -> swap: <app data>/ui/current.json names the version (written to .tmp and renamed: atomic); the server reads
//! the pointer through Shared and serves the bundle's files in place of the embedded ones at the next page load;
//! the open page sees state.ui.active_version change and reloads itself when idle
//! -> confirm: the first page load from a new bundle starts a 10 s wait for the page's health ping
//! (POST /api/ui/health after its first paint; a JS error on first paint posts the error instead); no ping, an
//! error, or a bundle that fails to unpack rolls back to the embedded interface and marks the version bad
//! (<app data>/ui/bad.json): it is never applied again
//! The kill switch is the manifest without a `ui` object: the engine falls back to the embedded interface on the
//! next check. Same origin, no remote script: the bundle is served from 127.0.0.1 by this engine like the embedded
//! files, and the signature is the only trust.
use crate::engine::{Cmd, Shared};
use crate::manifest::{self, UiEntry};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// The page's health ping must arrive this long after the first load of a new bundle.
pub const HEALTH_WAIT_S: u64 = 10;
/// The version of the interface compiled into this engine (app/igneum-app/ui/VERSION).
pub const EMBEDDED_VERSION: &str = include_str!("../ui/VERSION");
/// The files a bundle may serve: fixed names, nothing else is read from the bundle folder.
pub const SERVED: [&str; 15] = ["index.html", "app.css", "app.js", "mark.svg", "live-dag.js", "proof-core.js", "VERSION", "fonts/IBMPlexMono-400.woff2", "fonts/IBMPlexMono-500.woff2", "fonts/IBMPlexSans-400.woff2", "fonts/IBMPlexSans-500.woff2", "fonts/IBMPlexSans-600.woff2", "fonts/Unbounded-500.woff2", "fonts/Unbounded-700.woff2", "fonts/Unbounded-900.woff2"];
/// What a bundle must carry to be swapped in.
const REQUIRED: [&str; 3] = ["index.html", "app.js", "app.css"];
pub fn embedded_version() -> &'static str {
EMBEDDED_VERSION.trim()
}
pub enum Event {
/// the download, the checks and the unpack finished: the bundle folder, or why not (with the version)
Installed(String, Result<PathBuf, String>),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Decision {
Apply,
Skip(String),
}
/// Whether a published entry is for this engine now (pure; the tests cover every branch).
pub fn decide(e: &UiEntry, engine: &str, embedded: &str, active: Option<&str>, bad: &[String], builtin: bool) -> Decision {
if builtin {
return Decision::Skip("the built-in interface is chosen in Settings".into());
}
if manifest::newer(&e.min_engine, engine) {
return Decision::Skip(format!("interface {} needs engine {} or newer (this is {engine})", e.version, e.min_engine));
}
if bad.iter().any(|b| b == &e.version) {
return Decision::Skip(format!("interface {} failed here before and is not tried again", e.version));
}
if active == Some(e.version.as_str()) {
return Decision::Skip(format!("interface {} is active", e.version));
}
if e.version == embedded {
return Decision::Skip(format!("interface {} is the built-in one", e.version));
}
Decision::Apply
}
/// The pointer file: which bundle the server serves ("embedded" or a version) and when it was swapped in.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Current {
pub version: String,
pub installed_at: u64,
/// the page confirmed this bundle with a health ping
pub confirmed: bool,
}
fn read_current(dir: &Path) -> Current {
let v: serde_json::Value = std::fs::read_to_string(dir.join("current.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(serde_json::Value::Null);
Current { version: v.get("version").and_then(|x| x.as_str()).unwrap_or("embedded").to_string(), installed_at: v.get("installed_at").and_then(|x| x.as_u64()).unwrap_or(0), confirmed: v.get("confirmed").and_then(|x| x.as_bool()).unwrap_or(false) }
}
/// Writes the pointer atomically (the .tmp then the rename).
pub fn write_current(dir: &Path, c: &Current) -> Result<(), String> {
let _ = std::fs::create_dir_all(dir);
let tmp = dir.join("current.json.tmp");
std::fs::write(&tmp, serde_json::json!({ "version": c.version, "installed_at": c.installed_at, "confirmed": c.confirmed }).to_string()).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, dir.join("current.json")).map_err(|e| e.to_string())
}
fn read_bad(dir: &Path) -> Vec<String> {
std::fs::read_to_string(dir.join("bad.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
}
fn write_bad(dir: &Path, bad: &[String]) {
let _ = std::fs::write(dir.join("bad.json"), serde_json::to_string(bad).unwrap_or_default());
}
/// The bundle folder for a version, if it holds what the server needs.
pub fn bundle_dir(dir: &Path, version: &str) -> Option<PathBuf> {
if version == "embedded" || version.is_empty() {
return None;
}
let d = dir.join(version);
if REQUIRED.iter().all(|f| d.join(f).is_file()) { Some(d) } else { None }
}
/// Size, sha256 and the entry's own signature, then the unpack into <dir>/<version>: the folder on success.
/// `pub_hex` is the release key (manifest::OTA_PUBLIC_KEY_HEX in the engine; the tests pass their own).
pub fn install_bundle(e: &UiEntry, file: &Path, dir: &Path, pub_hex: &str) -> Result<PathBuf, String> {
let size = std::fs::metadata(file).map(|m| m.len()).map_err(|er| format!("{}: {er}", file.display()))?;
if size != e.size {
return Err(format!("interface {}: the download is {size} bytes, the manifest says {}", e.version, e.size));
}
let sum = manifest::sha256_file(file).map_err(|er| er.to_string())?;
if sum != e.sha256 {
return Err(format!("interface {}: sha256 mismatch", e.version));
}
manifest::verify_ui_entry(e, pub_hex).map_err(|er| format!("interface {}: {er}", e.version))?;
let fresh = dir.join(format!("{}.new", e.version));
let _ = std::fs::remove_dir_all(&fresh);
std::fs::create_dir_all(&fresh).map_err(|er| er.to_string())?;
let mut c = std::process::Command::new(crate::platform::tool("tar"));
c.args(["-xzf", &file.display().to_string(), "-C", &fresh.display().to_string()]);
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(60)).ok_or("tar is not available")?;
// a bundle packed with a top-level folder: take it
let root = if REQUIRED.iter().all(|f| fresh.join(f).is_file()) {
fresh.clone()
} else {
let inner = std::fs::read_dir(&fresh).ok().into_iter().flatten().filter_map(|d| d.ok()).map(|d| d.path()).find(|p| p.is_dir() && REQUIRED.iter().all(|f| p.join(f).is_file()));
match inner {
Some(p) => p,
None => {
let _ = std::fs::remove_dir_all(&fresh);
return Err(format!("interface {}: the bundle has no index.html, app.js and app.css ({})", e.version, out.lines().last().unwrap_or("tar said nothing")));
}
}
};
let dest = dir.join(&e.version);
let _ = std::fs::remove_dir_all(&dest);
std::fs::rename(&root, &dest).map_err(|er| format!("interface {}: {er}", e.version))?;
let _ = std::fs::remove_dir_all(&fresh);
// the bundle's own VERSION must agree with the manifest (a renamed bundle is refused)
let v = std::fs::read_to_string(dest.join("VERSION")).unwrap_or_default();
if v.trim() != e.version {
let _ = std::fs::remove_dir_all(&dest);
return Err(format!("interface {}: the bundle's VERSION file says '{}'", e.version, v.trim()));
}
Ok(dest)
}
/// The engine's side: what is active, what is pending, the health wait, the rollback.
pub struct UiOta {
dir: PathBuf,
current: Current,
bad: Vec<String>,
builtin: bool,
busy: bool,
/// the manifest entry seen last (for the Settings line and the retry after an error)
entry: Option<UiEntry>,
/// a bundle served to a page and not yet confirmed: (version, when the page loaded)
waiting: Option<(String, Instant)>,
error: String,
/// the whole download thread's last reason, kept for the state
last_skip: String,
health_wait: Duration,
}
impl UiOta {
pub fn new(shared: &Arc<Shared>) -> UiOta {
let dir = shared.runtime.app_dir.join("ui");
let _ = std::fs::create_dir_all(&dir);
let builtin = shared.settings.lock().unwrap().ui_builtin;
let mut u = UiOta { dir: dir.clone(), current: read_current(&dir), bad: read_bad(&dir), builtin, busy: false, entry: None, waiting: None, error: String::new(), last_skip: String::new(), health_wait: Duration::from_secs(HEALTH_WAIT_S) };
// a pointer to a folder that is not there (a cleaned app data folder) falls back without a word
if bundle_dir(&dir, &u.current.version).is_none() && u.current.version != "embedded" {
shared.log(&format!("ui: the pointer names interface {} but its folder is gone; the built-in interface serves", u.current.version));
u.current = Current { version: "embedded".into(), installed_at: 0, confirmed: true };
let _ = write_current(&dir, &u.current);
}
u.apply_pointer(shared);
if u.current.version != "embedded" {
shared.log(&format!("ui bundle {} active (installed {}){}", u.current.version, u.current.installed_at, if u.builtin { ", but the built-in interface is chosen" } else { "" }));
}
u.publish(shared);
u
}
/// What the server serves: the bundle folder, or None for the embedded files.
fn apply_pointer(&self, shared: &Arc<Shared>) {
let d = if self.builtin { None } else { bundle_dir(&self.dir, &self.current.version) };
shared.set_ui_dir(d);
}
pub fn active_version(&self) -> &str {
&self.current.version
}
/// Called with every verified manifest: the `ui` entry or None (the kill switch).
pub fn consider(&mut self, shared: &Arc<Shared>, entry: Option<&UiEntry>, engine: &str) {
let Some(e) = entry else {
self.entry = None;
if self.current.version != "embedded" {
shared.event("info", &format!("the interface channel was withdrawn; the built-in interface {} serves again", embedded_version()));
shared.log(&format!("ui: no ui object in the manifest; interface {} retired", self.current.version));
self.set_current(shared, Current { version: "embedded".into(), installed_at: crate::platform::unix_now(), confirmed: true });
}
self.publish(shared);
return;
};
self.entry = Some(e.clone());
if self.busy {
return;
}
match decide(e, engine, embedded_version(), Some(&self.current.version), &self.bad, self.builtin) {
Decision::Skip(why) => {
if why != self.last_skip {
shared.log(&format!("ui: {why}"));
self.last_skip = why;
}
}
Decision::Apply => {
self.last_skip = String::new();
self.busy = true;
self.error = String::new();
shared.event("info", &format!("interface {} is published: downloading ({} KB)", e.version, e.size / 1000));
let e2 = e.clone();
let dir = self.dir.clone();
let shared2 = shared.clone();
std::thread::spawn(move || {
let r = download_and_install(&e2, &dir);
shared2.send(Cmd::UiOta(Event::Installed(e2.version.clone(), r)));
});
}
}
self.publish(shared);
}
pub fn event(&mut self, shared: &Arc<Shared>, ev: Event) {
self.busy = false;
match ev {
Event::Installed(version, Ok(_dir)) => {
shared.event("ok", &format!("interface {version} installed; the window takes it at its next load"));
self.set_current(shared, Current { version: version.clone(), installed_at: crate::platform::unix_now(), confirmed: false });
shared.log(&format!("ui bundle {version} active (installed {}), awaiting the page's health ping", self.current.installed_at));
}
Event::Installed(version, Err(e)) => {
self.mark_bad(shared, &version, &e);
}
}
self.publish(shared);
}
fn set_current(&mut self, shared: &Arc<Shared>, c: Current) {
self.current = c;
if let Err(e) = write_current(&self.dir, &self.current) {
shared.log(&format!("ui: could not write the pointer: {e}"));
}
self.waiting = None;
self.apply_pointer(shared);
}
fn mark_bad(&mut self, shared: &Arc<Shared>, version: &str, why: &str) {
if !self.bad.iter().any(|b| b == version) {
self.bad.push(version.to_string());
write_bad(&self.dir, &self.bad);
}
self.error = why.to_string();
shared.event("error", &format!("interface {version} was refused: {why}. The built-in interface serves"));
shared.log(&format!("ui: {version} marked bad: {why}"));
if self.current.version == version {
self.set_current(shared, Current { version: "embedded".into(), installed_at: crate::platform::unix_now(), confirmed: true });
}
}
/// The server served index.html from a bundle: an unconfirmed one starts the health wait.
pub fn page_loaded(&mut self, version: &str, now: Instant) {
if version == self.current.version && !self.current.confirmed && self.waiting.is_none() {
self.waiting = Some((version.to_string(), now));
}
}
/// The page's ping after its first paint, or the error it caught before it.
pub fn health(&mut self, shared: &Arc<Shared>, error: Option<&str>) {
let Some((version, _)) = self.waiting.clone() else { return };
match error {
None => {
self.waiting = None;
self.current.confirmed = true;
let _ = write_current(&self.dir, &self.current);
shared.log(&format!("ui bundle {version} confirmed by the page"));
self.publish(shared);
}
Some(e) => {
self.mark_bad(shared, &version, &format!("a script error on first paint: {}", e.chars().take(200).collect::<String>()));
self.publish(shared);
}
}
}
/// The wait ran out: the page never confirmed the bundle.
pub fn tick(&mut self, shared: &Arc<Shared>, now: Instant) {
if let Some((version, since)) = self.waiting.clone() {
if now.duration_since(since) >= self.health_wait {
self.mark_bad(shared, &version, &format!("the page did not answer within {} s of loading it", self.health_wait.as_secs()));
self.publish(shared);
}
}
}
pub fn set_builtin(&mut self, shared: &Arc<Shared>, on: bool) {
self.builtin = on;
{
let mut s = shared.settings.lock().unwrap();
s.ui_builtin = on;
s.save(&shared.settings_path);
}
shared.state.lock().unwrap().settings.ui_builtin = on;
self.waiting = None;
self.apply_pointer(shared);
shared.event("info", if on { "the built-in interface serves from the next page load" } else { "the over-the-air interface serves again when one is active" });
self.publish(shared);
}
/// Settings > Interface: what serves, from where, since when.
pub fn publish(&self, shared: &Arc<Shared>) {
let mut st = shared.state.lock().unwrap();
let u = &mut st.ui;
let ota_active = !self.builtin && bundle_dir(&self.dir, &self.current.version).is_some();
u.embedded_version = embedded_version().into();
u.active_version = if ota_active { self.current.version.clone() } else { embedded_version().into() };
u.source = if ota_active { "ota".into() } else { "embedded".into() };
u.installed_at = if ota_active { self.current.installed_at as f64 } else { 0.0 };
u.confirmed = !ota_active || self.current.confirmed;
u.published_version = self.entry.as_ref().map(|e| e.version.clone()).unwrap_or_default();
u.busy = self.busy;
u.error = self.error.clone();
u.bad = self.bad.clone();
u.builtin = self.builtin;
u.note = self.last_skip.clone();
}
}
/// The download thread: curl with resume into <dir>/<version>.tar.gz, then install_bundle.
fn download_and_install(e: &UiEntry, dir: &Path) -> Result<PathBuf, String> {
let _ = std::fs::create_dir_all(dir);
let file = dir.join(format!("{}.tar.gz", e.version));
let part = dir.join(format!("{}.tar.gz.part", e.version));
let mut c = std::process::Command::new(crate::platform::tool("curl"));
c.args(["-fsSL", "--max-time", "300", "-C", "-", "-o", &part.display().to_string(), &e.url]);
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(320)).ok_or("curl is not available")?;
let t = out.trim();
if !t.is_empty() && !part.is_file() {
return Err(format!("interface {}: {}", e.version, t.lines().last().unwrap_or("curl failed")));
}
std::fs::rename(&part, &file).map_err(|er| er.to_string())?;
let r = install_bundle(e, &file, dir, manifest::OTA_PUBLIC_KEY_HEX);
if r.is_err() {
let _ = std::fs::remove_file(&file);
}
r
}
/// The content type a served bundle file gets (the same list the embedded files use).
pub fn content_type(rel: &str) -> &'static str {
if rel.ends_with(".html") { "text/html; charset=utf-8" } else if rel.ends_with(".css") { "text/css; charset=utf-8" } else if rel.ends_with(".js") { "application/javascript; charset=utf-8" } else if rel.ends_with(".svg") { "image/svg+xml" } else if rel.ends_with(".woff2") { "font/woff2" } else { "text/plain; charset=utf-8" }
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
fn entry(version: &str, min_engine: &str) -> UiEntry {
UiEntry { version: version.into(), sha256: "ab".repeat(32), size: 1, url: "https://dl.igneum.network/dl/x/ui/igneum-ui-1.0.1.tar.gz".into(), min_engine: min_engine.into(), signature: "cd".repeat(64) }
}
#[test]
fn the_decision_covers_every_reason_to_skip() {
let e = entry("1.0.1", "0.3.19");
assert_eq!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &[], false), Decision::Apply);
assert_eq!(decide(&e, "0.3.20", "1.0.0", Some("1.0.0"), &[], false), Decision::Apply);
assert!(matches!(decide(&e, "0.3.18", "1.0.0", Some("embedded"), &[], false), Decision::Skip(w) if w.contains("needs engine 0.3.19")), "a too-new min_engine is ignored");
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("1.0.1"), &[], false), Decision::Skip(w) if w.contains("is active")));
assert!(matches!(decide(&e, "0.3.19", "1.0.1", Some("embedded"), &[], false), Decision::Skip(w) if w.contains("built-in one")));
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &["1.0.1".to_string()], false), Decision::Skip(w) if w.contains("failed here before")));
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &[], true), Decision::Skip(w) if w.contains("chosen in Settings")));
}
/// A bundle folder, packed with the system tar, hashed and signed with a throwaway key.
fn make_bundle(root: &Path, version: &str, with_index: bool, sk: &SigningKey) -> (UiEntry, PathBuf) {
let src = root.join("src");
let _ = std::fs::remove_dir_all(&src);
std::fs::create_dir_all(src.join("fonts")).unwrap();
if with_index {
std::fs::write(src.join("index.html"), "<!doctype html><title>x</title>").unwrap();
}
std::fs::write(src.join("app.js"), "var x = 1;").unwrap();
std::fs::write(src.join("app.css"), "body{}").unwrap();
std::fs::write(src.join("VERSION"), format!("{version}\n")).unwrap();
std::fs::write(src.join("fonts/IBMPlexMono-400.woff2"), b"wOF2").unwrap();
let tar = root.join(format!("igneum-ui-{version}.tar.gz"));
let mut c = std::process::Command::new("tar");
c.args(["-czf", &tar.display().to_string(), "-C", &src.display().to_string(), "."]);
let ok = crate::platform::quiet(&mut c).status().unwrap().success();
assert!(ok, "tar packs the fixture");
let sha = manifest::sha256_file(&tar).unwrap();
let size = std::fs::metadata(&tar).unwrap().len();
let sig = manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes(version, &sha, "0.3.19")).to_bytes());
(UiEntry { version: version.into(), sha256: sha, size, url: "https://dl.igneum.network/dl/x/ui/x.tar.gz".into(), min_engine: "0.3.19".into(), signature: sig }, tar)
}
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("igneum-uiota-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_good_bundle_installs_and_the_pointer_swaps_atomically() {
let root = tmp("good");
let sk = SigningKey::from_bytes(&[7u8; 32]);
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
let (e, tar) = make_bundle(&root, "1.0.1", true, &sk);
let dir = root.join("ui");
let dest = install_bundle(&e, &tar, &dir, &pk).expect("installs");
assert_eq!(dest, dir.join("1.0.1"));
assert!(bundle_dir(&dir, "1.0.1").is_some());
assert!(!dir.join("1.0.1.new").exists(), "the staging folder is gone");
write_current(&dir, &Current { version: "1.0.1".into(), installed_at: 5, confirmed: false }).unwrap();
assert_eq!(read_current(&dir).version, "1.0.1");
assert!(!dir.join("current.json.tmp").exists());
assert_eq!(content_type("app.js"), "application/javascript; charset=utf-8");
assert_eq!(bundle_dir(&dir, "embedded"), None);
}
#[test]
fn a_bad_signature_is_refused_before_anything_is_unpacked() {
let root = tmp("badsig");
let sk = SigningKey::from_bytes(&[7u8; 32]);
let other = manifest::hex_encode(SigningKey::from_bytes(&[8u8; 32]).verifying_key().as_bytes());
let (e, tar) = make_bundle(&root, "1.0.2", true, &sk);
let dir = root.join("ui");
let err = install_bundle(&e, &tar, &dir, &other).unwrap_err();
assert!(err.contains("signature does not verify"), "{err}");
assert!(!dir.join("1.0.2").exists());
// a tampered hash is caught first
let mut t = e.clone();
t.sha256 = "00".repeat(32);
let err = install_bundle(&t, &tar, &dir, &manifest::hex_encode(sk.verifying_key().as_bytes())).unwrap_err();
assert!(err.contains("sha256 mismatch"), "{err}");
// a wrong size too
let mut t = e.clone();
t.size += 1;
assert!(install_bundle(&t, &tar, &dir, &manifest::hex_encode(sk.verifying_key().as_bytes())).unwrap_err().contains("bytes"));
}
#[test]
fn a_broken_bundle_without_index_html_is_refused_and_leaves_nothing_behind() {
let root = tmp("broken");
let sk = SigningKey::from_bytes(&[7u8; 32]);
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
let (e, tar) = make_bundle(&root, "1.0.3", false, &sk);
let dir = root.join("ui");
let err = install_bundle(&e, &tar, &dir, &pk).unwrap_err();
assert!(err.contains("no index.html"), "{err}");
assert!(!dir.join("1.0.3").exists() && !dir.join("1.0.3.new").exists());
}
#[test]
fn a_renamed_bundle_is_refused_by_its_version_file() {
let root = tmp("renamed");
let sk = SigningKey::from_bytes(&[7u8; 32]);
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
let (mut e, tar) = make_bundle(&root, "1.0.4", true, &sk);
e.version = "1.0.5".into();
e.signature = manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes("1.0.5", &e.sha256, "0.3.19")).to_bytes());
let err = install_bundle(&e, &tar, &root.join("ui"), &pk).unwrap_err();
assert!(err.contains("VERSION file says '1.0.4'"), "{err}");
}
#[test]
fn the_health_wait_rolls_back_to_the_embedded_interface_and_marks_the_version_bad() {
// the state machine without threads: a UiOta over a temp dir, driven by hand
let root = tmp("health");
let dir = root.join("ui");
std::fs::create_dir_all(dir.join("1.0.6")).unwrap();
for f in REQUIRED {
std::fs::write(dir.join("1.0.6").join(f), "x").unwrap();
}
write_current(&dir, &Current { version: "1.0.6".into(), installed_at: 1, confirmed: false }).unwrap();
let mut u = UiOta { dir: dir.clone(), current: read_current(&dir), bad: vec![], builtin: false, busy: false, entry: None, waiting: None, error: String::new(), last_skip: String::new(), health_wait: Duration::from_secs(10) };
let t0 = Instant::now();
u.page_loaded("1.0.6", t0);
assert!(u.waiting.is_some());
// a ping in time confirms
let shared = crate::engine::Shared::for_tests(root.join("data"));
u.health(&shared, None);
assert!(u.current.confirmed && u.waiting.is_none());
assert_eq!(read_current(&dir).confirmed, true);
// a second bundle that never answers
std::fs::create_dir_all(dir.join("1.0.7")).unwrap();
for f in REQUIRED {
std::fs::write(dir.join("1.0.7").join(f), "x").unwrap();
}
u.set_current(&shared, Current { version: "1.0.7".into(), installed_at: 2, confirmed: false });
u.page_loaded("1.0.7", t0);
u.tick(&shared, t0 + Duration::from_secs(9));
assert_eq!(u.current.version, "1.0.7", "still waiting inside the window");
u.tick(&shared, t0 + Duration::from_secs(10));
assert_eq!(u.current.version, "embedded", "rolled back");
assert_eq!(read_current(&dir).version, "embedded");
assert_eq!(read_bad(&dir), vec!["1.0.7".to_string()]);
assert!(shared.ui_dir().is_none(), "the server serves the embedded files again");
// a script error on first paint rolls back the same way
std::fs::create_dir_all(dir.join("1.0.8")).unwrap();
for f in REQUIRED {
std::fs::write(dir.join("1.0.8").join(f), "x").unwrap();
}
u.set_current(&shared, Current { version: "1.0.8".into(), installed_at: 3, confirmed: false });
u.page_loaded("1.0.8", t0);
u.health(&shared, Some("TypeError: x is not a function"));
assert_eq!(u.current.version, "embedded");
assert!(u.bad.contains(&"1.0.8".to_string()));
// and the decision never applies it again
let e = entry("1.0.8", "0.3.19");
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &u.bad, false), Decision::Skip(_)));
}
}

View file

@ -0,0 +1 @@
1.0.0