1use std::fs;2use std::io::Read;3use std::path::Path;45use serde::{Deserialize, Serialize};6use toml_edit::{DocumentMut, value};78use crate::appliance::LifecycleGuard;9use crate::config::{10 ConfigRevision, HostConfig, LoadedHostConfig, MAX_CONFIG_BYTES, VaapiRateControl, VideoSource,11};12use crate::{appliance, identity, secure_state};1314const REQUEST_VERSION: u16 = 1;15const RESULT_VERSION: u16 = 1;16const JOURNAL_VERSION: u16 = 1;17const MAX_REQUEST_BYTES: u64 = 16 * 1024;18const MAX_JOURNAL_BYTES: u64 = 16 * 1024;19const CONFIG_LOCK_FILE: &str = "config-transaction-v1.lock";20const JOURNAL_FILE: &str = "config-transaction-v1.json";21const BASE_FILE: &str = "config-base-v1.toml";22const CANDIDATE_FILE: &str = "config-candidate-v1.toml";2324pub type ManagementResult<T> = std::result::Result<T, ManagementError>;25262728pub 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}414243pub struct ManagementError {44 code: ManagementErrorCode,45 source: anyhow::Error,46}4748impl ManagementError {49 fn new(code: ManagementErrorCode, source: impl Into<anyhow::Error>) -> Self {50 Self {51 code,52 source: source.into(),53 }54 }5556 pub fn response(&self) -> ManagementErrorV1 {57 ManagementErrorV1 {58 schema_version: RESULT_VERSION,59 error: ManagementErrorBody { code: self.code },60 }61 }62}6364impl 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}7374impl std::error::Error for ManagementError {75 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {76 Some(self.source.as_ref())77 }78}798081pub struct ManagementErrorV1 {82 schema_version: u16,83 error: ManagementErrorBody,84}858687struct ManagementErrorBody {88 code: ManagementErrorCode,89}90919293pub struct ConfigRequestV1 {94 95 _schema_version: u16,96 expected_revision: ConfigRevision,97 settings: ManagedSettingsV1,98}99100101struct ConfigRequestEnvelope {102 schema_version: u64,103}104105106107pub struct ManagedSettingsV1 {108 resolution: ManagedResolutionV1,109 framerate: u32,110 rate_control: Option<ManagedRateControlV1>,111}112113114115pub enum ManagedResolutionV1 {116 Native,117 Fixed { width: u32, height: u32 },118}119120121122pub enum ManagedRateControlV1 {123 Cbr { bitrate_kbps: u32 },124 Cqp { quantizer: u8 },125}126127128pub struct ConfigTransactionSummaryV1 {129 transaction: String,130 state: TransactionState,131 base_revision: ConfigRevision,132 candidate_revision: ConfigRevision,133}134135136pub struct ConfigShowV1 {137 schema_version: u16,138 revision: ConfigRevision,139 settings: ManagedSettingsV1,140 pending_transaction: Option<ConfigTransactionSummaryV1>,141}142143144pub struct ConfigValidateV1 {145 schema_version: u16,146 base_revision: ConfigRevision,147 candidate_revision: ConfigRevision,148 changed: bool,149 settings: ManagedSettingsV1,150}151152153pub 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}161162163pub struct ConfigCommitV1 {164 schema_version: u16,165 operation: &'static str,166 transaction: String,167 revision: ConfigRevision,168}169170171pub struct ConfigRollbackV1 {172 schema_version: u16,173 operation: &'static str,174 transaction: String,175 restored_revision: ConfigRevision,176}177178179180pub enum TransactionState {181 Prepared,182 PendingValidation,183 Committing,184 RollingBack,185}186187188189struct 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 runtime_directory_id: Option<ConfigRevision>,199}200201enum RecoveryCompletion {202 Committed(TransactionJournalV1),203 RolledBack(TransactionJournalV1),204}205206struct RecoveryOutcome {207 journal: Option<TransactionJournalV1>,208 completion: Option<RecoveryCompletion>,209}210211pub fn read_request(reader: impl Read) -> ManagementResult<ConfigRequestV1> {212 let mut bytes = Vec::new();213 reader214 .take(MAX_REQUEST_BYTES.saturating_add(1))215 .read_to_end(&mut bytes)216 .map_err(|error| ManagementError::new(ManagementErrorCode::InvalidRequest, error))?;217 if bytes.is_empty() || bytes.len() as u64 > MAX_REQUEST_BYTES {218 return Err(ManagementError::new(219 ManagementErrorCode::InvalidRequest,220 anyhow::anyhow!("configuration request is empty or exceeds its fixed bound"),221 ));222 }223 let envelope: ConfigRequestEnvelope = serde_json::from_slice(&bytes)224 .map_err(|error| ManagementError::new(ManagementErrorCode::InvalidRequest, error))?;225 if envelope.schema_version != u64::from(REQUEST_VERSION) {226 return Err(ManagementError::new(227 ManagementErrorCode::UnsupportedSchema,228 anyhow::anyhow!("unsupported configuration request schema"),229 ));230 }231 let request: ConfigRequestV1 = serde_json::from_slice(&bytes)232 .map_err(|error| ManagementError::new(ManagementErrorCode::InvalidRequest, error))?;233 ConfigRevision::parse(request.expected_revision.as_str())234 .map_err(|error| ManagementError::new(ManagementErrorCode::InvalidRequest, error))?;235 Ok(request)236}237238pub fn show(config_path: &Path) -> ManagementResult<ConfigShowV1> {239 let loaded = load_config(config_path)?;240 let pending_transaction = inspect_transaction(&loaded, config_path)?;241 Ok(ConfigShowV1 {242 schema_version: RESULT_VERSION,243 revision: loaded.revision,244 settings: project_settings(&loaded.config)?,245 pending_transaction,246 })247}248249pub fn validate(250 config_path: &Path,251 request: &ConfigRequestV1,252) -> ManagementResult<ConfigValidateV1> {253 let loaded = load_config(config_path)?;254 require_expected_revision(&loaded, request)?;255 let candidate = build_candidate(&loaded, request)?;256 Ok(ConfigValidateV1 {257 schema_version: RESULT_VERSION,258 base_revision: loaded.revision,259 candidate_revision: ConfigRevision::from_bytes(&candidate.bytes),260 changed: candidate.bytes != loaded.bytes,261 settings: project_settings(&candidate.config)?,262 })263}264265pub fn set(266 config_path: &Path,267 request: &ConfigRequestV1,268 runtime_root: Option<&Path>,269) -> ManagementResult<ConfigSetV1> {270 let bootstrap = load_config(config_path)?;271 let state_directory = bootstrap.config.state_path.clone();272 let runtime_directory = appliance::resolve_management_runtime_directory(runtime_root)273 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;274 bootstrap275 .config276 .ensure_runtime_directory()277 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;278 let (_lifecycle, _config_lock) =279 acquire_management_locks_at(&state_directory, Some(&runtime_directory))?;280281 let mut loaded = load_config(config_path)?;282 ensure_bootstrap_paths_unchanged(&bootstrap, &loaded)?;283 let recovered = recover(config_path, &mut loaded)?;284 if recovered.journal.is_some() {285 return Err(ManagementError::new(286 ManagementErrorCode::TransactionPending,287 anyhow::anyhow!("a configuration transaction is already pending"),288 ));289 }290 loaded = load_config(config_path)?;291 ensure_bootstrap_paths_unchanged(&bootstrap, &loaded)?;292 require_expected_revision(&loaded, request)?;293 let candidate = build_candidate(&loaded, request)?;294 let candidate_revision = ConfigRevision::from_bytes(&candidate.bytes);295 if candidate.bytes == loaded.bytes {296 return Ok(ConfigSetV1 {297 schema_version: RESULT_VERSION,298 transaction: None,299 base_revision: loaded.revision.clone(),300 candidate_revision,301 changed: false,302 restart_required: false,303 });304 }305306 let host = identity::load(&loaded.config.identity_path)307 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?308 .public();309 let baseline_runtime_instance = appliance::latest_runtime_instance(&runtime_directory, host)310 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;311 let runtime_directory_id = runtime_directory_id(&runtime_directory)?;312313 write_artifact(&state_directory, BASE_FILE, &loaded.bytes)?;314 write_artifact(&state_directory, CANDIDATE_FILE, &candidate.bytes)?;315 let mut journal = TransactionJournalV1 {316 version: JOURNAL_VERSION,317 transaction: random_transaction_id()?,318 state: TransactionState::Prepared,319 config_path_id: config_path_id(config_path)?,320 base_revision: loaded.revision.clone(),321 candidate_revision: candidate_revision.clone(),322 baseline_runtime_instance,323 runtime_directory_id: Some(runtime_directory_id),324 };325 write_journal(&state_directory, &journal)?;326 replace_config(config_path, &candidate.bytes)?;327 let installed = load_config(config_path)?;328 if installed.revision != candidate_revision {329 return Err(ManagementError::new(330 ManagementErrorCode::TransactionConflict,331 anyhow::anyhow!("installed configuration revision changed unexpectedly"),332 ));333 }334 journal.state = TransactionState::PendingValidation;335 write_journal(&state_directory, &journal)?;336 Ok(ConfigSetV1 {337 schema_version: RESULT_VERSION,338 transaction: Some(journal.transaction),339 base_revision: loaded.revision,340 candidate_revision,341 changed: true,342 restart_required: true,343 })344}345346pub fn commit(347 config_path: &Path,348 transaction: &str,349 expected_instance: &str,350 runtime_root: Option<&Path>,351) -> ManagementResult<ConfigCommitV1> {352 validate_transaction_id(transaction)?;353 validate_transaction_id(expected_instance)?;354 let bootstrap = load_config(config_path)?;355 let state_directory = bootstrap.config.state_path.clone();356 let runtime_directory = appliance::resolve_management_runtime_directory(runtime_root)357 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;358 let (_lifecycle, _config_lock) =359 acquire_management_locks_at(&state_directory, Some(&runtime_directory))?;360 let observed_journal = read_journal(&state_directory)?.ok_or_else(|| {361 ManagementError::new(362 ManagementErrorCode::TransactionNotFound,363 anyhow::anyhow!("no configuration transaction is pending"),364 )365 })?;366 validate_journal(&observed_journal, config_path)?;367 require_transaction(&observed_journal, transaction)?;368 let runtime_directory_id = runtime_directory_id(&runtime_directory)?;369 if observed_journal.runtime_directory_id.as_ref() != Some(&runtime_directory_id) {370 return Err(ManagementError::new(371 ManagementErrorCode::HealthNotProven,372 anyhow::anyhow!(373 "candidate validation runtime directory does not match the transaction"374 ),375 ));376 }377 let mut loaded = load_config(config_path)?;378 ensure_bootstrap_paths_unchanged(&bootstrap, &loaded)?;379 let recovered = recover(config_path, &mut loaded)?;380 if let Some(RecoveryCompletion::Committed(journal)) = recovered.completion {381 require_transaction(&journal, transaction)?;382 return Ok(ConfigCommitV1 {383 schema_version: RESULT_VERSION,384 operation: "config_commit",385 transaction: journal.transaction,386 revision: journal.candidate_revision,387 });388 }389 let mut journal = recovered.journal.ok_or_else(|| {390 ManagementError::new(391 ManagementErrorCode::TransactionNotFound,392 anyhow::anyhow!("no configuration transaction is pending"),393 )394 })?;395 require_transaction(&journal, transaction)?;396 if journal.state != TransactionState::PendingValidation397 || loaded.revision != journal.candidate_revision398 {399 return Err(transaction_conflict("candidate is not pending validation"));400 }401 if journal.baseline_runtime_instance.as_deref() == Some(expected_instance) {402 return Err(ManagementError::new(403 ManagementErrorCode::HealthNotProven,404 anyhow::anyhow!("candidate validation requires a new daemon instance"),405 ));406 }407 let secret = identity::load(&loaded.config.identity_path)408 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;409 appliance::validate_candidate_runtime(410 &runtime_directory,411 secret.public(),412 &journal.candidate_revision,413 expected_instance,414 )415 .map_err(|error| ManagementError::new(ManagementErrorCode::HealthNotProven, error))?;416417 journal.state = TransactionState::Committing;418 write_journal(&state_directory, &journal)?;419 cleanup_transaction(&state_directory)?;420 Ok(ConfigCommitV1 {421 schema_version: RESULT_VERSION,422 operation: "config_commit",423 transaction: journal.transaction,424 revision: journal.candidate_revision,425 })426}427428pub fn rollback(config_path: &Path, transaction: &str) -> ManagementResult<ConfigRollbackV1> {429 validate_transaction_id(transaction)?;430 let bootstrap = load_config(config_path)?;431 let state_directory = bootstrap.config.state_path.clone();432 let (_lifecycle, _config_lock) = acquire_management_locks(&state_directory, false)?;433 let mut loaded = load_config(config_path)?;434 ensure_bootstrap_paths_unchanged(&bootstrap, &loaded)?;435 let recovered = recover(config_path, &mut loaded)?;436 if let Some(RecoveryCompletion::RolledBack(journal)) = recovered.completion {437 require_transaction(&journal, transaction)?;438 return Ok(ConfigRollbackV1 {439 schema_version: RESULT_VERSION,440 operation: "config_rollback",441 transaction: journal.transaction,442 restored_revision: journal.base_revision,443 });444 }445 let mut journal = recovered.journal.ok_or_else(|| {446 ManagementError::new(447 ManagementErrorCode::TransactionNotFound,448 anyhow::anyhow!("no configuration transaction is pending"),449 )450 })?;451 require_transaction(&journal, transaction)?;452 if journal.state != TransactionState::PendingValidation453 || loaded.revision != journal.candidate_revision454 {455 return Err(transaction_conflict("candidate cannot be rolled back"));456 }457 journal.state = TransactionState::RollingBack;458 write_journal(&state_directory, &journal)?;459 let base = read_artifact(&state_directory, BASE_FILE, &journal.base_revision)?;460 replace_config(config_path, &base)?;461 loaded = load_config(config_path)?;462 if loaded.revision != journal.base_revision {463 return Err(transaction_conflict("base configuration was not restored"));464 }465 cleanup_transaction(&state_directory)?;466 Ok(ConfigRollbackV1 {467 schema_version: RESULT_VERSION,468 operation: "config_rollback",469 transaction: journal.transaction,470 restored_revision: journal.base_revision,471 })472}473474pub fn inspect_transaction(475 loaded: &LoadedHostConfig,476 config_path: &Path,477) -> ManagementResult<Option<ConfigTransactionSummaryV1>> {478 let state_directory = &loaded.config.state_path;479 match fs::symlink_metadata(state_directory) {480 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),481 Err(error) => {482 return Err(ManagementError::new(483 ManagementErrorCode::UnsafeStorage,484 error,485 ));486 }487 Ok(_) => {}488 }489 let Some(journal) = read_journal(state_directory)? else {490 return Ok(None);491 };492 validate_journal(&journal, config_path)?;493 if loaded.revision != journal.base_revision && loaded.revision != journal.candidate_revision {494 return Err(transaction_conflict(495 "live configuration conflicts with its transaction",496 ));497 }498 Ok(Some(ConfigTransactionSummaryV1 {499 transaction: journal.transaction,500 state: journal.state,501 base_revision: journal.base_revision,502 candidate_revision: journal.candidate_revision,503 }))504}505506fn recover(config_path: &Path, loaded: &mut LoadedHostConfig) -> ManagementResult<RecoveryOutcome> {507 let state_directory = loaded.config.state_path.clone();508 let Some(mut journal) = read_journal(&state_directory)? else {509 remove_artifact_if_present(&state_directory, BASE_FILE)?;510 remove_artifact_if_present(&state_directory, CANDIDATE_FILE)?;511 return Ok(RecoveryOutcome {512 journal: None,513 completion: None,514 });515 };516 validate_journal(&journal, config_path)?;517 match journal.state {518 TransactionState::Prepared => {519 let _base = read_artifact(&state_directory, BASE_FILE, &journal.base_revision)?;520 let candidate = read_artifact(521 &state_directory,522 CANDIDATE_FILE,523 &journal.candidate_revision,524 )?;525 if loaded.revision == journal.base_revision {526 replace_config(config_path, &candidate)?;527 *loaded = load_config(config_path)?;528 }529 if loaded.revision != journal.candidate_revision {530 return Err(transaction_conflict(531 "prepared transaction has conflicting live bytes",532 ));533 }534 journal.state = TransactionState::PendingValidation;535 write_journal(&state_directory, &journal)?;536 Ok(RecoveryOutcome {537 journal: Some(journal),538 completion: None,539 })540 }541 TransactionState::PendingValidation => {542 let _base = read_artifact(&state_directory, BASE_FILE, &journal.base_revision)?;543 let _candidate = read_artifact(544 &state_directory,545 CANDIDATE_FILE,546 &journal.candidate_revision,547 )?;548 if loaded.revision != journal.candidate_revision {549 return Err(transaction_conflict(550 "pending transaction has conflicting live bytes",551 ));552 }553 Ok(RecoveryOutcome {554 journal: Some(journal),555 completion: None,556 })557 }558 TransactionState::Committing => {559 if loaded.revision != journal.candidate_revision {560 return Err(transaction_conflict(561 "committing transaction has conflicting live bytes",562 ));563 }564 cleanup_transaction(&state_directory)?;565 Ok(RecoveryOutcome {566 journal: None,567 completion: Some(RecoveryCompletion::Committed(journal)),568 })569 }570 TransactionState::RollingBack => {571 if loaded.revision == journal.candidate_revision {572 let base = read_artifact(&state_directory, BASE_FILE, &journal.base_revision)?;573 replace_config(config_path, &base)?;574 *loaded = load_config(config_path)?;575 }576 if loaded.revision != journal.base_revision {577 return Err(transaction_conflict(578 "rolling-back transaction has conflicting live bytes",579 ));580 }581 cleanup_transaction(&state_directory)?;582 Ok(RecoveryOutcome {583 journal: None,584 completion: Some(RecoveryCompletion::RolledBack(journal)),585 })586 }587 }588}589590fn load_config(path: &Path) -> ManagementResult<LoadedHostConfig> {591 HostConfig::load_document(path)592 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))593}594595pub fn prepare_service(config_path: &Path) -> ManagementResult<(LoadedHostConfig, LifecycleGuard)> {596 let bootstrap = load_config(config_path)?;597 let state_directory = bootstrap.config.state_path.clone();598 bootstrap599 .config600 .ensure_runtime_directory()601 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;602 let lifecycle = match LifecycleGuard::try_acquire(&state_directory, true) {603 Ok(lifecycle) => lifecycle,604 Err(error) => return Err(classify_lifecycle_error(&state_directory, error)),605 };606 finish_service_prepare(config_path, &bootstrap, lifecycle)607}608609fn finish_service_prepare(610 config_path: &Path,611 bootstrap: &LoadedHostConfig,612 lifecycle: LifecycleGuard,613) -> ManagementResult<(LoadedHostConfig, LifecycleGuard)> {614 let state_directory = bootstrap.config.state_path.clone();615 let _config_lock = classify_config_lock(secure_state::try_open_lifetime_lock(616 &state_directory,617 CONFIG_LOCK_FILE,618 ))?;619 let mut loaded = load_config(config_path)?;620 ensure_bootstrap_paths_unchanged(bootstrap, &loaded)?;621 recover(config_path, &mut loaded)?;622 loaded = load_config(config_path)?;623 ensure_bootstrap_paths_unchanged(bootstrap, &loaded)?;624 Ok((loaded, lifecycle))625}626627fn acquire_management_locks(628 state_directory: &Path,629 require_global: bool,630) -> ManagementResult<(LifecycleGuard, fs::File)> {631 let lifecycle = match LifecycleGuard::try_acquire(state_directory, require_global) {632 Ok(lifecycle) => lifecycle,633 Err(error) => return Err(classify_lifecycle_error(state_directory, error)),634 };635 finish_acquire_management_locks(state_directory, lifecycle)636}637638fn acquire_management_locks_at(639 state_directory: &Path,640 runtime_directory: Option<&Path>,641) -> ManagementResult<(LifecycleGuard, fs::File)> {642 let lifecycle = match LifecycleGuard::try_acquire_at(state_directory, runtime_directory) {643 Ok(lifecycle) => lifecycle,644 Err(error) => return Err(classify_lifecycle_error(state_directory, error)),645 };646 finish_acquire_management_locks(state_directory, lifecycle)647}648649fn finish_acquire_management_locks(650 state_directory: &Path,651 lifecycle: LifecycleGuard,652) -> ManagementResult<(LifecycleGuard, fs::File)> {653 let config_lock = classify_config_lock(secure_state::try_open_lifetime_lock(654 state_directory,655 CONFIG_LOCK_FILE,656 ))?;657 Ok((lifecycle, config_lock))658}659660fn classify_lifecycle_error(661 _state_directory: &Path,662 error: secure_state::LockAcquireError,663) -> ManagementError {664 match error {665 secure_state::LockAcquireError::Unsafe(error) => {666 ManagementError::new(ManagementErrorCode::UnsafeStorage, error)667 }668 secure_state::LockAcquireError::Busy => ManagementError::new(669 ManagementErrorCode::LifecycleBusy,670 anyhow::anyhow!("Sigil lifecycle lock is held"),671 ),672 }673}674675fn classify_config_lock(676 result: std::result::Result<fs::File, secure_state::LockAcquireError>,677) -> ManagementResult<fs::File> {678 result.map_err(|error| match error {679 secure_state::LockAcquireError::Busy => ManagementError::new(680 ManagementErrorCode::TransactionBusy,681 anyhow::anyhow!("another configuration transaction is active"),682 ),683 secure_state::LockAcquireError::Unsafe(error) => {684 ManagementError::new(ManagementErrorCode::UnsafeStorage, error)685 }686 })687}688689fn require_expected_revision(690 loaded: &LoadedHostConfig,691 request: &ConfigRequestV1,692) -> ManagementResult<()> {693 ConfigRevision::parse(request.expected_revision.as_str())694 .map_err(|error| ManagementError::new(ManagementErrorCode::InvalidRequest, error))?;695 if loaded.revision != request.expected_revision {696 return Err(ManagementError::new(697 ManagementErrorCode::RevisionConflict,698 anyhow::anyhow!("configuration revision changed"),699 ));700 }701 Ok(())702}703704fn build_candidate(705 loaded: &LoadedHostConfig,706 request: &ConfigRequestV1,707) -> ManagementResult<LoadedHostConfig> {708 let source = std::str::from_utf8(&loaded.bytes)709 .map_err(|error| ManagementError::new(ManagementErrorCode::ValidationFailed, error))?;710 let mut document = source711 .parse::<DocumentMut>()712 .map_err(|error| ManagementError::new(ManagementErrorCode::ValidationFailed, error))?;713 document["framerate"] = value(i64::from(request.settings.framerate));714 match request.settings.resolution {715 ManagedResolutionV1::Native => {716 document.remove("width");717 document.remove("height");718 }719 ManagedResolutionV1::Fixed { width, height } => {720 document["width"] = value(i64::from(width));721 document["height"] = value(i64::from(height));722 }723 }724 match (725 loaded.config.source.clone(),726 request.settings.rate_control.clone(),727 ) {728 (VideoSource::TestPattern, None) => {}729 (VideoSource::GamescopePipewire, Some(rate_control)) => {730 let table = document731 .get_mut("gamescope_pipewire")732 .and_then(toml_edit::Item::as_table_mut)733 .ok_or_else(|| {734 ManagementError::new(735 ManagementErrorCode::ValidationFailed,736 anyhow::anyhow!("gamescope configuration table is missing"),737 )738 })?;739 match rate_control {740 ManagedRateControlV1::Cbr { bitrate_kbps } => {741 table["rate_control"] = value("cbr");742 table["bitrate_kbps"] = value(i64::from(bitrate_kbps));743 table.remove("quantizer");744 }745 ManagedRateControlV1::Cqp { quantizer } => {746 table["rate_control"] = value("cqp");747 table["quantizer"] = value(i64::from(quantizer));748 table.remove("bitrate_kbps");749 }750 }751 }752 _ => {753 return Err(ManagementError::new(754 ManagementErrorCode::ValidationFailed,755 anyhow::anyhow!("rate-control settings do not match the configured source"),756 ));757 }758 }759 let bytes = document.to_string().into_bytes();760 let config = HostConfig::parse(&bytes)761 .map_err(|error| ManagementError::new(ManagementErrorCode::ValidationFailed, error))?;762 if config.identity_path != loaded.config.identity_path763 || config.state_path != loaded.config.state_path764 {765 return Err(ManagementError::new(766 ManagementErrorCode::ValidationFailed,767 anyhow::anyhow!("managed configuration cannot change identity or state paths"),768 ));769 }770 Ok(LoadedHostConfig {771 revision: ConfigRevision::from_bytes(&bytes),772 bytes,773 config,774 })775}776777fn project_settings(config: &HostConfig) -> ManagementResult<ManagedSettingsV1> {778 let resolution = match config.configured_dimensions() {779 Some((width, height)) => ManagedResolutionV1::Fixed { width, height },780 None => ManagedResolutionV1::Native,781 };782 let rate_control = match (&config.source, &config.gamescope_pipewire) {783 (VideoSource::TestPattern, None) => None,784 (VideoSource::GamescopePipewire, Some(gamescope)) => Some(match gamescope.rate_control {785 VaapiRateControl::Cbr => ManagedRateControlV1::Cbr {786 bitrate_kbps: gamescope787 .bitrate_kbps788 .expect("validated CBR bitrate is present"),789 },790 VaapiRateControl::Cqp => ManagedRateControlV1::Cqp {791 quantizer: gamescope792 .quantizer793 .expect("validated CQP quantizer is present"),794 },795 }),796 _ => {797 return Err(ManagementError::new(798 ManagementErrorCode::ValidationFailed,799 anyhow::anyhow!("validated source configuration is inconsistent"),800 ));801 }802 };803 Ok(ManagedSettingsV1 {804 resolution,805 framerate: config.framerate,806 rate_control,807 })808}809810fn ensure_bootstrap_paths_unchanged(811 bootstrap: &LoadedHostConfig,812 current: &LoadedHostConfig,813) -> ManagementResult<()> {814 if bootstrap.config.state_path != current.config.state_path815 || bootstrap.config.identity_path != current.config.identity_path816 {817 return Err(transaction_conflict(818 "configuration identity or state path changed while acquiring locks",819 ));820 }821 Ok(())822}823824fn replace_config(config_path: &Path, bytes: &[u8]) -> ManagementResult<()> {825 let parent = config_path826 .parent()827 .filter(|path| !path.as_os_str().is_empty())828 .unwrap_or(Path::new("."));829 let file_name = config_path830 .file_name()831 .and_then(|name| name.to_str())832 .ok_or_else(|| {833 ManagementError::new(834 ManagementErrorCode::UnsafeStorage,835 anyhow::anyhow!("config filename is invalid"),836 )837 })?;838 secure_state::atomic_write_exact_in_integrity_protected_directory(839 parent,840 file_name,841 bytes,842 MAX_CONFIG_BYTES,843 )844 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))845}846847fn write_artifact(state_directory: &Path, file_name: &str, bytes: &[u8]) -> ManagementResult<()> {848 secure_state::atomic_write_exact(state_directory, file_name, bytes, MAX_CONFIG_BYTES)849 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))850}851852fn read_artifact(853 state_directory: &Path,854 file_name: &str,855 expected_revision: &ConfigRevision,856) -> ManagementResult<Vec<u8>> {857 let bytes = secure_state::read_bounded(state_directory, file_name, MAX_CONFIG_BYTES)858 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?859 .ok_or_else(|| transaction_conflict("configuration recovery artifact is missing"))?;860 if ConfigRevision::from_bytes(&bytes) != *expected_revision {861 return Err(transaction_conflict(862 "configuration recovery artifact revision does not match its journal",863 ));864 }865 Ok(bytes)866}867868fn write_journal(state_directory: &Path, journal: &TransactionJournalV1) -> ManagementResult<()> {869 let bytes = serde_json::to_vec(journal)870 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;871 secure_state::atomic_write(state_directory, JOURNAL_FILE, &bytes, MAX_JOURNAL_BYTES)872 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))873}874875fn read_journal(state_directory: &Path) -> ManagementResult<Option<TransactionJournalV1>> {876 let Some(bytes) = secure_state::read_bounded(state_directory, JOURNAL_FILE, MAX_JOURNAL_BYTES)877 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?878 else {879 return Ok(None);880 };881 let journal: TransactionJournalV1 = serde_json::from_slice(&bytes)882 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;883 Ok(Some(journal))884}885886fn validate_journal(journal: &TransactionJournalV1, config_path: &Path) -> ManagementResult<()> {887 if journal.version != JOURNAL_VERSION {888 return Err(ManagementError::new(889 ManagementErrorCode::UnsupportedSchema,890 anyhow::anyhow!("unsupported configuration transaction journal"),891 ));892 }893 validate_stored_id(&journal.transaction)?;894 ConfigRevision::parse(journal.base_revision.as_str())895 .and_then(|_| ConfigRevision::parse(journal.candidate_revision.as_str()))896 .and_then(|_| ConfigRevision::parse(journal.config_path_id.as_str()))897 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;898 if journal.config_path_id != config_path_id(config_path)? {899 return Err(transaction_conflict(900 "configuration transaction belongs to a different config file",901 ));902 }903 if journal.base_revision == journal.candidate_revision {904 return Err(ManagementError::new(905 ManagementErrorCode::UnsafeStorage,906 anyhow::anyhow!("transaction revisions must differ"),907 ));908 }909 if let Some(instance) = &journal.baseline_runtime_instance {910 validate_stored_id(instance)?;911 }912 if let Some(runtime_directory_id) = &journal.runtime_directory_id {913 ConfigRevision::parse(runtime_directory_id.as_str())914 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;915 }916 Ok(())917}918919fn config_path_id(path: &Path) -> ManagementResult<ConfigRevision> {920 canonical_path_id(path, "configuration")921}922923fn runtime_directory_id(path: &Path) -> ManagementResult<ConfigRevision> {924 canonical_path_id(path, "runtime directory")925}926927fn canonical_path_id(path: &Path, description: &str) -> ManagementResult<ConfigRevision> {928 let canonical = path929 .canonicalize()930 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;931 let value = canonical.to_str().ok_or_else(|| {932 ManagementError::new(933 ManagementErrorCode::UnsafeStorage,934 anyhow::anyhow!("{description} path is not valid UTF-8"),935 )936 })?;937 Ok(ConfigRevision::from_bytes(value.as_bytes()))938}939940fn require_transaction(journal: &TransactionJournalV1, expected: &str) -> ManagementResult<()> {941 if journal.transaction != expected {942 return Err(transaction_conflict(943 "configuration transaction ID does not match",944 ));945 }946 Ok(())947}948949fn validate_transaction_id(value: &str) -> ManagementResult<()> {950 if value.len() != 32951 || !value952 .bytes()953 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())954 {955 return Err(ManagementError::new(956 ManagementErrorCode::InvalidRequest,957 anyhow::anyhow!("transaction identifier is invalid"),958 ));959 }960 Ok(())961}962963fn validate_stored_id(value: &str) -> ManagementResult<()> {964 validate_transaction_id(value)965 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error.source))966}967968fn random_transaction_id() -> ManagementResult<String> {969 let mut bytes = [0_u8; 16];970 getrandom::fill(&mut bytes)971 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;972 let mut value = String::with_capacity(32);973 for byte in bytes {974 use std::fmt::Write as _;975 write!(&mut value, "{byte:02x}").expect("writing into a String cannot fail");976 }977 Ok(value)978}979980fn cleanup_transaction(state_directory: &Path) -> ManagementResult<()> {981 remove_artifact_if_present(state_directory, BASE_FILE)?;982 remove_artifact_if_present(state_directory, CANDIDATE_FILE)?;983 remove_artifact_if_present(state_directory, JOURNAL_FILE)?;984 Ok(())985}986987fn remove_artifact_if_present(state_directory: &Path, file_name: &str) -> ManagementResult<()> {988 secure_state::remove_file_if_exists(state_directory, file_name)989 .map(|_| ())990 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))991}992993fn transaction_conflict(message: &'static str) -> ManagementError {994 ManagementError::new(995 ManagementErrorCode::TransactionConflict,996 anyhow::anyhow!(message),997 )998}99910001001mod tests {1002 use super::*;10031004 struct Fixture {1005 _directory: tempfile::TempDir,1006 config_path: std::path::PathBuf,1007 state_directory: std::path::PathBuf,1008 base: LoadedHostConfig,1009 }10101011 impl Fixture {1012 fn new() -> Self {1013 let directory = tempfile::tempdir().unwrap();1014 1015 {1016 use std::os::unix::fs::PermissionsExt as _;1017 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o755)).unwrap();1018 }1019 let state_directory = directory.path().join("state");1020 secure_state::ensure_private_directory(&state_directory).unwrap();1021 let config_path = directory.path().join("host.toml");1022 let identity_path = directory.path().join("host.key");1023 let bytes = format!(1024 "# retained operator comment\nidentity_path = {:?}\nstate_path = {:?}\nsource = \"test-pattern\"\nwidth = 1280\nheight = 800\nframerate = 60\ncodec = \"h264\"\n",1025 identity_path, state_directory1026 )1027 .into_bytes();1028 fs::write(&config_path, bytes).unwrap();1029 let base = HostConfig::load_document(&config_path).unwrap();1030 Self {1031 _directory: directory,1032 config_path,1033 state_directory,1034 base,1035 }1036 }10371038 fn request(&self, framerate: u32) -> ConfigRequestV1 {1039 ConfigRequestV1 {1040 _schema_version: REQUEST_VERSION,1041 expected_revision: self.base.revision.clone(),1042 settings: ManagedSettingsV1 {1043 resolution: ManagedResolutionV1::Native,1044 framerate,1045 rate_control: None,1046 },1047 }1048 }10491050 fn prepared(&self, state: TransactionState) -> (TransactionJournalV1, LoadedHostConfig) {1051 let candidate = build_candidate(&self.base, &self.request(72)).unwrap();1052 write_artifact(&self.state_directory, BASE_FILE, &self.base.bytes).unwrap();1053 write_artifact(&self.state_directory, CANDIDATE_FILE, &candidate.bytes).unwrap();1054 let journal = TransactionJournalV1 {1055 version: JOURNAL_VERSION,1056 transaction: "0123456789abcdef0123456789abcdef".to_owned(),1057 state,1058 config_path_id: config_path_id(&self.config_path).unwrap(),1059 base_revision: self.base.revision.clone(),1060 candidate_revision: candidate.revision.clone(),1061 baseline_runtime_instance: None,1062 runtime_directory_id: None,1063 };1064 write_journal(&self.state_directory, &journal).unwrap();1065 (journal, candidate)1066 }1067 }10681069 1070 fn bounded_request_rejects_unknown_fields_and_oversize_input() {1071 let unknown = br#"{"schema_version":1,"expected_revision":"sha256:0000000000000000000000000000000000000000000000000000000000000000","settings":{"resolution":{"mode":"native"},"framerate":60,"rate_control":null},"unexpected":true}"#;1072 assert_eq!(1073 read_request(&unknown[..]).unwrap_err().code,1074 ManagementErrorCode::InvalidRequest1075 );1076 assert_eq!(1077 read_request(&vec![b' '; MAX_REQUEST_BYTES as usize + 1][..])1078 .unwrap_err()1079 .code,1080 ManagementErrorCode::InvalidRequest1081 );1082 let future = br#"{"schema_version":2,"future_shape":{"anything":true}}"#;1083 assert_eq!(1084 read_request(&future[..]).unwrap_err().code,1085 ManagementErrorCode::UnsupportedSchema1086 );1087 let duplicate = br#"{"schema_version":1,"schema_version":2,"expected_revision":"sha256:0000000000000000000000000000000000000000000000000000000000000000","settings":{"resolution":{"mode":"native"},"framerate":60,"rate_control":null}}"#;1088 assert_eq!(1089 read_request(&duplicate[..]).unwrap_err().code,1090 ManagementErrorCode::InvalidRequest1091 );1092 let duplicate_expected = br#"{"schema_version":1,"expected_revision":"sha256:0000000000000000000000000000000000000000000000000000000000000000","expected_revision":"sha256:1111111111111111111111111111111111111111111111111111111111111111","settings":{"resolution":{"mode":"native"},"framerate":60,"rate_control":null}}"#;1093 assert_eq!(1094 read_request(&duplicate_expected[..]).unwrap_err().code,1095 ManagementErrorCode::InvalidRequest1096 );1097 }10981099 1100 fn candidate_preserves_comments_and_immutable_paths() {1101 let fixture = Fixture::new();1102 let candidate = build_candidate(&fixture.base, &fixture.request(72)).unwrap();1103 let text = std::str::from_utf8(&candidate.bytes).unwrap();1104 assert!(text.contains("# retained operator comment"));1105 assert!(!text.contains("width ="));1106 assert!(!text.contains("height ="));1107 assert!(text.contains("framerate = 72"));1108 assert_eq!(1109 candidate.config.identity_path,1110 fixture.base.config.identity_path1111 );1112 assert_eq!(candidate.config.state_path, fixture.base.config.state_path);1113 }11141115 1116 fn invalid_explicit_runtime_root_fails_before_config_mutation() {1117 let fixture = Fixture::new();1118 let original = fs::read(&fixture.config_path).unwrap();1119 let error = set(1120 &fixture.config_path,1121 &fixture.request(72),1122 Some(Path::new("relative-runtime")),1123 )1124 .unwrap_err();1125 assert_eq!(error.code, ManagementErrorCode::UnsafeStorage);1126 assert_eq!(fs::read(&fixture.config_path).unwrap(), original);1127 assert!(read_journal(&fixture.state_directory).unwrap().is_none());1128 assert!(!fixture.state_directory.join(BASE_FILE).exists());1129 assert!(!fixture.state_directory.join(CANDIDATE_FILE).exists());1130 }11311132 1133 1134 fn writable_config_parent_fails_before_transaction_mutation() {1135 use std::os::unix::fs::PermissionsExt as _;11361137 let fixture = Fixture::new();1138 let original = fs::read(&fixture.config_path).unwrap();1139 fs::set_permissions(fixture._directory.path(), fs::Permissions::from_mode(0o775)).unwrap();1140 let error = set(&fixture.config_path, &fixture.request(72), None).unwrap_err();1141 assert_eq!(error.code, ManagementErrorCode::UnsafeStorage);1142 assert_eq!(fs::read(&fixture.config_path).unwrap(), original);1143 assert!(read_journal(&fixture.state_directory).unwrap().is_none());1144 assert!(!fixture.state_directory.join(BASE_FILE).exists());1145 assert!(!fixture.state_directory.join(CANDIDATE_FILE).exists());1146 }11471148 1149 fn prepared_recovery_forward_applies_the_exact_candidate() {1150 let fixture = Fixture::new();1151 let (journal, candidate) = fixture.prepared(TransactionState::Prepared);1152 let mut loaded = fixture.base.clone();1153 let outcome = recover(&fixture.config_path, &mut loaded).unwrap();1154 assert_eq!(loaded.bytes, candidate.bytes);1155 assert_eq!(loaded.revision, journal.candidate_revision);1156 assert_eq!(1157 outcome.journal.unwrap().state,1158 TransactionState::PendingValidation1159 );1160 }11611162 1163 fn configured_service_recovers_before_using_its_definitive_reload() {1164 let fixture = Fixture::new();1165 let (_journal, candidate) = fixture.prepared(TransactionState::Prepared);1166 let runtime = tempfile::tempdir().unwrap();1167 1168 {1169 use std::os::unix::fs::PermissionsExt as _;1170 fs::set_permissions(runtime.path(), fs::Permissions::from_mode(0o700)).unwrap();1171 }1172 let lifecycle =1173 LifecycleGuard::try_acquire_at(&fixture.state_directory, Some(runtime.path())).unwrap();1174 let (loaded, _lifecycle) =1175 finish_service_prepare(&fixture.config_path, &fixture.base, lifecycle).unwrap();1176 assert_eq!(loaded.revision, candidate.revision);1177 assert_eq!(loaded.bytes, candidate.bytes);1178 assert_eq!(1179 read_journal(&fixture.state_directory)1180 .unwrap()1181 .unwrap()1182 .state,1183 TransactionState::PendingValidation1184 );1185 }11861187 1188 fn pending_recovery_rejects_untracked_live_bytes() {1189 let fixture = Fixture::new();1190 fixture.prepared(TransactionState::PendingValidation);1191 let conflicting = fixture1192 .base1193 .bytes1194 .replace_range_owned(b"framerate = 60", b"framerate = 90");1195 replace_config(&fixture.config_path, &conflicting).unwrap();1196 let mut loaded = load_config(&fixture.config_path).unwrap();1197 let Err(error) = recover(&fixture.config_path, &mut loaded) else {1198 panic!("untracked live bytes must fail closed");1199 };1200 assert_eq!(error.code, ManagementErrorCode::TransactionConflict);1201 }12021203 1204 fn rolling_back_recovery_finishes_after_base_restore_and_partial_cleanup() {1205 let fixture = Fixture::new();1206 let (journal, _candidate) = fixture.prepared(TransactionState::RollingBack);1207 replace_config(&fixture.config_path, &fixture.base.bytes).unwrap();1208 secure_state::remove_file_if_exists(&fixture.state_directory, BASE_FILE).unwrap();1209 let mut loaded = load_config(&fixture.config_path).unwrap();1210 let outcome = recover(&fixture.config_path, &mut loaded).unwrap();1211 assert_eq!(loaded.revision, fixture.base.revision);1212 assert!(matches!(1213 outcome.completion,1214 Some(RecoveryCompletion::RolledBack(completed))1215 if completed.transaction == journal.transaction1216 ));1217 assert!(read_journal(&fixture.state_directory).unwrap().is_none());1218 assert!(1219 secure_state::read_bounded(&fixture.state_directory, CANDIDATE_FILE, MAX_CONFIG_BYTES)1220 .unwrap()1221 .is_none()1222 );1223 }12241225 1226 fn committing_recovery_finishes_cleanup_without_artifacts() {1227 let fixture = Fixture::new();1228 let (journal, candidate) = fixture.prepared(TransactionState::Committing);1229 replace_config(&fixture.config_path, &candidate.bytes).unwrap();1230 secure_state::remove_file_if_exists(&fixture.state_directory, BASE_FILE).unwrap();1231 secure_state::remove_file_if_exists(&fixture.state_directory, CANDIDATE_FILE).unwrap();1232 let mut loaded = load_config(&fixture.config_path).unwrap();1233 let outcome = recover(&fixture.config_path, &mut loaded).unwrap();1234 assert_eq!(loaded.revision, candidate.revision);1235 assert!(matches!(1236 outcome.completion,1237 Some(RecoveryCompletion::Committed(completed))1238 if completed.transaction == journal.transaction1239 ));1240 assert!(read_journal(&fixture.state_directory).unwrap().is_none());1241 }12421243 1244 fn malformed_journal_identifier_is_unsafe_storage_not_caller_input() {1245 let fixture = Fixture::new();1246 let (mut journal, _candidate) = fixture.prepared(TransactionState::Prepared);1247 journal.transaction = "NOT-A-TRANSACTION-ID".to_owned();1248 write_journal(&fixture.state_directory, &journal).unwrap();1249 let mut loaded = fixture.base.clone();1250 let Err(error) = recover(&fixture.config_path, &mut loaded) else {1251 panic!("malformed protected journal must fail closed");1252 };1253 assert_eq!(error.code, ManagementErrorCode::UnsafeStorage);1254 }12551256 1257 fn malformed_runtime_directory_binding_is_unsafe_storage() {1258 let fixture = Fixture::new();1259 let (journal, _candidate) = fixture.prepared(TransactionState::PendingValidation);1260 let mut value = serde_json::to_value(journal).unwrap();1261 value["runtime_directory_id"] = serde_json::json!("not-a-revision");1262 secure_state::atomic_write(1263 &fixture.state_directory,1264 JOURNAL_FILE,1265 &serde_json::to_vec(&value).unwrap(),1266 MAX_JOURNAL_BYTES,1267 )1268 .unwrap();1269 assert_eq!(1270 read_journal(&fixture.state_directory).unwrap_err().code,1271 ManagementErrorCode::UnsafeStorage1272 );1273 }12741275 1276 fn lock_failures_do_not_probe_or_invert_lock_order() {1277 let fixture = Fixture::new();1278 assert_eq!(1279 classify_lifecycle_error(1280 &fixture.state_directory,1281 secure_state::LockAcquireError::Busy,1282 )1283 .code,1284 ManagementErrorCode::LifecycleBusy1285 );12861287 1288 {1289 use std::os::unix::fs::PermissionsExt as _;1290 fs::set_permissions(&fixture.state_directory, fs::Permissions::from_mode(0o755))1291 .unwrap();1292 assert_eq!(1293 classify_config_lock(secure_state::try_open_lifetime_lock(1294 &fixture.state_directory,1295 CONFIG_LOCK_FILE,1296 ))1297 .unwrap_err()1298 .code,1299 ManagementErrorCode::UnsafeStorage1300 );1301 }1302 }13031304 trait ReplaceRangeOwned {1305 fn replace_range_owned(&self, from: &[u8], to: &[u8]) -> Vec<u8>;1306 }13071308 impl ReplaceRangeOwned for Vec<u8> {1309 fn replace_range_owned(&self, from: &[u8], to: &[u8]) -> Vec<u8> {1310 let offset = self1311 .windows(from.len())1312 .position(|window| window == from)1313 .unwrap();1314 let mut result = self.clone();1315 result.splice(offset..offset + from.len(), to.iter().copied());1316 result1317 }1318 }1319}