cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/website-download-wiring
goq / crates / sigil-host / src / appliance.rs
appliance.rs 54.6K
1use std::fs;2use std::path::{Path, PathBuf};3use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};4use std::sync::{Arc, Mutex};5use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};67use anyhow::{Context, Result, ensure};8use iroh::EndpointId;9use serde::{Deserialize, Serialize};10use sigil_protocol::InvitationGrants;1112use crate::authorization::{AuthorizationInspection, AuthorizationStore};13use crate::config::{ConfigRevision, HostConfig};14use crate::config_management::ConfigTransactionSummaryV1;15use crate::secure_state;16use crate::server::{MediaGenerationManager, SessionRegistry};1718const LIFECYCLE_LOCK_FILE: &str = "daemon-v1.lock";19const GLOBAL_LIFECYCLE_LOCK_FILE: &str = "daemon-global-v1.lock";20const RUNTIME_STATUS_FILE: &str = "daemon-status-v1.json";21// Frozen local-management ABI consumed by Sigil commands and the Decky22// plugin. A successor needs an explicit migration; never rename this in place.23const RUNTIME_DIRECTORY_COMPONENT: &str = "sigil-spark";24const RUNTIME_STATUS_VERSION: u16 = 3;25const MAX_RUNTIME_STATUS_BYTES: u64 = 16 * 1024;26const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);27const STALE_AFTER: Duration = Duration::from_secs(10);28const CLOCK_SKEW_TOLERANCE: Duration = Duration::from_secs(5);2930const DAEMON_STARTING: u8 = 0;31const DAEMON_READY: u8 = 1;32const DAEMON_STOPPING: u8 = 2;33const DAEMON_DEGRADED: u8 = 3;34const DAEMON_STOPPED: u8 = 4;3536pub struct LifecycleGuard {37 _files: Vec<fs::File>,38}3940impl LifecycleGuard {41 pub fn acquire(state_directory: &Path, require_global: bool) -> Result<Self> {42 Self::try_acquire(state_directory, require_global).map_err(anyhow::Error::new)43 }4445 pub(crate) fn try_acquire(46 state_directory: &Path,47 require_global: bool,48 ) -> std::result::Result<Self, secure_state::LockAcquireError> {49 let runtime_directory =50 configured_runtime_directory().map_err(secure_state::LockAcquireError::Unsafe)?;51 if require_global && runtime_directory.is_none() {52 return Err(secure_state::LockAcquireError::Unsafe(anyhow::anyhow!(53 "configured Sigil service requires a valid XDG_RUNTIME_DIR"54 )));55 }56 Self::try_acquire_at(state_directory, runtime_directory.as_deref())57 }5859 #[cfg(test)]60 fn acquire_at(state_directory: &Path, runtime_directory: Option<&Path>) -> Result<Self> {61 Self::try_acquire_at(state_directory, runtime_directory).map_err(anyhow::Error::new)62 }6364 pub(crate) fn try_acquire_at(65 state_directory: &Path,66 runtime_directory: Option<&Path>,67 ) -> std::result::Result<Self, secure_state::LockAcquireError> {68 let mut files = vec![secure_state::try_open_lifetime_lock(69 state_directory,70 LIFECYCLE_LOCK_FILE,71 )?];72 if let Some(runtime_directory) = runtime_directory {73 secure_state::ensure_private_directory(runtime_directory)74 .map_err(secure_state::LockAcquireError::Unsafe)?;75 files.push(secure_state::try_open_lifetime_lock(76 runtime_directory,77 GLOBAL_LIFECYCLE_LOCK_FILE,78 )?);79 }80 Ok(Self { _files: files })81 }82}8384#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]85#[serde(rename_all = "snake_case")]86enum DaemonState {87 Starting,88 Ready,89 Stopping,90 Degraded,91 Stopped,92 Offline,93 Unknown,94}9596#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]97#[serde(rename_all = "snake_case")]98enum RuntimeState {99 Fresh,100 Absent,101 Stale,102 Unavailable,103}104105#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]106#[serde(rename_all = "snake_case")]107enum SessionState {108 Active,109 Inactive,110 Unknown,111}112113#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]114#[serde(rename_all = "snake_case")]115enum OverallState {116 Ready,117 Active,118 Degraded,119 Unavailable,120}121122#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]123#[serde(rename_all = "snake_case")]124pub enum RuntimeErrorCode {125 AuthorizationState,126 Preflight,127 EndpointBind,128 RouterShutdown,129 ShutdownSignal,130}131132#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]133#[serde(deny_unknown_fields)]134pub struct RuntimeErrorV1 {135 code: RuntimeErrorCode,136 occurred_at_unix_ms: u64,137}138139#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]140#[serde(deny_unknown_fields)]141struct RuntimeStatusV1 {142 version: u16,143 instance_id: String,144 host_node_id: String,145 updated_at_unix_ms: u64,146 uptime_ms: u64,147 daemon: DaemonState,148 session_active: bool,149 last_error: Option<RuntimeErrorV1>,150}151152#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]153#[serde(deny_unknown_fields)]154struct RuntimeStatusV2 {155 version: u16,156 instance_id: String,157 host_node_id: String,158 loaded_config_revision: Option<ConfigRevision>,159 reached_ready: bool,160 updated_at_unix_ms: u64,161 uptime_ms: u64,162 daemon: DaemonState,163 session_active: bool,164 last_error: Option<RuntimeErrorV1>,165}166167#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]168#[serde(deny_unknown_fields)]169struct RuntimeStatusV3 {170 version: u16,171 instance_id: String,172 host_node_id: String,173 loaded_config_revision: Option<ConfigRevision>,174 reached_ready: bool,175 updated_at_unix_ms: u64,176 uptime_ms: u64,177 daemon: DaemonState,178 session_active: bool,179 session_mode: String,180 active_viewer_count: usize,181 media_generation_id: Option<u64>,182 roster_revision: Option<u64>,183 focus_occupied: Option<bool>,184 recovering_viewer_count: usize,185 configured_viewer_capacity: usize,186 authorization_revision: u64,187 last_error: Option<RuntimeErrorV1>,188}189190#[derive(Debug, Serialize)]191pub struct ApplianceStatusV2 {192 schema_version: u16,193 sigil_version: &'static str,194 overall: OverallState,195 config: ConfigStatus,196 identity: IdentityStatus,197 enrollment: EnrollmentStatus,198 runtime: RuntimeStatus,199}200201#[derive(Debug, Serialize)]202pub struct EnrollmentResetV1 {203 schema_version: u16,204 operation: &'static str,205 host_fingerprint: String,206 had_enrollment: bool,207 previous_epoch: u64,208 current_epoch: u64,209 invitations_invalidated: bool,210}211212#[derive(Debug, Serialize)]213struct ConfigStatus {214 state: &'static str,215 revision: ConfigRevision,216 pending_transaction: Option<ConfigTransactionSummaryV1>,217}218219#[derive(Debug, Serialize)]220struct IdentityStatus {221 state: &'static str,222 host_fingerprint: String,223}224225#[derive(Debug, Serialize)]226struct EnrollmentStatus {227 state: &'static str,228 storage: &'static str,229 peer_fingerprint: Option<String>,230 grants: Vec<&'static str>,231 epoch: u64,232 enrolled_at_unix: Option<u64>,233 enrollment_count: usize,234 authorization_revision: u64,235 viewers: Vec<EnrollmentViewerStatus>,236}237238#[derive(Debug, Serialize)]239struct EnrollmentViewerStatus {240 handle: String,241 grants: Vec<&'static str>,242 enrolled_at_unix: u64,243 authorization_revision: u64,244}245246#[derive(Debug, Serialize)]247struct RuntimeStatus {248 state: RuntimeState,249 daemon: DaemonState,250 uptime_ms: Option<u64>,251 heartbeat_age_ms: Option<u64>,252 session: SessionState,253 instance_id: Option<String>,254 loaded_config_revision: Option<ConfigRevision>,255 reached_ready: bool,256 mode: Option<String>,257 active_viewer_count: Option<usize>,258 media_generation_id: Option<u64>,259 roster_revision: Option<u64>,260 focus_occupied: Option<bool>,261 recovering_viewer_count: Option<usize>,262 configured_viewer_capacity: Option<usize>,263 authorization_revision: Option<u64>,264 last_error: Option<RuntimeErrorV1>,265}266267pub struct RuntimePublisher {268 state_directory: PathBuf,269 instance_id: String,270 host: EndpointId,271 loaded_config_revision: Option<ConfigRevision>,272 started: Instant,273 daemon_state: Arc<AtomicU8>,274 reached_ready: Arc<AtomicBool>,275 last_error: Arc<Mutex<Option<RuntimeErrorV1>>>,276 sessions: Arc<SessionRegistry>,277 generations: Arc<MediaGenerationManager>,278 write_lock: Arc<tokio::sync::Mutex<()>>,279 task: Option<tokio::task::JoinHandle<()>>,280}281282impl RuntimePublisher {283 pub fn start(284 host: EndpointId,285 sessions: Arc<SessionRegistry>,286 generations: Arc<MediaGenerationManager>,287 require_runtime: bool,288 loaded_config_revision: Option<ConfigRevision>,289 ) -> Result<Option<Self>> {290 let Some(state_directory) = configured_runtime_directory()? else {291 ensure!(292 !require_runtime,293 "configured Sigil service requires a valid XDG_RUNTIME_DIR"294 );295 tracing::warn!("XDG_RUNTIME_DIR is unavailable; appliance heartbeat is disabled");296 return Ok(None);297 };298 secure_state::ensure_private_directory(&state_directory)?;299 ensure!(300 !require_runtime || loaded_config_revision.is_some(),301 "configured Sigil service requires a loaded config revision"302 );303 Self::start_at(304 &state_directory,305 host,306 sessions,307 generations,308 loaded_config_revision,309 )310 .map(Some)311 }312313 fn start_at(314 state_directory: &Path,315 host: EndpointId,316 sessions: Arc<SessionRegistry>,317 generations: Arc<MediaGenerationManager>,318 loaded_config_revision: Option<ConfigRevision>,319 ) -> Result<Self> {320 let instance_id = random_instance_id()?;321 let daemon_state = Arc::new(AtomicU8::new(DAEMON_STARTING));322 let reached_ready = Arc::new(AtomicBool::new(false));323 let last_error = Arc::new(Mutex::new(None));324 let write_lock = Arc::new(tokio::sync::Mutex::new(()));325 let mut publisher = Self {326 state_directory: state_directory.to_path_buf(),327 instance_id,328 host,329 loaded_config_revision,330 started: Instant::now(),331 daemon_state,332 reached_ready,333 last_error,334 sessions,335 generations,336 write_lock,337 task: None,338 };339 publisher.write_snapshot()?;340341 let state_directory = publisher.state_directory.clone();342 let instance_id = publisher.instance_id.clone();343 let host = publisher.host;344 let loaded_config_revision = publisher.loaded_config_revision.clone();345 let started = publisher.started;346 let daemon_state = Arc::clone(&publisher.daemon_state);347 let reached_ready = Arc::clone(&publisher.reached_ready);348 let last_error = Arc::clone(&publisher.last_error);349 let sessions = Arc::clone(&publisher.sessions);350 let generations = Arc::clone(&publisher.generations);351 let write_lock = Arc::clone(&publisher.write_lock);352 let task = tokio::spawn(async move {353 let mut interval = tokio::time::interval(HEARTBEAT_INTERVAL);354 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);355 interval.tick().await;356 loop {357 interval.tick().await;358 let _write_guard = write_lock.lock().await;359 let state_directory = state_directory.clone();360 let instance_id = instance_id.clone();361 let loaded_config_revision = loaded_config_revision.clone();362 let daemon = decode_daemon_state(daemon_state.load(Ordering::Relaxed));363 let reached_ready = reached_ready.load(Ordering::Relaxed);364 let session = sessions.runtime_status();365 let recovering_viewer_count = generations.recovering_viewer_count();366 let last_error = last_error367 .lock()368 .expect("runtime status error state poisoned")369 .clone();370 match tokio::task::spawn_blocking(move || {371 write_runtime_snapshot(RuntimeSnapshotInput {372 state_directory,373 instance_id,374 host,375 loaded_config_revision,376 started,377 daemon,378 reached_ready,379 session,380 recovering_viewer_count,381 last_error,382 })383 })384 .await385 {386 Ok(Ok(())) => {}387 Ok(Err(error)) => {388 tracing::warn!(%error, "writing bounded Sigil runtime status failed");389 }390 Err(error) => {391 tracing::warn!(%error, "Sigil runtime status writer task failed");392 break;393 }394 }395 }396 });397 publisher.task = Some(task);398 Ok(publisher)399 }400401 pub async fn mark_ready(&self) -> Result<()> {402 self.reached_ready.store(true, Ordering::Relaxed);403 self.daemon_state.store(DAEMON_READY, Ordering::Relaxed);404 self.write_snapshot_async().await405 }406407 pub async fn mark_stopping(&self) -> Result<()> {408 self.daemon_state.store(DAEMON_STOPPING, Ordering::Relaxed);409 self.write_snapshot_async().await410 }411412 pub async fn mark_degraded(&self, code: RuntimeErrorCode) -> Result<()> {413 self.daemon_state.store(DAEMON_DEGRADED, Ordering::Relaxed);414 *self415 .last_error416 .lock()417 .expect("runtime status error state poisoned") = Some(RuntimeErrorV1 {418 code,419 occurred_at_unix_ms: unix_timestamp_millis()?,420 });421 self.write_snapshot_async().await422 }423424 pub async fn finish_clean(mut self) -> Result<()> {425 let write_lock = Arc::clone(&self.write_lock);426 let _write_guard = write_lock.lock().await;427 if let Some(task) = self.task.take() {428 task.abort();429 let _ = task.await;430 }431 self.daemon_state.store(DAEMON_STOPPED, Ordering::Relaxed);432 let state_directory = self.state_directory.clone();433 let instance_id = self.instance_id.clone();434 let host = self.host;435 let loaded_config_revision = self.loaded_config_revision.clone();436 let started = self.started;437 let reached_ready = self.reached_ready.load(Ordering::Relaxed);438 let session = self.sessions.runtime_status();439 let recovering_viewer_count = self.generations.recovering_viewer_count();440 let last_error = self441 .last_error442 .lock()443 .expect("runtime status error state poisoned")444 .clone();445 tokio::task::spawn_blocking(move || {446 write_runtime_snapshot(RuntimeSnapshotInput {447 state_directory,448 instance_id,449 host,450 loaded_config_revision,451 started,452 daemon: DaemonState::Stopped,453 reached_ready,454 session,455 recovering_viewer_count,456 last_error,457 })458 })459 .await460 .context("joining stopped runtime status write")?461 }462463 fn write_snapshot(&self) -> Result<()> {464 write_runtime_snapshot(RuntimeSnapshotInput {465 state_directory: self.state_directory.clone(),466 instance_id: self.instance_id.clone(),467 host: self.host,468 loaded_config_revision: self.loaded_config_revision.clone(),469 started: self.started,470 daemon: decode_daemon_state(self.daemon_state.load(Ordering::Relaxed)),471 reached_ready: self.reached_ready.load(Ordering::Relaxed),472 session: self.sessions.runtime_status(),473 recovering_viewer_count: self.generations.recovering_viewer_count(),474 last_error: self475 .last_error476 .lock()477 .expect("runtime status error state poisoned")478 .clone(),479 })480 }481482 async fn write_snapshot_async(&self) -> Result<()> {483 let write_lock = Arc::clone(&self.write_lock);484 let _write_guard = write_lock.lock().await;485 let state_directory = self.state_directory.clone();486 let instance_id = self.instance_id.clone();487 let host = self.host;488 let loaded_config_revision = self.loaded_config_revision.clone();489 let started = self.started;490 let daemon = decode_daemon_state(self.daemon_state.load(Ordering::Relaxed));491 let reached_ready = self.reached_ready.load(Ordering::Relaxed);492 let session = self.sessions.runtime_status();493 let recovering_viewer_count = self.generations.recovering_viewer_count();494 let last_error = self495 .last_error496 .lock()497 .expect("runtime status error state poisoned")498 .clone();499 tokio::task::spawn_blocking(move || {500 write_runtime_snapshot(RuntimeSnapshotInput {501 state_directory,502 instance_id,503 host,504 loaded_config_revision,505 started,506 daemon,507 reached_ready,508 session,509 recovering_viewer_count,510 last_error,511 })512 })513 .await514 .context("joining runtime status write")?515 }516}517518impl Drop for RuntimePublisher {519 fn drop(&mut self) {520 if let Some(task) = self.task.take() {521 task.abort();522 }523 }524}525526fn configured_runtime_directory() -> Result<Option<PathBuf>> {527 let Some(root) = std::env::var_os("XDG_RUNTIME_DIR") else {528 return Ok(None);529 };530 let root = PathBuf::from(root);531 ensure!(root.is_absolute(), "XDG_RUNTIME_DIR must be absolute");532 secure_state::validate_private_directory(&root)533 .context("validating XDG_RUNTIME_DIR for Sigil appliance status")?;534 Ok(Some(root.join(RUNTIME_DIRECTORY_COMPONENT)))535}536537pub(crate) fn resolve_management_runtime_directory(538 explicit_root: Option<&Path>,539) -> Result<PathBuf> {540 if let Some(root) = explicit_root {541 ensure!(root.is_absolute(), "--runtime-dir must be absolute");542 secure_state::validate_private_directory(root)543 .context("validating --runtime-dir for Sigil appliance management")?;544 #[cfg(unix)]545 {546 use std::os::unix::fs::PermissionsExt as _;547 ensure!(548 fs::symlink_metadata(root)?.permissions().mode() & 0o7777 == 0o700,549 "--runtime-dir must have mode 0700"550 );551 }552 return Ok(root.join(RUNTIME_DIRECTORY_COMPONENT));553 }554 configured_runtime_directory()?555 .context("configured Sigil service requires a valid XDG_RUNTIME_DIR")556}557558pub fn collect_status(config_path: &Path) -> Result<ApplianceStatusV2> {559 let loaded = HostConfig::load_document(config_path)?;560 let config = &loaded.config;561 let secret = crate::identity::load(&config.identity_path)?;562 let host = secret.public();563 let authorization = AuthorizationStore::inspect_existing(&config.state_path, host)?;564 let pending_transaction = crate::config_management::inspect_transaction(&loaded, config_path)565 .map_err(anyhow::Error::from)?;566 let runtime = match configured_runtime_directory()? {567 Some(runtime_directory) => {568 read_runtime_status(&runtime_directory, host, unix_timestamp_millis()?)?569 }570 None => unavailable_runtime(),571 };572 Ok(assemble_status(573 host,574 authorization,575 runtime,576 loaded.revision,577 pending_transaction,578 ))579}580581pub fn status_json(status: &ApplianceStatusV2, schema_version: u16) -> Result<serde_json::Value> {582 let mut value = serde_json::to_value(status)?;583 match schema_version {584 1 => {585 value["schema_version"] = serde_json::json!(1);586 let config = value["config"]587 .as_object_mut()588 .context("appliance config status is not an object")?;589 config.remove("revision");590 config.remove("pending_transaction");591 let runtime = value["runtime"]592 .as_object_mut()593 .context("appliance runtime status is not an object")?;594 runtime.remove("instance_id");595 runtime.remove("loaded_config_revision");596 runtime.remove("reached_ready");597 runtime.remove("mode");598 runtime.remove("active_viewer_count");599 runtime.remove("media_generation_id");600 runtime.remove("roster_revision");601 runtime.remove("focus_occupied");602 runtime.remove("recovering_viewer_count");603 runtime.remove("configured_viewer_capacity");604 runtime.remove("authorization_revision");605 let enrollment = value["enrollment"]606 .as_object_mut()607 .context("appliance enrollment status is not an object")?;608 enrollment.remove("enrollment_count");609 enrollment.remove("authorization_revision");610 enrollment.remove("viewers");611 }612 2 => {}613 _ => anyhow::bail!("unsupported appliance status schema"),614 }615 Ok(value)616}617618pub fn reset_enrollment(619 config_path: &Path,620 expected_host_fingerprint: &str,621) -> Result<EnrollmentResetV1> {622 let config = HostConfig::load(config_path)?;623 config.ensure_runtime_directory()?;624 let _lifecycle = LifecycleGuard::acquire(&config.state_path, false)?;625 let secret = crate::identity::load(&config.identity_path)?;626 let host = secret.public();627 let host_fingerprint = fingerprint(host);628 ensure!(629 expected_host_fingerprint == host_fingerprint,630 "expected host fingerprint does not match this Sigil appliance"631 );632633 let store = AuthorizationStore::open(config.state_path, host)?;634 let outcome = store.revoke_with_outcome(crate::authorization::unix_timestamp_now()?)?;635 ensure!(636 outcome.current_epoch637 == outcome638 .previous_epoch639 .checked_add(1)640 .context("authorization epoch exhausted")?,641 "enrollment reset did not advance the authorization epoch"642 );643644 Ok(EnrollmentResetV1 {645 schema_version: 1,646 operation: "enrollment_reset",647 host_fingerprint,648 had_enrollment: outcome.had_enrollment,649 previous_epoch: outcome.previous_epoch,650 current_epoch: outcome.current_epoch,651 invitations_invalidated: true,652 })653}654655fn assemble_status(656 host: EndpointId,657 authorization: AuthorizationInspection,658 runtime: RuntimeStatus,659 config_revision: ConfigRevision,660 pending_transaction: Option<ConfigTransactionSummaryV1>,661) -> ApplianceStatusV2 {662 let snapshot = authorization.snapshot;663 let grants = snapshot.grants.map(grant_list).unwrap_or_default();664 let enrollment_active = !snapshot.viewers.is_empty() || snapshot.peer.is_some();665 let enrollment_count = if snapshot.viewers.is_empty() {666 usize::from(snapshot.peer.is_some())667 } else {668 snapshot.viewers.len()669 };670 let viewers = snapshot671 .viewers672 .iter()673 .map(|viewer| EnrollmentViewerStatus {674 handle: viewer.handle.clone(),675 grants: grant_list(viewer.grants),676 enrolled_at_unix: viewer.enrolled_at_unix,677 authorization_revision: viewer.authorization_revision,678 })679 .collect();680 let overall = match (runtime.daemon, runtime.session) {681 (DaemonState::Ready, SessionState::Active) => OverallState::Active,682 (DaemonState::Ready, _) => OverallState::Ready,683 (DaemonState::Degraded, _) => OverallState::Degraded,684 _ => OverallState::Unavailable,685 };686 ApplianceStatusV2 {687 schema_version: 2,688 sigil_version: env!("CARGO_PKG_VERSION"),689 overall,690 config: ConfigStatus {691 state: "valid",692 revision: config_revision,693 pending_transaction,694 },695 identity: IdentityStatus {696 state: "ready",697 host_fingerprint: fingerprint(host),698 },699 enrollment: EnrollmentStatus {700 state: if enrollment_active { "active" } else { "none" },701 storage: if authorization.storage_present {702 "present"703 } else {704 "absent"705 },706 peer_fingerprint: snapshot.peer.map(fingerprint),707 grants,708 epoch: snapshot.epoch,709 enrolled_at_unix: snapshot.enrolled_at_unix,710 enrollment_count,711 authorization_revision: snapshot.committed_revision,712 viewers,713 },714 runtime,715 }716}717718fn read_runtime_status(719 state_directory: &Path,720 expected_host: EndpointId,721 now_unix_ms: u64,722) -> Result<RuntimeStatus> {723 match fs::symlink_metadata(state_directory) {724 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {725 return Ok(absent_runtime());726 }727 Err(error) => return Err(error).context("inspecting Sigil runtime state directory"),728 Ok(_) => {}729 }730 let Some(persisted) = read_persisted_runtime(state_directory)? else {731 return Ok(absent_runtime());732 };733 ensure!(734 persisted.host_node_id.parse::<EndpointId>().ok() == Some(expected_host),735 "Sigil runtime status belongs to a different host identity"736 );737 ensure!(738 persisted.instance_id.len() == 32739 && persisted740 .instance_id741 .bytes()742 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),743 "invalid Sigil runtime instance ID"744 );745 if let Some(revision) = &persisted.loaded_config_revision {746 ConfigRevision::parse(revision.as_str())?;747 }748 if let Some(error) = &persisted.last_error {749 ensure!(750 error.occurred_at_unix_ms <= persisted.updated_at_unix_ms,751 "Sigil runtime error timestamp follows its status update"752 );753 }754 let age_ms = match now_unix_ms.checked_sub(persisted.updated_at_unix_ms) {755 Some(age_ms) => age_ms,756 None if persisted.updated_at_unix_ms - now_unix_ms757 <= CLOCK_SKEW_TOLERANCE.as_millis() as u64 =>758 {759 0760 }761 None => return Ok(stale_runtime(persisted, None)),762 };763 if age_ms > STALE_AFTER.as_millis() as u64 {764 return Ok(stale_runtime(persisted, Some(age_ms)));765 }766 ensure!(767 !matches!(768 persisted.daemon,769 DaemonState::Offline | DaemonState::Unknown770 ),771 "runtime publisher wrote an invalid daemon state"772 );773 Ok(RuntimeStatus {774 state: RuntimeState::Fresh,775 daemon: persisted.daemon,776 uptime_ms: (!matches!(persisted.daemon, DaemonState::Stopped))777 .then_some(persisted.uptime_ms.saturating_add(age_ms)),778 heartbeat_age_ms: Some(age_ms),779 session: if persisted.daemon == DaemonState::Stopped {780 SessionState::Inactive781 } else if persisted.session_active {782 SessionState::Active783 } else {784 SessionState::Inactive785 },786 instance_id: Some(persisted.instance_id),787 loaded_config_revision: persisted.loaded_config_revision,788 reached_ready: persisted.reached_ready,789 mode: Some(persisted.session_mode),790 active_viewer_count: Some(persisted.active_viewer_count),791 media_generation_id: persisted.media_generation_id,792 roster_revision: persisted.roster_revision,793 focus_occupied: persisted.focus_occupied,794 recovering_viewer_count: Some(persisted.recovering_viewer_count),795 configured_viewer_capacity: Some(persisted.configured_viewer_capacity),796 authorization_revision: Some(persisted.authorization_revision),797 last_error: persisted.last_error,798 })799}800801fn read_persisted_runtime(state_directory: &Path) -> Result<Option<RuntimeStatusV3>> {802 let Some(bytes) = secure_state::read_bounded(803 state_directory,804 RUNTIME_STATUS_FILE,805 MAX_RUNTIME_STATUS_BYTES,806 )?807 else {808 return Ok(None);809 };810 let value: serde_json::Value =811 serde_json::from_slice(&bytes).context("parsing Sigil runtime status")?;812 let version = value813 .get("version")814 .and_then(serde_json::Value::as_u64)815 .context("Sigil runtime status version is missing")?;816 match version {817 1 => {818 let legacy: RuntimeStatusV1 =819 serde_json::from_value(value).context("parsing legacy Sigil runtime status")?;820 Ok(Some(RuntimeStatusV3 {821 version: RUNTIME_STATUS_VERSION,822 instance_id: legacy.instance_id,823 host_node_id: legacy.host_node_id,824 loaded_config_revision: None,825 reached_ready: false,826 updated_at_unix_ms: legacy.updated_at_unix_ms,827 uptime_ms: legacy.uptime_ms,828 daemon: legacy.daemon,829 session_active: legacy.session_active,830 session_mode: if legacy.session_active {831 "legacy_exclusive".to_owned()832 } else {833 "inactive".to_owned()834 },835 active_viewer_count: usize::from(legacy.session_active),836 media_generation_id: None,837 roster_revision: None,838 focus_occupied: None,839 recovering_viewer_count: 0,840 configured_viewer_capacity: usize::from(crate::config::DEFAULT_MAX_VIEWERS),841 authorization_revision: 1,842 last_error: legacy.last_error,843 }))844 }845 2 => {846 let legacy: RuntimeStatusV2 =847 serde_json::from_value(value).context("parsing Sigil runtime status v2")?;848 Ok(Some(RuntimeStatusV3 {849 version: RUNTIME_STATUS_VERSION,850 instance_id: legacy.instance_id,851 host_node_id: legacy.host_node_id,852 loaded_config_revision: legacy.loaded_config_revision,853 reached_ready: legacy.reached_ready,854 updated_at_unix_ms: legacy.updated_at_unix_ms,855 uptime_ms: legacy.uptime_ms,856 daemon: legacy.daemon,857 session_active: legacy.session_active,858 session_mode: if legacy.session_active {859 "legacy_exclusive".to_owned()860 } else {861 "inactive".to_owned()862 },863 active_viewer_count: usize::from(legacy.session_active),864 media_generation_id: None,865 roster_revision: None,866 focus_occupied: None,867 recovering_viewer_count: 0,868 configured_viewer_capacity: usize::from(crate::config::DEFAULT_MAX_VIEWERS),869 authorization_revision: 1,870 last_error: legacy.last_error,871 }))872 }873 3 => {874 let current: RuntimeStatusV3 =875 serde_json::from_value(value).context("parsing Sigil runtime status v3")?;876 validate_runtime_session(&current)?;877 Ok(Some(current))878 }879 _ => anyhow::bail!("unsupported Sigil runtime status version"),880 }881}882883fn validate_runtime_session(status: &RuntimeStatusV3) -> Result<()> {884 ensure!(885 (1..=usize::from(crate::config::MAX_VIEWERS)).contains(&status.configured_viewer_capacity),886 "runtime viewer capacity is out of bounds"887 );888 ensure!(889 status.active_viewer_count <= status.configured_viewer_capacity,890 "runtime active viewer count exceeds configured capacity"891 );892 ensure!(893 status.recovering_viewer_count <= status.active_viewer_count,894 "runtime recovering viewer count exceeds active viewers"895 );896 ensure!(897 status.authorization_revision != 0,898 "runtime authorization revision must be non-zero"899 );900 ensure!(901 status.media_generation_id.is_none_or(|value| value != 0)902 && status.roster_revision.is_none_or(|value| value != 0),903 "runtime generation and roster revisions must be non-zero when present"904 );905 match status.session_mode.as_str() {906 "inactive" => ensure!(907 !status.session_active908 && status.active_viewer_count == 0909 && status.media_generation_id.is_none()910 && status.roster_revision.is_none()911 && status.focus_occupied.is_none(),912 "inactive runtime session fields are inconsistent"913 ),914 "legacy_exclusive" => ensure!(915 status.session_active916 && status.active_viewer_count == 1917 && status.roster_revision.is_none()918 && status.focus_occupied.is_none(),919 "legacy runtime session fields are inconsistent"920 ),921 "moq_multi_viewer" => ensure!(922 status.session_active923 && status.active_viewer_count > 0924 && status.media_generation_id.is_some()925 && status.roster_revision.is_some()926 && status.focus_occupied.is_some(),927 "multi-viewer runtime session fields are inconsistent"928 ),929 _ => anyhow::bail!("runtime session mode is unsupported"),930 }931 Ok(())932}933934pub(crate) fn latest_runtime_instance(935 state_directory: &Path,936 expected_host: EndpointId,937) -> Result<Option<String>> {938 match fs::symlink_metadata(state_directory) {939 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),940 Err(error) => return Err(error).context("inspecting Sigil runtime state directory"),941 Ok(_) => {}942 }943 let Some(persisted) = read_persisted_runtime(state_directory)? else {944 return Ok(None);945 };946 ensure!(947 persisted.host_node_id.parse::<EndpointId>().ok() == Some(expected_host),948 "Sigil runtime status belongs to a different host identity"949 );950 ensure!(951 persisted.instance_id.len() == 32952 && persisted953 .instance_id954 .bytes()955 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),956 "invalid Sigil runtime instance ID"957 );958 Ok(Some(persisted.instance_id))959}960961fn stale_runtime(persisted: RuntimeStatusV3, heartbeat_age_ms: Option<u64>) -> RuntimeStatus {962 let stopped = persisted.daemon == DaemonState::Stopped;963 RuntimeStatus {964 state: RuntimeState::Stale,965 daemon: if stopped {966 DaemonState::Stopped967 } else {968 DaemonState::Unknown969 },970 uptime_ms: None,971 heartbeat_age_ms,972 session: if stopped {973 SessionState::Inactive974 } else {975 SessionState::Unknown976 },977 instance_id: None,978 loaded_config_revision: None,979 reached_ready: false,980 mode: None,981 active_viewer_count: None,982 media_generation_id: None,983 roster_revision: None,984 focus_occupied: None,985 recovering_viewer_count: None,986 configured_viewer_capacity: None,987 authorization_revision: None,988 last_error: persisted.last_error,989 }990}991992fn absent_runtime() -> RuntimeStatus {993 RuntimeStatus {994 state: RuntimeState::Absent,995 daemon: DaemonState::Offline,996 uptime_ms: None,997 heartbeat_age_ms: None,998 session: SessionState::Unknown,999 instance_id: None,1000 loaded_config_revision: None,1001 reached_ready: false,1002 mode: None,1003 active_viewer_count: None,1004 media_generation_id: None,1005 roster_revision: None,1006 focus_occupied: None,1007 recovering_viewer_count: None,1008 configured_viewer_capacity: None,1009 authorization_revision: None,1010 last_error: None,1011 }1012}10131014fn unavailable_runtime() -> RuntimeStatus {1015 RuntimeStatus {1016 state: RuntimeState::Unavailable,1017 daemon: DaemonState::Unknown,1018 uptime_ms: None,1019 heartbeat_age_ms: None,1020 session: SessionState::Unknown,1021 instance_id: None,1022 loaded_config_revision: None,1023 reached_ready: false,1024 mode: None,1025 active_viewer_count: None,1026 media_generation_id: None,1027 roster_revision: None,1028 focus_occupied: None,1029 recovering_viewer_count: None,1030 configured_viewer_capacity: None,1031 authorization_revision: None,1032 last_error: None,1033 }1034}10351036pub(crate) fn validate_candidate_runtime(1037 state_directory: &Path,1038 expected_host: EndpointId,1039 expected_revision: &ConfigRevision,1040 expected_instance: &str,1041) -> Result<()> {1042 validate_candidate_runtime_at(1043 state_directory,1044 expected_host,1045 expected_revision,1046 expected_instance,1047 unix_timestamp_millis()?,1048 )1049}10501051fn validate_candidate_runtime_at(1052 state_directory: &Path,1053 expected_host: EndpointId,1054 expected_revision: &ConfigRevision,1055 expected_instance: &str,1056 now_unix_ms: u64,1057) -> Result<()> {1058 let status = read_runtime_status(state_directory, expected_host, now_unix_ms)?;1059 ensure!(1060 status.state == RuntimeState::Fresh,1061 "runtime evidence is not fresh"1062 );1063 ensure!(1064 status.daemon == DaemonState::Stopped,1065 "candidate daemon has not stopped cleanly"1066 );1067 ensure!(1068 status.instance_id.as_deref() == Some(expected_instance),1069 "runtime evidence belongs to a different daemon instance"1070 );1071 ensure!(1072 status.loaded_config_revision.as_ref() == Some(expected_revision),1073 "runtime evidence belongs to a different configuration revision"1074 );1075 ensure!(status.reached_ready, "candidate daemon never reached ready");1076 ensure!(1077 status.last_error.is_none(),1078 "candidate daemon reported an error"1079 );1080 Ok(())1081}10821083struct RuntimeSnapshotInput {1084 state_directory: PathBuf,1085 instance_id: String,1086 host: EndpointId,1087 loaded_config_revision: Option<ConfigRevision>,1088 started: Instant,1089 daemon: DaemonState,1090 reached_ready: bool,1091 session: crate::server::SessionRuntimeStatus,1092 recovering_viewer_count: usize,1093 last_error: Option<RuntimeErrorV1>,1094}10951096fn write_runtime_snapshot(input: RuntimeSnapshotInput) -> Result<()> {1097 let session_active = input.session.active_viewers != 0;1098 let status = RuntimeStatusV3 {1099 version: RUNTIME_STATUS_VERSION,1100 instance_id: input.instance_id,1101 host_node_id: input.host.to_string(),1102 loaded_config_revision: input.loaded_config_revision,1103 reached_ready: input.reached_ready,1104 updated_at_unix_ms: unix_timestamp_millis()?,1105 uptime_ms: u64::try_from(input.started.elapsed().as_millis()).unwrap_or(u64::MAX),1106 daemon: input.daemon,1107 session_active,1108 session_mode: input.session.mode.to_owned(),1109 active_viewer_count: input.session.active_viewers,1110 media_generation_id: input.session.media_generation_id,1111 roster_revision: input.session.roster_revision,1112 focus_occupied: input.session.focus_occupied,1113 recovering_viewer_count: input1114 .recovering_viewer_count1115 .min(input.session.active_viewers),1116 configured_viewer_capacity: input.session.configured_capacity,1117 authorization_revision: input.session.authorization_revision,1118 last_error: input.last_error,1119 };1120 validate_runtime_session(&status)?;1121 let bytes = serde_json::to_vec(&status)?;1122 secure_state::atomic_write(1123 &input.state_directory,1124 RUNTIME_STATUS_FILE,1125 &bytes,1126 MAX_RUNTIME_STATUS_BYTES,1127 )1128}11291130fn decode_daemon_state(value: u8) -> DaemonState {1131 match value {1132 DAEMON_READY => DaemonState::Ready,1133 DAEMON_STOPPING => DaemonState::Stopping,1134 DAEMON_DEGRADED => DaemonState::Degraded,1135 DAEMON_STOPPED => DaemonState::Stopped,1136 _ => DaemonState::Starting,1137 }1138}11391140fn random_instance_id() -> Result<String> {1141 let mut bytes = [0_u8; 16];1142 getrandom::fill(&mut bytes).context("generating runtime instance ID")?;1143 let mut output = String::with_capacity(32);1144 for byte in bytes {1145 use std::fmt::Write as _;1146 write!(&mut output, "{byte:02x}").expect("writing into a String cannot fail");1147 }1148 Ok(output)1149}11501151fn unix_timestamp_millis() -> Result<u64> {1152 Ok(u64::try_from(1153 SystemTime::now()1154 .duration_since(UNIX_EPOCH)1155 .context("system clock is before the Unix epoch")?1156 .as_millis(),1157 )1158 .unwrap_or(u64::MAX))1159}11601161pub(crate) fn fingerprint(id: EndpointId) -> String {1162 let full = id.to_string();1163 debug_assert_eq!(full.len(), 64);1164 format!("{}…{}", &full[..8], &full[full.len() - 8..])1165}11661167fn grant_list(grants: InvitationGrants) -> Vec<&'static str> {1168 let mut names = vec!["view"];1169 if grants.contains(InvitationGrants::POINTER_KEYBOARD) {1170 names.push("pointer_keyboard");1171 }1172 if grants.contains(InvitationGrants::GAMEPAD) {1173 names.push("gamepad");1174 }1175 names1176}11771178#[cfg(test)]1179mod tests {1180 use super::*;1181 use iroh::SecretKey;11821183 #[cfg(unix)]1184 use std::os::unix::fs::PermissionsExt;11851186 fn private_directory() -> tempfile::TempDir {1187 let directory = tempfile::tempdir().unwrap();1188 #[cfg(unix)]1189 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();1190 directory1191 }11921193 fn runtime_document(updated_at_unix_ms: u64) -> RuntimeStatusV1 {1194 let host = SecretKey::from_bytes(&[7; 32]).public();1195 RuntimeStatusV1 {1196 version: 1,1197 instance_id: "0123456789abcdef0123456789abcdef".to_owned(),1198 host_node_id: host.to_string(),1199 updated_at_unix_ms,1200 uptime_ms: 1_000,1201 daemon: DaemonState::Ready,1202 session_active: true,1203 last_error: None,1204 }1205 }12061207 fn current_runtime_document(1208 updated_at_unix_ms: u64,1209 revision: ConfigRevision,1210 ) -> RuntimeStatusV3 {1211 let host = SecretKey::from_bytes(&[7; 32]).public();1212 RuntimeStatusV3 {1213 version: RUNTIME_STATUS_VERSION,1214 instance_id: "0123456789abcdef0123456789abcdef".to_owned(),1215 host_node_id: host.to_string(),1216 loaded_config_revision: Some(revision),1217 reached_ready: true,1218 updated_at_unix_ms,1219 uptime_ms: 1_000,1220 daemon: DaemonState::Stopped,1221 session_active: false,1222 session_mode: "inactive".to_owned(),1223 active_viewer_count: 0,1224 media_generation_id: None,1225 roster_revision: None,1226 focus_occupied: None,1227 recovering_viewer_count: 0,1228 configured_viewer_capacity: usize::from(crate::config::DEFAULT_MAX_VIEWERS),1229 authorization_revision: 1,1230 last_error: None,1231 }1232 }12331234 #[test]1235 fn lifecycle_lock_is_global_across_distinct_state_directories() {1236 let first_state = private_directory();1237 let second_state = private_directory();1238 let runtime = private_directory();1239 let first = LifecycleGuard::acquire_at(first_state.path(), Some(runtime.path())).unwrap();1240 assert!(LifecycleGuard::acquire_at(second_state.path(), Some(runtime.path())).is_err());1241 drop(first);1242 LifecycleGuard::acquire_at(second_state.path(), Some(runtime.path())).unwrap();1243 }12441245 #[test]1246 fn lifecycle_lock_without_runtime_remains_state_scoped() {1247 let first_state = private_directory();1248 let second_state = private_directory();1249 let first = LifecycleGuard::acquire_at(first_state.path(), None).unwrap();1250 assert!(LifecycleGuard::acquire_at(first_state.path(), None).is_err());1251 let second = LifecycleGuard::acquire_at(second_state.path(), None).unwrap();1252 drop(first);1253 drop(second);1254 LifecycleGuard::acquire_at(first_state.path(), None).unwrap();1255 }12561257 #[cfg(unix)]1258 #[test]1259 fn explicit_management_runtime_root_is_strict_and_appends_one_child() {1260 use std::os::unix::fs::{PermissionsExt as _, symlink};12611262 assert_eq!(RUNTIME_DIRECTORY_COMPONENT, "sigil-spark");1263 let root = private_directory();1264 assert_eq!(1265 resolve_management_runtime_directory(Some(root.path())).unwrap(),1266 root.path().join(RUNTIME_DIRECTORY_COMPONENT)1267 );1268 assert!(resolve_management_runtime_directory(Some(Path::new("relative"))).is_err());12691270 fs::set_permissions(root.path(), fs::Permissions::from_mode(0o750)).unwrap();1271 assert!(resolve_management_runtime_directory(Some(root.path())).is_err());1272 fs::set_permissions(root.path(), fs::Permissions::from_mode(0o1700)).unwrap();1273 assert!(resolve_management_runtime_directory(Some(root.path())).is_err());1274 fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700)).unwrap();12751276 let link_parent = private_directory();1277 let link = link_parent.path().join("runtime-link");1278 symlink(root.path(), &link).unwrap();1279 assert!(resolve_management_runtime_directory(Some(&link)).is_err());1280 }12811282 #[test]1283 fn missing_runtime_is_offline_without_creating_state() {1284 let directory = private_directory();1285 let missing = directory.path().join("missing");1286 let host = SecretKey::from_bytes(&[7; 32]).public();1287 let status = read_runtime_status(&missing, host, 20_000).unwrap();1288 assert_eq!(status.state, RuntimeState::Absent);1289 assert_eq!(status.daemon, DaemonState::Offline);1290 assert_eq!(status.session, SessionState::Unknown);1291 assert!(!missing.exists());1292 }12931294 #[test]1295 fn runtime_freshness_suppresses_stale_session_claims() {1296 let directory = private_directory();1297 let fresh = serde_json::to_vec(&runtime_document(19_000)).unwrap();1298 secure_state::atomic_write(1299 directory.path(),1300 RUNTIME_STATUS_FILE,1301 &fresh,1302 MAX_RUNTIME_STATUS_BYTES,1303 )1304 .unwrap();1305 let host = SecretKey::from_bytes(&[7; 32]).public();1306 let status = read_runtime_status(directory.path(), host, 20_000).unwrap();1307 assert_eq!(status.state, RuntimeState::Fresh);1308 assert_eq!(status.session, SessionState::Active);1309 assert_eq!(status.uptime_ms, Some(2_000));13101311 let status = read_runtime_status(directory.path(), host, 30_001).unwrap();1312 assert_eq!(status.state, RuntimeState::Stale);1313 assert_eq!(status.daemon, DaemonState::Unknown);1314 assert_eq!(status.session, SessionState::Unknown);1315 assert_eq!(status.uptime_ms, None);1316 }13171318 #[test]1319 fn clock_skew_is_bounded_and_unknown_or_cross_host_documents_fail() {1320 let directory = private_directory();1321 let host = SecretKey::from_bytes(&[7; 32]).public();1322 let future = serde_json::to_vec(&runtime_document(20_001)).unwrap();1323 secure_state::atomic_write(1324 directory.path(),1325 RUNTIME_STATUS_FILE,1326 &future,1327 MAX_RUNTIME_STATUS_BYTES,1328 )1329 .unwrap();1330 assert_eq!(1331 read_runtime_status(directory.path(), host, 20_000)1332 .unwrap()1333 .state,1334 RuntimeState::Fresh1335 );13361337 let far_future = serde_json::to_vec(&runtime_document(26_000)).unwrap();1338 secure_state::atomic_write(1339 directory.path(),1340 RUNTIME_STATUS_FILE,1341 &far_future,1342 MAX_RUNTIME_STATUS_BYTES,1343 )1344 .unwrap();1345 assert_eq!(1346 read_runtime_status(directory.path(), host, 20_000)1347 .unwrap()1348 .state,1349 RuntimeState::Stale1350 );13511352 let mut unknown = runtime_document(20_000);1353 unknown.version = 99;1354 let unknown = serde_json::to_vec(&unknown).unwrap();1355 secure_state::atomic_write(1356 directory.path(),1357 RUNTIME_STATUS_FILE,1358 &unknown,1359 MAX_RUNTIME_STATUS_BYTES,1360 )1361 .unwrap();1362 assert!(read_runtime_status(directory.path(), host, 20_000).is_err());13631364 let other_host = SecretKey::from_bytes(&[8; 32]).public();1365 let cross_host = serde_json::to_vec(&runtime_document(20_000)).unwrap();1366 secure_state::atomic_write(1367 directory.path(),1368 RUNTIME_STATUS_FILE,1369 &cross_host,1370 MAX_RUNTIME_STATUS_BYTES,1371 )1372 .unwrap();1373 assert!(read_runtime_status(directory.path(), other_host, 20_000).is_err());1374 }13751376 #[test]1377 fn candidate_health_requires_exact_fresh_stopped_ready_instance_and_revision() {1378 let directory = private_directory();1379 let host = SecretKey::from_bytes(&[7; 32]).public();1380 let revision = ConfigRevision::from_bytes(b"candidate");1381 let instance = "0123456789abcdef0123456789abcdef";1382 let valid = current_runtime_document(20_000, revision.clone());1383 let write = |document: &RuntimeStatusV3| {1384 secure_state::atomic_write(1385 directory.path(),1386 RUNTIME_STATUS_FILE,1387 &serde_json::to_vec(document).unwrap(),1388 MAX_RUNTIME_STATUS_BYTES,1389 )1390 .unwrap();1391 };1392 write(&valid);1393 validate_candidate_runtime_at(directory.path(), host, &revision, instance, 20_000).unwrap();13941395 let mut invalid = valid.clone();1396 invalid.reached_ready = false;1397 write(&invalid);1398 assert!(1399 validate_candidate_runtime_at(directory.path(), host, &revision, instance, 20_000)1400 .is_err()1401 );14021403 let mut invalid = valid.clone();1404 invalid.daemon = DaemonState::Ready;1405 write(&invalid);1406 assert!(1407 validate_candidate_runtime_at(directory.path(), host, &revision, instance, 20_000)1408 .is_err()1409 );14101411 write(&valid);1412 assert!(1413 validate_candidate_runtime_at(1414 directory.path(),1415 host,1416 &ConfigRevision::from_bytes(b"other"),1417 instance,1418 20_000,1419 )1420 .is_err()1421 );1422 assert!(1423 validate_candidate_runtime_at(1424 directory.path(),1425 host,1426 &revision,1427 "fedcba9876543210fedcba9876543210",1428 20_000,1429 )1430 .is_err()1431 );1432 assert!(1433 validate_candidate_runtime_at(directory.path(), host, &revision, instance, 40_001)1434 .is_err()1435 );1436 }14371438 #[test]1439 fn assembled_status_redacts_endpoint_ids_and_orders_grants() {1440 let host = SecretKey::from_bytes(&[7; 32]).public();1441 let peer = SecretKey::from_bytes(&[9; 32]).public();1442 let status = assemble_status(1443 host,1444 AuthorizationInspection {1445 snapshot: crate::authorization::AuthorizationSnapshot {1446 epoch: 4,1447 peer: Some(peer),1448 grants: Some(InvitationGrants::ALL),1449 enrolled_at_unix: Some(123),1450 committed_revision: 1,1451 viewers: Vec::new(),1452 },1453 storage_present: true,1454 },1455 RuntimeStatus {1456 state: RuntimeState::Fresh,1457 daemon: DaemonState::Ready,1458 uptime_ms: Some(50),1459 heartbeat_age_ms: Some(1),1460 session: SessionState::Active,1461 instance_id: Some("0123456789abcdef0123456789abcdef".to_owned()),1462 loaded_config_revision: Some(ConfigRevision::from_bytes(b"config")),1463 reached_ready: true,1464 mode: Some("moq_multi_viewer".to_owned()),1465 active_viewer_count: Some(3),1466 media_generation_id: Some(7),1467 roster_revision: Some(9),1468 focus_occupied: Some(true),1469 recovering_viewer_count: Some(1),1470 configured_viewer_capacity: Some(3),1471 authorization_revision: Some(4),1472 last_error: None,1473 },1474 ConfigRevision::from_bytes(b"config"),1475 None,1476 );1477 let json = serde_json::to_string(&status).unwrap();1478 assert!(!json.contains(&host.to_string()));1479 assert!(!json.contains(&peer.to_string()));1480 assert!(json.contains(&fingerprint(host)));1481 assert!(json.contains(&fingerprint(peer)));1482 assert!(json.contains("\"grants\":[\"view\",\"pointer_keyboard\",\"gamepad\"]"));1483 }14841485 #[test]1486 fn public_status_v1_shape_remains_compatible_and_v2_is_explicit() {1487 let host = SecretKey::from_bytes(&[7; 32]).public();1488 let revision = ConfigRevision::from_bytes(b"config");1489 let status = assemble_status(1490 host,1491 AuthorizationInspection {1492 snapshot: crate::authorization::AuthorizationSnapshot {1493 epoch: 0,1494 peer: None,1495 grants: None,1496 enrolled_at_unix: None,1497 committed_revision: 1,1498 viewers: Vec::new(),1499 },1500 storage_present: false,1501 },1502 absent_runtime(),1503 revision,1504 None,1505 );1506 let legacy = status_json(&status, 1).unwrap();1507 assert_eq!(legacy["schema_version"], 1);1508 assert!(legacy["config"].get("revision").is_none());1509 assert!(legacy["config"].get("pending_transaction").is_none());1510 assert!(legacy["runtime"].get("instance_id").is_none());1511 assert!(legacy["runtime"].get("loaded_config_revision").is_none());1512 assert!(legacy["runtime"].get("reached_ready").is_none());15131514 let current = status_json(&status, 2).unwrap();1515 assert_eq!(current["schema_version"], 2);1516 assert!(current["config"].get("revision").is_some());1517 assert!(current["runtime"].get("reached_ready").is_some());1518 assert!(current["runtime"].get("active_viewer_count").is_some());1519 }15201521 #[tokio::test]1522 async fn publisher_writes_transitions_and_retains_an_explicit_stopped_state() {1523 let directory = private_directory();1524 let sessions = Arc::new(SessionRegistry::default());1525 let generations = MediaGenerationManager::new(crate::server::moq_test_config(), [7; 32]);1526 let host = SecretKey::from_bytes(&[7; 32]).public();1527 let revision = ConfigRevision::from_bytes(b"config");1528 let publisher = RuntimePublisher::start_at(1529 directory.path(),1530 host,1531 sessions,1532 generations,1533 Some(revision.clone()),1534 )1535 .unwrap();1536 publisher.mark_ready().await.unwrap();1537 publisher.write_snapshot().unwrap();1538 let status =1539 read_runtime_status(directory.path(), host, unix_timestamp_millis().unwrap()).unwrap();1540 assert_eq!(status.daemon, DaemonState::Ready);1541 publisher.mark_stopping().await.unwrap();1542 publisher.finish_clean().await.unwrap();1543 let status =1544 read_runtime_status(directory.path(), host, unix_timestamp_millis().unwrap()).unwrap();1545 assert_eq!(status.daemon, DaemonState::Stopped);1546 assert_eq!(status.session, SessionState::Inactive);1547 assert_eq!(status.loaded_config_revision, Some(revision));1548 assert!(status.reached_ready);1549 }1550}