cat / goq
clone

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

feat: add transactional Sigil configuration (#29)

FelineStateMachine committed b44b012
Add exact-revision config projection, bounded validate/set/commit/rollback commands, durable crash recovery, and runtime health binding for Decky management.

Extend appliance status to schema v2 and cover candidate commit plus byte-exact rollback in the integration proof.
commit
b44b0125e47c28e83261b3ddbf463e036663bf17
parent
81b3725e647dc9f1619fdf248983466996b36efe
author
FelineStateMachine <dami.etoile@gmail.com>
12 files changed, +2520 -185
MCargo.lock3+
MREADME.md7+-
Mcrates/sigil-host/Cargo.toml2+
Mcrates/sigil-host/src/appliance.rs501+++++++++++--
Mcrates/sigil-host/src/config.rs122+++-
Acrates/sigil-host/src/config_management.rs1197++++++++++++++++++++++++++++++
Mcrates/sigil-host/src/main.rs304+++++++-
Mcrates/sigil-host/src/secure_state.rs125+++-
Adocs/sigil-appliance-config-v1.md93++
Mdocs/sigil-appliance-status-v1.md69+-
Adocs/sigil-appliance-status-v2.md77++
Mscripts/tests/sigil-appliance-status.sh205+++++-
M Cargo.lock +3 -0
M README.md +5 -2
M crates/sigil-host/Cargo.toml +2 -0
M crates/sigil-host/src/appliance.rs +430 -71
@@ -1,6 +1,6 @@
11 use std::fs;22 use std::path::{Path, PathBuf};3-use std::sync::atomic::{AtomicU8, Ordering};3+use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};44 use std::sync::{Arc, Mutex};55 use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};66
@@ -10,14 +10,15 @@ use serde::{Deserialize, Serialize};
1010 use sigil_protocol::InvitationGrants;1111 1212 use crate::authorization::{AuthorizationInspection, AuthorizationStore};13-use crate::config::HostConfig;13+use crate::config::{ConfigRevision, HostConfig};14+use crate::config_management::ConfigTransactionSummaryV1;1415 use crate::secure_state;1516 use crate::server::SessionRegistry;1617 1718 const LIFECYCLE_LOCK_FILE: &str = "daemon-v1.lock";1819 const GLOBAL_LIFECYCLE_LOCK_FILE: &str = "daemon-global-v1.lock";1920 const RUNTIME_STATUS_FILE: &str = "daemon-status-v1.json";20-const RUNTIME_STATUS_VERSION: u16 = 1;21+const RUNTIME_STATUS_VERSION: u16 = 2;2122 const MAX_RUNTIME_STATUS_BYTES: u64 = 16 * 1024;2223 const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);2324 const STALE_AFTER: Duration = Duration::from_secs(10);
@@ -35,22 +36,40 @@ pub struct LifecycleGuard {
3536 3637 impl LifecycleGuard {3738 pub fn acquire(state_directory: &Path, require_global: bool) -> Result<Self> {38- let runtime_directory = configured_runtime_directory()?;39- ensure!(40- !require_global || runtime_directory.is_some(),41- "configured Sigil service requires a valid XDG_RUNTIME_DIR"42- );43- Self::acquire_at(state_directory, runtime_directory.as_deref())39+ Self::try_acquire(state_directory, require_global).map_err(anyhow::Error::new)40+ }41+42+ pub(crate) fn try_acquire(43+ state_directory: &Path,44+ require_global: bool,45+ ) -> std::result::Result<Self, secure_state::LockAcquireError> {46+ let runtime_directory =47+ configured_runtime_directory().map_err(secure_state::LockAcquireError::Unsafe)?;48+ if require_global && runtime_directory.is_none() {49+ return Err(secure_state::LockAcquireError::Unsafe(anyhow::anyhow!(50+ "configured Sigil service requires a valid XDG_RUNTIME_DIR"51+ )));52+ }53+ Self::try_acquire_at(state_directory, runtime_directory.as_deref())4454 }4555 56+ #[cfg(test)]4657 fn acquire_at(state_directory: &Path, runtime_directory: Option<&Path>) -> Result<Self> {47- let mut files = vec![secure_state::open_lifetime_lock(58+ Self::try_acquire_at(state_directory, runtime_directory).map_err(anyhow::Error::new)59+ }60+61+ pub(crate) fn try_acquire_at(62+ state_directory: &Path,63+ runtime_directory: Option<&Path>,64+ ) -> std::result::Result<Self, secure_state::LockAcquireError> {65+ let mut files = vec![secure_state::try_open_lifetime_lock(4866 state_directory,4967 LIFECYCLE_LOCK_FILE,5068 )?];5169 if let Some(runtime_directory) = runtime_directory {52- secure_state::ensure_private_directory(runtime_directory)?;53- files.push(secure_state::open_lifetime_lock(70+ secure_state::ensure_private_directory(runtime_directory)71+ .map_err(secure_state::LockAcquireError::Unsafe)?;72+ files.push(secure_state::try_open_lifetime_lock(5473 runtime_directory,5574 GLOBAL_LIFECYCLE_LOCK_FILE,5675 )?);
@@ -127,8 +146,23 @@ struct RuntimeStatusV1 {
127146 last_error: Option<RuntimeErrorV1>,128147 }129148 149+#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]150+#[serde(deny_unknown_fields)]151+struct RuntimeStatusV2 {152+ version: u16,153+ instance_id: String,154+ host_node_id: String,155+ loaded_config_revision: Option<ConfigRevision>,156+ reached_ready: bool,157+ updated_at_unix_ms: u64,158+ uptime_ms: u64,159+ daemon: DaemonState,160+ session_active: bool,161+ last_error: Option<RuntimeErrorV1>,162+}163+130164 #[derive(Debug, Serialize)]131-pub struct ApplianceStatusV1 {165+pub struct ApplianceStatusV2 {132166 schema_version: u16,133167 sigil_version: &'static str,134168 overall: OverallState,
@@ -152,6 +186,8 @@ pub struct EnrollmentResetV1 {
152186 #[derive(Debug, Serialize)]153187 struct ConfigStatus {154188 state: &'static str,189+ revision: ConfigRevision,190+ pending_transaction: Option<ConfigTransactionSummaryV1>,155191 }156192 157193 #[derive(Debug, Serialize)]
@@ -177,6 +213,9 @@ struct RuntimeStatus {
177213 uptime_ms: Option<u64>,178214 heartbeat_age_ms: Option<u64>,179215 session: SessionState,216+ instance_id: Option<String>,217+ loaded_config_revision: Option<ConfigRevision>,218+ reached_ready: bool,180219 last_error: Option<RuntimeErrorV1>,181220 }182221
@@ -184,8 +223,10 @@ pub struct RuntimePublisher {
184223 state_directory: PathBuf,185224 instance_id: String,186225 host: EndpointId,226+ loaded_config_revision: Option<ConfigRevision>,187227 started: Instant,188228 daemon_state: Arc<AtomicU8>,229+ reached_ready: Arc<AtomicBool>,189230 last_error: Arc<Mutex<Option<RuntimeErrorV1>>>,190231 sessions: Arc<SessionRegistry>,191232 write_lock: Arc<tokio::sync::Mutex<()>>,
@@ -197,6 +238,7 @@ impl RuntimePublisher {
197238 host: EndpointId,198239 sessions: Arc<SessionRegistry>,199240 require_runtime: bool,241+ loaded_config_revision: Option<ConfigRevision>,200242 ) -> Result<Option<Self>> {201243 let Some(state_directory) = configured_runtime_directory()? else {202244 ensure!(
@@ -207,24 +249,32 @@ impl RuntimePublisher {
207249 return Ok(None);208250 };209251 secure_state::ensure_private_directory(&state_directory)?;210- Self::start_at(&state_directory, host, sessions).map(Some)252+ ensure!(253+ !require_runtime || loaded_config_revision.is_some(),254+ "configured Sigil service requires a loaded config revision"255+ );256+ Self::start_at(&state_directory, host, sessions, loaded_config_revision).map(Some)211257 }212258 213259 fn start_at(214260 state_directory: &Path,215261 host: EndpointId,216262 sessions: Arc<SessionRegistry>,263+ loaded_config_revision: Option<ConfigRevision>,217264 ) -> Result<Self> {218265 let instance_id = random_instance_id()?;219266 let daemon_state = Arc::new(AtomicU8::new(DAEMON_STARTING));267+ let reached_ready = Arc::new(AtomicBool::new(false));220268 let last_error = Arc::new(Mutex::new(None));221269 let write_lock = Arc::new(tokio::sync::Mutex::new(()));222270 let mut publisher = Self {223271 state_directory: state_directory.to_path_buf(),224272 instance_id,225273 host,274+ loaded_config_revision,226275 started: Instant::now(),227276 daemon_state,277+ reached_ready,228278 last_error,229279 sessions,230280 write_lock,
@@ -235,8 +285,10 @@ impl RuntimePublisher {
235285 let state_directory = publisher.state_directory.clone();236286 let instance_id = publisher.instance_id.clone();237287 let host = publisher.host;288+ let loaded_config_revision = publisher.loaded_config_revision.clone();238289 let started = publisher.started;239290 let daemon_state = Arc::clone(&publisher.daemon_state);291+ let reached_ready = Arc::clone(&publisher.reached_ready);240292 let last_error = Arc::clone(&publisher.last_error);241293 let sessions = Arc::clone(&publisher.sessions);242294 let write_lock = Arc::clone(&publisher.write_lock);
@@ -249,22 +301,26 @@ impl RuntimePublisher {
249301 let _write_guard = write_lock.lock().await;250302 let state_directory = state_directory.clone();251303 let instance_id = instance_id.clone();304+ let loaded_config_revision = loaded_config_revision.clone();252305 let daemon = decode_daemon_state(daemon_state.load(Ordering::Relaxed));306+ let reached_ready = reached_ready.load(Ordering::Relaxed);253307 let session_active = sessions.has_session();254308 let last_error = last_error255309 .lock()256310 .expect("runtime status error state poisoned")257311 .clone();258312 match tokio::task::spawn_blocking(move || {259- write_runtime_snapshot(260- &state_directory,261- &instance_id,313+ write_runtime_snapshot(RuntimeSnapshotInput {314+ state_directory,315+ instance_id,262316 host,317+ loaded_config_revision,263318 started,264319 daemon,320+ reached_ready,265321 session_active,266322 last_error,267- )323+ })268324 })269325 .await270326 {
@@ -284,6 +340,7 @@ impl RuntimePublisher {
284340 }285341 286342 pub async fn mark_ready(&self) -> Result<()> {343+ self.reached_ready.store(true, Ordering::Relaxed);287344 self.daemon_state.store(DAEMON_READY, Ordering::Relaxed);288345 self.write_snapshot_async().await289346 }
@@ -316,7 +373,9 @@ impl RuntimePublisher {
316373 let state_directory = self.state_directory.clone();317374 let instance_id = self.instance_id.clone();318375 let host = self.host;376+ let loaded_config_revision = self.loaded_config_revision.clone();319377 let started = self.started;378+ let reached_ready = self.reached_ready.load(Ordering::Relaxed);320379 let session_active = self.sessions.has_session();321380 let last_error = self322381 .last_error
@@ -324,33 +383,38 @@ impl RuntimePublisher {
324383 .expect("runtime status error state poisoned")325384 .clone();326385 tokio::task::spawn_blocking(move || {327- write_runtime_snapshot(328- &state_directory,329- &instance_id,386+ write_runtime_snapshot(RuntimeSnapshotInput {387+ state_directory,388+ instance_id,330389 host,390+ loaded_config_revision,331391 started,332- DaemonState::Stopped,392+ daemon: DaemonState::Stopped,393+ reached_ready,333394 session_active,334395 last_error,335- )396+ })336397 })337398 .await338399 .context("joining stopped runtime status write")?339400 }340401 341402 fn write_snapshot(&self) -> Result<()> {342- write_runtime_snapshot(343- &self.state_directory,344- &self.instance_id,345- self.host,346- self.started,347- decode_daemon_state(self.daemon_state.load(Ordering::Relaxed)),348- self.sessions.has_session(),349- self.last_error403+ write_runtime_snapshot(RuntimeSnapshotInput {404+ state_directory: self.state_directory.clone(),405+ instance_id: self.instance_id.clone(),406+ host: self.host,407+ loaded_config_revision: self.loaded_config_revision.clone(),408+ started: self.started,409+ daemon: decode_daemon_state(self.daemon_state.load(Ordering::Relaxed)),410+ reached_ready: self.reached_ready.load(Ordering::Relaxed),411+ session_active: self.sessions.has_session(),412+ last_error: self413+ .last_error350414 .lock()351415 .expect("runtime status error state poisoned")352416 .clone(),353- )417+ })354418 }355419 356420 async fn write_snapshot_async(&self) -> Result<()> {
@@ -359,8 +423,10 @@ impl RuntimePublisher {
359423 let state_directory = self.state_directory.clone();360424 let instance_id = self.instance_id.clone();361425 let host = self.host;426+ let loaded_config_revision = self.loaded_config_revision.clone();362427 let started = self.started;363428 let daemon = decode_daemon_state(self.daemon_state.load(Ordering::Relaxed));429+ let reached_ready = self.reached_ready.load(Ordering::Relaxed);364430 let session_active = self.sessions.has_session();365431 let last_error = self366432 .last_error
@@ -368,15 +434,17 @@ impl RuntimePublisher {
368434 .expect("runtime status error state poisoned")369435 .clone();370436 tokio::task::spawn_blocking(move || {371- write_runtime_snapshot(372- &state_directory,373- &instance_id,437+ write_runtime_snapshot(RuntimeSnapshotInput {438+ state_directory,439+ instance_id,374440 host,441+ loaded_config_revision,375442 started,376443 daemon,444+ reached_ready,377445 session_active,378446 last_error,379- )447+ })380448 })381449 .await382450 .context("joining runtime status write")?
@@ -402,18 +470,50 @@ fn configured_runtime_directory() -> Result<Option<PathBuf>> {
402470 Ok(Some(root.join("sigil-spark")))403471 }404472 405-pub fn collect_status(config_path: &Path) -> Result<ApplianceStatusV1> {406- let config = HostConfig::load(config_path)?;473+pub fn collect_status(config_path: &Path) -> Result<ApplianceStatusV2> {474+ let loaded = HostConfig::load_document(config_path)?;475+ let config = &loaded.config;407476 let secret = crate::identity::load(&config.identity_path)?;408477 let host = secret.public();409478 let authorization = AuthorizationStore::inspect_existing(&config.state_path, host)?;479+ let pending_transaction = crate::config_management::inspect_transaction(&loaded, config_path)480+ .map_err(anyhow::Error::from)?;410481 let runtime = match configured_runtime_directory()? {411482 Some(runtime_directory) => {412483 read_runtime_status(&runtime_directory, host, unix_timestamp_millis()?)?413484 }414485 None => unavailable_runtime(),415486 };416- Ok(assemble_status(host, authorization, runtime))487+ Ok(assemble_status(488+ host,489+ authorization,490+ runtime,491+ loaded.revision,492+ pending_transaction,493+ ))494+}495+496+pub fn status_json(status: &ApplianceStatusV2, schema_version: u16) -> Result<serde_json::Value> {497+ let mut value = serde_json::to_value(status)?;498+ match schema_version {499+ 1 => {500+ value["schema_version"] = serde_json::json!(1);501+ let config = value["config"]502+ .as_object_mut()503+ .context("appliance config status is not an object")?;504+ config.remove("revision");505+ config.remove("pending_transaction");506+ let runtime = value["runtime"]507+ .as_object_mut()508+ .context("appliance runtime status is not an object")?;509+ runtime.remove("instance_id");510+ runtime.remove("loaded_config_revision");511+ runtime.remove("reached_ready");512+ }513+ 2 => {}514+ _ => anyhow::bail!("unsupported appliance status schema"),515+ }516+ Ok(value)417517 }418518 419519 pub fn reset_enrollment(
@@ -457,7 +557,9 @@ fn assemble_status(
457557 host: EndpointId,458558 authorization: AuthorizationInspection,459559 runtime: RuntimeStatus,460-) -> ApplianceStatusV1 {560+ config_revision: ConfigRevision,561+ pending_transaction: Option<ConfigTransactionSummaryV1>,562+) -> ApplianceStatusV2 {461563 let snapshot = authorization.snapshot;462564 let grants = snapshot.grants.map(grant_list).unwrap_or_default();463565 let enrollment_active = snapshot.peer.is_some();
@@ -467,11 +569,15 @@ fn assemble_status(
467569 (DaemonState::Degraded, _) => OverallState::Degraded,468570 _ => OverallState::Unavailable,469571 };470- ApplianceStatusV1 {471- schema_version: 1,572+ ApplianceStatusV2 {573+ schema_version: 2,472574 sigil_version: env!("CARGO_PKG_VERSION"),473575 overall,474- config: ConfigStatus { state: "valid" },576+ config: ConfigStatus {577+ state: "valid",578+ revision: config_revision,579+ pending_transaction,580+ },475581 identity: IdentityStatus {476582 state: "ready",477583 host_fingerprint: fingerprint(host),
@@ -504,20 +610,9 @@ fn read_runtime_status(
504610 Err(error) => return Err(error).context("inspecting Sigil runtime state directory"),505611 Ok(_) => {}506612 }507- let Some(bytes) = secure_state::read_bounded(508- state_directory,509- RUNTIME_STATUS_FILE,510- MAX_RUNTIME_STATUS_BYTES,511- )?512- else {613+ let Some(persisted) = read_persisted_runtime(state_directory)? else {513614 return Ok(absent_runtime());514615 };515- let persisted: RuntimeStatusV1 =516- serde_json::from_slice(&bytes).context("parsing Sigil runtime status")?;517- ensure!(518- persisted.version == RUNTIME_STATUS_VERSION,519- "unsupported Sigil runtime status version"520- );521616 ensure!(522617 persisted.host_node_id.parse::<EndpointId>().ok() == Some(expected_host),523618 "Sigil runtime status belongs to a different host identity"
@@ -527,9 +622,12 @@ fn read_runtime_status(
527622 && persisted528623 .instance_id529624 .bytes()530- .all(|byte| byte.is_ascii_hexdigit()),625+ .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),531626 "invalid Sigil runtime instance ID"532627 );628+ if let Some(revision) = &persisted.loaded_config_revision {629+ ConfigRevision::parse(revision.as_str())?;630+ }533631 if let Some(error) = &persisted.last_error {534632 ensure!(535633 error.occurred_at_unix_ms <= persisted.updated_at_unix_ms,
@@ -568,11 +666,85 @@ fn read_runtime_status(
568666 } else {569667 SessionState::Inactive570668 },669+ instance_id: Some(persisted.instance_id),670+ loaded_config_revision: persisted.loaded_config_revision,671+ reached_ready: persisted.reached_ready,571672 last_error: persisted.last_error,572673 })573674 }574675 575-fn stale_runtime(persisted: RuntimeStatusV1, heartbeat_age_ms: Option<u64>) -> RuntimeStatus {676+fn read_persisted_runtime(state_directory: &Path) -> Result<Option<RuntimeStatusV2>> {677+ let Some(bytes) = secure_state::read_bounded(678+ state_directory,679+ RUNTIME_STATUS_FILE,680+ MAX_RUNTIME_STATUS_BYTES,681+ )?682+ else {683+ return Ok(None);684+ };685+ let value: serde_json::Value =686+ serde_json::from_slice(&bytes).context("parsing Sigil runtime status")?;687+ let version = value688+ .get("version")689+ .and_then(serde_json::Value::as_u64)690+ .context("Sigil runtime status version is missing")?;691+ match version {692+ 1 => {693+ let legacy: RuntimeStatusV1 =694+ serde_json::from_value(value).context("parsing legacy Sigil runtime status")?;695+ Ok(Some(RuntimeStatusV2 {696+ version: legacy.version,697+ instance_id: legacy.instance_id,698+ host_node_id: legacy.host_node_id,699+ loaded_config_revision: None,700+ reached_ready: false,701+ updated_at_unix_ms: legacy.updated_at_unix_ms,702+ uptime_ms: legacy.uptime_ms,703+ daemon: legacy.daemon,704+ session_active: legacy.session_active,705+ last_error: legacy.last_error,706+ }))707+ }708+ 2 => {709+ let current: RuntimeStatusV2 =710+ serde_json::from_value(value).context("parsing Sigil runtime status v2")?;711+ ensure!(712+ current.version == RUNTIME_STATUS_VERSION,713+ "unsupported Sigil runtime status version"714+ );715+ Ok(Some(current))716+ }717+ _ => anyhow::bail!("unsupported Sigil runtime status version"),718+ }719+}720+721+pub(crate) fn latest_runtime_instance(expected_host: EndpointId) -> Result<Option<String>> {722+ let state_directory = configured_runtime_directory()?723+ .context("configured Sigil service requires a valid XDG_RUNTIME_DIR")?;724+ match fs::symlink_metadata(&state_directory) {725+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),726+ Err(error) => return Err(error).context("inspecting Sigil runtime state directory"),727+ Ok(_) => {}728+ }729+ let Some(persisted) = read_persisted_runtime(&state_directory)? else {730+ return Ok(None);731+ };732+ ensure!(733+ persisted.host_node_id.parse::<EndpointId>().ok() == Some(expected_host),734+ "Sigil runtime status belongs to a different host identity"735+ );736+ ensure!(737+ persisted.instance_id.len() == 32738+ && persisted739+ .instance_id740+ .bytes()741+ .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),742+ "invalid Sigil runtime instance ID"743+ );744+ Ok(Some(persisted.instance_id))745+}746+747+fn stale_runtime(persisted: RuntimeStatusV2, heartbeat_age_ms: Option<u64>) -> RuntimeStatus {576748 let stopped = persisted.daemon == DaemonState::Stopped;577749 RuntimeStatus {578750 state: RuntimeState::Stale,
@@ -588,6 +760,9 @@ fn stale_runtime(persisted: RuntimeStatusV1, heartbeat_age_ms: Option<u64>) -> R
588760 } else {589761 SessionState::Unknown590762 },763+ instance_id: None,764+ loaded_config_revision: None,765+ reached_ready: false,591766 last_error: persisted.last_error,592767 }593768 }
@@ -599,6 +774,9 @@ fn absent_runtime() -> RuntimeStatus {
599774 uptime_ms: None,600775 heartbeat_age_ms: None,601776 session: SessionState::Unknown,777+ instance_id: None,778+ loaded_config_revision: None,779+ reached_ready: false,602780 last_error: None,603781 }604782 }
@@ -610,32 +788,89 @@ fn unavailable_runtime() -> RuntimeStatus {
610788 uptime_ms: None,611789 heartbeat_age_ms: None,612790 session: SessionState::Unknown,791+ instance_id: None,792+ loaded_config_revision: None,793+ reached_ready: false,613794 last_error: None,614795 }615796 }616797 617-fn write_runtime_snapshot(798+pub(crate) fn validate_candidate_runtime(799+ expected_host: EndpointId,800+ expected_revision: &ConfigRevision,801+ expected_instance: &str,802+) -> Result<()> {803+ let state_directory = configured_runtime_directory()?804+ .context("configured Sigil service requires a valid XDG_RUNTIME_DIR")?;805+ validate_candidate_runtime_at(806+ &state_directory,807+ expected_host,808+ expected_revision,809+ expected_instance,810+ unix_timestamp_millis()?,811+ )812+}813+814+fn validate_candidate_runtime_at(618815 state_directory: &Path,619- instance_id: &str,816+ expected_host: EndpointId,817+ expected_revision: &ConfigRevision,818+ expected_instance: &str,819+ now_unix_ms: u64,820+) -> Result<()> {821+ let status = read_runtime_status(state_directory, expected_host, now_unix_ms)?;822+ ensure!(823+ status.state == RuntimeState::Fresh,824+ "runtime evidence is not fresh"825+ );826+ ensure!(827+ status.daemon == DaemonState::Stopped,828+ "candidate daemon has not stopped cleanly"829+ );830+ ensure!(831+ status.instance_id.as_deref() == Some(expected_instance),832+ "runtime evidence belongs to a different daemon instance"833+ );834+ ensure!(835+ status.loaded_config_revision.as_ref() == Some(expected_revision),836+ "runtime evidence belongs to a different configuration revision"837+ );838+ ensure!(status.reached_ready, "candidate daemon never reached ready");839+ ensure!(840+ status.last_error.is_none(),841+ "candidate daemon reported an error"842+ );843+ Ok(())844+}845+846+struct RuntimeSnapshotInput {847+ state_directory: PathBuf,848+ instance_id: String,620849 host: EndpointId,850+ loaded_config_revision: Option<ConfigRevision>,621851 started: Instant,622852 daemon: DaemonState,853+ reached_ready: bool,623854 session_active: bool,624855 last_error: Option<RuntimeErrorV1>,625-) -> Result<()> {626- let status = RuntimeStatusV1 {856+}857+858+fn write_runtime_snapshot(input: RuntimeSnapshotInput) -> Result<()> {859+ let status = RuntimeStatusV2 {627860 version: RUNTIME_STATUS_VERSION,628- instance_id: instance_id.to_owned(),629- host_node_id: host.to_string(),861+ instance_id: input.instance_id,862+ host_node_id: input.host.to_string(),863+ loaded_config_revision: input.loaded_config_revision,864+ reached_ready: input.reached_ready,630865 updated_at_unix_ms: unix_timestamp_millis()?,631- uptime_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),632- daemon,633- session_active,634- last_error,866+ uptime_ms: u64::try_from(input.started.elapsed().as_millis()).unwrap_or(u64::MAX),867+ daemon: input.daemon,868+ session_active: input.session_active,869+ last_error: input.last_error,635870 };636871 let bytes = serde_json::to_vec(&status)?;637872 secure_state::atomic_write(638- state_directory,873+ &input.state_directory,639874 RUNTIME_STATUS_FILE,640875 &bytes,641876 MAX_RUNTIME_STATUS_BYTES,
@@ -708,7 +943,7 @@ mod tests {
708943 fn runtime_document(updated_at_unix_ms: u64) -> RuntimeStatusV1 {709944 let host = SecretKey::from_bytes(&[7; 32]).public();710945 RuntimeStatusV1 {711- version: RUNTIME_STATUS_VERSION,946+ version: 1,712947 instance_id: "0123456789abcdef0123456789abcdef".to_owned(),713948 host_node_id: host.to_string(),714949 updated_at_unix_ms,
@@ -719,6 +954,25 @@ mod tests {
719954 }720955 }721956 957+ fn current_runtime_document(958+ updated_at_unix_ms: u64,959+ revision: ConfigRevision,960+ ) -> RuntimeStatusV2 {961+ let host = SecretKey::from_bytes(&[7; 32]).public();962+ RuntimeStatusV2 {963+ version: RUNTIME_STATUS_VERSION,964+ instance_id: "0123456789abcdef0123456789abcdef".to_owned(),965+ host_node_id: host.to_string(),966+ loaded_config_revision: Some(revision),967+ reached_ready: true,968+ updated_at_unix_ms,969+ uptime_ms: 1_000,970+ daemon: DaemonState::Stopped,971+ session_active: false,972+ last_error: None,973+ }974+ }975+722976 #[test]723977 fn lifecycle_lock_is_global_across_distinct_state_directories() {724978 let first_state = private_directory();
@@ -801,7 +1055,7 @@ mod tests {
8011055 );8021056 8031057 let mut unknown = runtime_document(20_000);804- unknown.version = 2;1058+ unknown.version = 99;8051059 let unknown = serde_json::to_vec(&unknown).unwrap();8061060 secure_state::atomic_write(8071061 directory.path(),
@@ -824,6 +1078,68 @@ mod tests {
8241078 assert!(read_runtime_status(directory.path(), other_host, 20_000).is_err());8251079 }8261080 1081+ #[test]1082+ fn candidate_health_requires_exact_fresh_stopped_ready_instance_and_revision() {1083+ let directory = private_directory();1084+ let host = SecretKey::from_bytes(&[7; 32]).public();1085+ let revision = ConfigRevision::from_bytes(b"candidate");1086+ let instance = "0123456789abcdef0123456789abcdef";1087+ let valid = current_runtime_document(20_000, revision.clone());1088+ let write = |document: &RuntimeStatusV2| {1089+ secure_state::atomic_write(1090+ directory.path(),1091+ RUNTIME_STATUS_FILE,1092+ &serde_json::to_vec(document).unwrap(),1093+ MAX_RUNTIME_STATUS_BYTES,1094+ )1095+ .unwrap();1096+ };1097+ write(&valid);1098+ validate_candidate_runtime_at(directory.path(), host, &revision, instance, 20_000).unwrap();1099+1100+ let mut invalid = valid.clone();1101+ invalid.reached_ready = false;1102+ write(&invalid);1103+ assert!(1104+ validate_candidate_runtime_at(directory.path(), host, &revision, instance, 20_000)1105+ .is_err()1106+ );1107+1108+ let mut invalid = valid.clone();1109+ invalid.daemon = DaemonState::Ready;1110+ write(&invalid);1111+ assert!(1112+ validate_candidate_runtime_at(directory.path(), host, &revision, instance, 20_000)1113+ .is_err()1114+ );1115+1116+ write(&valid);1117+ assert!(1118+ validate_candidate_runtime_at(1119+ directory.path(),1120+ host,1121+ &ConfigRevision::from_bytes(b"other"),1122+ instance,1123+ 20_000,1124+ )1125+ .is_err()1126+ );1127+ assert!(1128+ validate_candidate_runtime_at(1129+ directory.path(),1130+ host,1131+ &revision,1132+ "fedcba9876543210fedcba9876543210",1133+ 20_000,1134+ )1135+ .is_err()1136+ );1137+ assert!(1138+ validate_candidate_runtime_at(directory.path(), host, &revision, instance, 40_001)1139+ .is_err()1140+ );1141+ }1142+8271143 #[test]8281144 fn assembled_status_redacts_endpoint_ids_and_orders_grants() {8291145 let host = SecretKey::from_bytes(&[7; 32]).public();
@@ -845,8 +1161,13 @@ mod tests {
8451161 uptime_ms: Some(50),8461162 heartbeat_age_ms: Some(1),8471163 session: SessionState::Active,1164+ instance_id: Some("0123456789abcdef0123456789abcdef".to_owned()),1165+ loaded_config_revision: Some(ConfigRevision::from_bytes(b"config")),1166+ reached_ready: true,8481167 last_error: None,8491168 },1169+ ConfigRevision::from_bytes(b"config"),1170+ None,8501171 );8511172 let json = serde_json::to_string(&status).unwrap();8521173 assert!(!json.contains(&host.to_string()));
@@ -856,12 +1177,48 @@ mod tests {
8561177 assert!(json.contains("\"grants\":[\"view\",\"pointer_keyboard\",\"gamepad\"]"));8571178 }8581179 1180+ #[test]1181+ fn public_status_v1_shape_remains_compatible_and_v2_is_explicit() {1182+ let host = SecretKey::from_bytes(&[7; 32]).public();1183+ let revision = ConfigRevision::from_bytes(b"config");1184+ let status = assemble_status(1185+ host,1186+ AuthorizationInspection {1187+ snapshot: crate::authorization::AuthorizationSnapshot {1188+ epoch: 0,1189+ peer: None,1190+ grants: None,1191+ enrolled_at_unix: None,1192+ },1193+ storage_present: false,1194+ },1195+ absent_runtime(),1196+ revision,1197+ None,1198+ );1199+ let legacy = status_json(&status, 1).unwrap();1200+ assert_eq!(legacy["schema_version"], 1);1201+ assert!(legacy["config"].get("revision").is_none());1202+ assert!(legacy["config"].get("pending_transaction").is_none());1203+ assert!(legacy["runtime"].get("instance_id").is_none());1204+ assert!(legacy["runtime"].get("loaded_config_revision").is_none());1205+ assert!(legacy["runtime"].get("reached_ready").is_none());1206+1207+ let current = status_json(&status, 2).unwrap();1208+ assert_eq!(current["schema_version"], 2);1209+ assert!(current["config"].get("revision").is_some());1210+ assert!(current["runtime"].get("reached_ready").is_some());1211+ }1212+8591213 #[tokio::test]8601214 async fn publisher_writes_transitions_and_retains_an_explicit_stopped_state() {8611215 let directory = private_directory();8621216 let sessions = Arc::new(SessionRegistry::default());8631217 let host = SecretKey::from_bytes(&[7; 32]).public();864- let publisher = RuntimePublisher::start_at(directory.path(), host, sessions).unwrap();1218+ let revision = ConfigRevision::from_bytes(b"config");1219+ let publisher =1220+ RuntimePublisher::start_at(directory.path(), host, sessions, Some(revision.clone()))1221+ .unwrap();8651222 publisher.mark_ready().await.unwrap();8661223 publisher.write_snapshot().unwrap();8671224 let status =
@@ -873,5 +1230,7 @@ mod tests {
8731230 read_runtime_status(directory.path(), host, unix_timestamp_millis().unwrap()).unwrap();8741231 assert_eq!(status.daemon, DaemonState::Stopped);8751232 assert_eq!(status.session, SessionState::Inactive);1233+ assert_eq!(status.loaded_config_revision, Some(revision));1234+ assert!(status.reached_ready);8761235 }8771236 }
M crates/sigil-host/src/config.rs +118 -4
@@ -1,14 +1,75 @@
11 use std::collections::BTreeMap;2+use std::fmt;23 use std::fs::{self, OpenOptions};34 use std::io::Read;45 use std::path::{Path, PathBuf};56 67 use anyhow::{Context, Result, ensure};78 use serde::{Deserialize, Serialize};9+use sha2::{Digest, Sha256};810 911 #[cfg(unix)]1012 use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};1113 14+pub const MAX_CONFIG_BYTES: u64 = 64 * 1024;15+const REVISION_PREFIX: &str = "sha256:";16+17+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]18+#[serde(transparent)]19+pub struct ConfigRevision(String);20+21+impl ConfigRevision {22+ pub fn from_bytes(bytes: &[u8]) -> Self {23+ let digest = Sha256::digest(bytes);24+ let mut value = String::with_capacity(REVISION_PREFIX.len() + digest.len() * 2);25+ value.push_str(REVISION_PREFIX);26+ for byte in digest {27+ use fmt::Write as _;28+ write!(&mut value, "{byte:02x}").expect("writing into a String cannot fail");29+ }30+ Self(value)31+ }32+33+ pub fn parse(value: &str) -> Result<Self> {34+ let digest = value.strip_prefix(REVISION_PREFIX).unwrap_or_default();35+ ensure!(36+ digest.len() == 6437+ && digest38+ .bytes()39+ .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),40+ "config revision must be sha256 followed by 64 lowercase hexadecimal digits"41+ );42+ Ok(Self(value.to_owned()))43+ }44+45+ pub fn as_str(&self) -> &str {46+ &self.047+ }48+}49+50+impl<'de> Deserialize<'de> for ConfigRevision {51+ fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>52+ where53+ D: serde::Deserializer<'de>,54+ {55+ let value = String::deserialize(deserializer)?;56+ Self::parse(&value).map_err(serde::de::Error::custom)57+ }58+}59+60+impl fmt::Display for ConfigRevision {61+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {62+ formatter.write_str(&self.0)63+ }64+}65+66+#[derive(Clone, Debug)]67+pub struct LoadedHostConfig {68+ pub config: HostConfig,69+ pub bytes: Vec<u8>,70+ pub revision: ConfigRevision,71+}72+1273 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]1374 #[serde(rename_all = "kebab-case")]1475 pub enum VideoSource {
@@ -317,6 +378,10 @@ impl HostConfig {
317378 pub const TEST_PATTERN_HEIGHT: u32 = 800;318379 319380 pub fn load(path: &Path) -> Result<Self> {381+ Ok(Self::load_document(path)?.config)382+ }383+384+ pub fn load_document(path: &Path) -> Result<LoadedHostConfig> {320385 let mut options = OpenOptions::new();321386 options.read(true);322387 #[cfg(unix)]
@@ -331,11 +396,36 @@ impl HostConfig {
331396 .metadata()332397 .with_context(|| format!("inspecting opened config {}", path.display()))?;333398 validate_file_security(path, &metadata)?;334- let mut contents = String::new();335- file.read_to_string(&mut contents)399+ ensure!(400+ metadata.len() <= MAX_CONFIG_BYTES,401+ "config exceeds its fixed size bound"402+ );403+ let mut bytes = Vec::with_capacity(metadata.len() as usize);404+ Read::by_ref(&mut file)405+ .take(MAX_CONFIG_BYTES.saturating_add(1))406+ .read_to_end(&mut bytes)336407 .with_context(|| format!("reading config {}", path.display()))?;337- let config: Self = toml::from_str(&contents)338- .with_context(|| format!("parsing config {}", path.display()))?;408+ ensure!(409+ bytes.len() as u64 <= MAX_CONFIG_BYTES,410+ "config exceeds its fixed size bound"411+ );412+ let config =413+ Self::parse(&bytes).with_context(|| format!("parsing config {}", path.display()))?;414+ let revision = ConfigRevision::from_bytes(&bytes);415+ Ok(LoadedHostConfig {416+ config,417+ bytes,418+ revision,419+ })420+ }421+422+ pub fn parse(bytes: &[u8]) -> Result<Self> {423+ ensure!(424+ bytes.len() as u64 <= MAX_CONFIG_BYTES,425+ "config exceeds its fixed size bound"426+ );427+ let contents = std::str::from_utf8(bytes).context("config is not valid UTF-8")?;428+ let config: Self = toml::from_str(contents).context("parsing config TOML")?;339429 config.validate()?;340430 Ok(config)341431 }
@@ -501,6 +591,30 @@ fn validate_file_security(path: &Path, metadata: &fs::Metadata) -> Result<()> {
501591 mod tests {502592 use super::*;503593 594+ #[test]595+ fn config_revision_is_exact_bounded_and_strict() {596+ let first = ConfigRevision::from_bytes(b"a = 1\n");597+ let second = ConfigRevision::from_bytes(b"a = 1\n\n");598+ assert_ne!(first, second);599+ assert_eq!(ConfigRevision::parse(first.as_str()).unwrap(), first);600+ assert!(ConfigRevision::parse("sha256:ABC").is_err());601+ assert!(ConfigRevision::parse("md5:00000000000000000000000000000000").is_err());602+ }603+604+ #[test]605+ fn load_document_hashes_the_same_bounded_bytes_it_parses() {606+ let directory = tempfile::tempdir().unwrap();607+ let path = directory.path().join("host.toml");608+ let bytes = b"identity_path = \"/tmp/host.key\"\nstate_path = \"/tmp/state\"\n";609+ fs::write(&path, bytes).unwrap();610+ let loaded = HostConfig::load_document(&path).unwrap();611+ assert_eq!(loaded.bytes, bytes);612+ assert_eq!(loaded.revision, ConfigRevision::from_bytes(bytes));613+614+ fs::write(&path, vec![b' '; MAX_CONFIG_BYTES as usize + 1]).unwrap();615+ assert!(HostConfig::load_document(&path).is_err());616+ }617+504618 #[test]505619 fn rejects_unknown_fields() {506620 let input = r#"
A crates/sigil-host/src/config_management.rs +1197 -0
@@ -0,0 +1,1197 @@
1+use std::fs;2+use std::io::Read;3+use std::path::Path;4+5+use serde::{Deserialize, Serialize};6+use toml_edit::{DocumentMut, value};7+8+use crate::appliance::LifecycleGuard;9+use crate::config::{10+ ConfigRevision, HostConfig, LoadedHostConfig, MAX_CONFIG_BYTES, VaapiRateControl, VideoSource,11+};12+use crate::{appliance, identity, secure_state};13+14+const REQUEST_VERSION: u16 = 1;15+const RESULT_VERSION: u16 = 1;16+const JOURNAL_VERSION: u16 = 1;17+const MAX_REQUEST_BYTES: u64 = 16 * 1024;18+const MAX_JOURNAL_BYTES: u64 = 16 * 1024;19+const CONFIG_LOCK_FILE: &str = "config-transaction-v1.lock";20+const JOURNAL_FILE: &str = "config-transaction-v1.json";21+const BASE_FILE: &str = "config-base-v1.toml";22+const CANDIDATE_FILE: &str = "config-candidate-v1.toml";23+24+pub type ManagementResult<T> = std::result::Result<T, ManagementError>;25+26+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]27+#[serde(rename_all = "snake_case")]28+pub enum ManagementErrorCode {29+ UnsupportedSchema,30+ InvalidRequest,31+ ValidationFailed,32+ RevisionConflict,33+ LifecycleBusy,34+ TransactionBusy,35+ TransactionPending,36+ TransactionNotFound,37+ TransactionConflict,38+ HealthNotProven,39+ UnsafeStorage,40+}41+42+#[derive(Debug)]43+pub struct ManagementError {44+ code: ManagementErrorCode,45+ source: anyhow::Error,46+}47+48+impl ManagementError {49+ fn new(code: ManagementErrorCode, source: impl Into<anyhow::Error>) -> Self {50+ Self {51+ code,52+ source: source.into(),53+ }54+ }55+56+ pub fn response(&self) -> ManagementErrorV1 {57+ ManagementErrorV1 {58+ schema_version: RESULT_VERSION,59+ error: ManagementErrorBody { code: self.code },60+ }61+ }62+}63+64+impl std::fmt::Display for ManagementError {65+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {66+ write!(67+ formatter,68+ "configuration management failed: {}",69+ self.source70+ )71+ }72+}73+74+impl std::error::Error for ManagementError {75+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {76+ Some(self.source.as_ref())77+ }78+}79+80+#[derive(Debug, Serialize)]81+pub struct ManagementErrorV1 {82+ schema_version: u16,83+ error: ManagementErrorBody,84+}85+86+#[derive(Debug, Serialize)]87+struct ManagementErrorBody {88+ code: ManagementErrorCode,89+}90+91+#[derive(Clone, Debug, Deserialize)]92+#[serde(deny_unknown_fields)]93+pub struct ConfigRequestV1 {94+ #[serde(rename = "schema_version")]95+ _schema_version: u16,96+ expected_revision: ConfigRevision,97+ settings: ManagedSettingsV1,98+}99+100+#[derive(Deserialize)]101+struct ConfigRequestEnvelope {102+ schema_version: u64,103+}104+105+#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]106+#[serde(deny_unknown_fields)]107+pub struct ManagedSettingsV1 {108+ resolution: ManagedResolutionV1,109+ framerate: u32,110+ rate_control: Option<ManagedRateControlV1>,111+}112+113+#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]114+#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]115+pub enum ManagedResolutionV1 {116+ Native,117+ Fixed { width: u32, height: u32 },118+}119+120+#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]121+#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]122+pub enum ManagedRateControlV1 {123+ Cbr { bitrate_kbps: u32 },124+ Cqp { quantizer: u8 },125+}126+127+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]128+pub struct ConfigTransactionSummaryV1 {129+ transaction: String,130+ state: TransactionState,131+ base_revision: ConfigRevision,132+ candidate_revision: ConfigRevision,133+}134+135+#[derive(Debug, Serialize)]136+pub struct ConfigShowV1 {137+ schema_version: u16,138+ revision: ConfigRevision,139+ settings: ManagedSettingsV1,140+ pending_transaction: Option<ConfigTransactionSummaryV1>,141+}142+143+#[derive(Debug, Serialize)]144+pub struct ConfigValidateV1 {145+ schema_version: u16,146+ base_revision: ConfigRevision,147+ candidate_revision: ConfigRevision,148+ changed: bool,149+ settings: ManagedSettingsV1,150+}151+152+#[derive(Debug, Serialize)]153+pub struct ConfigSetV1 {154+ schema_version: u16,155+ transaction: Option<String>,156+ base_revision: ConfigRevision,157+ candidate_revision: ConfigRevision,158+ changed: bool,159+ restart_required: bool,160+}161+162+#[derive(Debug, Serialize)]163+pub struct ConfigCommitV1 {164+ schema_version: u16,165+ operation: &'static str,166+ transaction: String,167+ revision: ConfigRevision,168+}169+170+#[derive(Debug, Serialize)]171+pub struct ConfigRollbackV1 {172+ schema_version: u16,173+ operation: &'static str,174+ transaction: String,175+ restored_revision: ConfigRevision,176+}177+178+#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]179+#[serde(rename_all = "snake_case")]180+pub enum TransactionState {181+ Prepared,182+ PendingValidation,183+ Committing,184+ RollingBack,185+}186+187+#[derive(Clone, Debug, Deserialize, Serialize)]188+#[serde(deny_unknown_fields)]189+struct TransactionJournalV1 {190+ version: u16,191+ transaction: String,192+ state: TransactionState,193+ config_path_id: ConfigRevision,194+ base_revision: ConfigRevision,195+ candidate_revision: ConfigRevision,196+ baseline_runtime_instance: Option<String>,197+}198+199+enum RecoveryCompletion {200+ Committed(TransactionJournalV1),201+ RolledBack(TransactionJournalV1),202+}203+204+struct RecoveryOutcome {205+ journal: Option<TransactionJournalV1>,206+ completion: Option<RecoveryCompletion>,207+}208+209+pub fn read_request(reader: impl Read) -> ManagementResult<ConfigRequestV1> {210+ let mut bytes = Vec::new();211+ reader212+ .take(MAX_REQUEST_BYTES.saturating_add(1))213+ .read_to_end(&mut bytes)214+ .map_err(|error| ManagementError::new(ManagementErrorCode::InvalidRequest, error))?;215+ if bytes.is_empty() || bytes.len() as u64 > MAX_REQUEST_BYTES {216+ return Err(ManagementError::new(217+ ManagementErrorCode::InvalidRequest,218+ anyhow::anyhow!("configuration request is empty or exceeds its fixed bound"),219+ ));220+ }221+ let envelope: ConfigRequestEnvelope = serde_json::from_slice(&bytes)222+ .map_err(|error| ManagementError::new(ManagementErrorCode::InvalidRequest, error))?;223+ if envelope.schema_version != u64::from(REQUEST_VERSION) {224+ return Err(ManagementError::new(225+ ManagementErrorCode::UnsupportedSchema,226+ anyhow::anyhow!("unsupported configuration request schema"),227+ ));228+ }229+ let request: ConfigRequestV1 = serde_json::from_slice(&bytes)230+ .map_err(|error| ManagementError::new(ManagementErrorCode::InvalidRequest, error))?;231+ ConfigRevision::parse(request.expected_revision.as_str())232+ .map_err(|error| ManagementError::new(ManagementErrorCode::InvalidRequest, error))?;233+ Ok(request)234+}235+236+pub fn show(config_path: &Path) -> ManagementResult<ConfigShowV1> {237+ let loaded = load_config(config_path)?;238+ let pending_transaction = inspect_transaction(&loaded, config_path)?;239+ Ok(ConfigShowV1 {240+ schema_version: RESULT_VERSION,241+ revision: loaded.revision,242+ settings: project_settings(&loaded.config)?,243+ pending_transaction,244+ })245+}246+247+pub fn validate(248+ config_path: &Path,249+ request: &ConfigRequestV1,250+) -> ManagementResult<ConfigValidateV1> {251+ let loaded = load_config(config_path)?;252+ require_expected_revision(&loaded, request)?;253+ let candidate = build_candidate(&loaded, request)?;254+ Ok(ConfigValidateV1 {255+ schema_version: RESULT_VERSION,256+ base_revision: loaded.revision,257+ candidate_revision: ConfigRevision::from_bytes(&candidate.bytes),258+ changed: candidate.bytes != loaded.bytes,259+ settings: project_settings(&candidate.config)?,260+ })261+}262+263+pub fn set(config_path: &Path, request: &ConfigRequestV1) -> ManagementResult<ConfigSetV1> {264+ let bootstrap = load_config(config_path)?;265+ let state_directory = bootstrap.config.state_path.clone();266+ bootstrap267+ .config268+ .ensure_runtime_directory()269+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;270+ let (_lifecycle, _config_lock) = acquire_management_locks(&state_directory)?;271+272+ let mut loaded = load_config(config_path)?;273+ ensure_bootstrap_paths_unchanged(&bootstrap, &loaded)?;274+ let recovered = recover(config_path, &mut loaded)?;275+ if recovered.journal.is_some() {276+ return Err(ManagementError::new(277+ ManagementErrorCode::TransactionPending,278+ anyhow::anyhow!("a configuration transaction is already pending"),279+ ));280+ }281+ loaded = load_config(config_path)?;282+ ensure_bootstrap_paths_unchanged(&bootstrap, &loaded)?;283+ require_expected_revision(&loaded, request)?;284+ let candidate = build_candidate(&loaded, request)?;285+ let candidate_revision = ConfigRevision::from_bytes(&candidate.bytes);286+ if candidate.bytes == loaded.bytes {287+ return Ok(ConfigSetV1 {288+ schema_version: RESULT_VERSION,289+ transaction: None,290+ base_revision: loaded.revision.clone(),291+ candidate_revision,292+ changed: false,293+ restart_required: false,294+ });295+ }296+297+ let host = identity::load(&loaded.config.identity_path)298+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?299+ .public();300+ let baseline_runtime_instance = appliance::latest_runtime_instance(host)301+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;302+303+ write_artifact(&state_directory, BASE_FILE, &loaded.bytes)?;304+ write_artifact(&state_directory, CANDIDATE_FILE, &candidate.bytes)?;305+ let mut journal = TransactionJournalV1 {306+ version: JOURNAL_VERSION,307+ transaction: random_transaction_id()?,308+ state: TransactionState::Prepared,309+ config_path_id: config_path_id(config_path)?,310+ base_revision: loaded.revision.clone(),311+ candidate_revision: candidate_revision.clone(),312+ baseline_runtime_instance,313+ };314+ write_journal(&state_directory, &journal)?;315+ replace_config(config_path, &candidate.bytes)?;316+ let installed = load_config(config_path)?;317+ if installed.revision != candidate_revision {318+ return Err(ManagementError::new(319+ ManagementErrorCode::TransactionConflict,320+ anyhow::anyhow!("installed configuration revision changed unexpectedly"),321+ ));322+ }323+ journal.state = TransactionState::PendingValidation;324+ write_journal(&state_directory, &journal)?;325+ Ok(ConfigSetV1 {326+ schema_version: RESULT_VERSION,327+ transaction: Some(journal.transaction),328+ base_revision: loaded.revision,329+ candidate_revision,330+ changed: true,331+ restart_required: true,332+ })333+}334+335+pub fn commit(336+ config_path: &Path,337+ transaction: &str,338+ expected_instance: &str,339+) -> ManagementResult<ConfigCommitV1> {340+ validate_transaction_id(transaction)?;341+ validate_transaction_id(expected_instance)?;342+ let bootstrap = load_config(config_path)?;343+ let state_directory = bootstrap.config.state_path.clone();344+ let (_lifecycle, _config_lock) = acquire_management_locks(&state_directory)?;345+ let mut loaded = load_config(config_path)?;346+ ensure_bootstrap_paths_unchanged(&bootstrap, &loaded)?;347+ let recovered = recover(config_path, &mut loaded)?;348+ if let Some(RecoveryCompletion::Committed(journal)) = recovered.completion {349+ require_transaction(&journal, transaction)?;350+ return Ok(ConfigCommitV1 {351+ schema_version: RESULT_VERSION,352+ operation: "config_commit",353+ transaction: journal.transaction,354+ revision: journal.candidate_revision,355+ });356+ }357+ let mut journal = recovered.journal.ok_or_else(|| {358+ ManagementError::new(359+ ManagementErrorCode::TransactionNotFound,360+ anyhow::anyhow!("no configuration transaction is pending"),361+ )362+ })?;363+ require_transaction(&journal, transaction)?;364+ if journal.state != TransactionState::PendingValidation365+ || loaded.revision != journal.candidate_revision366+ {367+ return Err(transaction_conflict("candidate is not pending validation"));368+ }369+ if journal.baseline_runtime_instance.as_deref() == Some(expected_instance) {370+ return Err(ManagementError::new(371+ ManagementErrorCode::HealthNotProven,372+ anyhow::anyhow!("candidate validation requires a new daemon instance"),373+ ));374+ }375+ let secret = identity::load(&loaded.config.identity_path)376+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;377+ appliance::validate_candidate_runtime(378+ secret.public(),379+ &journal.candidate_revision,380+ expected_instance,381+ )382+ .map_err(|error| ManagementError::new(ManagementErrorCode::HealthNotProven, error))?;383+384+ journal.state = TransactionState::Committing;385+ write_journal(&state_directory, &journal)?;386+ cleanup_transaction(&state_directory)?;387+ Ok(ConfigCommitV1 {388+ schema_version: RESULT_VERSION,389+ operation: "config_commit",390+ transaction: journal.transaction,391+ revision: journal.candidate_revision,392+ })393+}394+395+pub fn rollback(config_path: &Path, transaction: &str) -> ManagementResult<ConfigRollbackV1> {396+ validate_transaction_id(transaction)?;397+ let bootstrap = load_config(config_path)?;398+ let state_directory = bootstrap.config.state_path.clone();399+ let (_lifecycle, _config_lock) = acquire_management_locks(&state_directory)?;400+ let mut loaded = load_config(config_path)?;401+ ensure_bootstrap_paths_unchanged(&bootstrap, &loaded)?;402+ let recovered = recover(config_path, &mut loaded)?;403+ if let Some(RecoveryCompletion::RolledBack(journal)) = recovered.completion {404+ require_transaction(&journal, transaction)?;405+ return Ok(ConfigRollbackV1 {406+ schema_version: RESULT_VERSION,407+ operation: "config_rollback",408+ transaction: journal.transaction,409+ restored_revision: journal.base_revision,410+ });411+ }412+ let mut journal = recovered.journal.ok_or_else(|| {413+ ManagementError::new(414+ ManagementErrorCode::TransactionNotFound,415+ anyhow::anyhow!("no configuration transaction is pending"),416+ )417+ })?;418+ require_transaction(&journal, transaction)?;419+ if journal.state != TransactionState::PendingValidation420+ || loaded.revision != journal.candidate_revision421+ {422+ return Err(transaction_conflict("candidate cannot be rolled back"));423+ }424+ journal.state = TransactionState::RollingBack;425+ write_journal(&state_directory, &journal)?;426+ let base = read_artifact(&state_directory, BASE_FILE, &journal.base_revision)?;427+ replace_config(config_path, &base)?;428+ loaded = load_config(config_path)?;429+ if loaded.revision != journal.base_revision {430+ return Err(transaction_conflict("base configuration was not restored"));431+ }432+ cleanup_transaction(&state_directory)?;433+ Ok(ConfigRollbackV1 {434+ schema_version: RESULT_VERSION,435+ operation: "config_rollback",436+ transaction: journal.transaction,437+ restored_revision: journal.base_revision,438+ })439+}440+441+pub fn inspect_transaction(442+ loaded: &LoadedHostConfig,443+ config_path: &Path,444+) -> ManagementResult<Option<ConfigTransactionSummaryV1>> {445+ let state_directory = &loaded.config.state_path;446+ match fs::symlink_metadata(state_directory) {447+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),448+ Err(error) => {449+ return Err(ManagementError::new(450+ ManagementErrorCode::UnsafeStorage,451+ error,452+ ));453+ }454+ Ok(_) => {}455+ }456+ let Some(journal) = read_journal(state_directory)? else {457+ return Ok(None);458+ };459+ validate_journal(&journal, config_path)?;460+ if loaded.revision != journal.base_revision && loaded.revision != journal.candidate_revision {461+ return Err(transaction_conflict(462+ "live configuration conflicts with its transaction",463+ ));464+ }465+ Ok(Some(ConfigTransactionSummaryV1 {466+ transaction: journal.transaction,467+ state: journal.state,468+ base_revision: journal.base_revision,469+ candidate_revision: journal.candidate_revision,470+ }))471+}472+473+fn recover(config_path: &Path, loaded: &mut LoadedHostConfig) -> ManagementResult<RecoveryOutcome> {474+ let state_directory = loaded.config.state_path.clone();475+ let Some(mut journal) = read_journal(&state_directory)? else {476+ remove_artifact_if_present(&state_directory, BASE_FILE)?;477+ remove_artifact_if_present(&state_directory, CANDIDATE_FILE)?;478+ return Ok(RecoveryOutcome {479+ journal: None,480+ completion: None,481+ });482+ };483+ validate_journal(&journal, config_path)?;484+ match journal.state {485+ TransactionState::Prepared => {486+ let _base = read_artifact(&state_directory, BASE_FILE, &journal.base_revision)?;487+ let candidate = read_artifact(488+ &state_directory,489+ CANDIDATE_FILE,490+ &journal.candidate_revision,491+ )?;492+ if loaded.revision == journal.base_revision {493+ replace_config(config_path, &candidate)?;494+ *loaded = load_config(config_path)?;495+ }496+ if loaded.revision != journal.candidate_revision {497+ return Err(transaction_conflict(498+ "prepared transaction has conflicting live bytes",499+ ));500+ }501+ journal.state = TransactionState::PendingValidation;502+ write_journal(&state_directory, &journal)?;503+ Ok(RecoveryOutcome {504+ journal: Some(journal),505+ completion: None,506+ })507+ }508+ TransactionState::PendingValidation => {509+ let _base = read_artifact(&state_directory, BASE_FILE, &journal.base_revision)?;510+ let _candidate = read_artifact(511+ &state_directory,512+ CANDIDATE_FILE,513+ &journal.candidate_revision,514+ )?;515+ if loaded.revision != journal.candidate_revision {516+ return Err(transaction_conflict(517+ "pending transaction has conflicting live bytes",518+ ));519+ }520+ Ok(RecoveryOutcome {521+ journal: Some(journal),522+ completion: None,523+ })524+ }525+ TransactionState::Committing => {526+ if loaded.revision != journal.candidate_revision {527+ return Err(transaction_conflict(528+ "committing transaction has conflicting live bytes",529+ ));530+ }531+ cleanup_transaction(&state_directory)?;532+ Ok(RecoveryOutcome {533+ journal: None,534+ completion: Some(RecoveryCompletion::Committed(journal)),535+ })536+ }537+ TransactionState::RollingBack => {538+ if loaded.revision == journal.candidate_revision {539+ let base = read_artifact(&state_directory, BASE_FILE, &journal.base_revision)?;540+ replace_config(config_path, &base)?;541+ *loaded = load_config(config_path)?;542+ }543+ if loaded.revision != journal.base_revision {544+ return Err(transaction_conflict(545+ "rolling-back transaction has conflicting live bytes",546+ ));547+ }548+ cleanup_transaction(&state_directory)?;549+ Ok(RecoveryOutcome {550+ journal: None,551+ completion: Some(RecoveryCompletion::RolledBack(journal)),552+ })553+ }554+ }555+}556+557+fn load_config(path: &Path) -> ManagementResult<LoadedHostConfig> {558+ HostConfig::load_document(path)559+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))560+}561+562+pub fn prepare_service(config_path: &Path) -> ManagementResult<(LoadedHostConfig, LifecycleGuard)> {563+ let bootstrap = load_config(config_path)?;564+ let state_directory = bootstrap.config.state_path.clone();565+ bootstrap566+ .config567+ .ensure_runtime_directory()568+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;569+ let lifecycle = match LifecycleGuard::try_acquire(&state_directory, true) {570+ Ok(lifecycle) => lifecycle,571+ Err(error) => return Err(classify_lifecycle_error(&state_directory, error)),572+ };573+ finish_service_prepare(config_path, &bootstrap, lifecycle)574+}575+576+fn finish_service_prepare(577+ config_path: &Path,578+ bootstrap: &LoadedHostConfig,579+ lifecycle: LifecycleGuard,580+) -> ManagementResult<(LoadedHostConfig, LifecycleGuard)> {581+ let state_directory = bootstrap.config.state_path.clone();582+ let _config_lock = classify_config_lock(secure_state::try_open_lifetime_lock(583+ &state_directory,584+ CONFIG_LOCK_FILE,585+ ))?;586+ let mut loaded = load_config(config_path)?;587+ ensure_bootstrap_paths_unchanged(bootstrap, &loaded)?;588+ recover(config_path, &mut loaded)?;589+ loaded = load_config(config_path)?;590+ ensure_bootstrap_paths_unchanged(bootstrap, &loaded)?;591+ Ok((loaded, lifecycle))592+}593+594+fn acquire_management_locks(595+ state_directory: &Path,596+) -> ManagementResult<(LifecycleGuard, fs::File)> {597+ let lifecycle = match LifecycleGuard::try_acquire(state_directory, true) {598+ Ok(lifecycle) => lifecycle,599+ Err(error) => return Err(classify_lifecycle_error(state_directory, error)),600+ };601+ let config_lock = classify_config_lock(secure_state::try_open_lifetime_lock(602+ state_directory,603+ CONFIG_LOCK_FILE,604+ ))?;605+ Ok((lifecycle, config_lock))606+}607+608+fn classify_lifecycle_error(609+ _state_directory: &Path,610+ error: secure_state::LockAcquireError,611+) -> ManagementError {612+ match error {613+ secure_state::LockAcquireError::Unsafe(error) => {614+ ManagementError::new(ManagementErrorCode::UnsafeStorage, error)615+ }616+ secure_state::LockAcquireError::Busy => ManagementError::new(617+ ManagementErrorCode::LifecycleBusy,618+ anyhow::anyhow!("Sigil lifecycle lock is held"),619+ ),620+ }621+}622+623+fn classify_config_lock(624+ result: std::result::Result<fs::File, secure_state::LockAcquireError>,625+) -> ManagementResult<fs::File> {626+ result.map_err(|error| match error {627+ secure_state::LockAcquireError::Busy => ManagementError::new(628+ ManagementErrorCode::TransactionBusy,629+ anyhow::anyhow!("another configuration transaction is active"),630+ ),631+ secure_state::LockAcquireError::Unsafe(error) => {632+ ManagementError::new(ManagementErrorCode::UnsafeStorage, error)633+ }634+ })635+}636+637+fn require_expected_revision(638+ loaded: &LoadedHostConfig,639+ request: &ConfigRequestV1,640+) -> ManagementResult<()> {641+ ConfigRevision::parse(request.expected_revision.as_str())642+ .map_err(|error| ManagementError::new(ManagementErrorCode::InvalidRequest, error))?;643+ if loaded.revision != request.expected_revision {644+ return Err(ManagementError::new(645+ ManagementErrorCode::RevisionConflict,646+ anyhow::anyhow!("configuration revision changed"),647+ ));648+ }649+ Ok(())650+}651+652+fn build_candidate(653+ loaded: &LoadedHostConfig,654+ request: &ConfigRequestV1,655+) -> ManagementResult<LoadedHostConfig> {656+ let source = std::str::from_utf8(&loaded.bytes)657+ .map_err(|error| ManagementError::new(ManagementErrorCode::ValidationFailed, error))?;658+ let mut document = source659+ .parse::<DocumentMut>()660+ .map_err(|error| ManagementError::new(ManagementErrorCode::ValidationFailed, error))?;661+ document["framerate"] = value(i64::from(request.settings.framerate));662+ match request.settings.resolution {663+ ManagedResolutionV1::Native => {664+ document.remove("width");665+ document.remove("height");666+ }667+ ManagedResolutionV1::Fixed { width, height } => {668+ document["width"] = value(i64::from(width));669+ document["height"] = value(i64::from(height));670+ }671+ }672+ match (673+ loaded.config.source.clone(),674+ request.settings.rate_control.clone(),675+ ) {676+ (VideoSource::TestPattern, None) => {}677+ (VideoSource::GamescopePipewire, Some(rate_control)) => {678+ let table = document679+ .get_mut("gamescope_pipewire")680+ .and_then(toml_edit::Item::as_table_mut)681+ .ok_or_else(|| {682+ ManagementError::new(683+ ManagementErrorCode::ValidationFailed,684+ anyhow::anyhow!("gamescope configuration table is missing"),685+ )686+ })?;687+ match rate_control {688+ ManagedRateControlV1::Cbr { bitrate_kbps } => {689+ table["rate_control"] = value("cbr");690+ table["bitrate_kbps"] = value(i64::from(bitrate_kbps));691+ table.remove("quantizer");692+ }693+ ManagedRateControlV1::Cqp { quantizer } => {694+ table["rate_control"] = value("cqp");695+ table["quantizer"] = value(i64::from(quantizer));696+ table.remove("bitrate_kbps");697+ }698+ }699+ }700+ _ => {701+ return Err(ManagementError::new(702+ ManagementErrorCode::ValidationFailed,703+ anyhow::anyhow!("rate-control settings do not match the configured source"),704+ ));705+ }706+ }707+ let bytes = document.to_string().into_bytes();708+ let config = HostConfig::parse(&bytes)709+ .map_err(|error| ManagementError::new(ManagementErrorCode::ValidationFailed, error))?;710+ if config.identity_path != loaded.config.identity_path711+ || config.state_path != loaded.config.state_path712+ {713+ return Err(ManagementError::new(714+ ManagementErrorCode::ValidationFailed,715+ anyhow::anyhow!("managed configuration cannot change identity or state paths"),716+ ));717+ }718+ Ok(LoadedHostConfig {719+ revision: ConfigRevision::from_bytes(&bytes),720+ bytes,721+ config,722+ })723+}724+725+fn project_settings(config: &HostConfig) -> ManagementResult<ManagedSettingsV1> {726+ let resolution = match config.configured_dimensions() {727+ Some((width, height)) => ManagedResolutionV1::Fixed { width, height },728+ None => ManagedResolutionV1::Native,729+ };730+ let rate_control = match (&config.source, &config.gamescope_pipewire) {731+ (VideoSource::TestPattern, None) => None,732+ (VideoSource::GamescopePipewire, Some(gamescope)) => Some(match gamescope.rate_control {733+ VaapiRateControl::Cbr => ManagedRateControlV1::Cbr {734+ bitrate_kbps: gamescope735+ .bitrate_kbps736+ .expect("validated CBR bitrate is present"),737+ },738+ VaapiRateControl::Cqp => ManagedRateControlV1::Cqp {739+ quantizer: gamescope740+ .quantizer741+ .expect("validated CQP quantizer is present"),742+ },743+ }),744+ _ => {745+ return Err(ManagementError::new(746+ ManagementErrorCode::ValidationFailed,747+ anyhow::anyhow!("validated source configuration is inconsistent"),748+ ));749+ }750+ };751+ Ok(ManagedSettingsV1 {752+ resolution,753+ framerate: config.framerate,754+ rate_control,755+ })756+}757+758+fn ensure_bootstrap_paths_unchanged(759+ bootstrap: &LoadedHostConfig,760+ current: &LoadedHostConfig,761+) -> ManagementResult<()> {762+ if bootstrap.config.state_path != current.config.state_path763+ || bootstrap.config.identity_path != current.config.identity_path764+ {765+ return Err(transaction_conflict(766+ "configuration identity or state path changed while acquiring locks",767+ ));768+ }769+ Ok(())770+}771+772+fn replace_config(config_path: &Path, bytes: &[u8]) -> ManagementResult<()> {773+ let parent = config_path774+ .parent()775+ .filter(|path| !path.as_os_str().is_empty())776+ .unwrap_or(Path::new("."));777+ let file_name = config_path778+ .file_name()779+ .and_then(|name| name.to_str())780+ .ok_or_else(|| {781+ ManagementError::new(782+ ManagementErrorCode::UnsafeStorage,783+ anyhow::anyhow!("config filename is invalid"),784+ )785+ })?;786+ secure_state::atomic_write_exact(parent, file_name, bytes, MAX_CONFIG_BYTES)787+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))788+}789+790+fn write_artifact(state_directory: &Path, file_name: &str, bytes: &[u8]) -> ManagementResult<()> {791+ secure_state::atomic_write_exact(state_directory, file_name, bytes, MAX_CONFIG_BYTES)792+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))793+}794+795+fn read_artifact(796+ state_directory: &Path,797+ file_name: &str,798+ expected_revision: &ConfigRevision,799+) -> ManagementResult<Vec<u8>> {800+ let bytes = secure_state::read_bounded(state_directory, file_name, MAX_CONFIG_BYTES)801+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?802+ .ok_or_else(|| transaction_conflict("configuration recovery artifact is missing"))?;803+ if ConfigRevision::from_bytes(&bytes) != *expected_revision {804+ return Err(transaction_conflict(805+ "configuration recovery artifact revision does not match its journal",806+ ));807+ }808+ Ok(bytes)809+}810+811+fn write_journal(state_directory: &Path, journal: &TransactionJournalV1) -> ManagementResult<()> {812+ let bytes = serde_json::to_vec(journal)813+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;814+ secure_state::atomic_write(state_directory, JOURNAL_FILE, &bytes, MAX_JOURNAL_BYTES)815+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))816+}817+818+fn read_journal(state_directory: &Path) -> ManagementResult<Option<TransactionJournalV1>> {819+ let Some(bytes) = secure_state::read_bounded(state_directory, JOURNAL_FILE, MAX_JOURNAL_BYTES)820+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?821+ else {822+ return Ok(None);823+ };824+ let journal: TransactionJournalV1 = serde_json::from_slice(&bytes)825+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;826+ Ok(Some(journal))827+}828+829+fn validate_journal(journal: &TransactionJournalV1, config_path: &Path) -> ManagementResult<()> {830+ if journal.version != JOURNAL_VERSION {831+ return Err(ManagementError::new(832+ ManagementErrorCode::UnsupportedSchema,833+ anyhow::anyhow!("unsupported configuration transaction journal"),834+ ));835+ }836+ validate_stored_id(&journal.transaction)?;837+ ConfigRevision::parse(journal.base_revision.as_str())838+ .and_then(|_| ConfigRevision::parse(journal.candidate_revision.as_str()))839+ .and_then(|_| ConfigRevision::parse(journal.config_path_id.as_str()))840+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;841+ if journal.config_path_id != config_path_id(config_path)? {842+ return Err(transaction_conflict(843+ "configuration transaction belongs to a different config file",844+ ));845+ }846+ if journal.base_revision == journal.candidate_revision {847+ return Err(ManagementError::new(848+ ManagementErrorCode::UnsafeStorage,849+ anyhow::anyhow!("transaction revisions must differ"),850+ ));851+ }852+ if let Some(instance) = &journal.baseline_runtime_instance {853+ validate_stored_id(instance)?;854+ }855+ Ok(())856+}857+858+fn config_path_id(path: &Path) -> ManagementResult<ConfigRevision> {859+ let canonical = path860+ .canonicalize()861+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;862+ let value = canonical.to_str().ok_or_else(|| {863+ ManagementError::new(864+ ManagementErrorCode::UnsafeStorage,865+ anyhow::anyhow!("configuration path is not valid UTF-8"),866+ )867+ })?;868+ Ok(ConfigRevision::from_bytes(value.as_bytes()))869+}870+871+fn require_transaction(journal: &TransactionJournalV1, expected: &str) -> ManagementResult<()> {872+ if journal.transaction != expected {873+ return Err(transaction_conflict(874+ "configuration transaction ID does not match",875+ ));876+ }877+ Ok(())878+}879+880+fn validate_transaction_id(value: &str) -> ManagementResult<()> {881+ if value.len() != 32882+ || !value883+ .bytes()884+ .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())885+ {886+ return Err(ManagementError::new(887+ ManagementErrorCode::InvalidRequest,888+ anyhow::anyhow!("transaction identifier is invalid"),889+ ));890+ }891+ Ok(())892+}893+894+fn validate_stored_id(value: &str) -> ManagementResult<()> {895+ validate_transaction_id(value)896+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error.source))897+}898+899+fn random_transaction_id() -> ManagementResult<String> {900+ let mut bytes = [0_u8; 16];901+ getrandom::fill(&mut bytes)902+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;903+ let mut value = String::with_capacity(32);904+ for byte in bytes {905+ use std::fmt::Write as _;906+ write!(&mut value, "{byte:02x}").expect("writing into a String cannot fail");907+ }908+ Ok(value)909+}910+911+fn cleanup_transaction(state_directory: &Path) -> ManagementResult<()> {912+ remove_artifact_if_present(state_directory, BASE_FILE)?;913+ remove_artifact_if_present(state_directory, CANDIDATE_FILE)?;914+ remove_artifact_if_present(state_directory, JOURNAL_FILE)?;915+ Ok(())916+}917+918+fn remove_artifact_if_present(state_directory: &Path, file_name: &str) -> ManagementResult<()> {919+ secure_state::remove_file_if_exists(state_directory, file_name)920+ .map(|_| ())921+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))922+}923+924+fn transaction_conflict(message: &'static str) -> ManagementError {925+ ManagementError::new(926+ ManagementErrorCode::TransactionConflict,927+ anyhow::anyhow!(message),928+ )929+}930+931+#[cfg(test)]932+mod tests {933+ use super::*;934+935+ struct Fixture {936+ _directory: tempfile::TempDir,937+ config_path: std::path::PathBuf,938+ state_directory: std::path::PathBuf,939+ base: LoadedHostConfig,940+ }941+942+ impl Fixture {943+ fn new() -> Self {944+ let directory = tempfile::tempdir().unwrap();945+ #[cfg(unix)]946+ {947+ use std::os::unix::fs::PermissionsExt as _;948+ fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();949+ }950+ let state_directory = directory.path().join("state");951+ secure_state::ensure_private_directory(&state_directory).unwrap();952+ let config_path = directory.path().join("host.toml");953+ let identity_path = directory.path().join("host.key");954+ let bytes = format!(955+ "# retained operator comment\nidentity_path = {:?}\nstate_path = {:?}\nsource = \"test-pattern\"\nwidth = 1280\nheight = 800\nframerate = 60\ncodec = \"h264\"\n",956+ identity_path, state_directory957+ )958+ .into_bytes();959+ fs::write(&config_path, bytes).unwrap();960+ let base = HostConfig::load_document(&config_path).unwrap();961+ Self {962+ _directory: directory,963+ config_path,964+ state_directory,965+ base,966+ }967+ }968+969+ fn request(&self, framerate: u32) -> ConfigRequestV1 {970+ ConfigRequestV1 {971+ _schema_version: REQUEST_VERSION,972+ expected_revision: self.base.revision.clone(),973+ settings: ManagedSettingsV1 {974+ resolution: ManagedResolutionV1::Native,975+ framerate,976+ rate_control: None,977+ },978+ }979+ }980+981+ fn prepared(&self, state: TransactionState) -> (TransactionJournalV1, LoadedHostConfig) {982+ let candidate = build_candidate(&self.base, &self.request(72)).unwrap();983+ write_artifact(&self.state_directory, BASE_FILE, &self.base.bytes).unwrap();984+ write_artifact(&self.state_directory, CANDIDATE_FILE, &candidate.bytes).unwrap();985+ let journal = TransactionJournalV1 {986+ version: JOURNAL_VERSION,987+ transaction: "0123456789abcdef0123456789abcdef".to_owned(),988+ state,989+ config_path_id: config_path_id(&self.config_path).unwrap(),990+ base_revision: self.base.revision.clone(),991+ candidate_revision: candidate.revision.clone(),992+ baseline_runtime_instance: None,993+ };994+ write_journal(&self.state_directory, &journal).unwrap();995+ (journal, candidate)996+ }997+ }998+999+ #[test]1000+ fn bounded_request_rejects_unknown_fields_and_oversize_input() {1001+ let unknown = br#"{"schema_version":1,"expected_revision":"sha256:0000000000000000000000000000000000000000000000000000000000000000","settings":{"resolution":{"mode":"native"},"framerate":60,"rate_control":null},"unexpected":true}"#;1002+ assert_eq!(1003+ read_request(&unknown[..]).unwrap_err().code,1004+ ManagementErrorCode::InvalidRequest1005+ );1006+ assert_eq!(1007+ read_request(&vec![b' '; MAX_REQUEST_BYTES as usize + 1][..])1008+ .unwrap_err()1009+ .code,1010+ ManagementErrorCode::InvalidRequest1011+ );1012+ let future = br#"{"schema_version":2,"future_shape":{"anything":true}}"#;1013+ assert_eq!(1014+ read_request(&future[..]).unwrap_err().code,1015+ ManagementErrorCode::UnsupportedSchema1016+ );1017+ let duplicate = br#"{"schema_version":1,"schema_version":2,"expected_revision":"sha256:0000000000000000000000000000000000000000000000000000000000000000","settings":{"resolution":{"mode":"native"},"framerate":60,"rate_control":null}}"#;1018+ assert_eq!(1019+ read_request(&duplicate[..]).unwrap_err().code,1020+ ManagementErrorCode::InvalidRequest1021+ );1022+ let duplicate_expected = br#"{"schema_version":1,"expected_revision":"sha256:0000000000000000000000000000000000000000000000000000000000000000","expected_revision":"sha256:1111111111111111111111111111111111111111111111111111111111111111","settings":{"resolution":{"mode":"native"},"framerate":60,"rate_control":null}}"#;1023+ assert_eq!(1024+ read_request(&duplicate_expected[..]).unwrap_err().code,1025+ ManagementErrorCode::InvalidRequest1026+ );1027+ }1028+1029+ #[test]1030+ fn candidate_preserves_comments_and_immutable_paths() {1031+ let fixture = Fixture::new();1032+ let candidate = build_candidate(&fixture.base, &fixture.request(72)).unwrap();1033+ let text = std::str::from_utf8(&candidate.bytes).unwrap();1034+ assert!(text.contains("# retained operator comment"));1035+ assert!(!text.contains("width ="));1036+ assert!(!text.contains("height ="));1037+ assert!(text.contains("framerate = 72"));1038+ assert_eq!(1039+ candidate.config.identity_path,1040+ fixture.base.config.identity_path1041+ );1042+ assert_eq!(candidate.config.state_path, fixture.base.config.state_path);1043+ }1044+1045+ #[test]1046+ fn prepared_recovery_forward_applies_the_exact_candidate() {1047+ let fixture = Fixture::new();1048+ let (journal, candidate) = fixture.prepared(TransactionState::Prepared);1049+ let mut loaded = fixture.base.clone();1050+ let outcome = recover(&fixture.config_path, &mut loaded).unwrap();1051+ assert_eq!(loaded.bytes, candidate.bytes);1052+ assert_eq!(loaded.revision, journal.candidate_revision);1053+ assert_eq!(1054+ outcome.journal.unwrap().state,1055+ TransactionState::PendingValidation1056+ );1057+ }1058+1059+ #[test]1060+ fn configured_service_recovers_before_using_its_definitive_reload() {1061+ let fixture = Fixture::new();1062+ let (_journal, candidate) = fixture.prepared(TransactionState::Prepared);1063+ let runtime = tempfile::tempdir().unwrap();1064+ #[cfg(unix)]1065+ {1066+ use std::os::unix::fs::PermissionsExt as _;1067+ fs::set_permissions(runtime.path(), fs::Permissions::from_mode(0o700)).unwrap();1068+ }1069+ let lifecycle =1070+ LifecycleGuard::try_acquire_at(&fixture.state_directory, Some(runtime.path())).unwrap();1071+ let (loaded, _lifecycle) =1072+ finish_service_prepare(&fixture.config_path, &fixture.base, lifecycle).unwrap();1073+ assert_eq!(loaded.revision, candidate.revision);1074+ assert_eq!(loaded.bytes, candidate.bytes);1075+ assert_eq!(1076+ read_journal(&fixture.state_directory)1077+ .unwrap()1078+ .unwrap()1079+ .state,1080+ TransactionState::PendingValidation1081+ );1082+ }1083+1084+ #[test]1085+ fn pending_recovery_rejects_untracked_live_bytes() {1086+ let fixture = Fixture::new();1087+ fixture.prepared(TransactionState::PendingValidation);1088+ let conflicting = fixture1089+ .base1090+ .bytes1091+ .replace_range_owned(b"framerate = 60", b"framerate = 90");1092+ replace_config(&fixture.config_path, &conflicting).unwrap();1093+ let mut loaded = load_config(&fixture.config_path).unwrap();1094+ let Err(error) = recover(&fixture.config_path, &mut loaded) else {1095+ panic!("untracked live bytes must fail closed");1096+ };1097+ assert_eq!(error.code, ManagementErrorCode::TransactionConflict);1098+ }1099+1100+ #[test]1101+ fn rolling_back_recovery_finishes_after_base_restore_and_partial_cleanup() {1102+ let fixture = Fixture::new();1103+ let (journal, _candidate) = fixture.prepared(TransactionState::RollingBack);1104+ replace_config(&fixture.config_path, &fixture.base.bytes).unwrap();1105+ secure_state::remove_file_if_exists(&fixture.state_directory, BASE_FILE).unwrap();1106+ let mut loaded = load_config(&fixture.config_path).unwrap();1107+ let outcome = recover(&fixture.config_path, &mut loaded).unwrap();1108+ assert_eq!(loaded.revision, fixture.base.revision);1109+ assert!(matches!(1110+ outcome.completion,1111+ Some(RecoveryCompletion::RolledBack(completed))1112+ if completed.transaction == journal.transaction1113+ ));1114+ assert!(read_journal(&fixture.state_directory).unwrap().is_none());1115+ assert!(1116+ secure_state::read_bounded(&fixture.state_directory, CANDIDATE_FILE, MAX_CONFIG_BYTES)1117+ .unwrap()1118+ .is_none()1119+ );1120+ }1121+1122+ #[test]1123+ fn committing_recovery_finishes_cleanup_without_artifacts() {1124+ let fixture = Fixture::new();1125+ let (journal, candidate) = fixture.prepared(TransactionState::Committing);1126+ replace_config(&fixture.config_path, &candidate.bytes).unwrap();1127+ secure_state::remove_file_if_exists(&fixture.state_directory, BASE_FILE).unwrap();1128+ secure_state::remove_file_if_exists(&fixture.state_directory, CANDIDATE_FILE).unwrap();1129+ let mut loaded = load_config(&fixture.config_path).unwrap();1130+ let outcome = recover(&fixture.config_path, &mut loaded).unwrap();1131+ assert_eq!(loaded.revision, candidate.revision);1132+ assert!(matches!(1133+ outcome.completion,1134+ Some(RecoveryCompletion::Committed(completed))1135+ if completed.transaction == journal.transaction1136+ ));1137+ assert!(read_journal(&fixture.state_directory).unwrap().is_none());1138+ }1139+1140+ #[test]1141+ fn malformed_journal_identifier_is_unsafe_storage_not_caller_input() {1142+ let fixture = Fixture::new();1143+ let (mut journal, _candidate) = fixture.prepared(TransactionState::Prepared);1144+ journal.transaction = "NOT-A-TRANSACTION-ID".to_owned();1145+ write_journal(&fixture.state_directory, &journal).unwrap();1146+ let mut loaded = fixture.base.clone();1147+ let Err(error) = recover(&fixture.config_path, &mut loaded) else {1148+ panic!("malformed protected journal must fail closed");1149+ };1150+ assert_eq!(error.code, ManagementErrorCode::UnsafeStorage);1151+ }1152+1153+ #[test]1154+ fn lock_failures_do_not_probe_or_invert_lock_order() {1155+ let fixture = Fixture::new();1156+ assert_eq!(1157+ classify_lifecycle_error(1158+ &fixture.state_directory,1159+ secure_state::LockAcquireError::Busy,1160+ )1161+ .code,1162+ ManagementErrorCode::LifecycleBusy1163+ );1164+1165+ #[cfg(unix)]1166+ {1167+ use std::os::unix::fs::PermissionsExt as _;1168+ fs::set_permissions(&fixture.state_directory, fs::Permissions::from_mode(0o755))1169+ .unwrap();1170+ assert_eq!(1171+ classify_config_lock(secure_state::try_open_lifetime_lock(1172+ &fixture.state_directory,1173+ CONFIG_LOCK_FILE,1174+ ))1175+ .unwrap_err()1176+ .code,1177+ ManagementErrorCode::UnsafeStorage1178+ );1179+ }1180+ }1181+1182+ trait ReplaceRangeOwned {1183+ fn replace_range_owned(&self, from: &[u8], to: &[u8]) -> Vec<u8>;1184+ }1185+1186+ impl ReplaceRangeOwned for Vec<u8> {1187+ fn replace_range_owned(&self, from: &[u8], to: &[u8]) -> Vec<u8> {1188+ let offset = self1189+ .windows(from.len())1190+ .position(|window| window == from)1191+ .unwrap();1192+ let mut result = self.clone();1193+ result.splice(offset..offset + from.len(), to.iter().copied());1194+ result1195+ }1196+ }1197+}
M crates/sigil-host/src/main.rs +269 -35
@@ -3,6 +3,7 @@ mod audio;
33 mod authorization;44 mod clock;55 mod config;6+mod config_management;67 mod cursor;78 mod identity;89 mod input;
@@ -14,6 +15,7 @@ mod source;
1415 use std::fs::OpenOptions;1516 use std::io::Write as _;1617 use std::path::{Path, PathBuf};18+use std::process::ExitCode;1719 use std::sync::Arc;1820 use std::time::{Duration, Instant};1921
@@ -36,7 +38,7 @@ use tracing_subscriber::EnvFilter;
3638 use crate::authorization::{3739 AuthorizationPolicy, AuthorizationStore, grant_names, unix_timestamp_now,3840 };39-use crate::config::{HostConfig, InputMode, VideoSource};41+use crate::config::{ConfigRevision, HostConfig, InputMode, VideoSource};4042 use crate::cursor::PointerPositionTracker;4143 use crate::input::InputBackend;4244 use crate::server::{
@@ -99,6 +101,9 @@ enum ApplianceCommand {
99101 /// Emit the stable machine-readable schema.100102 #[arg(long, required = true)]101103 json: bool,104+ /// Public status schema. Version 1 remains the compatibility default.105+ #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u16).range(1..=2))]106+ schema_version: u16,102107 },103108 /// Revoke the enrolled Portal and invalidate every outstanding invitation.104109 EnrollmentReset {
@@ -111,6 +116,56 @@ enum ApplianceCommand {
111116 #[arg(long, required = true)]112117 json: bool,113118 },119+ /// Inspect or transactionally update the validated host configuration.120+ Config {121+ #[command(subcommand)]122+ command: ApplianceConfigCommand,123+ },124+}125+126+#[derive(Debug, Subcommand)]127+enum ApplianceConfigCommand {128+ /// Show only the controller-editable configuration projection.129+ Show {130+ #[arg(long)]131+ config: PathBuf,132+ #[arg(long, required = true)]133+ json: bool,134+ },135+ /// Validate a bounded JSON request from stdin without writing.136+ Validate {137+ #[arg(long)]138+ config: PathBuf,139+ #[arg(long, required = true)]140+ json: bool,141+ },142+ /// Stage and atomically install a validated candidate while Sigil is stopped.143+ Set {144+ #[arg(long)]145+ config: PathBuf,146+ #[arg(long, required = true)]147+ json: bool,148+ },149+ /// Commit a candidate proven by one exact stopped daemon instance.150+ Commit {151+ #[arg(long)]152+ config: PathBuf,153+ #[arg(long)]154+ transaction: String,155+ #[arg(long)]156+ expected_instance: String,157+ #[arg(long, required = true)]158+ json: bool,159+ },160+ /// Restore the byte-exact prior configuration while Sigil is stopped.161+ Rollback {162+ #[arg(long)]163+ config: PathBuf,164+ #[arg(long)]165+ transaction: String,166+ #[arg(long, required = true)]167+ json: bool,168+ },114169 }115170 116171 #[derive(Debug, Subcommand)]
@@ -247,8 +302,21 @@ struct ServeArgs {
247302 max_runtime_seconds: Option<u64>,248303 }249304 305+enum CliFailure {306+ General(anyhow::Error),307+ Management(config_management::ManagementError),308+}309+310+impl From<anyhow::Error> for CliFailure {311+ fn from(error: anyhow::Error) -> Self {312+ Self::General(error)313+ }314+}315+316+type CliResult<T> = std::result::Result<T, CliFailure>;317+250318 #[tokio::main]251-async fn main() -> Result<()> {319+async fn main() -> ExitCode {252320 tracing_subscriber::fmt()253321 .with_env_filter(254322 EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
@@ -257,30 +325,96 @@ async fn main() -> Result<()> {
257325 .init();258326 server::install_panic_hook();259327 328+ match run().await {329+ Ok(()) => ExitCode::SUCCESS,330+ Err(CliFailure::Management(error)) => {331+ let response = serde_json::to_string(&error.response())332+ .expect("serializing a fixed management error cannot fail");333+ eprintln!("{response}");334+ ExitCode::FAILURE335+ }336+ Err(CliFailure::General(error)) => {337+ eprintln!("error: {error:#}");338+ ExitCode::FAILURE339+ }340+ }341+}342+343+async fn run() -> CliResult<()> {260344 match Cli::parse().command {261345 Command::Appliance { command } => appliance_command(command),262- Command::Identity { command } => identity_command(command),263- Command::Config { command } => config_command(command).await,264- Command::Capture { command } => capture_command(command).await,265- Command::Invitation { command } => invitation_command(command),266- Command::Enrollment { command } => enrollment_command(command),267- Command::Serve(args) => serve_command(args).await,346+ Command::Identity { command } => identity_command(command).map_err(Into::into),347+ Command::Config { command } => config_command(command).await.map_err(Into::into),348+ Command::Capture { command } => capture_command(command).await.map_err(Into::into),349+ Command::Invitation { command } => invitation_command(command).map_err(Into::into),350+ Command::Enrollment { command } => enrollment_command(command).map_err(Into::into),351+ Command::Serve(args) => serve_command(args).await.map_err(Into::into),268352 }269353 }270354 271-fn appliance_command(command: ApplianceCommand) -> Result<()> {355+fn appliance_command(command: ApplianceCommand) -> CliResult<()> {272356 match command {273- ApplianceCommand::Status { config, json: _ } => {274- let status = appliance::collect_status(&config)?;275- println!("{}", serde_json::to_string(&status)?);357+ ApplianceCommand::Status {358+ config,359+ json: _,360+ schema_version,361+ } => {362+ let status = appliance::collect_status(&config).map_err(CliFailure::from)?;363+ println!("{}", appliance::status_json(&status, schema_version)?);276364 }277365 ApplianceCommand::EnrollmentReset {278366 config,279367 expected_host_fingerprint,280368 json: _,281369 } => {282- let result = appliance::reset_enrollment(&config, &expected_host_fingerprint)?;283- println!("{}", serde_json::to_string(&result)?);370+ let result = appliance::reset_enrollment(&config, &expected_host_fingerprint)371+ .map_err(CliFailure::from)?;372+ println!(373+ "{}",374+ serde_json::to_string(&result).map_err(anyhow::Error::from)?375+ );376+ }377+ ApplianceCommand::Config { command } => {378+ let result = match command {379+ ApplianceConfigCommand::Show { config, json: _ } => serde_json::to_value(380+ config_management::show(&config).map_err(CliFailure::Management)?,381+ ),382+ ApplianceConfigCommand::Validate { config, json: _ } => {383+ let request = config_management::read_request(std::io::stdin().lock())384+ .map_err(CliFailure::Management)?;385+ serde_json::to_value(386+ config_management::validate(&config, &request)387+ .map_err(CliFailure::Management)?,388+ )389+ }390+ ApplianceConfigCommand::Set { config, json: _ } => {391+ let request = config_management::read_request(std::io::stdin().lock())392+ .map_err(CliFailure::Management)?;393+ serde_json::to_value(394+ config_management::set(&config, &request)395+ .map_err(CliFailure::Management)?,396+ )397+ }398+ ApplianceConfigCommand::Commit {399+ config,400+ transaction,401+ expected_instance,402+ json: _,403+ } => serde_json::to_value(404+ config_management::commit(&config, &transaction, &expected_instance)405+ .map_err(CliFailure::Management)?,406+ ),407+ ApplianceConfigCommand::Rollback {408+ config,409+ transaction,410+ json: _,411+ } => serde_json::to_value(412+ config_management::rollback(&config, &transaction)413+ .map_err(CliFailure::Management)?,414+ ),415+ }416+ .map_err(anyhow::Error::from)?;417+ println!("{result}");284418 }285419 }286420 Ok(())
@@ -623,17 +757,25 @@ impl Drop for CaptureTaskGuard {
623757 }624758 625759 async fn serve_command(args: ServeArgs) -> Result<()> {626- let mut config = load_serve_config(&args)?;627- config.validate()?;628- config.ensure_runtime_directory()?;629760 let configured_service = args.config.is_some();630- let _lifecycle = appliance::LifecycleGuard::acquire(&config.state_path, configured_service)?;761+ let (mut config, loaded_config_revision, _lifecycle) = if let Some(config_path) = &args.config {762+ let (loaded, lifecycle) =763+ config_management::prepare_service(config_path).map_err(anyhow::Error::new)?;764+ (loaded.config, Some(loaded.revision), lifecycle)765+ } else {766+ let loaded = load_serve_config(&args)?;767+ loaded.config.validate()?;768+ loaded.config.ensure_runtime_directory()?;769+ let lifecycle = appliance::LifecycleGuard::acquire(&loaded.config.state_path, false)?;770+ (loaded.config, loaded.revision, lifecycle)771+ };631772 let secret = identity::load(&config.identity_path)?;632773 let sessions = Arc::new(SessionRegistry::default());633774 let publisher = appliance::RuntimePublisher::start(634775 secret.public(),635776 Arc::clone(&sessions),636777 configured_service,778+ loaded_config_revision,637779 )?;638780 let authorization_result = if configured_service {639781 AuthorizationStore::open(config.state_path.clone(), secret.public())
@@ -903,10 +1045,16 @@ async fn wait_for_shutdown_signal() -> Result<&'static str> {
9031045 }9041046 }9051047 906-fn load_serve_config(args: &ServeArgs) -> Result<HostConfig> {907- if let Some(path) = &args.config {908- return HostConfig::load(path);909- }1048+struct LoadedServeConfig {1049+ config: HostConfig,1050+ revision: Option<ConfigRevision>,1051+}1052+1053+fn load_serve_config(args: &ServeArgs) -> Result<LoadedServeConfig> {1054+ ensure!(1055+ args.config.is_none(),1056+ "configured service loading must hold its lifecycle transaction locks"1057+ );9101058 let identity_path = args9111059 .identity9121060 .clone()
@@ -924,19 +1072,22 @@ fn load_serve_config(args: &ServeArgs) -> Result<HostConfig> {
9241072 .unwrap_or_else(|| Path::new("."))9251073 .join("runtime")9261074 });927- Ok(HostConfig {928- identity_path,929- state_path,930- source: source.into(),931- width: Some(args.width),932- height: Some(args.height),933- framerate: args.framerate,934- codec: "h264".into(),935- input_mode: InputMode::Log,936- uinput: None,937- ffmpeg_path: args.ffmpeg.clone(),938- gamescope_pipewire: None,939- audio: None,1075+ Ok(LoadedServeConfig {1076+ config: HostConfig {1077+ identity_path,1078+ state_path,1079+ source: source.into(),1080+ width: Some(args.width),1081+ height: Some(args.height),1082+ framerate: args.framerate,1083+ codec: "h264".into(),1084+ input_mode: InputMode::Log,1085+ uinput: None,1086+ ffmpeg_path: args.ffmpeg.clone(),1087+ gamescope_pipewire: None,1088+ audio: None,1089+ },1090+ revision: None,9401091 })9411092 }9421093
@@ -1055,6 +1206,32 @@ mod tests {
10551206 Cli::try_parse_from(["sigil", "appliance", "status", "--config", "/tmp/host.toml"])10561207 .is_err()10571208 );1209+ assert!(1210+ Cli::try_parse_from([1211+ "sigil",1212+ "appliance",1213+ "status",1214+ "--config",1215+ "/tmp/host.toml",1216+ "--json",1217+ "--schema-version",1218+ "2",1219+ ])1220+ .is_ok()1221+ );1222+ assert!(1223+ Cli::try_parse_from([1224+ "sigil",1225+ "appliance",1226+ "status",1227+ "--config",1228+ "/tmp/host.toml",1229+ "--json",1230+ "--schema-version",1231+ "3",1232+ ])1233+ .is_err()1234+ );10581235 }10591236 10601237 #[test]
@@ -1083,6 +1260,63 @@ mod tests {
10831260 );10841261 }10851262 1263+ #[test]1264+ fn appliance_config_commands_require_explicit_machine_inputs() {1265+ assert!(1266+ Cli::try_parse_from([1267+ "sigil",1268+ "appliance",1269+ "config",1270+ "show",1271+ "--config",1272+ "/tmp/host.toml",1273+ "--json",1274+ ])1275+ .is_ok()1276+ );1277+ assert!(1278+ Cli::try_parse_from([1279+ "sigil",1280+ "appliance",1281+ "config",1282+ "set",1283+ "--config",1284+ "/tmp/host.toml",1285+ ])1286+ .is_err()1287+ );1288+ assert!(1289+ Cli::try_parse_from([1290+ "sigil",1291+ "appliance",1292+ "config",1293+ "commit",1294+ "--config",1295+ "/tmp/host.toml",1296+ "--transaction",1297+ "0123456789abcdef0123456789abcdef",1298+ "--expected-instance",1299+ "fedcba9876543210fedcba9876543210",1300+ "--json",1301+ ])1302+ .is_ok()1303+ );1304+ assert!(1305+ Cli::try_parse_from([1306+ "sigil",1307+ "appliance",1308+ "config",1309+ "commit",1310+ "--config",1311+ "/tmp/host.toml",1312+ "--transaction",1313+ "0123456789abcdef0123456789abcdef",1314+ "--json",1315+ ])1316+ .is_err()1317+ );1318+ }1319+10861320 #[test]10871321 fn invitation_file_uses_create_new_semantics() {10881322 let directory = tempfile::tempdir().unwrap();
M crates/sigil-host/src/secure_state.rs +110 -15
@@ -46,8 +46,37 @@ pub fn validate_private_directory(path: &Path) -> Result<()> {
4646 Ok(())4747 }4848 49-pub fn open_lifetime_lock(directory: &Path, file_name: &str) -> Result<File> {50- validate_private_directory(directory)?;49+#[derive(Debug)]50+pub enum LockAcquireError {51+ Busy,52+ Unsafe(anyhow::Error),53+}54+55+impl std::fmt::Display for LockAcquireError {56+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {57+ match self {58+ Self::Busy => {59+ formatter.write_str("another Sigil daemon already owns this state directory")60+ }61+ Self::Unsafe(error) => write!(formatter, "unsafe lock storage: {error}"),62+ }63+ }64+}65+66+impl std::error::Error for LockAcquireError {67+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {68+ match self {69+ Self::Busy => None,70+ Self::Unsafe(error) => Some(error.as_ref()),71+ }72+ }73+}74+75+pub fn try_open_lifetime_lock(76+ directory: &Path,77+ file_name: &str,78+) -> std::result::Result<File, LockAcquireError> {79+ validate_private_directory(directory).map_err(LockAcquireError::Unsafe)?;5180 let path = directory.join(file_name);5281 let mut options = OpenOptions::new();5382 options.read(true).write(true).create(true);
@@ -57,19 +86,22 @@ pub fn open_lifetime_lock(directory: &Path, file_name: &str) -> Result<File> {
5786 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);5887 let file = options5988 .open(&path)60- .with_context(|| format!("opening lifecycle lock {}", path.display()))?;61- validate_private_file(&file, &path)?;89+ .with_context(|| format!("opening lifecycle lock {}", path.display()))90+ .map_err(LockAcquireError::Unsafe)?;91+ validate_private_file(&file, &path).map_err(LockAcquireError::Unsafe)?;6292 6393 #[cfg(unix)]6494 {6595 use std::os::fd::AsRawFd;6696 6797 let status = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };68- ensure!(69- status == 0,70- "another Sigil daemon already owns this state directory: {}",71- std::io::Error::last_os_error()72- );98+ if status != 0 {99+ let error = std::io::Error::last_os_error();100+ if error.kind() == std::io::ErrorKind::WouldBlock {101+ return Err(LockAcquireError::Busy);102+ }103+ return Err(LockAcquireError::Unsafe(error.into()));104+ }73105 }74106 75107 Ok(file)
@@ -116,10 +148,22 @@ pub fn atomic_write(
116148 file_name: &str,117149 bytes: &[u8],118150 maximum_bytes: u64,151+) -> Result<()> {152+ let mut terminated = Vec::with_capacity(bytes.len().saturating_add(1));153+ terminated.extend_from_slice(bytes);154+ terminated.push(b'\n');155+ atomic_write_exact(directory, file_name, &terminated, maximum_bytes)156+}157+158+pub fn atomic_write_exact(159+ directory: &Path,160+ file_name: &str,161+ bytes: &[u8],162+ maximum_bytes: u64,119163 ) -> Result<()> {120164 validate_private_directory(directory)?;121165 ensure!(122- (bytes.len() as u64).saturating_add(1) <= maximum_bytes,166+ bytes.len() as u64 <= maximum_bytes,123167 "private state file exceeds its fixed size bound"124168 );125169 let mut random = [0_u8; 8];
@@ -143,7 +187,6 @@ pub fn atomic_write(
143187 })?;144188 let result = (|| -> Result<()> {145189 file.write_all(bytes)?;146- file.write_all(b"\n")?;147190 file.sync_all()?;148191 fs::rename(&temporary, &destination)?;149192 File::open(directory)?.sync_all()?;
@@ -155,6 +198,38 @@ pub fn atomic_write(
155198 result156199 }157200 201+pub fn remove_file_if_exists(directory: &Path, file_name: &str) -> Result<bool> {202+ validate_private_directory(directory)?;203+ let path = directory.join(file_name);204+ match fs::symlink_metadata(&path) {205+ Ok(metadata) => {206+ ensure!(207+ !metadata.file_type().is_symlink() && metadata.is_file(),208+ "private state cleanup target is not a regular file"209+ );210+ #[cfg(unix)]211+ {212+ ensure!(213+ metadata.mode() & 0o077 == 0,214+ "private state cleanup target is accessible by group or others"215+ );216+ ensure!(217+ metadata.uid() == unsafe { libc::geteuid() },218+ "private state cleanup target has the wrong owner"219+ );220+ }221+ fs::remove_file(&path)222+ .with_context(|| format!("removing private state {}", path.display()))?;223+ File::open(directory)?.sync_all()?;224+ Ok(true)225+ }226+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),227+ Err(error) => {228+ Err(error).with_context(|| format!("inspecting private state {}", path.display()))229+ }230+ }231+}232+158233 fn validate_private_file(file: &File, path: &Path) -> Result<()> {159234 let metadata = file.metadata()?;160235 ensure!(
@@ -195,10 +270,13 @@ mod tests {
195270 #[test]196271 fn lifetime_lock_is_exclusive_and_reacquirable() {197272 let directory = private_directory();198- let first = open_lifetime_lock(directory.path(), "daemon-v1.lock").unwrap();199- assert!(open_lifetime_lock(directory.path(), "daemon-v1.lock").is_err());273+ let first = try_open_lifetime_lock(directory.path(), "daemon-v1.lock").unwrap();274+ assert!(matches!(275+ try_open_lifetime_lock(directory.path(), "daemon-v1.lock"),276+ Err(LockAcquireError::Busy)277+ ));200278 drop(first);201- open_lifetime_lock(directory.path(), "daemon-v1.lock").unwrap();279+ try_open_lifetime_lock(directory.path(), "daemon-v1.lock").unwrap();202280 }203281 204282 #[test]
@@ -223,6 +301,20 @@ mod tests {
223301 );224302 }225303 304+ #[test]305+ fn exact_atomic_state_preserves_bytes_and_cleanup_is_durable() {306+ let directory = private_directory();307+ atomic_write_exact(directory.path(), "config.toml", b"x = 1\n\n", 64).unwrap();308+ assert_eq!(309+ read_bounded(directory.path(), "config.toml", 64)310+ .unwrap()311+ .unwrap(),312+ b"x = 1\n\n"313+ );314+ assert!(remove_file_if_exists(directory.path(), "config.toml").unwrap());315+ assert!(!remove_file_if_exists(directory.path(), "config.toml").unwrap());316+ }317+226318 #[cfg(unix)]227319 #[test]228320 fn rejects_symlink_and_permissive_state() {
@@ -234,6 +326,9 @@ mod tests {
234326 assert!(read_bounded(directory.path(), "status.json", 64).is_err());235327 236328 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o755)).unwrap();237- assert!(open_lifetime_lock(directory.path(), "daemon-v1.lock").is_err());329+ assert!(matches!(330+ try_open_lifetime_lock(directory.path(), "daemon-v1.lock"),331+ Err(LockAcquireError::Unsafe(_))332+ ));238333 }239334 }
A docs/sigil-appliance-config-v1.md +93 -0
@@ -0,0 +1,93 @@
1+# Sigil appliance configuration v12+3+`sigil appliance config` is the local, machine-readable settings interface for4+an unprivileged appliance controller such as the Decky plugin. It exposes a5+small typed projection instead of accepting arbitrary TOML edits.6+7+Every command requires the owner-only host configuration and explicit JSON8+output:9+10+```bash11+sigil appliance config show --config ~/.config/sigil-spark/host.toml --json12+sigil appliance config validate --config ~/.config/sigil-spark/host.toml --json <request.json13+sigil appliance config set --config ~/.config/sigil-spark/host.toml --json <request.json14+sigil appliance config commit --config ~/.config/sigil-spark/host.toml \15+ --transaction <id> --expected-instance <id> --json16+sigil appliance config rollback --config ~/.config/sigil-spark/host.toml \17+ --transaction <id> --json18+```19+20+`show` returns the exact-byte SHA-256 revision, editable settings, and a pending21+transaction summary. The editable v1 settings are:22+23+- Native dimensions or an explicit even width and height.24+- Frame rate.25+- CBR bitrate or CQP quantizer for a configured Gamescope PipeWire source.26+27+Source selection, codec, capture backend, executable and device paths,28+PipeWire selectors, audio/input configuration, identity, and state paths are29+not editable through this contract. Operator comments and all non-managed TOML30+fields survive an update.31+32+Requests are strict JSON and contain an optimistic-concurrency revision:33+34+```json35+{36+ "schema_version": 1,37+ "expected_revision": "sha256:<64 lowercase hexadecimal digits>",38+ "settings": {39+ "resolution": { "mode": "native" },40+ "framerate": 60,41+ "rate_control": { "mode": "cbr", "bitrate_kbps": 12000 }42+ }43+}44+```45+46+For a synthetic source, `rate_control` must be `null`. A fixed resolution uses47+`{"mode":"fixed","width":1280,"height":800}`; CQP uses48+`{"mode":"cqp","quantizer":24}`. `validate` performs no writes. A no-op49+`set` returns `changed: false`, no transaction ID, and does not require a50+restart.51+52+## Controller sequence53+54+Only a stopped Sigil can be changed. The controller performs:55+56+1. Stop `sigil-host.service` and wait until it is inactive.57+2. Call `set`. Sigil atomically installs the candidate and returns a transaction58+ ID plus candidate revision.59+3. Start the service and wait for appliance status to report a new instance in60+ `ready` state with the candidate revision.61+4. Stop the service cleanly and retain that exact instance ID.62+5. Call `commit` with the transaction and instance IDs. If startup or shutdown63+ fails, stop the service, call `rollback`, and restart the prior config.64+65+Commit succeeds only when the bounded runtime document is fresh, belongs to the66+current host, reports the exact candidate revision and a daemon instance newer67+than the one present at `set`, reached `ready` at least once, stopped cleanly,68+and contains no runtime error. A running daemon holds the same lifecycle locks,69+so set, commit, rollback, enrollment reset, and service execution cannot race.70+Configured service startup takes those locks before its definitive config reload71+and transaction recovery. An interrupted prepared install is therefore72+completed before capture or networking starts; startup can never launch bytes73+that were read before a concurrent `set`.74+75+## Durability and recovery76+77+Config files and requests are bounded to 64 KiB and 16 KiB respectively.78+Revisions hash the exact bytes read from the same opened file descriptor used79+for parsing. Writes and recovery artifacts are owner-only, no-follow, atomic,80+and directory-synced.81+82+The journal records `prepared`, `pending_validation`, `committing`, or83+`rolling_back`. Recovery deterministically finishes an interrupted candidate84+install, commit cleanup, or byte-exact rollback. Live bytes that match neither85+the journaled base nor candidate fail closed instead of being overwritten.86+Only one transaction may be pending.87+88+Management failures write one JSON object to stderr with `schema_version: 1`89+and one closed error code: `unsupported_schema`, `invalid_request`,90+`validation_failed`, `revision_conflict`, `lifecycle_busy`,91+`transaction_busy`, `transaction_pending`, `transaction_not_found`,92+`transaction_conflict`, `health_not_proven`, or `unsafe_storage`. They never93+include paths, configuration contents, or free-form internal errors.
M docs/sigil-appliance-status-v1.md +14 -55
@@ -1,9 +1,7 @@
11 # Sigil appliance status v122 3-`sigil appliance status` is the local, read-only contract for appliance4-management surfaces such as the planned Decky Loader plugin. Decky remains an5-unprivileged controller over the existing `sigil-host.service`; it does not6-launch Sigil as a child, host streaming itself, or add a network listener.3+`sigil appliance status` is the backward-compatible, local read-only appliance4+status contract:75 86 ```bash97 sigil appliance status \
@@ -11,56 +9,17 @@ sigil appliance status \
119 --json1210 ```1311 14-The command validates the owner-only configuration and identity, inspects the15-authorization document without creating files or taking its writer lock, and16-combines that durable state with Sigil's bounded runtime heartbeat. Output is a17-single JSON document with `schema_version: 1`.12+The single JSON document has `schema_version: 1`. It contains Sigil version and13+overall state; redacted host and enrolled-Portal fingerprints; ordered grants,14+epoch, and enrollment time; and bounded runtime freshness, daemon state,15+uptime, session state, and closed error codes. It never contains complete16+endpoint IDs, tickets, nonces, addresses, device selectors, filesystem paths,17+or free-form errors.1818 19-The public document includes only:19+Version 1 intentionally omits configuration revisions, pending transactions,20+daemon instance IDs, and sticky ready evidence. Controllers that manage21+configuration must explicitly request the22+[status v2 contract](sigil-appliance-status-v2.md).2023 21-- Sigil version and overall `ready`, `active`, `degraded`, or `unavailable`22- state.23-- Redacted host and enrolled-Portal fingerprints.24-- Canonically ordered enrollment grants, epoch, and enrollment time.25-- Runtime freshness, daemon state, uptime, session state, and a closed set of26- error codes.27-28-It never includes complete endpoint IDs, invitation material, replay-ledger29-entries, session nonces, addresses, PipeWire object names, filesystem paths, or30-free-form error text.31-32-## Runtime authority33-34-While serving, Sigil holds an exclusive nonblocking lock at35-`<state_path>/daemon-v1.lock`. A second daemon using the same state directory36-fails before capture, input, or network initialization. The zero-length lock37-file remains in place; the open file descriptor is the lifetime guard. When an38-XDG runtime directory is available, Sigil also holds39-`$XDG_RUNTIME_DIR/sigil-spark/daemon-global-v1.lock`. That per-user lock enforces40-the product's one-daemon boundary even if two commands reference different41-state directories. Configured production service mode requires a valid private42-`XDG_RUNTIME_DIR` and fails before capture or networking if it is unavailable.43-Only the explicit direct test-pattern proof may fall back to its per-state lock44-without publishing a heartbeat.45-46-Sigil atomically publishes47-`$XDG_RUNTIME_DIR/sigil-spark/daemon-status-v1.json` at most once per two-second48-heartbeat, plus explicit lifecycle transitions. Keeping the heartbeat in the49-per-user runtime directory prevents high-frequency process state from entering50-durable backups or surviving a reboot. The status file and its child directory51-must be regular, owner-only objects. Reads and writes are bounded, refuse52-symlinks, and use no-follow opens. Heartbeat I/O runs outside Tokio's async53-worker threads.54-55-The status command treats a missing runtime document as `offline`. A heartbeat56-older than ten seconds is `stale`; stale data never claims a live session or57-live uptime. Clean shutdown writes `stopping`, shuts down the Iroh router, and58-then writes `stopped`. A crash leaves a bounded document that naturally becomes59-stale, while the runtime directory itself is discarded at reboot. Every60-runtime document is bound to the current host identity before its state is61-combined with durable enrollment data.62-63-The Decky backend will merge this document with `systemctl --user` unit state.64-Service control, transactional configuration, enrollment reset, and identity65-factory reset are separate follow-up contracts; they are intentionally not66-part of status v1.24+Runtime authority, lifecycle locking, freshness, owner-only storage, and clean25+shutdown behavior are otherwise identical to status v2.
A docs/sigil-appliance-status-v2.md +77 -0
@@ -0,0 +1,77 @@
1+# Sigil appliance status v22+3+`sigil appliance status` is the local, read-only contract for appliance4+management surfaces such as the planned Decky Loader plugin. Decky remains an5+unprivileged controller over the existing `sigil-host.service`; it does not6+launch Sigil as a child, host streaming itself, or add a network listener.7+8+```bash9+sigil appliance status \10+ --config ~/.config/sigil-spark/host.toml \11+ --json --schema-version 212+```13+14+The command validates the owner-only configuration and identity, inspects the15+authorization document without creating files or taking its writer lock, and16+combines that durable state with Sigil's bounded runtime heartbeat. Output is a17+single JSON document with `schema_version: 2`.18+19+Without `--schema-version 2`, the command continues to emit the original v120+shape so independently updated scripts and management clients do not break.21+22+The public document includes only:23+24+- Sigil version and overall `ready`, `active`, `degraded`, or `unavailable`25+ state.26+- Redacted host and enrolled-Portal fingerprints.27+- Canonically ordered enrollment grants, epoch, and enrollment time.28+- Runtime freshness, daemon state, uptime, session state, and a closed set of29+ error codes.30+- The exact loaded configuration revision, fresh daemon instance ID, and sticky31+ `reached_ready` evidence used by the transactional configuration gate.32+- The current configuration revision and any pending transaction summary.33+34+It never includes complete endpoint IDs, invitation material, replay-ledger35+entries, session nonces, addresses, PipeWire object names, filesystem paths, or36+free-form error text.37+38+## Runtime authority39+40+While serving, Sigil holds an exclusive nonblocking lock at41+`<state_path>/daemon-v1.lock`. A second daemon using the same state directory42+fails before capture, input, or network initialization. The zero-length lock43+file remains in place; the open file descriptor is the lifetime guard. When an44+XDG runtime directory is available, Sigil also holds45+`$XDG_RUNTIME_DIR/sigil-spark/daemon-global-v1.lock`. That per-user lock enforces46+the product's one-daemon boundary even if two commands reference different47+state directories. Configured production service mode requires a valid private48+`XDG_RUNTIME_DIR` and fails before capture or networking if it is unavailable.49+Only the explicit direct test-pattern proof may fall back to its per-state lock50+without publishing a heartbeat.51+52+Sigil atomically publishes53+`$XDG_RUNTIME_DIR/sigil-spark/daemon-status-v1.json` at most once per two-second54+heartbeat, plus explicit lifecycle transitions. Keeping the heartbeat in the55+per-user runtime directory prevents high-frequency process state from entering56+durable backups or surviving a reboot. The status file and its child directory57+must be regular, owner-only objects. Reads and writes are bounded, refuse58+symlinks, and use no-follow opens. Heartbeat I/O runs outside Tokio's async59+worker threads.60+61+The status command treats a missing runtime document as `offline`. A heartbeat62+older than ten seconds is `stale`; stale data never claims a live session or63+live uptime. Clean shutdown writes `stopping`, shuts down the Iroh router, and64+then writes `stopped`. A crash leaves a bounded document that naturally becomes65+stale, while the runtime directory itself is discarded at reboot. Every66+runtime document is bound to the current host identity before its state is67+combined with durable enrollment data.68+69+Runtime status v2 remains able to read a version-1 heartbeat after an upgrade,70+but legacy heartbeats carry no configuration revision and never satisfy the71+configuration health gate. Stale or absent heartbeats do not expose an instance72+ID, loaded revision, or ready claim.73+74+The Decky backend merges this document with `systemctl --user` unit state.75+Service control, enrollment reset, and identity factory reset remain separate76+contracts. Transactional configuration is defined by the77+[configuration v1 contract](sigil-appliance-config-v1.md).
M scripts/tests/sigil-appliance-status.sh +202 -3
@@ -43,8 +43,13 @@ config_path="$temp_root/host.toml"
4343 host_log="$temp_root/host.log"4444 second_log="$temp_root/second.log"4545 live_status="$temp_root/live-status.json"46+legacy_status="$temp_root/legacy-status.json"4647 stopped_status="$temp_root/stopped-status.json"4748 reset_status="$temp_root/reset-status.json"49+config_show="$temp_root/config-show.json"50+config_request="$temp_root/config-request.json"51+config_set="$temp_root/config-set.json"52+candidate_status="$temp_root/candidate-status.json"4853 4954 mkdir -m 0700 "$identity_directory" "$state_directory" "$runtime_directory"5055 "$sigil" identity init --output "$identity_path" >"$temp_root/identity.log"
@@ -90,7 +95,43 @@ if [[ "$ready" != 'true' ]]; then
9095 fi9196 9297 XDG_RUNTIME_DIR="$runtime_directory" \93- "$sigil" appliance status --config "$config_path" --json >"$live_status"98+ "$sigil" appliance status --config "$config_path" --json --schema-version 2 >"$live_status"99+XDG_RUNTIME_DIR="$runtime_directory" \100+ "$sigil" appliance status --config "$config_path" --json >"$legacy_status"101+XDG_RUNTIME_DIR="$runtime_directory" \102+ "$sigil" appliance config show --config "$config_path" --json \103+ >"$temp_root/live-config-show.json"104+python3 - "$temp_root/live-config-show.json" "$temp_root/live-config-request.json" <<'PY'105+import json106+import pathlib107+import sys108+show = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))109+request = {110+ "schema_version": 1,111+ "expected_revision": show["revision"],112+ "settings": {113+ "resolution": {"mode": "native"},114+ "framerate": 72,115+ "rate_control": None,116+ },117+}118+pathlib.Path(sys.argv[2]).write_text(json.dumps(request), encoding="utf-8")119+PY120+if XDG_RUNTIME_DIR="$runtime_directory" \121+ "$sigil" appliance config set --config "$config_path" --json \122+ <"$temp_root/live-config-request.json" >"$temp_root/live-config-set.out" \123+ 2>"$temp_root/live-config-set.error"; then124+ printf 'config set unexpectedly mutated a live Sigil daemon\n' >&2125+ exit 1126+fi127+python3 - "$temp_root/live-config-set.error" <<'PY'128+import json129+import pathlib130+import sys131+error = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))132+if error != {"schema_version": 1, "error": {"code": "lifecycle_busy"}}:133+ raise SystemExit(f"unexpected live config error: {error}")134+PY94135 95136 if XDG_RUNTIME_DIR="$runtime_directory" \96137 "$sigil" serve \
@@ -115,7 +156,7 @@ raw = path.read_text(encoding="utf-8")
115156 status = json.loads(raw)116157 host_node_id = sys.argv[2]117158 118-if status.get("schema_version") != 1:159+if status.get("schema_version") != 2:119160 raise SystemExit("unexpected appliance status schema")120161 if status.get("overall") != "ready":121162 raise SystemExit(f"unexpected live overall state: {status.get('overall')}")
@@ -123,6 +164,10 @@ if status.get("runtime", {}).get("daemon") != "ready":
123164 raise SystemExit("live daemon state is not ready")124165 if status.get("runtime", {}).get("session") != "inactive":125166 raise SystemExit("live proof unexpectedly reports a session")167+if not status.get("runtime", {}).get("loaded_config_revision", "").startswith("sha256:"):168+ raise SystemExit("live runtime is not bound to an exact config revision")169+if status.get("runtime", {}).get("reached_ready") is not True:170+ raise SystemExit("live runtime did not retain its ready transition")126171 if status.get("enrollment", {}).get("state") != "none":127172 raise SystemExit("direct proof unexpectedly reports an enrollment")128173 if host_node_id in raw:
@@ -132,6 +177,21 @@ if len(fingerprint) != 17 or "…" not in fingerprint:
132177 raise SystemExit("host fingerprint is not bounded and redacted")133178 PY134179 180+python3 - "$legacy_status" <<'PY'181+import json182+import pathlib183+import sys184+185+status = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))186+if status.get("schema_version") != 1:187+ raise SystemExit("default appliance status did not preserve schema v1")188+if "revision" in status.get("config", {}) or "pending_transaction" in status.get("config", {}):189+ raise SystemExit("status v1 leaked status-v2 config fields")190+runtime = status.get("runtime", {})191+if any(field in runtime for field in ("instance_id", "loaded_config_revision", "reached_ready")):192+ raise SystemExit("status v1 leaked status-v2 runtime fields")193+PY194+135195 host_fingerprint="$(python3 - "$live_status" <<'PY'136196 import json137197 import pathlib
@@ -172,7 +232,7 @@ XDG_RUNTIME_DIR="$runtime_directory" \
172232 --expected-host-fingerprint "$host_fingerprint" \173233 --json >"$reset_status"174234 XDG_RUNTIME_DIR="$runtime_directory" \175- "$sigil" appliance status --config "$config_path" --json >"$stopped_status"235+ "$sigil" appliance status --config "$config_path" --json --schema-version 2 >"$stopped_status"176236 177237 python3 - "$stopped_status" "$reset_status" "$host_node_id" <<'PY'178238 import json
@@ -190,6 +250,10 @@ if runtime.get("session") != "inactive":
190250 raise SystemExit("stopped daemon did not report an inactive session")191251 if runtime.get("uptime_ms") is not None:192252 raise SystemExit("stopped daemon retained a live uptime")253+if not runtime.get("instance_id"):254+ raise SystemExit("fresh stopped state omitted its daemon instance")255+if runtime.get("reached_ready") is not True:256+ raise SystemExit("fresh stopped state forgot that it reached ready")193257 if reset.get("schema_version") != 1 or reset.get("operation") != "enrollment_reset":194258 raise SystemExit("unexpected enrollment reset schema")195259 if reset.get("had_enrollment") is not False:
@@ -204,6 +268,141 @@ if status.get("enrollment", {}).get("epoch") != reset.get("current_epoch"):
204268 raise SystemExit("status does not reflect the enrollment reset epoch")205269 PY206270 271+XDG_RUNTIME_DIR="$runtime_directory" \272+ "$sigil" appliance config show --config "$config_path" --json >"$config_show"273+python3 - "$config_show" "$config_request" <<'PY'274+import json275+import pathlib276+import sys277+278+show = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))279+if show.get("schema_version") != 1 or show.get("pending_transaction") is not None:280+ raise SystemExit("unexpected initial config projection")281+request = {282+ "schema_version": 1,283+ "expected_revision": show["revision"],284+ "settings": {285+ "resolution": {"mode": "native"},286+ "framerate": 72,287+ "rate_control": None,288+ },289+}290+pathlib.Path(sys.argv[2]).write_text(json.dumps(request), encoding="utf-8")291+PY292+293+base_hash="$(python3 - "$config_path" <<'PY'294+import hashlib295+import pathlib296+import sys297+print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())298+PY299+)"300+XDG_RUNTIME_DIR="$runtime_directory" \301+ "$sigil" appliance config validate --config "$config_path" --json \302+ <"$config_request" >"$temp_root/config-validate.json"303+[[ "$base_hash" == "$(python3 - "$config_path" <<'PY'304+import hashlib305+import pathlib306+import sys307+print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())308+PY309+)" ]]310+XDG_RUNTIME_DIR="$runtime_directory" \311+ "$sigil" appliance config set --config "$config_path" --json \312+ <"$config_request" >"$config_set"313+314+transaction="$(python3 - "$config_set" <<'PY'315+import json316+import pathlib317+import sys318+result = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))319+if result.get("changed") is not True or result.get("restart_required") is not True:320+ raise SystemExit("config set did not require candidate validation")321+print(result["transaction"])322+PY323+)"324+325+XDG_RUNTIME_DIR="$runtime_directory" \326+ "$sigil" serve --config "$config_path" --max-runtime-seconds 1 \327+ >"$temp_root/candidate.log" 2>&1328+XDG_RUNTIME_DIR="$runtime_directory" \329+ "$sigil" appliance status --config "$config_path" --json --schema-version 2 >"$candidate_status"330+candidate_instance="$(python3 - "$candidate_status" "$config_set" <<'PY'331+import json332+import pathlib333+import sys334+status = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))335+staged = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8"))336+runtime = status["runtime"]337+if runtime.get("daemon") != "stopped" or runtime.get("reached_ready") is not True:338+ raise SystemExit("candidate did not stop cleanly after reaching ready")339+if runtime.get("loaded_config_revision") != staged.get("candidate_revision"):340+ raise SystemExit("candidate runtime revision does not match staged config")341+if status.get("config", {}).get("pending_transaction", {}).get("transaction") != staged.get("transaction"):342+ raise SystemExit("status omitted the pending config transaction")343+print(runtime["instance_id"])344+PY345+)"346+XDG_RUNTIME_DIR="$runtime_directory" \347+ "$sigil" appliance config commit \348+ --config "$config_path" \349+ --transaction "$transaction" \350+ --expected-instance "$candidate_instance" \351+ --json >"$temp_root/config-commit.json"352+XDG_RUNTIME_DIR="$runtime_directory" \353+ "$sigil" appliance config show --config "$config_path" --json \354+ >"$temp_root/committed-show.json"355+356+python3 - "$temp_root/committed-show.json" "$temp_root/rollback-request.json" <<'PY'357+import json358+import pathlib359+import sys360+show = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))361+if show.get("pending_transaction") is not None or show["settings"]["framerate"] != 72:362+ raise SystemExit("committed config state is incorrect")363+request = {364+ "schema_version": 1,365+ "expected_revision": show["revision"],366+ "settings": {367+ "resolution": {"mode": "fixed", "width": 1280, "height": 800},368+ "framerate": 60,369+ "rate_control": None,370+ },371+}372+pathlib.Path(sys.argv[2]).write_text(json.dumps(request), encoding="utf-8")373+PY374+committed_hash="$(python3 - "$config_path" <<'PY'375+import hashlib376+import pathlib377+import sys378+print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())379+PY380+)"381+XDG_RUNTIME_DIR="$runtime_directory" \382+ "$sigil" appliance config set --config "$config_path" --json \383+ <"$temp_root/rollback-request.json" >"$temp_root/rollback-set.json"384+rollback_transaction="$(python3 - "$temp_root/rollback-set.json" <<'PY'385+import json386+import pathlib387+import sys388+print(json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))["transaction"])389+PY390+)"391+XDG_RUNTIME_DIR="$runtime_directory" \392+ "$sigil" appliance config rollback \393+ --config "$config_path" --transaction "$rollback_transaction" --json \394+ >"$temp_root/config-rollback.json"395+[[ "$committed_hash" == "$(python3 - "$config_path" <<'PY'396+import hashlib397+import pathlib398+import sys399+print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())400+PY401+)" ]]402+[[ ! -e "$state_directory/config-transaction-v1.json" ]]403+[[ ! -e "$state_directory/config-base-v1.toml" ]]404+[[ ! -e "$state_directory/config-candidate-v1.toml" ]]405+207406 if env -u XDG_RUNTIME_DIR \208407 "$sigil" serve --config "$config_path" --max-runtime-seconds 1 \209408 >"$temp_root/missing-runtime.log" 2>&1; then