pub mod manifest; pub mod transaction; use anyhow::{Context, Result, bail}; use chrono::{DateTime, Utc}; use iota_ipc::{ ClientMessage, DaemonMessage, HealthStatus, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, StartupPhase, read_msg, write_msg, }; use manifest::{Artifact, ReleaseManifest, verify_signature}; use serde::{Deserialize, Serialize}; use std::{ collections::BTreeMap, fs, path::{Path, PathBuf}, process::Command, time::Duration, }; use tokio::net::UnixStream; use transaction::{Activation, UpdateTransaction}; const MANIFEST_ENV: &str = "IOTA_UPDATE_MANIFEST"; const SIGNATURE_ENV: &str = "IOTA_UPDATE_SIGNATURE"; const PUBLIC_KEY_ENV: &str = "IOTA_UPDATE_PUBLIC_KEY"; const CHANNEL_ENV: &str = "IOTA_UPDATE_CHANNEL"; const SIGNING_KEY_ID_ENV: &str = "IOTA_UPDATE_SIGNING_KEY_ID"; const ACTIVATION_TIMEOUT_ENV: &str = "IOTA_UPDATE_ACTIVATION_TIMEOUT_SECONDS"; const ACTIVATION_RETRY_ENV: &str = "IOTA_UPDATE_ACTIVATION_RETRY_MILLISECONDS"; const DEFAULT_ACTIVATION_TIMEOUT_SECONDS: u64 = 60; const DEFAULT_ACTIVATION_RETRY_MILLISECONDS: u64 = 250; const REQUIRED_HOST_ARTIFACTS: &str = include_str!("../artifacts.tsv"); #[derive(Clone, Debug)] pub struct ActivationPolicy { pub timeout: Duration, pub retry_interval: Duration, } impl ActivationPolicy { fn from_environment() -> Result { Ok(Self { timeout: Duration::from_secs(environment_u64( ACTIVATION_TIMEOUT_ENV, DEFAULT_ACTIVATION_TIMEOUT_SECONDS, )?), retry_interval: Duration::from_millis(environment_u64( ACTIVATION_RETRY_ENV, DEFAULT_ACTIVATION_RETRY_MILLISECONDS, )?), }) } } #[derive(Clone, Debug)] struct UpdatePolicy { channel: String, signing_key_id: String, activation: ActivationPolicy, } impl UpdatePolicy { fn from_environment() -> Result { Ok(Self { channel: required_environment(CHANNEL_ENV)?, signing_key_id: required_environment(SIGNING_KEY_ID_ENV)?, activation: ActivationPolicy::from_environment()?, }) } } #[derive(Clone, Debug, Default, Deserialize, Serialize)] struct UpdateState { #[serde(default)] channels: BTreeMap, #[serde(default)] pending_activation: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] struct PendingActivation { previous_target: PathBuf, new_target: PathBuf, channel: String, release_sequence: u64, product_version: String, daemon_was_active: bool, selected: bool, } #[derive(Clone, Debug, Deserialize, Serialize)] struct ChannelState { highest_accepted_sequence: u64, highest_accepted_version: String, active_version: String, #[serde(default)] last_failed_sequence: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum CandidateDecision { NoUpdate, Install, } pub trait DaemonSupervisor { fn is_active(&self) -> Result; fn restart(&self) -> Result<()>; fn main_pid(&self) -> Result; } struct SystemdSupervisor; impl DaemonSupervisor for SystemdSupervisor { fn is_active(&self) -> Result { let status = Command::new("systemctl") .args(["is-active", "--quiet", "iota-daemon.service"]) .status() .context("query iota-daemon.service state")?; Ok(status.success()) } fn restart(&self) -> Result<()> { let status = Command::new("systemctl") .args(["restart", "iota-daemon.service"]) .status() .context("restart iota-daemon.service")?; if !status.success() { bail!("systemctl restart iota-daemon.service failed with {status}"); } Ok(()) } fn main_pid(&self) -> Result { let output = Command::new("systemctl") .args([ "show", "--property=MainPID", "--value", "iota-daemon.service", ]) .output() .context("query iota-daemon.service MainPID")?; if !output.status.success() { bail!("could not query iota-daemon.service MainPID"); } let text = String::from_utf8(output.stdout).context("systemctl returned non-UTF-8 MainPID")?; text.trim() .parse() .context("parse iota-daemon.service MainPID") } } pub async fn check_update() -> Result { let Some(release) = configured_release().await? else { return Ok(false); }; let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System) .map_err(|error| anyhow::anyhow!(error))?; let policy = UpdatePolicy::from_environment()?; let transaction = UpdateTransaction::from_paths(&paths)?; let _lock = transaction.acquire()?; let state = load_or_initialize_update_state(&paths)?; if state.pending_activation.is_some() { bail!("an interrupted update requires iota-updater apply recovery"); } let decision = evaluate_candidate( &release.manifest, current_version(&paths.install_root)?.as_deref(), state.channels.get(&policy.channel), &policy.channel, &policy.signing_key_id, Utc::now(), )?; Ok(decision == CandidateDecision::Install) } pub async fn apply_update() -> Result { let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System) .map_err(|error| anyhow::anyhow!(error))?; let policy = UpdatePolicy::from_environment()?; apply_update_with(&paths, &SystemdSupervisor, &policy).await } async fn apply_update_with( paths: &iota_paths::IotaPaths, supervisor: &impl DaemonSupervisor, policy: &UpdatePolicy, ) -> Result { let transaction = UpdateTransaction::from_paths(paths)?; let _lock = transaction.acquire()?; let mut state = load_or_initialize_update_state(paths)?; recover_interrupted_activation( paths, &transaction, &mut state, supervisor, &policy.activation, ) .await?; let Some(release) = configured_release().await? else { return Ok(false); }; let decision = evaluate_candidate( &release.manifest, current_version(&paths.install_root)?.as_deref(), state.channels.get(&policy.channel), &policy.channel, &policy.signing_key_id, Utc::now(), )?; if decision == CandidateDecision::NoUpdate { return Ok(false); } validate_manifest_compatibility( &release.manifest, &paths.database_file(), iota_ipc::MIN_PROTOCOL_VERSION, iota_ipc::PROTOCOL_VERSION, )?; if transaction.staging.exists() { fs::remove_dir_all(&transaction.staging).with_context(|| { format!( "remove stale update staging directory {}", transaction.staging.display() ) })?; } for artifact in &release.artifacts { let source = download_to_temporary_file(&artifact.url).await?; transaction.stage_artifact(source.path(), artifact)?; } fs::write( transaction.staging.join("manifest.json"), serde_json::to_vec_pretty(&release.manifest)?, ) .context("write staged release manifest")?; let daemon_was_active = supervisor.is_active()?; begin_activation_state( paths, &mut state, &transaction, &release.manifest, daemon_was_active, )?; let activation = match transaction.activate(&release.manifest.product_version) { Ok(activation) => activation, Err(error) => { remove_unselected_candidate(&state, &transaction.root)?; clear_pending_activation(paths, &mut state)?; return Err(error); } }; let activation_result = async { validate_activation_receipt(&state, &activation)?; if daemon_was_active { supervisor.restart()?; wait_for_ready_daemon( &iota_paths::socket_path(iota_paths::Scope::System), &activation.new_target, supervisor, &policy.activation, ) .await?; } commit_update_state(paths, &mut state, &release.manifest)?; Ok::<_, anyhow::Error>(()) } .await; if let Err(update_error) = activation_result { restore_previous_release( paths, &transaction, &activation, daemon_was_active, supervisor, &policy.activation, ) .await?; record_failed_release(paths, &mut state, &release.manifest)?; return Err( update_error.context("candidate release failed runtime activation and was rolled back") ); } Ok(true) } async fn recover_interrupted_activation( paths: &iota_paths::IotaPaths, transaction: &UpdateTransaction, state: &mut UpdateState, supervisor: &impl DaemonSupervisor, policy: &ActivationPolicy, ) -> Result<()> { let Some(pending) = state.pending_activation.clone() else { return Ok(()); }; validate_pending_candidate(&pending, &transaction.root)?; let current_target = transaction.current_target()?; let candidate_was_selected = pending.selected || current_target == pending.new_target; if current_target == pending.new_target { transaction .restore_activation(&Activation { previous_target: pending.previous_target.clone(), new_target: pending.new_target.clone(), }) .context("restore previous release after interrupted activation")?; } else if current_target != pending.previous_target { bail!( "cannot recover interrupted activation: current target {} matches neither {} nor {}", current_target.display(), pending.previous_target.display(), pending.new_target.display() ); } if candidate_was_selected && pending.daemon_was_active { supervisor .restart() .context("restart previous daemon after interrupted activation")?; wait_for_ready_daemon( &iota_paths::socket_path(iota_paths::Scope::System), &activation_target_path(&paths.install_root, &pending.previous_target), supervisor, policy, ) .await .context("previous daemon failed during interrupted update recovery")?; } if candidate_was_selected { record_failed_sequence(paths, state, &pending.channel, pending.release_sequence)?; } else { remove_unselected_candidate(state, &transaction.root)?; clear_pending_activation(paths, state)?; } Ok(()) } fn remove_unselected_candidate(state: &UpdateState, install_root: &Path) -> Result<()> { let Some(pending) = state.pending_activation.as_ref() else { return Ok(()); }; validate_pending_candidate(pending, install_root)?; if pending.new_target.exists() { fs::remove_dir_all(&pending.new_target).with_context(|| { format!( "remove unselected candidate release {}", pending.new_target.display() ) })?; } Ok(()) } fn validate_pending_candidate(pending: &PendingActivation, install_root: &Path) -> Result<()> { validate_version(&pending.product_version)?; let expected = install_root.join("versions").join(&pending.product_version); if pending.new_target != expected { bail!( "pending candidate path {} does not match expected path {}", pending.new_target.display(), expected.display() ); } Ok(()) } fn begin_activation_state( paths: &iota_paths::IotaPaths, state: &mut UpdateState, transaction: &UpdateTransaction, manifest: &ReleaseManifest, daemon_was_active: bool, ) -> Result<()> { let new_target = transaction .root .join("versions") .join(&manifest.product_version); if new_target.exists() { bail!("release version already exists: {}", new_target.display()); } let mut updated = state.clone(); updated.pending_activation = Some(PendingActivation { previous_target: transaction.current_target()?, new_target, channel: manifest.channel.clone(), release_sequence: manifest.release_sequence, product_version: manifest.product_version.clone(), daemon_was_active, selected: true, }); save_update_state(&paths.update_status_file(), &updated)?; *state = updated; Ok(()) } fn validate_activation_receipt(state: &UpdateState, activation: &Activation) -> Result<()> { let pending = state .pending_activation .as_ref() .context("activation has no persisted rollback receipt")?; if pending.previous_target != activation.previous_target || pending.new_target != activation.new_target { bail!("activation receipt does not match persisted rollback state"); } Ok(()) } fn clear_pending_activation(paths: &iota_paths::IotaPaths, state: &mut UpdateState) -> Result<()> { let mut updated = state.clone(); updated.pending_activation = None; save_update_state(&paths.update_status_file(), &updated)?; *state = updated; Ok(()) } async fn restore_previous_release( paths: &iota_paths::IotaPaths, transaction: &UpdateTransaction, activation: &Activation, daemon_was_active: bool, supervisor: &impl DaemonSupervisor, policy: &ActivationPolicy, ) -> Result<()> { transaction .restore_activation(activation) .context("restore previous release after failed activation")?; if daemon_was_active { supervisor .restart() .context("restart previous daemon after failed activation")?; wait_for_ready_daemon( &iota_paths::socket_path(iota_paths::Scope::System), &activation_target_path(&paths.install_root, &activation.previous_target), supervisor, policy, ) .await .context("previous daemon failed after update rollback")?; } Ok(()) } fn activation_target_path(install_root: &Path, target: &Path) -> PathBuf { if target.is_absolute() { target.to_owned() } else { install_root.join(target) } } async fn probe_daemon(socket: &Path) -> Result { let mut stream = UnixStream::connect(socket) .await .with_context(|| format!("connect to {}", socket.display()))?; write_msg( &mut stream, &ClientMessage::Hello { supported_versions: (MIN_PROTOCOL_VERSION..=PROTOCOL_VERSION).collect(), }, ) .await .context("send updater IPC hello")?; match read_msg::<_, DaemonMessage>(&mut stream).await? { DaemonMessage::HelloAck(ack) => Ok(ack), other => bail!("expected daemon HelloAck, received {other:?}"), } } async fn wait_for_ready_daemon( socket: &Path, expected_release: &Path, supervisor: &impl DaemonSupervisor, policy: &ActivationPolicy, ) -> Result<()> { let deadline = tokio::time::Instant::now() + policy.timeout; loop { if let Ok(ack) = probe_daemon(socket).await && ack.startup_phase == StartupPhase::Ready && ack.health != HealthStatus::Failed { verify_running_executable(supervisor, expected_release)?; return Ok(()); } if tokio::time::Instant::now() >= deadline { bail!("updated daemon did not reach the required ready state"); } tokio::time::sleep(policy.retry_interval).await; } } fn verify_running_executable( supervisor: &impl DaemonSupervisor, expected_release: &Path, ) -> Result<()> { let pid = supervisor.main_pid()?; if pid == 0 { bail!("iota-daemon.service has no MainPID"); } let actual = fs::canonicalize(format!("/proc/{pid}/exe")) .context("resolve running daemon executable")?; let expected = fs::canonicalize(expected_release.join("bin/iota-daemon")) .context("resolve expected daemon executable")?; if actual != expected { bail!( "running daemon executable mismatch: expected {}, got {}", expected.display(), actual.display() ); } Ok(()) } fn evaluate_candidate( manifest: &ReleaseManifest, current_version: Option<&str>, channel_state: Option<&ChannelState>, expected_channel: &str, expected_key_id: &str, now: DateTime, ) -> Result { validate_manifest_identity(manifest, expected_channel, expected_key_id)?; validate_manifest_freshness(manifest, now)?; if manifest.release_sequence == 0 { bail!("release sequence must be greater than zero"); } if let Some(state) = channel_state { if manifest.release_sequence < state.highest_accepted_sequence { bail!( "release sequence {} is older than trusted sequence {}", manifest.release_sequence, state.highest_accepted_sequence ); } if manifest.release_sequence == state.highest_accepted_sequence && manifest.product_version != state.highest_accepted_version { bail!( "release sequence {} was already associated with version {}", manifest.release_sequence, state.highest_accepted_version ); } if state.last_failed_sequence == Some(manifest.release_sequence) { bail!( "release sequence {} previously failed activation", manifest.release_sequence ); } if manifest.release_sequence == state.highest_accepted_sequence { return Ok(CandidateDecision::NoUpdate); } } if current_version == Some(manifest.product_version.as_str()) { return Ok(CandidateDecision::NoUpdate); } Ok(CandidateDecision::Install) } fn validate_manifest_identity( manifest: &ReleaseManifest, expected_channel: &str, expected_key_id: &str, ) -> Result<()> { if manifest.channel != expected_channel { bail!( "release channel mismatch: expected {}, got {}", expected_channel, manifest.channel ); } if manifest.release_signing_key_id != expected_key_id { bail!( "release signing key id mismatch: expected {}, got {}", expected_key_id, manifest.release_signing_key_id ); } Ok(()) } fn validate_manifest_freshness(manifest: &ReleaseManifest, now: DateTime) -> Result<()> { let published_at = DateTime::parse_from_rfc3339(&manifest.published_at) .context("parse manifest published_at")? .with_timezone(&Utc); let expires_at = DateTime::parse_from_rfc3339(&manifest.expires_at) .context("parse manifest expires_at")? .with_timezone(&Utc); if expires_at <= published_at { bail!("release manifest expires_at must be after published_at"); } if now > expires_at { bail!("release manifest expired at {}", manifest.expires_at); } Ok(()) } fn load_or_initialize_update_state(paths: &iota_paths::IotaPaths) -> Result { let path = paths.update_status_file(); if path.exists() { let bytes = fs::read(&path).with_context(|| format!("read update status {}", path.display()))?; return serde_json::from_slice(&bytes) .with_context(|| format!("parse update status {}", path.display())); } let installed = read_installed_manifest(&paths.install_root.join("current")).context( "initialize anti-rollback state from installed manifest; legacy installations require an installer migration", )?; if installed.release_sequence == 0 { bail!("installed release sequence must be greater than zero"); } let mut state = UpdateState::default(); state.channels.insert( installed.channel.clone(), ChannelState { highest_accepted_sequence: installed.release_sequence, highest_accepted_version: installed.product_version.clone(), active_version: installed.product_version, last_failed_sequence: None, }, ); save_update_state(&path, &state)?; Ok(state) } fn save_update_state(path: &Path, state: &UpdateState) -> Result<()> { let parent = path.parent().context("update status path has no parent")?; fs::create_dir_all(parent)?; let mut temporary = tempfile::NamedTempFile::new_in(parent).context("create temporary update status file")?; serde_json::to_writer_pretty(temporary.as_file_mut(), state) .context("serialize update status")?; temporary .as_file_mut() .sync_all() .context("flush update status")?; temporary .persist(path) .map_err(|error| error.error) .context("replace update status")?; fs::File::open(parent) .context("open update status directory")? .sync_all() .context("flush update status directory")?; Ok(()) } fn commit_update_state( paths: &iota_paths::IotaPaths, state: &mut UpdateState, manifest: &ReleaseManifest, ) -> Result<()> { let mut updated = state.clone(); updated.channels.insert( manifest.channel.clone(), ChannelState { highest_accepted_sequence: manifest.release_sequence, highest_accepted_version: manifest.product_version.clone(), active_version: manifest.product_version.clone(), last_failed_sequence: None, }, ); updated.pending_activation = None; save_update_state(&paths.update_status_file(), &updated)?; *state = updated; Ok(()) } fn record_failed_release( paths: &iota_paths::IotaPaths, state: &mut UpdateState, manifest: &ReleaseManifest, ) -> Result<()> { record_failed_sequence(paths, state, &manifest.channel, manifest.release_sequence) } fn record_failed_sequence( paths: &iota_paths::IotaPaths, state: &mut UpdateState, channel_name: &str, release_sequence: u64, ) -> Result<()> { let mut updated = state.clone(); let active_version = current_version(&paths.install_root)? .context("active release has no product version while recording failed update")?; let channel = updated .channels .entry(channel_name.to_owned()) .or_insert_with(|| ChannelState { highest_accepted_sequence: 0, highest_accepted_version: String::new(), active_version, last_failed_sequence: None, }); channel.last_failed_sequence = Some(release_sequence); updated.pending_activation = None; save_update_state(&paths.update_status_file(), &updated)?; *state = updated; Ok(()) } fn required_environment(name: &'static str) -> Result { let value = std::env::var(name).with_context(|| format!("{name} is required"))?; if value.is_empty() { bail!("{name} must not be empty"); } Ok(value) } fn environment_u64(name: &'static str, default: u64) -> Result { let value = match std::env::var(name) { Ok(value) => value .parse::() .with_context(|| format!("{name} must be an unsigned integer"))?, Err(std::env::VarError::NotPresent) => default, Err(std::env::VarError::NotUnicode(_)) => bail!("{name} must be valid UTF-8"), }; if value == 0 { bail!("{name} must be greater than zero"); } Ok(value) } /// Switches `current` to an already-installed release version. pub fn rollback(version: &str) -> Result<()> { validate_version(version)?; let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System) .map_err(|error| anyhow::anyhow!(error))?; let transaction = UpdateTransaction::from_paths(&paths)?; let _lock = transaction.acquire()?; let mut state = load_or_initialize_update_state(&paths)?; if state.pending_activation.is_some() { bail!("an interrupted update requires iota-updater apply recovery"); } let release_dir = transaction.root.join("versions").join(version); if !release_dir.is_dir() { bail!( "installed release version does not exist: {}", release_dir.display() ); } let active_manifest = read_installed_manifest(&transaction.root.join("current"))?; let target_manifest = read_installed_manifest(&release_dir)?; validate_rollback_manifests(&active_manifest, &target_manifest, version)?; validate_manifest_compatibility( &target_manifest, &paths.database_file(), iota_ipc::MIN_PROTOCOL_VERSION, iota_ipc::PROTOCOL_VERSION, )?; if active_manifest.channel != target_manifest.channel { bail!( "rollback target channel {} does not match active channel {}", target_manifest.channel, active_manifest.channel ); } let previous_target = fs::read_link(transaction.root.join("current")) .context("read current release link before rollback")?; transaction.rollback(version)?; let activation = Activation { previous_target, new_target: release_dir, }; if let Err(error) = set_active_version(&paths, &mut state, &target_manifest) { transaction .restore_activation(&activation) .context("restore active release after rollback state commit failed")?; return Err(error.context("persist rollback update state")); } Ok(()) } fn set_active_version( paths: &iota_paths::IotaPaths, state: &mut UpdateState, manifest: &ReleaseManifest, ) -> Result<()> { let mut updated = state.clone(); let channel = updated .channels .get_mut(&manifest.channel) .context("rollback channel has no trusted baseline")?; channel.active_version = manifest.product_version.clone(); save_update_state(&paths.update_status_file(), &updated)?; *state = updated; Ok(()) } struct ConfiguredRelease { manifest: ReleaseManifest, artifacts: Vec, } async fn configured_release() -> Result> { let Some(manifest_location) = std::env::var_os(MANIFEST_ENV) else { return Ok(None); }; let manifest_location = manifest_location .into_string() .map_err(|_| anyhow::anyhow!("{MANIFEST_ENV} must be valid UTF-8"))?; if manifest_location.is_empty() { bail!("{MANIFEST_ENV} must not be empty"); } let signature_location = std::env::var(SIGNATURE_ENV).unwrap_or_else(|_| format!("{manifest_location}.sig")); let key_text = std::env::var(PUBLIC_KEY_ENV) .with_context(|| format!("{PUBLIC_KEY_ENV} is required when {MANIFEST_ENV} is set"))?; configured_release_from_locations(&manifest_location, &signature_location, &key_text) .await .map(Some) } async fn configured_release_from_locations( manifest_location: &str, signature_location: &str, key_text: &str, ) -> Result { let manifest_bytes = read_location(manifest_location).await?; let manifest: ReleaseManifest = serde_json::from_slice(&manifest_bytes).context("parse release manifest")?; validate_version(&manifest.product_version)?; let signature_text = String::from_utf8(read_location(signature_location).await?) .context("release signature must be UTF-8 hex")?; let signature = hex::decode(signature_text.trim()).context("decode release signature hex")?; let key = hex::decode(key_text.trim()).context("decode release public key hex")?; let public_key: [u8; 32] = key .try_into() .map_err(|_| anyhow::anyhow!("release public key must be 32 bytes"))?; verify_signature(&manifest, &signature, &public_key)?; let artifacts = select_host_artifacts(&manifest, std::env::consts::OS, std::env::consts::ARCH)?; Ok(ConfiguredRelease { manifest, artifacts, }) } fn select_host_artifacts( manifest: &ReleaseManifest, operating_system: &str, architecture: &str, ) -> Result> { let requirements = required_host_artifacts()?; let artifacts: Vec = manifest .artifacts .iter() .filter(|artifact| artifact.os == operating_system && artifact.architecture == architecture) .cloned() .collect(); for artifact in &artifacts { if !requirements .iter() .any(|(role, path)| *role == artifact.role.as_str() && *path == artifact.path.as_str()) { bail!( "release has unexpected artifact role/path for {operating_system}/{architecture}: {}/{}", artifact.role, artifact.path ); } } for (role, path) in &requirements { let count = artifacts .iter() .filter(|artifact| artifact.role == *role && artifact.path == *path) .count(); if count != 1 { bail!( "release must contain exactly one {role} artifact at {path} for {operating_system}/{architecture}; found {count}" ); } } if artifacts.len() != requirements.len() { bail!( "release has an invalid artifact count for {operating_system}/{architecture}: expected {}, found {}", requirements.len(), artifacts.len() ); } Ok(artifacts) } fn required_host_artifacts() -> Result> { REQUIRED_HOST_ARTIFACTS .lines() .filter(|line| !line.is_empty()) .map(|line| { line.split_once('\t') .context("invalid embedded updater artifact contract") }) .collect() } async fn read_location(location: &str) -> Result> { if location.starts_with("https://") || location.starts_with("http://") { let response = reqwest::get(location) .await .with_context(|| format!("download {location}"))? .error_for_status() .with_context(|| format!("download {location}"))?; return Ok(response.bytes().await?.to_vec()); } let path = location.strip_prefix("file://").unwrap_or(location); fs::read(path).with_context(|| format!("read update input {path}")) } async fn download_to_temporary_file(location: &str) -> Result { let file = tempfile::NamedTempFile::new().context("create temporary update artifact")?; fs::write(file.path(), read_location(location).await?) .with_context(|| format!("write downloaded update artifact from {location}"))?; Ok(file) } fn current_version(install_root: &Path) -> Result> { let manifest_path = install_root.join("current/manifest.json"); if !manifest_path.exists() { return Ok(None); } let bytes = fs::read(&manifest_path).with_context(|| { format!( "read installed release manifest {}", manifest_path.display() ) })?; let value: serde_json::Value = serde_json::from_slice(&bytes).with_context(|| { format!( "parse installed release manifest {}", manifest_path.display() ) })?; Ok(value .get("product_version") .and_then(|value| value.as_str()) .map(str::to_owned)) } fn read_installed_manifest(release_dir: &Path) -> Result { let manifest_path = release_dir.join("manifest.json"); let bytes = fs::read(&manifest_path).with_context(|| { format!( "read installed release manifest {}", manifest_path.display() ) })?; serde_json::from_slice(&bytes).with_context(|| { format!( "parse installed release manifest {}", manifest_path.display() ) }) } fn validate_manifest_compatibility( manifest: &ReleaseManifest, database_path: &Path, installed_ipc_min: u16, installed_ipc_max: u16, ) -> Result<()> { if manifest.supported_ipc_min > manifest.supported_ipc_max { bail!( "release has an invalid IPC range: {}..={}", manifest.supported_ipc_min, manifest.supported_ipc_max ); } if installed_ipc_min > installed_ipc_max { bail!("installed Iota has an invalid IPC compatibility range"); } if manifest.supported_ipc_max < installed_ipc_min || manifest.supported_ipc_min > installed_ipc_max { bail!( "release IPC range {}..={} is incompatible with installed range {}..={}", manifest.supported_ipc_min, manifest.supported_ipc_max, installed_ipc_min, installed_ipc_max ); } if let Some(installed_schema) = installed_data_schema(database_path)? && installed_schema < manifest.minimum_data_schema { bail!( "release requires data schema {} or newer, but installed database uses schema {}", manifest.minimum_data_schema, installed_schema ); } Ok(()) } fn validate_rollback_manifests( active: &ReleaseManifest, target: &ReleaseManifest, requested_version: &str, ) -> Result<()> { if !active.rollback_compatible { bail!( "active release {} does not permit rollback", active.product_version ); } if target.product_version != requested_version { bail!( "rollback target manifest version {} does not match requested version {requested_version}", target.product_version ); } Ok(()) } fn installed_data_schema(database_path: &Path) -> Result> { if !database_path.exists() { return Ok(None); } let connection = rusqlite::Connection::open_with_flags( database_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, ) .with_context(|| format!("open installed database {}", database_path.display()))?; let user_version: i64 = connection .pragma_query_value(None, "user_version", |row| row.get(0)) .with_context(|| format!("read data schema from {}", database_path.display()))?; let user_version = user_version.try_into().with_context(|| { format!( "installed database has a negative data schema: {}", database_path.display() ) })?; Ok(Some(user_version)) } fn validate_version(version: &str) -> Result<()> { let mut characters = version.chars(); if !characters .next() .is_some_and(|character| character.is_ascii_alphanumeric()) || !characters.all(|character| { character.is_ascii_alphanumeric() || matches!(character, '.' | '+' | '_' | '-') }) { bail!("release product_version contains unsupported characters"); } Ok(()) } #[cfg(test)] mod tests { use super::*; use ed25519_dalek::{Signer, SigningKey}; struct CurrentProcessSupervisor; impl DaemonSupervisor for CurrentProcessSupervisor { fn is_active(&self) -> Result { Ok(true) } fn restart(&self) -> Result<()> { Ok(()) } fn main_pid(&self) -> Result { Ok(std::process::id()) } } fn test_paths(root: &Path) -> iota_paths::IotaPaths { let state_dir = root.join("state"); iota_paths::IotaPaths { scope: iota_paths::Scope::User, config_dir: root.join("config"), config_file: root.join("config/config.yaml"), state_dir: state_dir.clone(), storage_dir: state_dir.join("storage"), identity_dir: state_dir.join("identity"), cache_dir: root.join("cache"), runtime_dir: Some(root.join("run")), log_dir: root.join("logs"), asset_dir: root.join("web"), install_root: root.join("install"), ipc_endpoint: iota_paths::IpcEndpoint::UnixSocket(root.join("run/iota.sock")), } } fn artifact(role: &str, path: &str, os: &str, architecture: &str) -> Artifact { Artifact { role: role.into(), os: os.into(), architecture: architecture.into(), path: path.into(), url: format!("file:///release/{}", path.replace('/', "-")), sha256: "00".repeat(32), size: 1, } } fn release_manifest(artifacts: Vec) -> ReleaseManifest { ReleaseManifest { product_version: "1.2.3".into(), channel: "stable".into(), release_sequence: 12, published_at: "2026-09-10T00:00:00Z".into(), expires_at: "2030-09-10T00:00:00Z".into(), minimum_data_schema: 1, supported_ipc_min: 1, supported_ipc_max: 1, artifacts, release_signing_key_id: "test".into(), rollback_compatible: true, } } fn host_artifacts() -> Vec { required_host_artifacts() .unwrap() .into_iter() .map(|(role, path)| artifact(role, path, std::env::consts::OS, std::env::consts::ARCH)) .collect() } #[tokio::test] async fn loads_a_signed_manifest_and_selects_complete_host_release() { let directory = tempfile::tempdir().unwrap(); let mut artifacts = host_artifacts(); artifacts.push(artifact("daemon", "bin/iota-daemon", "other", "other")); let manifest = release_manifest(artifacts); let signing_key = SigningKey::from_bytes(&[7; 32]); let signature = signing_key.sign(&manifest::canonical_bytes(&manifest).unwrap()); let manifest_path = directory.path().join("manifest.json"); let signature_path = directory.path().join("manifest.json.sig"); fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap(); fs::write(&signature_path, hex::encode(signature.to_bytes())).unwrap(); let release = configured_release_from_locations( manifest_path.to_str().unwrap(), signature_path.to_str().unwrap(), &hex::encode(signing_key.verifying_key().to_bytes()), ) .await .unwrap(); assert_eq!(release.manifest.product_version, "1.2.3"); assert_eq!(release.artifacts.len(), 3); assert!( release .artifacts .iter() .all(|artifact| artifact.os == std::env::consts::OS) ); } #[test] fn rejects_host_release_missing_an_executable() { let mut artifacts = host_artifacts(); artifacts.retain(|artifact| artifact.path != "bin/iota-updater"); let manifest = release_manifest(artifacts); let error = select_host_artifacts(&manifest, std::env::consts::OS, std::env::consts::ARCH) .unwrap_err(); assert!(error.to_string().contains("bin/iota-updater")); } #[test] fn rejects_duplicate_or_unexpected_host_artifacts() { let mut duplicate = host_artifacts(); duplicate.push(duplicate[0].clone()); let duplicate_error = select_host_artifacts( &release_manifest(duplicate), std::env::consts::OS, std::env::consts::ARCH, ) .unwrap_err(); assert!(duplicate_error.to_string().contains("exactly one")); let mut unexpected = host_artifacts(); unexpected.push(artifact( "helper", "bin/iota-helper", std::env::consts::OS, std::env::consts::ARCH, )); let unexpected_error = select_host_artifacts( &release_manifest(unexpected), std::env::consts::OS, std::env::consts::ARCH, ) .unwrap_err(); assert!(unexpected_error.to_string().contains("unexpected artifact")); } #[test] fn rejects_manifest_without_an_overlapping_ipc_range() { let directory = tempfile::tempdir().unwrap(); let mut manifest = release_manifest(host_artifacts()); manifest.supported_ipc_min = 5; manifest.supported_ipc_max = 6; let error = validate_manifest_compatibility(&manifest, &directory.path().join("missing.db"), 2, 4) .unwrap_err(); assert!(error.to_string().contains("incompatible")); } #[test] fn rejects_manifest_requiring_a_newer_installed_data_schema() { let directory = tempfile::tempdir().unwrap(); let database = directory.path().join("messages.sqlite3"); let connection = rusqlite::Connection::open(&database).unwrap(); connection.pragma_update(None, "user_version", 25).unwrap(); let mut manifest = release_manifest(host_artifacts()); manifest.minimum_data_schema = 26; manifest.supported_ipc_min = 2; manifest.supported_ipc_max = 4; let error = validate_manifest_compatibility(&manifest, &database, 2, 4).unwrap_err(); assert!( error .to_string() .contains("installed database uses schema 25") ); } #[test] fn accepts_compatible_manifest_when_no_database_exists() { let directory = tempfile::tempdir().unwrap(); let mut manifest = release_manifest(host_artifacts()); manifest.minimum_data_schema = 26; manifest.supported_ipc_min = 4; manifest.supported_ipc_max = 5; validate_manifest_compatibility(&manifest, &directory.path().join("missing.db"), 2, 4) .unwrap(); } #[test] fn rejects_rollback_when_active_release_disallows_it() { let mut active = release_manifest(host_artifacts()); active.rollback_compatible = false; let mut target = release_manifest(host_artifacts()); target.product_version = "1.1.0".into(); let error = validate_rollback_manifests(&active, &target, "1.1.0").unwrap_err(); assert!(error.to_string().contains("does not permit rollback")); } #[test] fn candidate_policy_rejects_replay_and_sequence_reuse() { let state = ChannelState { highest_accepted_sequence: 12, highest_accepted_version: "1.2.3".into(), active_version: "1.1.0".into(), last_failed_sequence: None, }; let mut replay = release_manifest(host_artifacts()); replay.release_sequence = 11; assert!( evaluate_candidate( &replay, Some("1.1.0"), Some(&state), "stable", "test", Utc::now(), ) .unwrap_err() .to_string() .contains("older than trusted sequence") ); let mut reused = release_manifest(host_artifacts()); reused.product_version = "1.2.4".into(); assert!( evaluate_candidate( &reused, Some("1.1.0"), Some(&state), "stable", "test", Utc::now(), ) .unwrap_err() .to_string() .contains("already associated") ); } #[test] fn accepted_sequence_is_not_reinstalled_after_explicit_rollback() { let state = ChannelState { highest_accepted_sequence: 12, highest_accepted_version: "1.2.3".into(), active_version: "1.1.0".into(), last_failed_sequence: None, }; let decision = evaluate_candidate( &release_manifest(host_artifacts()), Some("1.1.0"), Some(&state), "stable", "test", Utc::now(), ) .unwrap(); assert_eq!(decision, CandidateDecision::NoUpdate); } #[test] fn candidate_policy_rejects_expired_wrong_channel_and_failed_release() { let mut expired = release_manifest(host_artifacts()); expired.published_at = "2020-01-01T00:00:00Z".into(); expired.expires_at = "2020-01-02T00:00:00Z".into(); assert!( evaluate_candidate(&expired, None, None, "stable", "test", Utc::now(),) .unwrap_err() .to_string() .contains("expired") ); let manifest = release_manifest(host_artifacts()); assert!( evaluate_candidate(&manifest, None, None, "dev", "test", Utc::now()) .unwrap_err() .to_string() .contains("channel mismatch") ); let state = ChannelState { highest_accepted_sequence: 11, highest_accepted_version: "1.2.2".into(), active_version: "1.2.2".into(), last_failed_sequence: Some(12), }; assert!( evaluate_candidate( &manifest, Some("1.2.2"), Some(&state), "stable", "test", Utc::now(), ) .unwrap_err() .to_string() .contains("previously failed activation") ); } #[test] fn update_state_is_replaced_atomically() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("update-status.json"); let mut state = UpdateState::default(); state.channels.insert( "stable".into(), ChannelState { highest_accepted_sequence: 12, highest_accepted_version: "1.2.3".into(), active_version: "1.2.3".into(), last_failed_sequence: None, }, ); save_update_state(&path, &state).unwrap(); let loaded: UpdateState = serde_json::from_slice(&fs::read(path).unwrap()).unwrap(); assert_eq!(loaded.channels["stable"].highest_accepted_sequence, 12); } #[tokio::test] async fn readiness_probe_checks_ipc_state_and_running_executable() { let directory = tempfile::tempdir().unwrap(); let socket = directory.path().join("iota.sock"); let release = directory.path().join("versions/1.2.3"); fs::create_dir_all(release.join("bin")).unwrap(); std::os::unix::fs::symlink( fs::canonicalize("/proc/self/exe").unwrap(), release.join("bin/iota-daemon"), ) .unwrap(); let listener = tokio::net::UnixListener::bind(&socket).unwrap(); let server = tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let hello = read_msg::<_, ClientMessage>(&mut stream).await.unwrap(); assert!(matches!(hello, ClientMessage::Hello { .. })); write_msg( &mut stream, &DaemonMessage::HelloAck(iota_ipc::HelloAck { protocol_version: PROTOCOL_VERSION, daemon_version: "test".into(), instance_id: "test".into(), startup_phase: StartupPhase::Ready, capabilities: Vec::new(), lifecycle: iota_ipc::LifecyclePhase::Ready, health: HealthStatus::Degraded, deployment_mode: iota_ipc::DeploymentMode::default(), supervisor: iota_ipc::SupervisorKind::default(), }), ) .await .unwrap(); }); wait_for_ready_daemon( &socket, &release, &CurrentProcessSupervisor, &ActivationPolicy { timeout: Duration::from_secs(1), retry_interval: Duration::from_millis(10), }, ) .await .unwrap(); server.await.unwrap(); } #[tokio::test] async fn interrupted_selected_candidate_is_restored_and_marked_failed() { let directory = tempfile::tempdir().unwrap(); let paths = test_paths(directory.path()); let transaction = UpdateTransaction::new(&paths.install_root); let old_release = paths.install_root.join("versions/1.0.0"); let new_release = paths.install_root.join("versions/1.1.0"); fs::create_dir_all(&old_release).unwrap(); fs::create_dir_all(&new_release).unwrap(); fs::write( old_release.join("manifest.json"), br#"{"product_version":"1.0.0"}"#, ) .unwrap(); fs::create_dir_all(&paths.install_root).unwrap(); std::os::unix::fs::symlink(&new_release, paths.install_root.join("current")).unwrap(); let mut state = UpdateState::default(); state.channels.insert( "stable".into(), ChannelState { highest_accepted_sequence: 1, highest_accepted_version: "1.0.0".into(), active_version: "1.0.0".into(), last_failed_sequence: None, }, ); state.pending_activation = Some(PendingActivation { previous_target: old_release.clone(), new_target: new_release, channel: "stable".into(), release_sequence: 2, product_version: "1.1.0".into(), daemon_was_active: false, selected: true, }); recover_interrupted_activation( &paths, &transaction, &mut state, &CurrentProcessSupervisor, &ActivationPolicy { timeout: Duration::from_secs(1), retry_interval: Duration::from_millis(10), }, ) .await .unwrap(); assert_eq!(transaction.current_target().unwrap(), old_release); assert_eq!(state.channels["stable"].last_failed_sequence, Some(2)); assert!(state.pending_activation.is_none()); } }