//! Application session operations for prepared client changes and saved state. //! //! This module owns the boundary between a byte-preserving configuration draft, //! encrypted vault data, backups, the recoverable saved-file adapter, and the //! explicit runtime-restart confirmation boundary. mod backups; mod commit; mod draft; mod restore; use draft::DraftChanges; use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsStr; use std::fmt; use std::net::IpAddr; use std::time::{Duration, Instant}; use thiserror::Error; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use crate::backup::{self, BackupError, BackupReason, HistoricalApplyStatus, SavedBytes}; use crate::delivery::files::{self, FileOutputError, PreparedDestination}; use crate::delivery::qr; use crate::delivery::{self, ClientConfigPayload, DeliverySettings, QrCodeOutput, QrError}; use crate::domain::{ AddressAvailability, ApplyStatus, Client, ClientAddresses, ClientStates, DomainError, PublicKey, Snapshot, allocate_ipv4, allocate_ipv6, validate_manual_ipv4, validate_manual_ipv6, }; use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore}; use crate::runtime::transport::TransportError; use crate::runtime::{self, RuntimeObservation, SavedPeerProjection, TechnicalMatch}; use crate::service::{self, RestartDeadline, RestartExecution, RestartOutcome}; use crate::vault::source::{ PreparedImportSetup, SelectedFileImportReport, SourceImportError, SourceImportReport, }; use crate::vault::{VaultBundle, VaultError, WireGuardPrivateKey}; use crate::wireguard::SectionKind; use crate::wireguard::{ConfigDocument, DocumentError, EditError}; /// Secret-free row for displaying every authoritative peer section. /// `mutable_identity` is present only when the peer has an unambiguous, /// supported projection into the client domain model. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ClientDisplay { pub peer_index: usize, pub line: usize, pub identity: Option, pub label: String, pub allowed_ips: Vec, pub has_subnet_routes: bool, pub issues: Vec, pub mutable_identity: Option, } impl ClientDisplay { /// Whether a retained parser issue describes a valid address shape the /// client model cannot manage, rather than a malformed configuration. pub fn is_capability_limitation_issue(&self, issue: &crate::wireguard::PeerIssue) -> bool { matches!( issue, crate::wireguard::PeerIssue::UnsupportedAllowedIpShape { .. } | crate::wireguard::PeerIssue::MissingIpv4HostRoute ) } pub fn presented_issues(&self) -> impl Iterator { self.issues .iter() .filter(|issue| !self.is_capability_limitation_issue(issue)) } } /// Secret-free display projection of the current document and client state. #[derive(Clone, Debug, Eq, PartialEq)] pub struct SessionDisplay { pub clients: Vec, pub document_issues: Vec, } /// An open, in-memory management session. The configuration and state drafts /// change independently of installed bytes until [`Self::commit`] succeeds. pub struct Application { store: SavedStore, saved: SavedBytes, draft: DraftChanges, historical_apply: HistoricalApplyStatus, verified_saved_projection: Option, active_restart_attempt: Option, next_restart_attempt: u64, next_issue_authorization: u64, initial_issues: BTreeMap, } #[allow(dead_code)] // M6.3 consumes the capability and its session-held key. struct InitialIssue { private_key: WireGuardPrivateKey, context: IssueContext, stage: IssueStage, } #[derive(Clone, Copy, Eq, PartialEq)] enum IssueStage { PendingDraft, AwaitingFirstFile, RetryAvailable, Authorizing(u64), RetryNeedsOverwriteConfirmation, RetryNeedsManualCleanup, } #[derive(Clone, Eq, PartialEq)] struct IssueContext { peer_directives: String, encrypted_record: String, } /// Private, operation-local authorization for a single first-file publication. /// Plaintext is never returned to a caller outside `deliver_first_issue`. struct FirstIssueAuthorization { identity: PublicKey, context: IssueContext, token: u64, payload: ClientConfigPayload, } /// How a failed file operation affected publication. The caller must report /// uncertainty honestly and request overwrite confirmation before retrying it. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[allow(dead_code)] // M6.3 constructs the delivery outcomes in production. pub(crate) enum FirstIssueDeliveryFailure { BeforePublication, PublicationUncertain, CleanupRequired, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum FirstIssueRetry { Available, OverwriteConfirmationRequired, /// Inspect and remove any retained private temporary file before retrying. ManualCleanupRequired, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] /// Confirmation flags acknowledge the specific recovery action reported by the /// preceding attempt; they do not authorize deleting any file automatically. pub enum FirstIssueAttempt { Initial, Retry { confirm_possible_overwrite: bool, confirm_manual_cleanup: bool, }, } /// Result of one bounded publication closure. The returned value can carry /// operation-local material (for example a QR derived from the same payload). #[allow(dead_code)] // M6.3 consumes the result of the publication boundary. pub enum FirstIssueDeliveryOutcome { Delivered(T), /// Publication completed, but the saved-state check that consumes the /// first-issue capability failed. The file exists; reissue now requires a /// fresh password authorization. PublishedButBookkeepingUncertain { value: T, error: AppError, }, Failed { error: E, retry: FirstIssueRetry, /// The committed client is not verified as applied to the live /// interface. Keep this warning available even when publication is /// uncertain and the caller must ask before retrying. activation_warning: bool, }, } /// Result of one mandatory file publication with an optional QR request. /// The outer first-issue outcome reports file/bookkeeping status; this value /// reports QR status independently so encoding failure cannot undo publication. pub struct FirstIssueFileAndQr { pub activation_warning: bool, pub qr: Result, } /// Result metadata for a successful first-issue file publication. pub struct FirstIssueFileDelivery { pub activation_warning: bool, } /// Stage at which a password-authorized reissue stopped. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ReissueStage { SavedState, Authorization, Rendering, FilePublication, } /// Fixed, secret-free reissue failure categories. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ReissueFailureKind { SavedStateUnavailable, VaultUnavailable, PasswordOrRecordRejected, ConfigurationRejected, FileOutput(FileOutputError), } /// A reissue failure identifies its stage without retaining password or payload data. #[derive(Clone, Copy, Debug, Eq, PartialEq, Error)] #[error("client reissue failed during {stage:?}: {kind:?}")] pub struct ReissueError { pub stage: ReissueStage, pub kind: ReissueFailureKind, } /// Result of mandatory file delivery and optional QR encoding from the same bytes. pub struct ReissueDelivery { pub activation_warning: bool, pub qr: Option>, } impl fmt::Debug for ReissueDelivery { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("ReissueDelivery") .field("activation_warning", &self.activation_warning) .field("qr_requested", &self.qr.is_some()) .field( "qr", &self .qr .as_ref() .map(|result| result.as_ref().map(|_| "[REDACTED]")), ) .finish() } } /// Read-only, saved-generation-bound restart preview for a future confirmation UI. pub struct RestartPreview { saved: SavedBytes, interface: String, unit: String, timeout: Duration, projection: SavedPeerProjection, expected_clients: Option, has_draft: bool, } impl RestartPreview { pub fn interface(&self) -> &str { &self.interface } pub fn systemd_unit(&self) -> &str { &self.unit } pub const fn has_draft(&self) -> bool { self.has_draft } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum RestartChoice { Cancel, RestartSavedState, DiscardDraftAndRestart, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum RestartStage { Cancelled, Preparation, Backup, Service, RuntimeRead, Verification, Persistence, Complete, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum RestartFailureKind { InvalidOrStaleSavedGeneration, UnsafeSaveConfig, UnsupportedSavedShape, BackupFailed, DraftDiscardFailed, ServiceFailed, RuntimeReadFailed, RuntimeNoDevice, RuntimePermissionDenied, RuntimeMalformed, RuntimeTransport, RuntimeTimeout, RuntimeLimitExceeded, RuntimeMismatch, VerificationPersistenceFailed, } /// Secret-free explanation for saved configuration that cannot be used as a /// restart verification target. The identity is a public WireGuard key. #[derive(Clone, Debug, Eq, PartialEq)] pub struct RestartDiagnostic { pub identity: Option, pub section_line: Option, pub reason: &'static str, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct RestartReport { pub stage: RestartStage, pub failure: Option, pub service_failure: Option, pub service_phase: Option, pub systemd_unit: Option, pub backup_path: Option, pub restart_submitted: bool, pub restart_may_still_complete: bool, pub manual_recovery_required: bool, } struct RestartAttempt { id: u64, saved: SavedBytes, } /// Read-only restore details and an opaque, byte-bound confirmation value. /// The value contains the validated archive payload; confirmation never rereads /// a path whose contents may have changed since preview. pub struct RestorePreview { created_at_utc: String, reason: BackupReason, historical_apply: HistoricalApplyStatus, installed_generation: SavedBytes, saved_bytes: SavedBytes, } impl RestorePreview { pub fn created_at_utc(&self) -> &str { &self.created_at_utc } pub const fn reason(&self) -> BackupReason { self.reason } pub fn historical_apply(&self) -> &HistoricalApplyStatus { &self.historical_apply } /// Safe comparison against current saved bytes for a restore confirmation screen. pub fn change_summary(&self, current: &SavedBytes) -> RestoreChangeSummary { let peer_map = |bytes: &[u8]| { ConfigDocument::parse(bytes) .map(|document| { document .peers() .iter() .filter_map(|peer| { peer.identity.map(|key| { ( key, peer.allowed_ips .iter() .map(ToString::to_string) .collect::>(), ) }) }) .collect::>() }) .unwrap_or_default() }; let current_peer_map = peer_map(¤t.server_config); let proposed_peer_map = peer_map(&self.saved_bytes.server_config); let current_peers = current_peer_map.keys().copied().collect::>(); let proposed_peers = proposed_peer_map.keys().copied().collect::>(); let changed_clients = current_peers .intersection(&proposed_peers) .filter(|key| current_peer_map.get(key) != proposed_peer_map.get(key)) .copied() .collect(); RestoreChangeSummary { settings_changed: current.settings != self.saved_bytes.settings, settings_fields_changed: settings_fields_changed( ¤t.settings, &self.saved_bytes.settings, ), added_clients: proposed_peers.difference(¤t_peers).copied().collect(), removed_clients: current_peers.difference(&proposed_peers).copied().collect(), changed_clients, server_config_changed: current.server_config != self.saved_bytes.server_config, vault_changed: current.vault != self.saved_bytes.vault, } } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct RestoreChangeSummary { pub settings_changed: bool, pub settings_fields_changed: Vec<&'static str>, pub server_config_changed: bool, pub vault_changed: bool, pub added_clients: Vec, pub removed_clients: Vec, pub changed_clients: Vec, } fn settings_fields_changed(current: &[u8], proposed: &[u8]) -> Vec<&'static str> { let parse = |bytes: &[u8]| { std::str::from_utf8(bytes) .ok() .and_then(|text| crate::config::SettingsFile::parse_toml(text).ok()) }; let (Some(current), Some(proposed)) = (parse(current), parse(proposed)) else { return vec!["settings file"]; }; let mut fields = Vec::new(); if current.interface_name != proposed.interface_name { fields.push("interface name"); } if current.server_config_path != proposed.server_config_path { fields.push("server configuration path"); } if current.client_source_directory != proposed.client_source_directory { fields.push("client source directory"); } if current.server_endpoint != proposed.server_endpoint { fields.push("server endpoint"); } if current.ipv4_allocation_range != proposed.ipv4_allocation_range { fields.push("IPv4 allocation range"); } if current.ipv6_allocation_range != proposed.ipv6_allocation_range { fields.push("IPv6 allocation range"); } if current.dns_addresses != proposed.dns_addresses { fields.push("DNS addresses"); } if current.systemd_unit != proposed.systemd_unit { fields.push("systemd unit"); } if current.restart_timeout_seconds != proposed.restart_timeout_seconds { fields.push("restart timeout"); } if current.data_root != proposed.data_root { fields.push("data directory"); } fields } impl fmt::Debug for RestorePreview { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("RestorePreview") .field("created_at_utc", &self.created_at_utc) .field("reason", &self.reason) .field("historical_apply", &self.historical_apply) .field("saved_bytes", &self.saved_bytes) .finish() } } impl fmt::Debug for Application { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("Application") .field("saved", &self.saved) .field("document", &self.draft.document) .field("states", &self.draft.states) .field("vault_present", &self.draft.vault.is_some()) .field("historical_apply", &self.historical_apply) .field( "restart_attempt_active", &self.active_restart_attempt.is_some(), ) .field( "pending_reservation_count", &self .draft .states .address_reservations() .pending_host_routes() .count(), ) .field("initial_issue_count", &self.initial_issues.len()) .finish() } } impl Application { /// Open validated installed bytes and apply conservative persisted address /// reservations to the caller's narrower client-state model. pub fn open(store: SavedStore, states: ClientStates) -> Result { let saved = store.read_saved()?; Self::open_saved(store, saved, states) } /// Open a session by deriving its supported state from the authoritative /// saved server document. Unsupported peers remain available through /// [`Self::display_projection`] but never gain a guessed mutation identity. pub fn open_from_saved_store(store: SavedStore) -> Result { let saved = store.read_saved()?; let document = ConfigDocument::parse(&saved.server_config)?; let states = ClientStates::new( snapshot_from_document(&document), None, ApplyStatus::Unknown, )?; Self::open_saved(store, saved, states) } fn open_saved( store: SavedStore, saved: SavedBytes, mut states: ClientStates, ) -> Result { states.begin_runtime_session(); let document = ConfigDocument::parse(&saved.server_config)?; let vault = saved .vault .as_deref() .map(std::str::from_utf8) .transpose() .map_err(|_| AppError::InvalidSavedState)? .map(VaultBundle::from_toml) .transpose()?; if let Some(bytes) = &saved.internal_state { PersistedState::from_toml(bytes)?.apply_to(&mut states)?; } let historical_apply = historical_apply_from_saved(&saved)?; Ok(Self { store, saved, draft: DraftChanges::new(document, states, vault), historical_apply, verified_saved_projection: None, active_restart_attempt: None, next_restart_attempt: 1, next_issue_authorization: 1, initial_issues: BTreeMap::new(), }) } pub fn saved_bytes(&self) -> &SavedBytes { &self.saved } pub fn document(&self) -> &ConfigDocument { &self.draft.document } pub fn states(&self) -> &ClientStates { &self.draft.states } /// Return a secret-free projection that includes unsupported and /// ambiguous peers instead of silently omitting them from the client list. pub fn display_projection(&self) -> SessionDisplay { let clients = self .draft .document .peers() .iter() .map(|peer| { let projected = peer .identity .and_then(|identity| self.draft.document.project_client(&identity).ok()); let metadata_label = match &peer.metadata { crate::wireguard::MetadataAttachment::Valid { metadata, .. } => { Some(metadata.alias.clone()) } _ => None, }; let identity = peer.identity; let label = metadata_label.unwrap_or_else(|| { identity.map_or_else( || format!("Peer at line {}", peer.section_line), |key| format!("{}…", key.to_string().chars().take(8).collect::()), ) }); ClientDisplay { peer_index: peer.index, line: peer.section_line, identity, label, allowed_ips: peer.allowed_ips.iter().map(ToString::to_string).collect(), has_subnet_routes: peer.has_subnet_routes, issues: peer.issues.clone(), mutable_identity: projected .filter(|client| self.draft.states.draft().get(&client.identity).is_some()) .map(|client| client.identity), } }) .collect(); SessionDisplay { clients, document_issues: self.draft.document.issues().to_vec(), } } /// Add one prepared public-key client to the byte-preserving server draft /// and the narrower in-memory client draft. pub fn add_client( &mut self, client: Client, created_utc: Option<&str>, ) -> Result<(), AppError> { let edited = ConfigDocument::parse( self.draft .document .add_client(&client, created_utc)? .render(), )?; ensure_document_client(&edited, &client)?; let mut next_states = self.draft.states.clone(); next_states.stage_client(client)?; let replacement = DraftChanges::stage_clients(edited, next_states); self.draft.publish_replacement(replacement); self.invalidate_active_issue_authorizations(); Ok(()) } /// Prepare a new dual-stack client and its encrypted key as one in-memory /// operation. `manual_addresses == None` selects the lowest available host /// in each configured range. No file is written and no output is authorized /// until a later successful Commit. pub fn prepare_new_client( &mut self, alias: &str, manual_addresses: Option, created_utc: Option<&str>, ) -> Result { let installed = self.store.read_saved()?; let settings = self.store.resolved_settings_for(&installed)?; let key = WireGuardPrivateKey::generate().map_err(|_| AppError::KeyGenerationFailed)?; let identity = key.public_key(); let server_addresses = server_interface_addresses(&self.draft.document)?; let mut occupied = self .draft .states .addresses_in_use() .into_iter() .collect::>(); occupied.extend(document_host_addresses(&self.draft.document)); let reserved = self .draft .states .address_reservations() .all() .collect::>(); let availability = AddressAvailability { server: &server_addresses, occupied: &occupied, reserved: &reserved, }; let addresses = match manual_addresses { Some(addresses) => { let ipv6 = addresses.ipv6.ok_or(AppError::NewClientMustBeDualStack)?; validate_manual_ipv4( addresses.ipv4, settings.ipv4_allocation_range, &availability, )?; validate_manual_ipv6(ipv6, settings.ipv6_allocation_range, &availability)?; ClientAddresses { ipv4: addresses.ipv4, ipv6: Some(ipv6), } } None => ClientAddresses { ipv4: allocate_ipv4(settings.ipv4_allocation_range, &availability)?, ipv6: Some(allocate_ipv6( settings.ipv6_allocation_range, &availability, )?), }, }; let client = Client::new(identity, alias, addresses)?; // Prepare every fallible replacement off to the side. The live app is // mutated only after document editing, state insertion, and encryption // have all succeeded. let next_document = self.draft.document.add_client(&client, created_utc)?; let mut next_states = self.draft.states.clone(); next_states.stage_client(client.clone())?; let existing_vault = self .draft .vault .as_ref() .ok_or(AppError::VaultMissingForCreation)?; let encoded = existing_vault.to_toml()?; let mut next_vault = VaultBundle::from_toml(&encoded)?; next_vault.encrypt_record(identity, &key)?; // Parse once before publication so a malformed editor result cannot // leave the three in-memory drafts out of sync. let next_document = ConfigDocument::parse(next_document.render())?; ensure_document_client(&next_document, &client)?; next_vault.to_toml()?; let context = issue_context(&next_document, &next_vault, identity)?; let replacement = DraftChanges::stage_clients_and_vault(next_document, next_states, Some(next_vault)); self.invalidate_active_issue_authorizations(); self.draft.publish_replacement(replacement); self.initial_issues.insert( identity, InitialIssue { private_key: key, context, stage: IssueStage::PendingDraft, }, ); Ok(identity) } /// Publish a password-free first issue while the committed peer, encrypted /// record, and one-use token are valid. The mutable app borrow remains held /// across `publish`, so no application mutation can interleave after the /// final saved-state validation and before the file adapter runs. #[allow(dead_code)] // M6.3 invokes this for its initial-file action. pub(crate) fn deliver_first_issue( &mut self, identity: PublicKey, attempt: FirstIssueAttempt, publish: impl FnOnce(&ClientConfigPayload) -> Result, ) -> Result, AppError> { self.deliver_first_issue_with_warning(identity, attempt, |payload, _| publish(payload)) } fn deliver_first_issue_with_warning( &mut self, identity: PublicKey, attempt: FirstIssueAttempt, publish: impl FnOnce(&ClientConfigPayload, bool) -> Result, ) -> Result, AppError> { let (is_retry, confirm_possible_overwrite, confirm_manual_cleanup) = match attempt { FirstIssueAttempt::Initial => (false, false, false), FirstIssueAttempt::Retry { confirm_possible_overwrite, confirm_manual_cleanup, } => (true, confirm_possible_overwrite, confirm_manual_cleanup), }; let authorization = self.authorize_first_issue_inner( identity, is_retry, confirm_possible_overwrite, confirm_manual_cleanup, )?; let activation_warning = match self.validate_first_issue_authorization(&authorization) { Ok(warning) => warning, Err(error) => { self.require_overwrite_confirmation(identity); return Err(error); } }; match publish(&authorization.payload, activation_warning) { Ok(value) => { if let Err(error) = self.mark_first_issue_delivered(authorization) { // Publication is definite. Drop the session-held key even // when saved-state bookkeeping cannot be completed. self.initial_issues.remove(&identity); return Ok( FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain { value, error, }, ); } Ok(FirstIssueDeliveryOutcome::Delivered(value)) } Err((error, failure)) => { let retry = match self.mark_first_issue_delivery_failed(authorization, failure) { Ok(retry) => retry, Err(error) => { self.require_overwrite_confirmation(identity); return Err(error); } }; Ok(FirstIssueDeliveryOutcome::Failed { error, retry, activation_warning, }) } } } /// Prepare a caller-selected plaintext destination without writing it. The /// returned destination records any existing regular file so a later /// overwrite confirmation is bound to this exact selection. pub fn prepare_first_issue_destination( &self, path: impl AsRef, ) -> Result { let protection = self.store.delivery_output_protection()?; files::prepare_destination(path, &protection) } /// Deliver the committed new-client configuration through the app-owned /// one-use authorization closure. A failed write retains retry eligibility; /// a post-publication sync failure requires an explicit overwrite retry. pub fn deliver_first_issue_file( &mut self, identity: PublicKey, attempt: FirstIssueAttempt, destination: PreparedDestination, ) -> Result, AppError> { self.deliver_first_issue_file_with(identity, attempt, destination, files::publish) } fn deliver_first_issue_file_with( &mut self, identity: PublicKey, attempt: FirstIssueAttempt, destination: PreparedDestination, publish_file: impl FnOnce( PreparedDestination, &ClientConfigPayload, ) -> Result<(), FileOutputError>, ) -> Result, AppError> { self.deliver_first_issue_with_warning(identity, attempt, |payload, activation_warning| { publish_file(destination, payload).map_err(|error| { let failure = match error { FileOutputError::PublicationUncertain => { FirstIssueDeliveryFailure::PublicationUncertain } FileOutputError::ManualCleanupRequired => { FirstIssueDeliveryFailure::CleanupRequired } _ => FirstIssueDeliveryFailure::BeforePublication, }; (error, failure) })?; Ok(FirstIssueFileDelivery { activation_warning }) }) } /// Publish the mandatory plaintext file, then optionally encode a QR from /// that same authorized payload. QR failure is returned separately and the /// successful file publication still consumes first-issue eligibility. pub fn deliver_first_issue_file_and_qr( &mut self, identity: PublicKey, attempt: FirstIssueAttempt, destination: PreparedDestination, ) -> Result, AppError> { self.deliver_first_issue_file_and_qr_with(identity, attempt, destination, |payload| { qr::encode_payload(payload) }) } fn deliver_first_issue_file_and_qr_with( &mut self, identity: PublicKey, attempt: FirstIssueAttempt, destination: PreparedDestination, encode: impl FnOnce(&ClientConfigPayload) -> Result, ) -> Result, AppError> { self.deliver_first_issue_file_and_qr_with_publisher( identity, attempt, destination, files::publish, encode, ) } fn deliver_first_issue_file_and_qr_with_publisher( &mut self, identity: PublicKey, attempt: FirstIssueAttempt, destination: PreparedDestination, publish_file: impl FnOnce( PreparedDestination, &ClientConfigPayload, ) -> Result<(), FileOutputError>, encode: impl FnOnce(&ClientConfigPayload) -> Result, ) -> Result, AppError> { self.deliver_first_issue_with_warning(identity, attempt, |payload, activation_warning| { publish_file(destination, payload).map_err(|error| { let failure = match error { FileOutputError::PublicationUncertain => { FirstIssueDeliveryFailure::PublicationUncertain } FileOutputError::ManualCleanupRequired => { FirstIssueDeliveryFailure::CleanupRequired } _ => FirstIssueDeliveryFailure::BeforePublication, }; (error, failure) })?; Ok(FirstIssueFileAndQr { activation_warning, qr: encode(payload), }) }) } /// Reissue a saved client's configuration after freshly unlocking its /// installed vault record with this action's password. This operation reads /// only installed saved bytes, never uses a draft or retained source file, /// and publishes the required file before attempting optional QR encoding. pub fn reissue_client_file( &self, identity: PublicKey, password: &str, destination: PreparedDestination, include_qr: bool, ) -> Result { let installed = self.store.read_saved().map_err(|_| ReissueError { stage: ReissueStage::SavedState, kind: ReissueFailureKind::SavedStateUnavailable, })?; let document = ConfigDocument::parse(&installed.server_config).map_err(|_| ReissueError { stage: ReissueStage::SavedState, kind: ReissueFailureKind::SavedStateUnavailable, })?; let encoded_vault = installed.vault.as_deref().ok_or(ReissueError { stage: ReissueStage::Authorization, kind: ReissueFailureKind::VaultUnavailable, })?; let vault_text = std::str::from_utf8(encoded_vault).map_err(|_| ReissueError { stage: ReissueStage::Authorization, kind: ReissueFailureKind::VaultUnavailable, })?; let vault = VaultBundle::from_toml(vault_text).map_err(|_| ReissueError { stage: ReissueStage::Authorization, kind: ReissueFailureKind::VaultUnavailable, })?; // Retrieval performs the password unlock and authenticated conflict // check on every invocation. The short-lived owner is dropped when // this method returns and is never kept in Application state. let record = vault .retrieve_client_record(identity, password) .map_err(|_| ReissueError { stage: ReissueStage::Authorization, kind: ReissueFailureKind::PasswordOrRecordRejected, })?; let resolved = self .store .resolved_settings_for(&installed) .map_err(|_| ReissueError { stage: ReissueStage::SavedState, kind: ReissueFailureKind::SavedStateUnavailable, })?; let delivery_settings = DeliverySettings { server_endpoint: &resolved.server_endpoint, dns_addresses: &resolved.dns_addresses, }; let payload = if record.is_new_template() { delivery::render_new_client( identity, record.private_key().ok_or(ReissueError { stage: ReissueStage::Authorization, kind: ReissueFailureKind::PasswordOrRecordRejected, })?, &document, &delivery_settings, ) } else { delivery::render_imported_client(identity, &record, &document, &delivery_settings) } .map_err(|_| ReissueError { stage: ReissueStage::Rendering, kind: ReissueFailureKind::ConfigurationRejected, })?; files::publish(destination, &payload).map_err(|error| ReissueError { stage: ReissueStage::FilePublication, kind: ReissueFailureKind::FileOutput(error), })?; // A historical timestamp is deliberately insufficient: after reopen, // the runtime observation is unknown. Warn unless this live session // has verified the exact saved peer snapshot currently on disk. let activation_warning = !self.saved_generation_is_verified_applied(&installed, &document); let qr = include_qr.then(|| qr::encode_payload(&payload)); Ok(ReissueDelivery { activation_warning, qr, }) } fn saved_generation_is_verified_applied( &self, installed: &SavedBytes, document: &ConfigDocument, ) -> bool { if !same_saved_bytes(&self.saved, installed) || self.verified_saved_projection.is_none() { return false; } let Ok((projection, _)) = restart_projection_from_document(document) else { return false; }; self.draft.states.applied() == &ApplyStatus::Verified && self.verified_saved_projection.as_ref() == Some(&projection) } /// Recheck both the one-use token and installed saved generation as the /// final step before handing plaintext to the bounded writer closure. fn validate_first_issue_authorization( &mut self, authorization: &FirstIssueAuthorization, ) -> Result { let installed = match self.store.read_saved() { Ok(saved) => saved, Err(error) => { self.initial_issues.clear(); return Err(error.into()); } }; let document = ConfigDocument::parse(&installed.server_config)?; let vault = installed .vault .as_deref() .map(std::str::from_utf8) .transpose() .map_err(|_| AppError::InvalidSavedState)? .map(VaultBundle::from_toml) .transpose()?; self.retain_matching_issues(&document, vault.as_ref()); let current = issue_context( &document, vault.as_ref().ok_or(AppError::FirstIssueUnavailable)?, authorization.identity, )?; if current != authorization.context || !matches!( self.initial_issues.get(&authorization.identity), Some(issue) if issue.stage == IssueStage::Authorizing(authorization.token) && issue.context == authorization.context ) { return Err(AppError::FirstIssueUnavailable); } Ok(!self.saved_generation_is_verified_applied(&installed, &document)) } fn require_overwrite_confirmation(&mut self, identity: PublicKey) { if let Some(issue) = self.initial_issues.get_mut(&identity) && matches!(issue.stage, IssueStage::Authorizing(_)) { issue.stage = IssueStage::RetryNeedsOverwriteConfirmation; } } fn authorize_first_issue_inner( &mut self, identity: PublicKey, is_retry: bool, confirm_possible_overwrite: bool, confirm_manual_cleanup: bool, ) -> Result { let installed = match self.store.read_saved() { Ok(saved) => saved, Err(error) => { self.initial_issues.clear(); return Err(error.into()); } }; let document = ConfigDocument::parse(&installed.server_config)?; let vault = installed .vault .as_deref() .map(std::str::from_utf8) .transpose() .map_err(|_| AppError::InvalidSavedState)? .map(VaultBundle::from_toml) .transpose()?; self.retain_matching_issues(&document, vault.as_ref()); let Some(issue) = self.initial_issues.get(&identity) else { return Err(AppError::FirstIssueUnavailable); }; let allowed = match issue.stage { IssueStage::AwaitingFirstFile => !is_retry, IssueStage::RetryAvailable => is_retry, IssueStage::RetryNeedsOverwriteConfirmation => is_retry && confirm_possible_overwrite, IssueStage::RetryNeedsManualCleanup => is_retry && confirm_manual_cleanup, IssueStage::PendingDraft | IssueStage::Authorizing(_) => false, }; if !allowed { return Err(AppError::FirstIssueUnavailable); } let private_key = &issue.private_key; let context = issue.context.clone(); let current_context = issue_context( &document, vault.as_ref().ok_or(AppError::FirstIssueUnavailable)?, identity, )?; if current_context != context { self.initial_issues.remove(&identity); return Err(AppError::FirstIssueUnavailable); } let resolved = self.store.resolved_settings_for(&installed)?; let delivery_settings = DeliverySettings { server_endpoint: &resolved.server_endpoint, dns_addresses: &resolved.dns_addresses, }; let payload = delivery::render_new_client(identity, private_key, &document, &delivery_settings)?; let token = self.next_issue_authorization; let next_token = token .checked_add(1) .ok_or(AppError::IssueAuthorizationExhausted)?; self.next_issue_authorization = next_token; let issue = self .initial_issues .get_mut(&identity) .ok_or(AppError::FirstIssueUnavailable)?; issue.stage = IssueStage::Authorizing(token); Ok(FirstIssueAuthorization { identity, context, token, payload, }) } /// Consume first-issue eligibility after complete file publication. fn mark_first_issue_delivered( &mut self, authorization: FirstIssueAuthorization, ) -> Result<(), AppError> { let installed = match self.store.read_saved() { Ok(saved) => saved, Err(error) => { self.initial_issues.clear(); return Err(error.into()); } }; let document = ConfigDocument::parse(&installed.server_config)?; let vault = installed .vault .as_deref() .map(std::str::from_utf8) .transpose() .map_err(|_| AppError::InvalidSavedState)? .map(VaultBundle::from_toml) .transpose()?; self.retain_matching_issues(&document, vault.as_ref()); let current = issue_context( &document, vault.as_ref().ok_or(AppError::FirstIssueUnavailable)?, authorization.identity, )?; let matches_authorization = matches!( self.initial_issues.get(&authorization.identity), Some(issue) if issue.stage == IssueStage::Authorizing(authorization.token) && issue.context == authorization.context && issue.context == current ); if !matches_authorization { return Err(AppError::FirstIssueUnavailable); } self.initial_issues.remove(&authorization.identity); Ok(()) } /// Record failed publication and explicitly return the session to a retry /// state. Uncertain publication requires confirmed overwrite on the retry. fn mark_first_issue_delivery_failed( &mut self, authorization: FirstIssueAuthorization, failure: FirstIssueDeliveryFailure, ) -> Result { let installed = match self.store.read_saved() { Ok(saved) => saved, Err(error) => { self.initial_issues.clear(); return Err(error.into()); } }; let document = ConfigDocument::parse(&installed.server_config)?; let vault = installed .vault .as_deref() .map(std::str::from_utf8) .transpose() .map_err(|_| AppError::InvalidSavedState)? .map(VaultBundle::from_toml) .transpose()?; self.retain_matching_issues(&document, vault.as_ref()); let current = issue_context( &document, vault.as_ref().ok_or(AppError::FirstIssueUnavailable)?, authorization.identity, )?; let Some(issue) = self.initial_issues.get_mut(&authorization.identity) else { return Err(AppError::FirstIssueUnavailable); }; if issue.stage != IssueStage::Authorizing(authorization.token) || issue.context != authorization.context || issue.context != current { return Err(AppError::FirstIssueUnavailable); } let retry = match failure { FirstIssueDeliveryFailure::BeforePublication => { issue.stage = IssueStage::RetryAvailable; FirstIssueRetry::Available } FirstIssueDeliveryFailure::PublicationUncertain => { issue.stage = IssueStage::RetryNeedsOverwriteConfirmation; FirstIssueRetry::OverwriteConfirmationRequired } FirstIssueDeliveryFailure::CleanupRequired => { issue.stage = IssueStage::RetryNeedsManualCleanup; FirstIssueRetry::ManualCleanupRequired } }; Ok(retry) } #[allow(dead_code)] // Used by the gated M6.3 capability methods. fn retain_matching_issues(&mut self, document: &ConfigDocument, vault: Option<&VaultBundle>) { retain_matching_issue_map(&mut self.initial_issues, document, vault); } fn invalidate_active_issue_authorizations(&mut self) { for issue in self.initial_issues.values_mut() { if matches!(issue.stage, IssueStage::Authorizing(_)) { issue.stage = IssueStage::RetryNeedsOverwriteConfirmation; } } } pub fn vault(&self) -> Option<&VaultBundle> { self.draft.vault.as_ref() } /// Whether a vault is present in either saved state or the current draft. /// This reveals only setup availability, never vault contents. pub fn vault_initialized(&self) -> bool { self.draft.vault.is_some() } /// Whether the in-memory client/document/vault draft differs from saved. pub fn has_draft_changes(&self) -> bool { self.has_draft() } /// Import one explicitly selected configured-directory entry into the /// in-memory vault draft. The result contains only redacted reconciliation /// data; saved files are changed only by a later Commit. pub fn import_selected_file( &mut self, selected_name: &OsStr, ) -> Result { let settings = self.store.resolved_settings_for(&self.saved)?; let existing_vault = self .draft .vault .as_ref() .ok_or(AppError::VaultMissingForCreation)?; let encoded = existing_vault.to_toml()?; let mut next_vault = VaultBundle::from_toml(&encoded)?; let report = crate::vault::source::import_selected_file( &mut next_vault, &settings, &self.draft.document, selected_name, ) .map_err(AppError::SourceImport)?; next_vault.to_toml()?; let replacement = DraftChanges::stage_vault(Some(next_vault)); self.draft.publish_replacement(replacement); Ok(report) } /// Replace a client's alias through the span-preserving config editor. /// A peer outside the narrower M1 state projection remains editable. pub fn rename_client(&mut self, identity: PublicKey, alias: &str) -> Result<(), AppError> { let edited = ConfigDocument::parse( self.draft .document .rename_client(&identity, alias)? .render(), )?; let mut next_states = self.draft.states.clone(); if let Some(client) = next_states.draft().get(&identity).cloned() { let replacement = Client::new(identity, alias, client.addresses)?; next_states.stage_client(replacement.clone())?; ensure_document_client(&edited, &replacement)?; } let replacement = DraftChanges::stage_clients(edited, next_states); self.draft.publish_replacement(replacement); self.invalidate_active_issue_authorizations(); Ok(()) } /// Delete one explicitly selected peer and its current encrypted record, if /// present. File-only vault records and unrelated records are retained. pub fn delete_client(&mut self, identity: PublicKey) -> Result { let saved_document = ConfigDocument::parse(&self.saved.server_config)?; let saved_peer_exists = saved_document .peers() .iter() .any(|peer| peer.identity == Some(identity)); let mut next_states = self.draft.states.clone(); if saved_peer_exists && self.draft.states.saved().get(&identity).is_none() { let peer = saved_document .peers() .iter() .find(|peer| peer.identity == Some(identity)) .ok_or(AppError::DeletedPeerAddressUnknown)?; next_states .stage_exact_host_routes(peer.allowed_ips.iter().copied()) .map_err(|_| AppError::DeletedPeerAddressUnknown)?; } let edited = ConfigDocument::parse(self.draft.document.delete_client(&identity)?.render())?; if edited .peers() .iter() .any(|peer| peer.identity == Some(identity)) { return Err(AppError::InvalidSavedState); } let mut next_vault = match self.draft.vault.as_ref() { Some(vault) => Some(VaultBundle::from_toml(&vault.to_toml()?)?), None => None, }; let removed_record = match next_vault.as_mut() { Some(vault) => vault.remove_record(identity)?, None => false, }; if let Some(vault) = &next_vault { vault.to_toml()?; } next_states.remove_draft_client(&identity); let replacement = DraftChanges::stage_clients_and_vault(edited, next_states, next_vault); self.draft.publish_replacement(replacement); self.invalidate_active_issue_authorizations(); self.initial_issues.remove(&identity); Ok(removed_record) } /// Stage an initial vault from a prepared read-only source import. It cannot /// replace an installed vault and does not change files until Commit. pub fn prepare_initial_vault(&mut self, bundle: VaultBundle) -> Result<(), AppError> { if self.draft.vault.is_some() || self.saved.vault.is_some() { return Err(AppError::VaultAlreadyInstalled); } bundle.to_toml()?; let replacement = DraftChanges::stage_vault(Some(bundle)); self.draft.publish_replacement(replacement); Ok(()) } /// Stage the bundle from M3's read-only initial import result and return /// its anomaly report unchanged for the caller to present to the operator. pub fn prepare_initial_setup( &mut self, prepared: PreparedImportSetup, ) -> Result { let PreparedImportSetup { bundle, report } = prepared; self.prepare_initial_vault(bundle)?; Ok(report) } /// Prepare a password-protected vault and import configured source files /// against this session's authoritative document. All results remain in /// the in-memory draft until Commit. pub fn prepare_initial_setup_from_sources( &mut self, vault_password: &str, ) -> Result { let settings = self.store.resolved_settings_for(&self.saved)?; let prepared = crate::vault::source::prepare_initial_setup( &settings, &self.draft.document, vault_password, ) .map_err(AppError::SourceImport)?; self.prepare_initial_setup(prepared) } /// Drop all uncommitted edits and restore the in-memory drafts from the last /// successfully installed session snapshot. No files are written. pub fn discard_draft(&mut self) -> Result<(), AppError> { let installed = self.store.read_saved()?; let next_document = ConfigDocument::parse(&installed.server_config)?; let next_vault = installed .vault .as_deref() .map(std::str::from_utf8) .transpose() .map_err(|_| AppError::InvalidSavedState)? .map(VaultBundle::from_toml) .transpose()?; let mut next_states = self.draft.states.clone(); next_states.discard_draft(); if let Some(bytes) = &installed.internal_state { PersistedState::from_toml(bytes)?.apply_to(&mut next_states)?; } else { next_states.runtime_knowledge_unknown(); } let next_historical_apply = historical_apply_from_saved(&installed)?; let replacement = DraftChanges::stage_clients_and_vault(next_document, next_states, next_vault); let saved_changed = !same_saved_bytes(&self.saved, &installed); self.saved = installed; self.draft.publish_replacement(replacement); if saved_changed { self.verified_saved_projection = None; } self.historical_apply = next_historical_apply; self.initial_issues .retain(|_, issue| issue.stage == IssueStage::AwaitingFirstFile); retain_matching_issue_map( &mut self.initial_issues, &self.draft.document, self.draft.vault.as_ref(), ); Ok(()) } /// Create the mandatory archive from current installed bytes without /// changing the current session or saved files. pub fn create_backup(&self, reason: BackupReason) -> Result { backups::create_backup(self, reason) } pub fn create_manual_backup(&self) -> Result { self.create_backup(BackupReason::Manual) } pub fn list_backups(&self) -> Result, AppError> { backups::list_backups(self) } /// Reusable mandatory backup boundary for M5 before it invokes restart. /// This method only archives saved bytes; it never calls a runtime adapter. pub fn create_pre_restart_backup(&mut self) -> Result { backups::create_pre_restart_backup(self) } /// Prepare a read-only preview bound to the exact currently installed generation. pub fn preview_restart(&self) -> Result { let installed = self.store.read_saved()?; if !same_saved_bytes(&installed, &self.saved) { return Err(AppError::SavedGenerationChanged); } let settings = self.store.resolved_settings_for(&installed)?; let document = ConfigDocument::parse(&installed.server_config)?; if document .sections() .iter() .filter(|section| section.kind == SectionKind::Interface) .count() != 1 { return Err(AppError::InvalidSavedState); } if document.has_unsafe_save_config() { return Err(AppError::UnsafeSaveConfig); } let (projection, expected_clients) = restart_projection_from_document(&document).map_err(AppError::UnsafeSavedPeers)?; let expected_clients = expected_clients.filter(|snapshot| snapshot == self.draft.states.saved()); Ok(RestartPreview { saved: clone_saved(&installed), interface: settings.interface_name, unit: settings.systemd_unit, timeout: settings.restart_timeout, projection, expected_clients, has_draft: self.has_draft(), }) } fn has_draft(&self) -> bool { let vault_differs = match &self.draft.vault { Some(vault) => vault.to_toml().map(String::into_bytes).ok() != self.saved.vault, None => self.saved.vault.is_some(), }; self.draft.states.draft() != self.draft.states.saved() || self.draft.document.render() != self.saved.server_config || self .draft .states .address_reservations() .pending_host_routes() .next() .is_some() || vault_differs } /// Confirm or cancel an explicit restart preview. The adapters use only the /// saved generation and share one monotonic deadline after backup completion. pub fn restart(&mut self, preview: RestartPreview, choice: RestartChoice) -> RestartReport { self.restart_with_adapters( preview, choice, service::restart_systemd_unit_report, |interface, timeout| { runtime::transport::observe_interface( interface, timeout, runtime::netlink::DecodeLimits::default(), ) }, |store, saved, backup| store.install(saved, backup), ) } fn restart_with_adapters( &mut self, preview: RestartPreview, choice: RestartChoice, service: impl FnOnce(&str, RestartDeadline) -> RestartExecution, runtime_read: impl FnOnce(&str, Duration) -> Result, install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, ) -> RestartReport { self.restart_with_adapters_and_clock( preview, choice, service, runtime_read, install, Instant::now, ) } /// Test-only adapter boundary for Alpine fixture apply. The supplied /// callback must report a completed fixture operation; observation and /// verification still use the real Rustix reader and normal persistence /// path. This method is not present in production builds. #[cfg(test)] #[allow(dead_code)] // Consumed by the M8.5 in-container scenario driver. pub(crate) fn restart_with_fixture_apply( &mut self, preview: RestartPreview, choice: RestartChoice, fixture_apply: impl FnOnce(&str, RestartDeadline) -> RestartExecution, ) -> RestartReport { self.restart_with_adapters( preview, choice, fixture_apply, |interface, timeout| { runtime::transport::observe_interface( interface, timeout, runtime::netlink::DecodeLimits::default(), ) }, |store, saved, backup| store.install(saved, backup), ) } fn restart_with_adapters_and_clock( &mut self, preview: RestartPreview, choice: RestartChoice, service: impl FnOnce(&str, RestartDeadline) -> RestartExecution, runtime_read: impl FnOnce(&str, Duration) -> Result, install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, now: impl Fn() -> Instant, ) -> RestartReport { if choice == RestartChoice::Cancel { return restart_report(RestartStage::Cancelled, None, None, false, false, false); } self.active_restart_attempt = None; if self.has_draft() != preview.has_draft || (preview.has_draft && choice != RestartChoice::DiscardDraftAndRestart) || (!preview.has_draft && choice == RestartChoice::DiscardDraftAndRestart) { return restart_report( RestartStage::Preparation, Some(RestartFailureKind::InvalidOrStaleSavedGeneration), None, false, false, false, ); } let installed = match self.store.read_saved() { Ok(saved) if same_saved_bytes(&saved, &preview.saved) && same_saved_bytes(&saved, &self.saved) => { saved } _ => { self.active_restart_attempt = None; return restart_report( RestartStage::Preparation, Some(RestartFailureKind::InvalidOrStaleSavedGeneration), None, false, false, false, ); } }; let settings = match self.store.resolved_settings_for(&installed) { Ok(settings) => settings, Err(_) => { return restart_report( RestartStage::Preparation, Some(RestartFailureKind::InvalidOrStaleSavedGeneration), None, false, false, false, ); } }; let document = match ConfigDocument::parse(&installed.server_config) { Ok(document) => document, Err(_) => { return restart_report( RestartStage::Preparation, Some(RestartFailureKind::InvalidOrStaleSavedGeneration), None, false, false, false, ); } }; if document .sections() .iter() .filter(|section| section.kind == SectionKind::Interface) .count() != 1 { return restart_report( RestartStage::Preparation, Some(RestartFailureKind::InvalidOrStaleSavedGeneration), None, false, false, false, ); } if document.has_unsafe_save_config() { return restart_report( RestartStage::Preparation, Some(RestartFailureKind::UnsafeSaveConfig), None, false, false, false, ); } let (projection, expected_clients) = match restart_projection_from_document(&document) { Ok(projection) => projection, Err(_) => { return restart_report( RestartStage::Preparation, Some(RestartFailureKind::UnsupportedSavedShape), None, false, false, false, ); } }; let expected_clients = expected_clients.filter(|snapshot| snapshot == self.draft.states.saved()); if settings.interface_name != preview.interface || settings.systemd_unit != preview.unit || settings.restart_timeout != preview.timeout || projection != preview.projection || expected_clients != preview.expected_clients { return restart_report( RestartStage::Preparation, Some(RestartFailureKind::InvalidOrStaleSavedGeneration), None, false, false, false, ); } let historical_apply = match historical_apply_from_saved(&installed) { Ok(status) => status, Err(_) => { return restart_report( RestartStage::Preparation, Some(RestartFailureKind::InvalidOrStaleSavedGeneration), None, false, false, false, ); } }; let backup_path = match backup::create_archive( self.store.archives_directory(), BackupReason::PreRestart, historical_apply, &installed, ) { Ok(path) => path, Err(_) => { self.active_restart_attempt = None; return restart_report( RestartStage::Backup, Some(RestartFailureKind::BackupFailed), None, false, false, false, ); } }; let id = self.next_restart_attempt; let Some(next_id) = id.checked_add(1) else { return restart_report( RestartStage::Preparation, Some(RestartFailureKind::InvalidOrStaleSavedGeneration), Some(backup_path), false, false, false, ); }; self.next_restart_attempt = next_id; self.active_restart_attempt = Some(id); if choice == RestartChoice::DiscardDraftAndRestart && self.discard_draft().is_err() { self.active_restart_attempt = None; return restart_report( RestartStage::Preparation, Some(RestartFailureKind::DraftDiscardFailed), Some(backup_path), false, false, false, ); } self.draft.states.runtime_knowledge_unknown(); let deadline = RestartDeadline::new(now(), settings.restart_timeout); let execution = service(&settings.systemd_unit, deadline); let job = execution.outcome; let submitted = execution.submitted; let service_phase = execution.phase; if job != RestartOutcome::Succeeded { self.active_restart_attempt = None; let may_complete = submitted && !matches!( job, RestartOutcome::Failed( service::RestartFailure::JobFailed | service::RestartFailure::JobDependency | service::RestartFailure::JobTimedOut | service::RestartFailure::JobCanceled | service::RestartFailure::JobSkipped | service::RestartFailure::JobResultUnknown ) ); let mut report = restart_report( RestartStage::Service, Some(RestartFailureKind::ServiceFailed), Some(backup_path), submitted, may_complete, submitted, ); report.service_failure = match job { RestartOutcome::Failed(failure) => Some(failure), RestartOutcome::Waiting | RestartOutcome::Succeeded => None, }; report.service_phase = Some(service_phase); report.systemd_unit = Some(settings.systemd_unit); return report; } let remaining = deadline.remaining_at(now()); if remaining.is_zero() { self.active_restart_attempt = None; return restart_report( RestartStage::RuntimeRead, Some(RestartFailureKind::RuntimeTimeout), Some(backup_path), true, false, true, ); } let observation = match runtime_read(&settings.interface_name, remaining) { Ok(observation) => observation, Err(error) => { self.active_restart_attempt = None; return restart_report( RestartStage::RuntimeRead, Some(match error { TransportError::NoDevice => RestartFailureKind::RuntimeNoDevice, TransportError::PermissionDenied => { RestartFailureKind::RuntimePermissionDenied } TransportError::Malformed => RestartFailureKind::RuntimeMalformed, TransportError::Transport => RestartFailureKind::RuntimeTransport, TransportError::Timeout => RestartFailureKind::RuntimeTimeout, TransportError::LimitExceeded => RestartFailureKind::RuntimeLimitExceeded, }), Some(backup_path), true, false, true, ); } }; if deadline.is_expired_at(now()) { self.active_restart_attempt = None; return restart_report( RestartStage::Verification, Some(RestartFailureKind::RuntimeTimeout), Some(backup_path), true, false, true, ); } if runtime::compare_peers(&projection, &settings.interface_name, &observation) != TechnicalMatch::Match { self.active_restart_attempt = None; return restart_report( RestartStage::Verification, Some(RestartFailureKind::RuntimeMismatch), Some(backup_path), true, false, true, ); } let Some(verified_at) = OffsetDateTime::now_utc() .replace_nanosecond(0) .ok() .and_then(|timestamp| timestamp.format(&Rfc3339).ok()) else { self.active_restart_attempt = None; return restart_report( RestartStage::Persistence, Some(RestartFailureKind::VerificationPersistenceFailed), Some(backup_path), true, false, true, ); }; let attempt = RestartAttempt { id, saved: clone_saved(&installed), }; let marked = self.mark_verified_with_attempt(attempt, expected_clients, verified_at, install); if marked.is_err() { self.active_restart_attempt = None; return restart_report( RestartStage::Persistence, Some(RestartFailureKind::VerificationPersistenceFailed), Some(backup_path), true, false, true, ); } restart_report( RestartStage::Complete, None, Some(backup_path), true, false, false, ) } /// Validate a selected archive without changing saved files or the draft. /// The returned value carries the exact validated bytes used at confirmation. pub fn preview_restore(&self, file_name: &str) -> Result { restore::preview_restore(self, file_name) } /// Explicitly confirm a previously previewed restore. The pre-restore /// archive is finalized before the journaled full replacement begins. pub fn confirm_restore( &mut self, preview: RestorePreview, ) -> Result { restore::confirm_restore(self, preview, |store, saved, _backup| { store.install(saved, BackupComplete(())) }) } #[cfg(test)] fn confirm_restore_with_install( &mut self, preview: RestorePreview, install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, ) -> Result { restore::confirm_restore(self, preview, install) } /// Persist applied state only with the one-use authorization produced by /// the active confirmed restart after full technical verification. fn mark_verified_with_attempt( &mut self, attempt: RestartAttempt, observed_management_snapshot: Option, verified_at_utc: String, install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, ) -> Result<(), AppError> { if self.active_restart_attempt != Some(attempt.id) { return Err(AppError::PreRestartBackupRequired); } let installed = self.store.read_saved()?; if !same_saved_bytes(&installed, &self.saved) || !same_saved_bytes(&installed, &attempt.saved) { return Err(AppError::SavedGenerationChanged); } let mut next_states = self.draft.states.clone(); next_states.prepare_technically_verified_result(observed_management_snapshot)?; let mut persisted = match &installed.internal_state { Some(bytes) => PersistedState::from_toml(bytes)?, None => PersistedState::new( next_states .reserved_addresses() .iter() .map(ToString::to_string), None, )?, }; persisted.reserved_addresses = next_states .reserved_addresses() .iter() .map(ToString::to_string) .collect(); let historical_apply = HistoricalApplyStatus::Verified { verified_at_utc: verified_at_utc.clone(), }; persisted.last_verified_at_utc = Some(verified_at_utc); let mut proposed = installed; proposed.internal_state = Some(persisted.to_toml()?); self.store.validate_saved(&proposed)?; if let Err(error) = install(&self.store, &proposed, BackupComplete(())) { self.invalidate_active_issue_authorizations(); match self.store.read_saved() { Ok(current) => { let document = ConfigDocument::parse(¤t.server_config).ok(); let vault = current .vault .as_deref() .and_then(|bytes| std::str::from_utf8(bytes).ok()) .and_then(|text| VaultBundle::from_toml(text).ok()); if let Some(document) = document { retain_matching_issue_map( &mut self.initial_issues, &document, vault.as_ref(), ); } else { self.initial_issues.clear(); } } Err(_) => self.initial_issues.clear(), } return Err(error.into()); } self.saved = proposed; self.draft.states = next_states; self.verified_saved_projection = Some(SavedPeerProjection::from_document(&self.draft.document)); self.historical_apply = historical_apply; self.active_restart_attempt = None; Ok(()) } /// Commit current configuration, vault, and reservations. The archive is /// complete before `SavedStore::install` receives its capability. In-memory /// saved state advances only after the adapter reports full success. pub fn commit(&mut self) -> Result { self.commit_with_adapters(backup::create_archive, |store, proposed, _backup| { store.install(proposed, BackupComplete(())) }) } fn commit_with_adapters( &mut self, create_backup: impl FnOnce( &std::path::Path, BackupReason, HistoricalApplyStatus, &SavedBytes, ) -> Result, install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, ) -> Result { commit::commit(self, create_backup, install) } } fn safe_archive_name(name: &str) -> bool { name.starts_with("wireguard-admin-") && name.ends_with(".tar") && name .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.')) } fn historical_apply_from_saved(saved: &SavedBytes) -> Result { let Some(bytes) = &saved.internal_state else { return Ok(HistoricalApplyStatus::Unknown); }; Ok( match PersistedState::from_toml(bytes)?.last_verified_at_utc { Some(verified_at_utc) => HistoricalApplyStatus::Verified { verified_at_utc }, None => HistoricalApplyStatus::Unknown, }, ) } fn same_saved_bytes(left: &SavedBytes, right: &SavedBytes) -> bool { left.settings == right.settings && left.server_config == right.server_config && left.internal_state == right.internal_state && left.vault == right.vault } fn clone_saved(saved: &SavedBytes) -> SavedBytes { SavedBytes { settings: saved.settings.clone(), server_config: saved.server_config.clone(), internal_state: saved.internal_state.clone(), vault: saved.vault.clone(), } } fn retain_matching_issue_map( issues: &mut BTreeMap, document: &ConfigDocument, vault: Option<&VaultBundle>, ) { issues.retain(|identity, issue| { if issue.stage == IssueStage::PendingDraft { return true; } vault .and_then(|vault| issue_context(document, vault, *identity).ok()) .is_some_and(|actual| actual == issue.context) }); } fn promote_matching_issue_map( issues: &mut BTreeMap, document: &ConfigDocument, vault: Option<&VaultBundle>, ) { issues.retain(|identity, issue| { let matches = vault .and_then(|vault| issue_context(document, vault, *identity).ok()) .is_some_and(|actual| actual == issue.context); if matches && issue.stage == IssueStage::PendingDraft { issue.stage = IssueStage::AwaitingFirstFile; } matches }); } fn issue_context( document: &ConfigDocument, vault: &VaultBundle, identity: PublicKey, ) -> Result { let matching = document .peers() .iter() .filter(|peer| { peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity) }) .collect::>(); let [peer] = matching.as_slice() else { return Err(AppError::FirstIssueUnavailable); }; if peer.identity != Some(identity) || !peer.issues.is_empty() { return Err(AppError::FirstIssueUnavailable); } // Alias/admin comments are descriptive metadata; only WireGuard directives // bind first-issue authority, so renames do not invalidate the creation. let peer_directives = document .source_slice(peer.section) .ok_or(AppError::FirstIssueUnavailable)? .lines() .map(str::trim) .filter(|line| !line.is_empty() && !line.starts_with(['#', ';'])) .collect::>() .join("\n"); let encrypted_record = vault .record_context(identity) .ok_or(AppError::FirstIssueUnavailable)? .to_owned(); Ok(IssueContext { peer_directives, encrypted_record, }) } fn server_interface_addresses(document: &ConfigDocument) -> Result, AppError> { let interfaces = document .sections() .iter() .filter(|section| section.kind == SectionKind::Interface) .collect::>(); let [interface] = interfaces.as_slice() else { return Err(AppError::InvalidSavedState); }; let source = document .source_slice(interface.span) .ok_or(AppError::InvalidSavedState)?; let mut addresses = Vec::new(); let mut address_directive_seen = false; for line in source.lines() { let trimmed = line.trim_matches(|ch: char| ch.is_ascii_whitespace()); if trimmed.is_empty() || trimmed.starts_with(['#', ';']) { continue; } let Some((name, value)) = trimmed.split_once('=') else { continue; }; if !name.trim().eq_ignore_ascii_case("Address") { continue; } if address_directive_seen { return Err(AppError::InvalidSavedState); } address_directive_seen = true; for item in value.split(',') { let network = item .trim() .parse::() .map_err(|_| AppError::InvalidSavedState)?; addresses.push(match network { ipnet::IpNet::V4(network) => IpAddr::V4(network.addr()), ipnet::IpNet::V6(network) => IpAddr::V6(network.addr()), }); } } if addresses.is_empty() { return Err(AppError::InvalidSavedState); } Ok(addresses) } fn document_host_addresses(document: &ConfigDocument) -> Vec { document .peers() .iter() .flat_map(|peer| peer.allowed_ips.iter()) .filter_map(|network| match network { ipnet::IpNet::V4(network) if network.prefix_len() == 32 => { Some(IpAddr::V4(network.addr())) } ipnet::IpNet::V6(network) if network.prefix_len() == 128 => { Some(IpAddr::V6(network.addr())) } _ => None, }) .collect() } fn restart_projection_from_document( document: &ConfigDocument, ) -> Result<(SavedPeerProjection, Option), Vec> { use crate::wireguard::{DocumentIssue, PeerIssue}; let mut diagnostics = Vec::new(); for issue in document.issues() { let (line, reason) = match issue { DocumentIssue::MalformedSectionHeader { line } => (*line, "malformed section header"), DocumentIssue::DirectiveOutsideSection { line } => { (*line, "directive appears outside a section") } DocumentIssue::MalformedDirective { line } => (*line, "malformed directive"), // Managed metadata is descriptive and does not affect the technical // peer set used for runtime verification. DocumentIssue::ManagedMetadata(_) => continue, }; diagnostics.push(RestartDiagnostic { identity: None, section_line: Some(line), reason, }); } for peer in document.peers() { let identity = peer .identity .or_else(|| peer.public_key_candidates.first().copied()); for issue in &peer.issues { let reason = match issue { PeerIssue::MissingPublicKey => Some("peer is missing a public key"), PeerIssue::MalformedPublicKey { .. } => Some("peer has a malformed public key"), PeerIssue::DuplicatePublicKeyField { .. } => { Some("peer has duplicate public key fields") } PeerIssue::DuplicatePeerIdentity { .. } => { Some("public key is used by multiple peer sections") } PeerIssue::MalformedAllowedIp { .. } => { Some("peer has a malformed AllowedIPs item") } PeerIssue::UnsupportedAllowedIpShape { .. } | PeerIssue::MissingIpv4HostRoute => { None } }; if let Some(reason) = reason { diagnostics.push(RestartDiagnostic { identity, section_line: Some(peer.section_line), reason, }); } } } let projection = SavedPeerProjection::from_document(document); if (projection.document_unverifiable || !projection.unverifiable_peer_indices.is_empty() || !projection.duplicate_public_keys.is_empty()) && diagnostics.is_empty() { diagnostics.push(RestartDiagnostic { identity: None, section_line: None, reason: "saved peer set is not uniquely verifiable", }); } if !diagnostics.is_empty() { return Err(diagnostics); } let mut clients = Vec::with_capacity(document.peers().len()); for peer in document.peers() { let Some(identity) = peer.identity else { return Err(vec![RestartDiagnostic { identity: None, section_line: Some(peer.section_line), reason: "peer identity is unavailable", }]); }; let Ok(client) = document.project_client(&identity) else { clients.clear(); break; }; clients.push(client); } let management_snapshot = (clients.len() == document.peers().len()) .then(|| Snapshot::new(clients).ok()) .flatten(); Ok((projection, management_snapshot)) } fn restart_report( stage: RestartStage, failure: Option, backup_path: Option, restart_submitted: bool, restart_may_still_complete: bool, manual_recovery_required: bool, ) -> RestartReport { RestartReport { stage, failure, service_failure: None, service_phase: None, systemd_unit: None, backup_path, restart_submitted, restart_may_still_complete, manual_recovery_required, } } fn snapshot_from_document(document: &ConfigDocument) -> Snapshot { let mut snapshot = Snapshot::default(); for client in document.peers().iter().filter_map(|peer| { let identity = peer.identity?; document.project_client(&identity).ok() }) { let _ = snapshot.insert(client); } snapshot } fn ensure_document_client(document: &ConfigDocument, client: &Client) -> Result<(), AppError> { if document.project_client(&client.identity)? != *client { return Err(AppError::InvalidSavedState); } Ok(()) } #[derive(Debug, Error)] pub enum AppError { #[error("saved state is invalid or incompatible with this session")] InvalidSavedState, #[error("a vault is already installed")] VaultAlreadyInstalled, #[error("the installed vault identity cannot be replaced")] VaultIdentityChange, #[error("a vault must be installed before creating a client")] VaultMissingForCreation, #[error("a new client must have both IPv4 and IPv6 addresses")] NewClientMustBeDualStack, #[error("first file issuance is not authorized for this client in this session")] FirstIssueUnavailable, #[error("first-issue authorization identifiers are exhausted for this session")] IssueAuthorizationExhausted, #[error("client key generation failed")] KeyGenerationFailed, #[error("the selected backup name is invalid")] InvalidArchiveSelection, #[error("the selected unsupported peer has no exact host route to reserve safely")] DeletedPeerAddressUnknown, #[error("runtime verification cannot be recorded without a completed pre-restart backup")] PreRestartBackupRequired, #[error("wg-quick SaveConfig may overwrite the saved server configuration during restart")] UnsafeSaveConfig, #[error("saved peer cannot be represented by the client management model")] UnsupportedSavedShape, #[error("saved server configuration cannot be verified safely: {0:?}")] UnsafeSavedPeers(Vec), #[error("installed saved bytes changed after this session opened")] SavedGenerationChanged, #[error(transparent)] Backup(#[from] BackupError), #[error(transparent)] Persistence(#[from] PersistenceError), #[error(transparent)] Vault(#[from] VaultError), #[error(transparent)] Document(#[from] DocumentError), #[error(transparent)] Edit(#[from] EditError), #[error(transparent)] Domain(#[from] DomainError), #[error(transparent)] Delivery(#[from] crate::delivery::DeliveryError), #[error(transparent)] FileOutput(#[from] FileOutputError), #[error(transparent)] SourceImport(#[from] SourceImportError), } #[cfg(test)] mod tests { use std::ffi::OsStr; use std::fs; use std::net::{Ipv4Addr, Ipv6Addr}; use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::time::{Duration, Instant}; use super::{ AppError, Application, FileOutputError, FirstIssueAttempt, FirstIssueDeliveryFailure, FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartFailureKind, RestartStage, clone_saved, snapshot_from_document, }; use crate::backup::{ArchiveCheckpoint, create_archive_with_hook}; use crate::backup::{BackupReason, HistoricalApplyStatus, create_archive, preview_archive}; use crate::config::SettingsFile; use crate::delivery::qr::QrError; use crate::domain::{ AddressAvailability, AddressFamily, ApplyStatus, Client, ClientAddresses, ClientStates, DomainError, PublicKey, Snapshot, allocate_ipv4, allocate_ipv6, }; use crate::persistence::{ BackupComplete, InstallCheckpoint, PersistedState, PersistenceError, SavedStore, }; use crate::runtime::transport::TransportError; use crate::runtime::{ InterfaceObservation, Observed, PeerSetCompleteness, RuntimeObservation, RuntimePeer, RuntimePeerSet, }; use crate::service::{RestartExecution, RestartFailure, RestartOutcome}; use crate::vault::{VaultBundle, WireGuardPrivateKey}; use crate::wireguard::ConfigDocument; const SETTINGS: &str = r#" interface_name = "wg0" server_config_path = "server.conf" client_source_directory = "clients" server_endpoint = "vpn.example.test:51820" ipv4_allocation_range = "10.20.0.0/24" ipv6_allocation_range = "fd20::/64" dns_addresses = ["10.20.0.1"] systemd_unit = "wg-quick@wg0.service" restart_timeout_seconds = 30 "#; #[test] fn restore_settings_projection_reports_changed_fields_without_exposing_values() { let proposed = SETTINGS.replace("vpn.example.test:51820", "vpn-new.example.test:51820"); let fields = super::settings_fields_changed(SETTINGS.as_bytes(), proposed.as_bytes()); assert_eq!(fields, ["server endpoint"]); let malformed = super::settings_fields_changed(SETTINGS.as_bytes(), b"invalid = ["); assert_eq!(malformed, ["settings file"]); } fn setup( root: &Path, archive_as_file: bool, ) -> (SavedStore, ClientStates, PublicKey, WireGuardPrivateKey) { fs::create_dir_all(root).expect("temporary root should be created"); let settings_file = SettingsFile::parse_toml(SETTINGS) .expect("settings fixture parses") .validate() .expect("settings fixture validates"); let resolved = settings_file.resolve_paths(root); fs::write(root.join("wireguard-admin.toml"), SETTINGS).expect("settings installed"); let secret = WireGuardPrivateKey::generate().expect("synthetic peer key"); let server_secret = WireGuardPrivateKey::generate().expect("synthetic server key"); let key = secret.public_key(); let server = format!( "[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24, fd20::1/64\n\n# admin note\n[Peer]\nPublicKey = {key}\nAllowedIPs = 10.20.0.2/32, fd20::2/128\n", server_secret.to_base64() ); fs::write(root.join("server.conf"), server.as_bytes()).expect("server installed"); fs::set_permissions(root.join("server.conf"), fs::Permissions::from_mode(0o600)) .expect("server fixture private"); if archive_as_file { fs::create_dir_all(root.join("Backup")).expect("backup root created"); fs::write(root.join("Backup/archives"), b"block archive directory").expect("blocker"); } let saved_client = Client::new( key, "legacy-view", ClientAddresses { ipv4: Ipv4Addr::new(10, 20, 0, 2), ipv6: Some("fd20::2".parse::().expect("IPv6 fixture")), }, ) .expect("state client valid"); let snapshot = Snapshot::new([saved_client]).expect("snapshot valid"); let states = ClientStates::new(snapshot.clone(), Some(snapshot), ApplyStatus::Verified) .expect("initial applied state valid"); let store = SavedStore::new(root, resolved).expect("saved store mapping valid"); (store, states, key, secret) } fn test_mark_verified( app: &mut Application, observed: Snapshot, verified_at: String, ) -> Result<(), AppError> { app.create_pre_restart_backup()?; let id = app.next_restart_attempt; app.next_restart_attempt += 1; app.active_restart_attempt = Some(id); let attempt = super::RestartAttempt { id, saved: clone_saved(&app.saved), }; app.mark_verified_with_attempt( attempt, Some(observed), verified_at, |store, saved, backup| store.install(saved, backup), ) } fn prepare_restartable(root: &Path) -> (Application, PublicKey) { let (store, states, key, _) = setup(root, false); let mut app = Application::open(store, states).expect("application opens"); app.rename_client(key, "restart fixture") .expect("install managed metadata"); app.commit().expect("restart fixture commits"); (app, key) } fn setup_with_vault(root: &Path) -> Application { let (store, states, _, _) = setup(root, false); let mut app = Application::open(store, states).expect("application opens"); app.prepare_initial_vault( VaultBundle::create("synthetic vault password").expect("synthetic vault"), ) .expect("vault stages"); app.commit().expect("initial vault commits"); app } fn observed_runtime(app: &Application) -> RuntimeObservation { let records = app.document().peers().iter().map(|peer| { let identity = peer.identity.expect("synthetic peer identity"); ( identity, RuntimePeer { allowed_ips: Observed::Available(peer.allowed_ips.clone()), last_handshake: Observed::Available(None), received_bytes: Observed::Available(0), transmitted_bytes: Observed::Available(0), }, ) }); RuntimeObservation { observed_at: None, interface: InterfaceObservation::Present { name: "wg0".to_owned(), peers: RuntimePeerSet::from_records(PeerSetCompleteness::Complete, records), }, } } #[test] fn new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let before_document = app.document().render().to_vec(); let before_saved = clone_saved(app.saved_bytes()); let before_draft = app.states().draft().clone(); let before_records = app.vault().expect("vault exists").record_count(); let existing_identity = app .states() .saved() .clients() .next() .expect("legacy client exists") .identity; assert!(matches!( app.deliver_first_issue(existing_identity, FirstIssueAttempt::Initial, |_| Ok::< _, ((), _), >( () )), Err(AppError::FirstIssueUnavailable) )); assert!(matches!( app.prepare_new_client("invalid\nalias", None, None), Err(AppError::Domain(_)) )); assert_eq!(app.document().render(), before_document); assert_eq!(app.saved_bytes().server_config, before_saved.server_config); assert_eq!(app.states().draft(), &before_draft); assert_eq!( app.vault().expect("vault exists").record_count(), before_records ); let identity = app .prepare_new_client("shared alias", None, Some("2026-10-20T00:00:00Z")) .expect("new client is staged"); assert!(app.initial_issues.contains_key(&identity)); assert!(matches!( app.prepare_new_client("invalid\nalias", None, None), Err(AppError::Domain(_)) )); assert!(app.initial_issues.contains_key(&identity)); let client = app.states().draft().get(&identity).expect("draft client"); assert_eq!(client.addresses.ipv4, Ipv4Addr::new(10, 20, 0, 3)); assert!(client.addresses.ipv6.is_some()); assert_eq!(app.states().saved().get(&identity), None); assert_eq!( app.vault().expect("vault exists").record_count(), before_records + 1 ); assert!(matches!( app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>( () )), Err(AppError::FirstIssueUnavailable) )); } #[test] fn missing_vault_after_client_preparation_does_not_publish_any_draft_or_issue() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, _, _) = setup(root.path(), false); let mut app = Application::open(store, states).expect("application opens"); let before_document = app.document().render().to_vec(); let before_draft = app.states().draft().clone(); assert!(matches!( app.prepare_new_client("missing vault", None, None), Err(AppError::VaultMissingForCreation) )); assert_eq!(app.document().render(), before_document); assert_eq!(app.states().draft(), &before_draft); assert!(app.vault().is_none()); assert!(app.initial_issues.is_empty()); } #[test] fn selected_file_import_uses_the_existing_vault_draft_without_saving() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let source_directory = root.path().join("clients"); fs::create_dir_all(&source_directory).expect("source directory"); fs::write( source_directory.join("invalid.conf"), b"not a client config\n", ) .expect("synthetic invalid source file"); let saved_vault = app.saved_bytes().vault.clone(); let draft_records = app.vault().expect("vault exists").record_count(); let report = app .import_selected_file(OsStr::new("invalid.conf")) .expect("invalid selected file is reported rather than fatal"); assert!(report.scan_complete); assert!(matches!( report.candidates[0].1, crate::vault::import::CandidateStatus::Invalid(_) )); assert_eq!( app.vault().expect("vault remains present").record_count(), draft_records ); assert_eq!(app.saved_bytes().vault, saved_vault); } #[test] fn selected_import_error_keeps_vault_and_pending_issue_unchanged() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let identity = app .prepare_new_client("pending import", None, None) .expect("client stages"); let before_document = app.document().render().to_vec(); let before_states = app.states().clone(); let before_vault = app.vault().expect("vault exists").to_toml().unwrap(); assert!(matches!( app.import_selected_file(OsStr::new("../invalid.conf")), Err(AppError::SourceImport( crate::vault::source::SourceImportError::InvalidSelection )) )); assert_eq!(app.document().render(), before_document); assert_eq!(app.states(), &before_states); assert_eq!( app.vault() .expect("vault remains present") .to_toml() .unwrap(), before_vault ); assert!(app.initial_issues.contains_key(&identity)); } #[test] fn manual_addresses_reject_occupied_and_out_of_range_without_partial_staging() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let before_document = app.document().render().to_vec(); let before_records = app.vault().expect("vault exists").record_count(); assert!(matches!( app.prepare_new_client( "occupied", Some(ClientAddresses { ipv4: Ipv4Addr::new(10, 20, 0, 2), ipv6: Some("fd20::3".parse().expect("IPv6")), }), None, ), Err(AppError::Domain( crate::domain::DomainError::AddressUnavailable )) )); assert!(matches!( app.prepare_new_client( "outside", Some(ClientAddresses { ipv4: Ipv4Addr::new(10, 21, 0, 3), ipv6: Some("fd20::3".parse().expect("IPv6")), }), None, ), Err(AppError::Domain( crate::domain::DomainError::AddressOutsideRange )) )); assert_eq!(app.document().render(), before_document); assert_eq!( app.vault().expect("vault exists").record_count(), before_records ); } #[test] fn commit_failure_keeps_pending_issue_and_successful_retry_authorizes_it() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let identity = app .prepare_new_client("retry", None, None) .expect("client stages"); let archives = root.path().join("Backup/archives"); let moved_archives = root.path().join("Backup/archives-saved"); fs::rename(&archives, &moved_archives).expect("move archive directory"); fs::write(&archives, b"block archive creation").expect("install blocker"); assert!(matches!(app.commit(), Err(AppError::Backup(_)))); assert!(matches!( app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>( () )), Err(AppError::FirstIssueUnavailable) )); fs::remove_file(&archives).expect("remove temporary blocker"); fs::rename(&moved_archives, &archives).expect("restore archive directory"); assert!(matches!( app.commit_with_adapters(crate::backup::create_archive, |_, _, _| Err( PersistenceError::RecoveryRequired ),), Err(AppError::Persistence(PersistenceError::RecoveryRequired)) )); assert!(matches!( app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>( () )), Err(AppError::FirstIssueUnavailable) )); app.commit().expect("retry commits"); let mut payload_was_available = false; let result = app .deliver_first_issue(identity, FirstIssueAttempt::Initial, |payload| { payload_was_available = !payload.is_empty(); Ok::<_, ((), _)>(()) }) .expect("committed creation can issue"); assert!(payload_was_available); assert!(matches!(result, FirstIssueDeliveryOutcome::Delivered(()))); } #[test] fn unrelated_rename_and_verified_restart_preserve_independent_first_issues() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let first = app .prepare_new_client("same alias", None, None) .expect("first client stages"); let second = app .prepare_new_client("same alias", None, None) .expect("second client stages"); assert_ne!(first, second); app.commit().expect("both clients commit"); let unrelated = app .states() .saved() .clients() .find(|client| client.alias == "legacy-view") .expect("saved client") .identity; app.rename_client(unrelated, "renamed unrelated client") .expect("unrelated rename stages"); app.commit().expect("unrelated rename commits"); let snapshot = snapshot_from_document(app.document()); test_mark_verified(&mut app, snapshot, "2026-10-20T01:00:00Z".to_owned()) .expect("verified restart state persists"); app.deliver_first_issue(first, FirstIssueAttempt::Initial, |payload| { assert!(!payload.is_empty()); Ok::<_, ((), _)>(()) }) .expect("first client remains eligible and its file succeeds"); assert!(matches!( app.deliver_first_issue(first, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>(())), Err(AppError::FirstIssueUnavailable) )); app.deliver_first_issue(second, FirstIssueAttempt::Initial, |payload| { assert!(!payload.is_empty()); Ok::<_, ((), _)>(()) }) .expect("second client's outstanding capability remains independent"); assert!(matches!( app.deliver_first_issue(second, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>(())), Err(AppError::FirstIssueUnavailable) )); } #[test] fn first_issue_authorization_is_single_flight_and_success_consumes_it() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let identity = app .prepare_new_client("one-use", None, None) .expect("client stages"); app.commit().expect("client commits"); let delivered = app .deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| { Ok::<_, ((), _)>(()) }) .expect("first authorization succeeds"); assert!(matches!( delivered, FirstIssueDeliveryOutcome::Delivered(()) )); assert!(matches!( app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>( () )), Err(AppError::FirstIssueUnavailable) )); // The initial operation already consumed eligibility on success. } #[test] fn committed_first_issue_publishes_selected_file_and_consumes_eligibility() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let identity = app .prepare_new_client("file delivery", None, None) .expect("client stages"); app.commit().expect("client commits"); let destination_path = root.path().join("chosen/output/client.conf"); let destination = app .prepare_first_issue_destination(&destination_path) .expect("explicit output path is safe"); assert!(matches!( app.deliver_first_issue_file(identity, FirstIssueAttempt::Initial, destination) .expect("authorized file is delivered"), FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning )); let metadata = fs::metadata(&destination_path).expect("client file exists"); assert_eq!(metadata.permissions().mode() & 0o777, 0o600); assert!( fs::read(&destination_path) .expect("client configuration readable") .starts_with(b"[Interface]\nPrivateKey = ") ); let retry_path = root.path().join("chosen/output/retry.conf"); let retry = app .prepare_first_issue_destination(&retry_path) .expect("new explicit path remains safe"); assert!(matches!( app.deliver_first_issue_file(identity, FirstIssueAttempt::Initial, retry), Err(AppError::FirstIssueUnavailable) )); assert!(!retry_path.exists()); } #[test] fn file_and_qr_share_payload_and_qr_failure_still_consumes_first_issue() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let identity = app .prepare_new_client("file and QR", None, None) .expect("client stages"); app.commit().expect("client commits"); let path = root.path().join("chosen/client.conf"); let destination = app .prepare_first_issue_destination(&path) .expect("destination prepares"); let result = app .deliver_first_issue_file_and_qr_with( identity, FirstIssueAttempt::Initial, destination, |payload| { assert!( payload.as_bytes() == fs::read(&path).expect("published file is readable") ); Err(QrError::CapacityExceeded) }, ) .expect("file action completes"); let FirstIssueDeliveryOutcome::Delivered(delivery) = result else { panic!("file publication succeeds independently of QR encoding"); }; assert!(delivery.activation_warning); assert!(matches!(delivery.qr, Err(QrError::CapacityExceeded))); let retry = app .prepare_first_issue_destination(root.path().join("chosen/retry.conf")) .expect("retry destination prepares"); assert!(matches!( app.deliver_first_issue_file_and_qr(identity, FirstIssueAttempt::Initial, retry), Err(AppError::FirstIssueUnavailable) )); } #[test] fn successful_file_qr_action_encodes_the_published_bytes() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let identity = app .prepare_new_client("successful QR", None, None) .expect("client stages"); app.commit().expect("client commits"); let path = root.path().join("chosen/client.conf"); let destination = app .prepare_first_issue_destination(&path) .expect("destination prepares"); let result = app .deliver_first_issue_file_and_qr_with( identity, FirstIssueAttempt::Initial, destination, |payload| { assert!( payload.as_bytes() == fs::read(&path).expect("published file is readable") ); crate::delivery::qr::encode_payload(payload) }, ) .expect("file and QR action completes"); let FirstIssueDeliveryOutcome::Delivered(delivery) = result else { panic!("file publication succeeds"); }; assert!(delivery.activation_warning); let matrix = delivery.qr.expect("QR encodes"); assert!((29..=185).contains(&matrix.side())); } #[test] fn first_issue_warning_tracks_verified_saved_generation_for_file_and_qr() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let legacy = app .states() .saved() .clients() .next() .expect("legacy client") .identity; app.rename_client(legacy, "legacy-view") .expect("write authoritative alias metadata"); let identities = (0..4) .map(|_| { app.prepare_new_client("activation", None, None) .expect("client stages") }) .collect::>(); app.commit().expect("clients commit"); for (index, identity) in identities[..2].iter().copied().enumerate() { let path = root.path().join(format!("unapplied-{index}.conf")); let destination = app .prepare_first_issue_destination(path) .expect("destination prepares"); if index == 0 { let result = app .deliver_first_issue_file(identity, FirstIssueAttempt::Initial, destination) .expect("file delivery succeeds"); assert!(matches!( result, FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning )); } else { let result = app .deliver_first_issue_file_and_qr( identity, FirstIssueAttempt::Initial, destination, ) .expect("file and QR delivery succeeds"); assert!(matches!( result, FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning )); } } let saved_snapshot = app.states().saved().clone(); test_mark_verified(&mut app, saved_snapshot, "2026-10-21T00:00:00Z".to_owned()) .expect("same saved generation is verified applied"); for (index, identity) in identities[2..].iter().copied().enumerate() { let path = root.path().join(format!("applied-{index}.conf")); let destination = app .prepare_first_issue_destination(path) .expect("destination prepares"); if index == 0 { let result = app .deliver_first_issue_file(identity, FirstIssueAttempt::Initial, destination) .expect("file delivery succeeds"); assert!(matches!( result, FirstIssueDeliveryOutcome::Delivered(delivery) if !delivery.activation_warning )); } else { let result = app .deliver_first_issue_file_and_qr( identity, FirstIssueAttempt::Initial, destination, ) .expect("file and QR delivery succeeds"); assert!(matches!( result, FirstIssueDeliveryOutcome::Delivered(delivery) if !delivery.activation_warning )); } } } #[test] fn historical_verified_timestamp_does_not_suppress_reissue_warning_after_reopen() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let identity = app .prepare_new_client("history warning", None, None) .expect("client stages"); app.commit().expect("client commits"); let saved = app.states().saved().clone(); test_mark_verified(&mut app, saved, "2026-10-21T00:00:00Z".to_owned()) .expect("saved generation verified"); let mut runtime_unknown = app.states().clone(); runtime_unknown.runtime_knowledge_unknown(); let reopened = Application::open(app.store.clone(), runtime_unknown) .expect("application reopens with unknown runtime"); let destination = reopened .prepare_first_issue_destination(root.path().join("history-reissue.conf")) .expect("destination prepares"); let result = reopened .reissue_client_file(identity, "synthetic vault password", destination, true) .expect("password-authorized reissue succeeds"); assert!(result.activation_warning); assert!(result.qr.is_some_and(|qr| qr.is_ok())); } #[test] fn qr_is_not_attempted_when_mandatory_file_publication_fails() { use std::sync::atomic::{AtomicBool, Ordering}; let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let identity = app .prepare_new_client("file failure", None, None) .expect("client stages"); app.commit().expect("client commits"); let path = root.path().join("chosen/existing.conf"); fs::create_dir_all(path.parent().expect("parent exists")).expect("parent creates"); fs::write(&path, b"existing synthetic bytes").expect("existing target writes"); let destination = app .prepare_first_issue_destination(&path) .expect("existing destination prepares"); let qr_called = AtomicBool::new(false); let result = app .deliver_first_issue_file_and_qr_with( identity, FirstIssueAttempt::Initial, destination, |_| { qr_called.store(true, Ordering::SeqCst); Err(QrError::EncodingFailed) }, ) .expect("file failure is reported as an operation result"); assert!(!qr_called.load(Ordering::SeqCst)); assert!(matches!( result, FirstIssueDeliveryOutcome::Failed { error: FileOutputError::OverwriteDeclined, retry: FirstIssueRetry::Available, .. } )); assert_eq!( fs::read(&path).expect("existing file retained"), b"existing synthetic bytes" ); } #[test] fn published_file_is_reported_when_saved_store_bookkeeping_fails() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let identity = app .prepare_new_client("bookkeeping fault", None, None) .expect("client stages"); app.commit().expect("client commits"); let saved_server_config = app.saved_bytes().server_config.clone(); let output_path = root.path().join("delivered/client.conf"); let destination = app .prepare_first_issue_destination(&output_path) .expect("destination prepares"); let result = app.deliver_first_issue_with_warning( identity, FirstIssueAttempt::Initial, |payload, activation_warning| { crate::delivery::files::publish(destination, payload).map_err(|error| { let failure = match error { FileOutputError::PublicationUncertain => { FirstIssueDeliveryFailure::PublicationUncertain } FileOutputError::ManualCleanupRequired => { FirstIssueDeliveryFailure::CleanupRequired } _ => FirstIssueDeliveryFailure::BeforePublication, }; (error, failure) })?; // Fault injection after definite publication and before the // application's post-publication saved-state read. fs::remove_file(root.path().join("server.conf")) .expect("inject saved-store read failure"); Ok::< _, ( crate::delivery::files::FileOutputError, FirstIssueDeliveryFailure, ), >(activation_warning) }, ); assert!(matches!( result, Ok( FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain { value: true, error: AppError::Persistence(PersistenceError::Io(_)), .. } ) )); assert!( fs::read(&output_path) .expect("published file remains available") .starts_with(b"[Interface]\nPrivateKey = ") ); fs::write(root.path().join("server.conf"), saved_server_config) .expect("restore the synthetic saved config"); fs::set_permissions( root.path().join("server.conf"), fs::Permissions::from_mode(0o600), ) .expect("keep restored config private"); assert!(matches!( app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| { Ok::<_, ((), FirstIssueDeliveryFailure)>(()) }), Err(AppError::FirstIssueUnavailable) )); } #[test] fn failed_file_publication_can_retry_in_a_different_directory() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let identity = app .prepare_new_client("retryable file", None, None) .expect("client stages"); app.commit().expect("client commits"); let first_directory = root.path().join("first-selection"); fs::create_dir(&first_directory).expect("first output directory"); let first_path = first_directory.join("client.conf"); let first = app .prepare_first_issue_destination(&first_path) .expect("first selection prepares"); fs::remove_dir(&first_directory).expect("simulate directory removal before publish"); fs::write(&first_directory, b"blocker").expect("replace directory with a file"); let failed = app .deliver_first_issue_file(identity, FirstIssueAttempt::Initial, first) .expect("file failure is recorded for retry"); assert!(matches!( failed, FirstIssueDeliveryOutcome::Failed { retry: FirstIssueRetry::Available, .. } )); fs::remove_file(&first_directory).expect("remove synthetic blocker"); let second_path = root.path().join("new-selection/client.conf"); let second = app .prepare_first_issue_destination(&second_path) .expect("new selection prepares"); assert!(matches!( app.deliver_first_issue_file( identity, FirstIssueAttempt::Retry { confirm_possible_overwrite: false, confirm_manual_cleanup: false, }, second, ) .expect("same-session retry succeeds"), FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning )); assert!(second_path.exists()); } #[test] fn failed_first_delivery_requires_consuming_capability_before_retry() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let identity = app .prepare_new_client("retry-output", None, None) .expect("client stages"); app.commit().expect("client commits"); let failed = app .deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| { Err::<(), _>(("write failed", FirstIssueDeliveryFailure::BeforePublication)) }) .expect("pre-publication failure returns eligibility"); assert!(matches!( failed, FirstIssueDeliveryOutcome::Failed { error: "write failed", retry: FirstIssueRetry::Available, activation_warning: true, } )); let retry = app .deliver_first_issue( identity, FirstIssueAttempt::Retry { confirm_possible_overwrite: false, confirm_manual_cleanup: false, }, |_| { Err::<(), _>(( "sync uncertain", FirstIssueDeliveryFailure::PublicationUncertain, )) }, ) .expect("pre-publication retry needs no overwrite confirmation"); assert!(matches!( retry, FirstIssueDeliveryOutcome::Failed { error: "sync uncertain", retry: FirstIssueRetry::OverwriteConfirmationRequired, activation_warning: true, } )); assert!(matches!( app.deliver_first_issue( identity, FirstIssueAttempt::Retry { confirm_possible_overwrite: false, confirm_manual_cleanup: false, }, |_| Ok::<_, ((), _)>(()) ), Err(AppError::FirstIssueUnavailable) )); app.deliver_first_issue( identity, FirstIssueAttempt::Retry { confirm_possible_overwrite: true, confirm_manual_cleanup: false, }, |_| Ok::<_, ((), _)>(()), ) .expect("possible existing output requires explicit overwrite confirmation"); assert!(matches!( app.deliver_first_issue( identity, FirstIssueAttempt::Retry { confirm_possible_overwrite: true, confirm_manual_cleanup: false, }, |_| Ok::<_, ((), _)>(()) ), Err(AppError::FirstIssueUnavailable) )); } #[test] fn temporary_cleanup_failure_requires_manual_cleanup_before_first_issue_retry() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let identity = app .prepare_new_client("cleanup recovery", None, None) .expect("client stages"); app.commit().expect("client commits"); let failed = app .deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| { Err::<(), _>(( "cleanup required", FirstIssueDeliveryFailure::CleanupRequired, )) }) .expect("cleanup failure is reported as recovery-required"); assert!(matches!( failed, FirstIssueDeliveryOutcome::Failed { error: "cleanup required", retry: FirstIssueRetry::ManualCleanupRequired, activation_warning: true, } )); assert!(matches!( app.deliver_first_issue( identity, FirstIssueAttempt::Retry { confirm_possible_overwrite: false, confirm_manual_cleanup: false, }, |_| Ok::<_, ((), FirstIssueDeliveryFailure)>(()) ), Err(AppError::FirstIssueUnavailable) )); app.deliver_first_issue( identity, FirstIssueAttempt::Retry { confirm_possible_overwrite: false, confirm_manual_cleanup: true, }, |_| Ok::<_, ((), FirstIssueDeliveryFailure)>(()), ) .expect("manual cleanup acknowledgement permits same-session retry"); } #[test] fn uncertain_first_file_publication_retains_warning_and_skips_qr() { use std::sync::atomic::{AtomicBool, Ordering}; let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let identity = app .prepare_new_client("uncertain publication", None, None) .expect("client stages"); app.commit().expect("client commits"); let file_path = root.path().join("file-only/client.conf"); let file_destination = app .prepare_first_issue_destination(&file_path) .expect("file destination prepares"); let file_result = app .deliver_first_issue_file_with( identity, FirstIssueAttempt::Initial, file_destination, |_, _| Err(FileOutputError::PublicationUncertain), ) .expect("uncertain publication is reported as an operation result"); assert!(matches!( file_result, FirstIssueDeliveryOutcome::Failed { error: FileOutputError::PublicationUncertain, retry: FirstIssueRetry::OverwriteConfirmationRequired, activation_warning: true, } )); // The uncertain result requires an explicit overwrite-confirmed retry. let qr_path = root.path().join("file-and-qr/client.conf"); let qr_destination = app .prepare_first_issue_destination(&qr_path) .expect("QR file destination prepares"); let qr_called = AtomicBool::new(false); let qr_result = app .deliver_first_issue_file_and_qr_with_publisher( identity, FirstIssueAttempt::Retry { confirm_possible_overwrite: true, confirm_manual_cleanup: false, }, qr_destination, |_, _| Err(FileOutputError::PublicationUncertain), |_| { qr_called.store(true, Ordering::SeqCst); Err(QrError::EncodingFailed) }, ) .expect("uncertain retry is reported as an operation result"); assert!(!qr_called.load(Ordering::SeqCst)); assert!(matches!( qr_result, FirstIssueDeliveryOutcome::Failed { error: FileOutputError::PublicationUncertain, retry: FirstIssueRetry::OverwriteConfirmationRequired, activation_warning: true, } )); } #[test] fn deleted_first_issue_never_invokes_publication_closure() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let identity = app .prepare_new_client("delete-capability", None, None) .expect("client stages"); app.commit().expect("client commits"); assert!(app.delete_client(identity).expect("client deletes")); let mut writer_called = false; assert!(matches!( app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| { writer_called = true; Ok::<_, ((), _)>(()) }), Err(AppError::FirstIssueUnavailable) )); assert!( !writer_called, "deleted client bytes are never handed to a writer" ); } #[test] fn deletion_and_discard_revoke_only_session_bound_initial_material() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let pending = app .prepare_new_client("discard", None, None) .expect("client stages"); app.discard_draft().expect("draft discards"); assert!(matches!( app.deliver_first_issue( pending, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>(()) ), Err(AppError::FirstIssueUnavailable) )); let deleted = app .prepare_new_client("delete", None, None) .expect("second client stages"); app.commit().expect("second client commits"); assert!(app.delete_client(deleted).expect("client deletes")); app.commit().expect("deletion commits"); assert!(matches!( app.deliver_first_issue( deleted, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>(()) ), Err(AppError::FirstIssueUnavailable) )); } #[test] fn backup_failure_preserves_installed_files_and_draft() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, key, _) = setup(root.path(), true); let before = store.read_saved().expect("installed bytes readable"); let mut app = Application::open(store.clone(), states).expect("application opens"); app.rename_client(key, "new alias") .expect("rename is staged"); assert!(matches!(app.commit(), Err(AppError::Backup(_)))); assert_eq!( store .read_saved() .expect("saved remains readable") .server_config, before.server_config ); assert!( String::from_utf8_lossy(app.document().render()) .contains("# wg-admin:alias=bmV3IGFsaWFz") ); assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert_eq!( app.states().saved().get(&key).expect("saved peer").alias, "legacy-view" ); } #[test] fn injected_archive_and_install_failures_preserve_bytes_and_session_saved_state() { let root = tempfile::tempdir().expect("temporary directory"); let (store, states, key, _) = setup(root.path(), false); let original = store.read_saved().expect("original bytes"); for point in [ ArchiveCheckpoint::Write, ArchiveCheckpoint::Sync, ArchiveCheckpoint::Publish, ArchiveCheckpoint::TemporaryCleanup, ArchiveCheckpoint::DirectorySync, ] { let mut app = Application::open(store.clone(), states.clone()).expect("open"); app.rename_client(key, "draft alias").expect("stage alias"); let result = app.commit_with_adapters( |directory, reason, history, saved| { create_archive_with_hook(directory, reason, history, saved, |current| { if current == point { Err(std::io::Error::other("archive fault")) } else { Ok(()) } }) }, |store, proposed, _backup| store.install(proposed, BackupComplete(())), ); assert!(result.is_err()); let on_disk = store.read_saved().expect("saved files stay readable"); assert_eq!(on_disk.server_config, original.server_config); assert_eq!(app.saved_bytes().server_config, original.server_config); assert_eq!(app.states().saved().get(&key).unwrap().alias, "legacy-view"); assert_eq!(app.states().draft().get(&key).unwrap().alias, "draft alias"); } for point in [ InstallCheckpoint::StageWrite(1), InstallCheckpoint::StageSync(1), InstallCheckpoint::Replaced(1), ] { let mut app = Application::open(store.clone(), states.clone()).expect("open"); app.rename_client(key, "draft alias").expect("stage alias"); let result = app.commit_with_adapters(create_archive, |store, proposed, _backup| { store.install_with_hook(proposed, |current| { if current == point { Err(std::io::Error::other("install fault")) } else { Ok(()) } }) }); assert!(result.is_err()); let on_disk = store.read_saved().expect("reopen recovers transaction"); assert_eq!(on_disk.settings, original.settings); assert_eq!(on_disk.server_config, original.server_config); assert_eq!(on_disk.internal_state, original.internal_state); assert_eq!(on_disk.vault, original.vault); assert_eq!(app.saved_bytes().server_config, original.server_config); assert_eq!(app.states().saved().get(&key).unwrap().alias, "legacy-view"); assert_eq!(app.states().draft().get(&key).unwrap().alias, "draft alias"); } } #[test] fn commit_advances_saved_state_without_applying_and_backup_contains_old_bytes() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, key, _) = setup(root.path(), false); let original = store.read_saved().expect("installed bytes readable"); let mut app = Application::open(store.clone(), states).expect("application opens"); app.rename_client(key, "new alias") .expect("rename is staged"); let archive = app.commit().expect("commit should complete"); let saved = store.read_saved().expect("new bytes readable"); assert!( String::from_utf8_lossy(&saved.server_config).contains("# wg-admin:alias=bmV3IGFsaWFz") ); assert!( !String::from_utf8_lossy(&original.server_config) .contains("# wg-admin:alias=bmV3IGFsaWFz") ); assert_eq!( app.states().saved().get(&key).expect("saved peer").alias, "new alias" ); assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert!(app.states().applied_snapshot().is_some()); let backup = preview_archive(&archive).expect("pre-commit backup parses"); assert_eq!(backup.reason, BackupReason::PreCommit); assert_eq!(backup.saved_bytes.server_config, original.server_config); assert_eq!(backup.saved_bytes.settings, original.settings); assert!(String::from_utf8_lossy(&saved.server_config).contains("# admin note\n")); } #[test] fn unchanged_commit_preserves_verified_apply_knowledge() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, _, _) = setup(root.path(), false); let mut app = Application::open(store, states).expect("application opens"); let observed = app.states().saved().clone(); test_mark_verified(&mut app, observed, "2026-10-29T12:00:00Z".to_owned()) .expect("synthetic verification is published"); assert_eq!(app.states().applied(), &ApplyStatus::Verified); app.commit().expect("unchanged commit completes"); assert_eq!(app.states().applied(), &ApplyStatus::Verified); assert_eq!(app.states().applied_snapshot(), Some(app.states().saved())); } #[test] fn unchanged_commit_preserves_unavailable_apply_knowledge() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, _, _) = setup(root.path(), false); let mut app = Application::open(store, states).expect("application opens"); app.draft.states.runtime_knowledge_unavailable(); app.commit().expect("unchanged commit completes"); assert_eq!(app.states().applied(), &ApplyStatus::Unavailable); } #[test] fn changed_saved_snapshot_demotes_verified_apply_knowledge_on_commit() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, key, _) = setup(root.path(), false); let mut app = Application::open(store, states).expect("application opens"); let observed = app.states().saved().clone(); test_mark_verified( &mut app, observed.clone(), "2026-10-29T12:00:00Z".to_owned(), ) .expect("synthetic verification is published"); app.rename_client(key, "changed saved peer") .expect("peer change is staged"); app.commit().expect("changed commit completes"); assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert_eq!(app.states().applied_snapshot(), Some(&observed)); assert_ne!(app.states().saved(), &observed); } #[test] fn discard_restores_draft_without_writing_installed_bytes() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, key, _) = setup(root.path(), false); let original = store.read_saved().expect("installed bytes readable"); let mut app = Application::open(store.clone(), states).expect("application opens"); app.rename_client(key, "temporary") .expect("rename is staged"); app.discard_draft().expect("draft can be discarded"); assert_eq!( store.read_saved().expect("saved readable").server_config, original.server_config ); assert!(!String::from_utf8_lossy(app.document().render()).contains("temporary")); assert_eq!(app.states().draft(), app.states().saved()); } #[test] fn discard_parse_failure_preserves_every_live_draft_component_and_issue() { let root = tempfile::tempdir().expect("temp directory"); let mut app = setup_with_vault(root.path()); let issue = app .prepare_new_client("discard failure", None, None) .expect("new client stages"); let before_saved = clone_saved(app.saved_bytes()); let before_document = app.document().render().to_vec(); let before_states = app.states().clone(); let before_vault = app.vault().expect("vault exists").to_toml().unwrap(); fs::write(root.path().join("server.conf"), [0xff]).expect("corrupt synthetic saved file"); assert!(app.discard_draft().is_err()); assert!(super::same_saved_bytes(app.saved_bytes(), &before_saved)); assert_eq!(app.document().render(), before_document); assert_eq!(app.states(), &before_states); assert_eq!( app.vault() .expect("vault remains present") .to_toml() .unwrap(), before_vault ); assert!(app.initial_issues.contains_key(&issue)); } #[test] fn initial_vault_is_prepared_then_installed_only_after_commit() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, _, _) = setup(root.path(), false); let mut app = Application::open(store.clone(), states).expect("application opens"); let secret = WireGuardPrivateKey::generate().expect("synthetic secret"); let bundle = VaultBundle::create("synthetic password").expect("synthetic vault"); app.prepare_initial_vault(bundle).expect("vault is staged"); assert!(store.read_saved().expect("saved readable").vault.is_none()); app.commit().expect("initial vault commit"); let installed = store.read_saved().expect("saved readable"); let text = std::str::from_utf8(installed.vault.as_deref().expect("vault installed")) .expect("vault UTF-8"); assert_eq!( VaultBundle::from_toml(text) .expect("vault loads") .record_count(), 0 ); assert_eq!(secret.public_key().as_bytes().len(), 32); } #[test] fn deletion_only_removes_selected_current_record_and_retains_other_records() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, selected, selected_secret) = setup(root.path(), false); let mut app = Application::open(store.clone(), states).expect("application opens"); let mut bundle = VaultBundle::create("synthetic password").expect("vault setup"); bundle .encrypt_record(selected, &selected_secret) .expect("selected record"); let retained_secret = WireGuardPrivateKey::generate().expect("secret"); let retained = retained_secret.public_key(); bundle .encrypt_record(retained, &retained_secret) .expect("file-only record"); app.prepare_initial_vault(bundle).expect("vault staged"); app.commit().expect("initial vault commit"); assert!( app.delete_client(selected) .expect("selected record removed") ); app.commit().expect("deletion committed"); let installed = store.read_saved().expect("saved readable"); let bundle = VaultBundle::from_toml( std::str::from_utf8(installed.vault.as_deref().expect("vault")).expect("utf8"), ) .expect("vault parses"); assert!(!bundle.contains_record(selected)); assert!(bundle.contains_record(retained)); let state = PersistedState::from_toml( installed .internal_state .as_deref() .expect("state is installed on commit"), ) .expect("state parses"); assert!(state.reserved_addresses.contains(&"10.20.0.2".to_owned())); assert!(state.reserved_addresses.contains(&"fd20::2".to_owned())); let reopened_states = ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown) .expect("empty supported snapshot is valid"); let reopened = Application::open(store, reopened_states).expect("session reopens"); assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); assert!( reopened .states() .reserved_addresses() .contains(&"10.20.0.2".parse().expect("IPv4 address")) ); assert!( reopened .states() .reserved_addresses() .contains(&"fd20::2".parse().expect("IPv6 address")) ); } #[test] fn deletion_of_unprojectable_peer_persists_its_exact_host_route_reservation() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, _, _) = setup(root.path(), false); let unsupported = PublicKey::from_bytes([9; 32]); let mut server = fs::read_to_string(root.path().join("server.conf")).expect("server fixture reads"); server.push_str(&format!( "\n# legacy peer remains outside the narrow state projection\n[Peer]\nPublicKey = {unsupported}\nAllowedIPs = 10.20.0.9/32\n" )); fs::write(root.path().join("server.conf"), server).expect("server fixture updated"); let mut app = Application::open(store.clone(), states).expect("application opens"); assert!( !app.delete_client(unsupported) .expect("selected server-only peer deleted") ); assert!(app.states().reserved_addresses().is_empty()); app.commit() .expect("commit preserves unsupported peer address"); let saved = store.read_saved().expect("saved bytes readable"); let state = PersistedState::from_toml( saved .internal_state .as_deref() .expect("state is installed on commit"), ) .expect("reservation state parses"); assert_eq!(state.reserved_addresses, ["10.20.0.9"]); assert!(!String::from_utf8_lossy(&saved.server_config).contains(&unsupported.to_string())); } #[test] fn deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, _, _) = setup(root.path(), false); let unsupported = PublicKey::from_bytes([9; 32]); let mut server = fs::read_to_string(root.path().join("server.conf")).expect("server fixture reads"); server.push_str(&format!( "\n[Peer]\nPublicKey = {unsupported}\nAllowedIPs = fd20::/64\n" )); fs::write(root.path().join("server.conf"), server).expect("server fixture updated"); let mut app = Application::open(store.clone(), states).expect("application opens"); let before = app.document().render().to_vec(); assert!(matches!( app.delete_client(unsupported), Err(super::AppError::DeletedPeerAddressUnknown) )); assert_eq!(app.document().render(), before); assert_eq!( store.read_saved().expect("saved readable").server_config, before ); } #[test] fn existing_vault_identity_cannot_be_replaced() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, _, _) = setup(root.path(), false); let mut app = Application::open(store, states).expect("application opens"); app.prepare_initial_vault(VaultBundle::create("first password").expect("vault")) .expect("first vault prepared"); app.commit().expect("first vault installed"); assert!(matches!( app.prepare_initial_vault(VaultBundle::create("second password").expect("vault")), Err(AppError::VaultAlreadyInstalled) )); } #[test] fn state_file_round_trip_keeps_pending_addresses() { let state = PersistedState::new(["10.20.0.9".to_owned()], None).expect("reservation valid"); assert!( PersistedState::from_toml(&state.to_toml().expect("state serializes")) .expect("state reloads") .reserved_addresses .contains(&"10.20.0.9".to_owned()) ); } #[test] fn preview_is_read_only_and_confirm_restores_without_unlocking_vault() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, key, _) = setup(root.path(), false); let mut app = Application::open(store.clone(), states).expect("application opens"); app.rename_client(key, "Laptop") .expect("managed alias is staged"); app.commit().expect("managed alias commit"); app.prepare_initial_vault(VaultBundle::create("synthetic password").expect("vault")) .expect("vault staged"); app.commit().expect("vault commit"); let saved = store.read_saved().expect("saved bytes"); let selected = create_archive( store.archives_directory(), BackupReason::Manual, HistoricalApplyStatus::Unknown, &saved, ) .expect("selected archive"); app.rename_client(key, "draft alias").expect("draft rename"); let before = store.read_saved().expect("installed bytes before preview"); let preview = app .preview_restore(selected.file_name().unwrap().to_str().unwrap()) .expect("archive preview validates"); assert_eq!( store .read_saved() .expect("preview is read only") .server_config, before.server_config ); assert_eq!( app.states().draft().get(&key).expect("draft client").alias, "draft alias" ); // Replacing the selected path after preview cannot change the confirmed bytes. fs::write(&selected, b"changed after preview").expect("mutate selected archive path"); app.confirm_restore(preview) .expect("restore uses previewed bytes"); assert!(!String::from_utf8_lossy(app.document().render()).contains("draft alias")); assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert!(app.vault().is_some()); assert!(app.states().saved().get(&key).is_some()); } #[test] fn partial_restore_failure_preserves_session_and_pre_restore_backup_then_reopens_to_old_set() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, key, _) = setup(root.path(), false); let server_config_path = SettingsFile::parse_toml(SETTINGS) .unwrap() .validate() .unwrap() .resolve_paths(root.path()) .server_config_path; let mut app = Application::open(store.clone(), states).expect("application opens"); let selected_saved = store.read_saved().expect("initial saved bytes"); let selected = create_archive( store.archives_directory(), BackupReason::Manual, HistoricalApplyStatus::Unknown, &selected_saved, ) .expect("selected restore archive"); app.rename_client(key, "currently saved") .expect("stage current alias"); app.commit().expect("save current alias"); let before_restore = store.read_saved().expect("current saved bytes"); app.rename_client(key, "draft survives failed restore") .expect("stage draft"); let applied_before = *app.states().applied(); let preview = app .preview_restore(selected.file_name().unwrap().to_str().unwrap()) .expect("selected archive preview"); let result = app.confirm_restore_with_install(preview, |store, saved, _backup| { store.install_with_hook(saved, |point| { if point == InstallCheckpoint::Replaced(2) { Err(std::io::Error::other("interrupt after restore replacement")) } else { Ok(()) } }) }); assert!(matches!( result, Err(AppError::Persistence(PersistenceError::RecoveryRequired)) )); // The server file was replaced before the injected failure. The app // session still exposes its old saved snapshot and retains its draft. assert_eq!( fs::read(&server_config_path).unwrap(), selected_saved.server_config ); assert_eq!(app.saved_bytes().settings, before_restore.settings); assert_eq!( app.saved_bytes().server_config, before_restore.server_config ); assert_eq!( app.saved_bytes().internal_state, before_restore.internal_state ); assert_eq!(app.saved_bytes().vault, before_restore.vault); assert_eq!( app.states().saved().get(&key).unwrap().alias, "currently saved" ); assert_eq!( app.states().draft().get(&key).unwrap().alias, "draft survives failed restore" ); assert_eq!(app.states().applied(), &applied_before); // A failed rollback remains blocked. A fresh store open retries the // journal recovery and restores the exact pre-restore file set. assert!(matches!( store.recover_with_hook(|point| { if point == InstallCheckpoint::RollbackReplace(3) { Err(std::io::Error::other("injected rollback failure")) } else { Ok(()) } }), Err(PersistenceError::RecoveryRequired) )); let resolved = SettingsFile::parse_toml(SETTINGS) .unwrap() .validate() .unwrap() .resolve_paths(root.path()); let reopened = SavedStore::new(root.path(), resolved).expect("store reopens"); let recovered = reopened.read_saved().expect("journal rolls back on reopen"); assert_eq!(recovered.settings, before_restore.settings); assert_eq!(recovered.server_config, before_restore.server_config); assert_eq!(recovered.internal_state, before_restore.internal_state); assert_eq!(recovered.vault, before_restore.vault); let pre_restore = app .list_backups() .expect("backups list after interrupted restore") .into_iter() .find(|backup| backup.reason == BackupReason::PreRestore) .expect("precautionary backup preserved"); let archived = preview_archive(&store.archives_directory().join(pre_restore.file_name)) .expect("pre-restore archive remains valid"); assert_eq!(archived.saved_bytes.settings, before_restore.settings); assert_eq!( archived.saved_bytes.server_config, before_restore.server_config ); assert_eq!( archived.saved_bytes.internal_state, before_restore.internal_state ); assert_eq!(archived.saved_bytes.vault, before_restore.vault); assert_eq!(app.states().applied(), &applied_before); } #[test] fn restore_accepts_changed_non_path_settings_and_rejects_changed_paths() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, _, _) = setup(root.path(), false); let app = Application::open(store.clone(), states).expect("application opens"); let mut saved = store.read_saved().expect("saved bytes"); saved.settings = String::from_utf8(saved.settings) .expect("settings utf8") .replace("vpn.example.test:51820", "vpn2.example.test:51821") .replace("10.20.0.1\"", "10.20.0.2\"") .into_bytes(); let accepted = create_archive( store.archives_directory(), BackupReason::Manual, HistoricalApplyStatus::Unknown, &saved, ) .expect("archive with changed operational settings"); assert!( app.preview_restore(accepted.file_name().unwrap().to_str().unwrap()) .is_ok() ); saved.settings = String::from_utf8(saved.settings) .expect("settings utf8") .replace("server.conf", "elsewhere.conf") .into_bytes(); let rejected = create_archive( store.archives_directory(), BackupReason::Manual, HistoricalApplyStatus::Unknown, &saved, ) .expect("archive with incompatible path"); assert!(matches!( app.preview_restore(rejected.file_name().unwrap().to_str().unwrap()), Err(super::AppError::Persistence(_)) )); } #[test] fn stale_restore_preview_is_rejected_before_backup_and_keeps_the_draft() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, key, _) = setup(root.path(), false); let mut app = Application::open(store.clone(), states).expect("application opens"); let saved = store.read_saved().expect("saved bytes"); let selected = create_archive( store.archives_directory(), BackupReason::Manual, HistoricalApplyStatus::Unknown, &saved, ) .expect("restore archive"); let preview = app .preview_restore(selected.file_name().unwrap().to_str().unwrap()) .expect("preview is prepared"); app.rename_client(key, "committed after preview") .expect("stage new saved generation"); app.commit().expect("save new generation"); app.rename_client(key, "draft retained after stale preview") .expect("stage follow-up draft"); let backup_count = app.list_backups().expect("list backups").len(); assert!(matches!( app.confirm_restore(preview), Err(AppError::SavedGenerationChanged) )); assert_eq!( app.list_backups().expect("no precautionary backup").len(), backup_count ); assert_eq!( app.states() .draft() .get(&key) .expect("draft survives") .alias, "draft retained after stale preview" ); assert_eq!( store .read_saved() .expect("current generation remains installed") .server_config, app.saved_bytes().server_config ); } #[test] fn restore_retains_removed_unprojectable_exact_hosts_through_reopen_and_allocation() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, _, _) = setup(root.path(), false); let original = store.read_saved().expect("initial saved bytes"); let selected = create_archive( store.archives_directory(), BackupReason::Manual, HistoricalApplyStatus::Unknown, &original, ) .expect("archive without unsupported peers"); let broad_key = WireGuardPrivateKey::generate() .expect("synthetic broad-route peer key") .public_key(); let pending_key = WireGuardPrivateKey::generate() .expect("synthetic exact-route peer key") .public_key(); let mut current = clone_saved(&original); current.server_config.extend_from_slice( format!( "\n[Peer]\nPublicKey = {broad_key}\nAllowedIPs = 10.20.0.77/32, 10.20.0.0/24, fd20::77/128\n\n[Peer]\nPublicKey = {pending_key}\nAllowedIPs = 10.20.0.79/32, fd20::79/128\n" ) .as_bytes(), ); store .install(¤t, BackupComplete(())) .expect("install synthetic current generation"); let mut app = Application::open(store.clone(), states).expect("open with peer routes"); // Missing managed metadata makes this peer unprojectable. Deleting it // stages its exact addresses without requiring a client snapshot. assert!( !app.delete_client(pending_key) .expect("stage exact-host deletion") ); let preview = app .preview_restore(selected.file_name().unwrap().to_str().unwrap()) .expect("preview combines current and pending reservations"); app.confirm_restore(preview).expect("restore succeeds"); let reopened = Application::open_from_saved_store(store.clone()) .expect("reservations survive a fresh application session"); let reserved = reopened .states() .reserved_addresses() .iter() .copied() .collect::>(); for address in ["10.20.0.77", "fd20::77", "10.20.0.79", "fd20::79"] { assert!(reserved.contains(&address.parse().unwrap()), "{address}"); } assert!( !reserved.contains(&"10.20.0.1".parse().unwrap()), "broad subnet routes must not be expanded into guessed reservations" ); assert_eq!( allocate_ipv4( "10.20.0.77/32".parse().unwrap(), &AddressAvailability { server: &[], occupied: &[], reserved: &reserved, }, ), Err(DomainError::AddressExhausted(AddressFamily::Ipv4)) ); assert_eq!( allocate_ipv6( "fd20::77/128".parse().unwrap(), &AddressAvailability { server: &[], occupied: &[], reserved: &reserved, }, ), Err(DomainError::AddressExhausted(AddressFamily::Ipv6)) ); } #[test] fn restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document() { let root = tempfile::tempdir().expect("temp directory"); let (store, _, _, _) = setup(root.path(), false); let mut current = store.read_saved().expect("initial saved bytes"); let retained_key = WireGuardPrivateKey::generate() .expect("synthetic retained-peer key") .public_key(); let deleted_key = WireGuardPrivateKey::generate() .expect("synthetic pending-deletion key") .public_key(); current.server_config.extend_from_slice( format!( "\n[Peer]\nPublicKey = {retained_key}\nAllowedIPs = 10.20.0.77/32, fd20::77/128\n\n[Peer]\nPublicKey = {deleted_key}\nAllowedIPs = 10.20.0.79/32, fd20::79/128\n" ) .as_bytes(), ); current.internal_state = Some( PersistedState::new(["10.20.0.88".to_owned()], None) .expect("synthetic archived reservation") .to_toml() .expect("archived reservation serializes"), ); store .install(¤t, BackupComplete(())) .expect("install synthetic unprojectable peers"); let archive = create_archive( store.archives_directory(), BackupReason::Manual, HistoricalApplyStatus::Unknown, ¤t, ) .expect("archive restored peer routes"); let mut app = Application::open_from_saved_store(store.clone()) .expect("application opens with unprojectable peers"); assert!( !app.delete_client(deleted_key) .expect("stage pending peer deletion") ); assert_eq!( app.states() .address_reservations() .pending_host_routes() .count(), 2 ); let preview = app .preview_restore(archive.file_name().unwrap().to_str().unwrap()) .expect("preview archive restoring exact routes"); app.confirm_restore(preview).expect("restore succeeds"); assert_eq!(app.states().reserved_addresses().len(), 1); assert!( app.states() .reserved_addresses() .contains(&"10.20.0.88".parse().expect("archived reservation")) ); assert!( app.states() .address_reservations() .pending_host_routes() .next() .is_none() ); let restored_state = PersistedState::from_toml( app.saved_bytes() .internal_state .as_deref() .expect("archived reservation remains persisted"), ) .expect("restored state remains valid"); assert_eq!(restored_state.reserved_addresses, ["10.20.0.88"]); } #[test] fn restoring_absent_optional_components_removes_installed_vault_and_state() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, _, _) = setup(root.path(), false); let mut app = Application::open(store.clone(), states).expect("application opens"); let initial = store.read_saved().expect("initial bytes"); let selected = create_archive( store.archives_directory(), BackupReason::Manual, HistoricalApplyStatus::Unknown, &initial, ) .expect("initial-state archive"); app.prepare_initial_vault(VaultBundle::create("synthetic password").expect("vault")) .expect("vault staged"); app.commit().expect("vault installed"); assert!(store.read_saved().expect("installed").vault.is_some()); let mut current = store.read_saved().expect("current saved bytes"); let removed_key = WireGuardPrivateKey::generate() .expect("synthetic removed-peer key") .public_key(); current.server_config.extend_from_slice( format!( "\n[Peer]\nPublicKey = {removed_key}\nAllowedIPs = 10.20.0.9/32, fd20::9/128\n" ) .as_bytes(), ); store .install(¤t, BackupComplete(())) .expect("install current peer removed by archive"); let preview = app .preview_restore(selected.file_name().unwrap().to_str().unwrap()) .expect("preview initial state"); app.confirm_restore(preview).expect("restore initial state"); let restored = store.read_saved().expect("restored state"); assert!(restored.vault.is_none()); let state = PersistedState::from_toml( restored .internal_state .as_deref() .expect("removed current routes are retained"), ) .expect("restored reservation state parses"); assert!(state.reserved_addresses.contains(&"10.20.0.9".to_owned())); assert!(state.reserved_addresses.contains(&"fd20::9".to_owned())); assert!(app.vault().is_none()); assert_eq!(app.states().applied(), &ApplyStatus::Unknown); } #[test] fn failed_pre_restore_backup_preserves_installed_files_and_draft() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, key, _) = setup(root.path(), false); let mut app = Application::open(store.clone(), states).expect("application opens"); let saved = store.read_saved().expect("saved"); let selected = create_archive( store.archives_directory(), BackupReason::Manual, HistoricalApplyStatus::Unknown, &saved, ) .expect("archive"); app.rename_client(key, "draft retained") .expect("draft rename"); let installed = store.read_saved().expect("installed state"); let preview = app .preview_restore(selected.file_name().unwrap().to_str().unwrap()) .expect("preview"); let archive_dir = store.archives_directory(); fs::remove_dir_all(archive_dir).expect("remove synthetic archive directory"); fs::write(archive_dir, b"cannot create a backup here").expect("replace with file"); assert!(app.confirm_restore(preview).is_err()); assert_eq!( store.read_saved().expect("files preserved").server_config, installed.server_config ); assert_eq!( app.states().draft().get(&key).expect("draft client").alias, "draft retained" ); } #[test] fn manual_and_pre_restart_backups_are_listed_without_runtime_calls() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, _, _) = setup(root.path(), false); let mut app = Application::open(store, states).expect("application opens"); app.create_manual_backup().expect("manual backup"); app.create_pre_restart_backup().expect("pre-restart backup"); let listed = app.list_backups().expect("backup listing"); assert_eq!(listed.len(), 2); assert!( listed .iter() .any(|item| item.reason == BackupReason::Manual) ); assert!( listed .iter() .any(|item| item.reason == BackupReason::PreRestart) ); assert!( listed .iter() .all(|item| item.historical_apply == HistoricalApplyStatus::Unknown) ); assert_eq!(app.states().applied(), &ApplyStatus::Unknown); } #[test] fn restart_cancel_preserves_draft_and_never_calls_adapters() { let root = tempfile::tempdir().expect("temp directory"); let (mut app, key) = prepare_restartable(root.path()); app.rename_client(key, "pending draft") .expect("stage draft alias"); let preview = app.preview_restart().expect("preview saved generation"); let called = std::cell::Cell::new(false); let report = app.restart_with_adapters( preview, RestartChoice::Cancel, |_, _| { called.set(true); RestartExecution { outcome: RestartOutcome::Succeeded, submitted: true, phase: crate::service::RestartPhase::WaitForJob, } }, |_, _| { called.set(true); Err(TransportError::Transport) }, |_, _, _| { called.set(true); Ok(()) }, ); assert_eq!(report.stage, RestartStage::Cancelled); assert!(!called.get()); assert_eq!( app.states().draft().get(&key).unwrap().alias, "pending draft" ); } #[test] fn restart_backup_failure_preserves_draft_and_submits_no_job() { let root = tempfile::tempdir().expect("temp directory"); let (mut app, key) = prepare_restartable(root.path()); app.rename_client(key, "pending draft") .expect("stage draft alias"); let preview = app.preview_restart().expect("preview saved generation"); let archives = root.path().join("Backup/archives"); fs::remove_dir_all(&archives).expect("remove archive directory"); fs::write(&archives, b"synthetic archive blocker").expect("install archive blocker"); let submitted = std::cell::Cell::new(false); let report = app.restart_with_adapters( preview, RestartChoice::DiscardDraftAndRestart, |_, _| { submitted.set(true); RestartExecution { outcome: RestartOutcome::Succeeded, submitted: true, phase: crate::service::RestartPhase::WaitForJob, } }, |_, _| Err(TransportError::Transport), |_, _, _| Ok(()), ); assert_eq!(report.stage, RestartStage::Backup); assert_eq!(report.failure, Some(RestartFailureKind::BackupFailed)); assert!(!report.restart_submitted); assert!(!submitted.get()); assert_eq!( app.states().draft().get(&key).unwrap().alias, "pending draft" ); } #[test] fn confirmed_restart_orders_backup_job_read_and_durable_publication() { let root = tempfile::tempdir().expect("temp directory"); let (mut app, key) = prepare_restartable(root.path()); app.rename_client(key, "discard me") .expect("stage draft alias"); let preview = app.preview_restart().expect("preview saved generation"); let observation = observed_runtime(&app); let archives = root.path().join("Backup/archives"); let events = std::cell::RefCell::new(Vec::new()); let report = app.restart_with_adapters( preview, RestartChoice::DiscardDraftAndRestart, |unit, _| { assert_eq!(unit, "wg-quick@wg0.service"); assert!( crate::backup::list_archives(&archives) .expect("archive listing") .iter() .any(|archive| archive.reason == BackupReason::PreRestart) ); events.borrow_mut().push("service"); RestartExecution { outcome: RestartOutcome::Succeeded, submitted: true, phase: crate::service::RestartPhase::WaitForJob, } }, |interface, remaining| { assert_eq!(interface, "wg0"); assert!(!remaining.is_zero()); events.borrow_mut().push("runtime"); Ok(observation) }, |store, saved, backup| { events.borrow_mut().push("persist"); store.install(saved, backup) }, ); assert_eq!(report.stage, RestartStage::Complete, "{report:?}"); assert!(report.backup_path.is_some()); assert!(report.restart_submitted); assert_eq!(*events.borrow(), vec!["service", "runtime", "persist"]); assert_eq!(app.states().applied(), &ApplyStatus::Verified); assert_eq!( app.states().draft().get(&key).unwrap().alias, "restart fixture" ); } #[test] fn restart_verifies_metadata_free_legacy_routes_without_partial_applied_snapshot() { let root = tempfile::tempdir().expect("temp directory"); let (mut app, _) = prepare_restartable(root.path()); let deleted_key = WireGuardPrivateKey::generate() .expect("synthetic deleted-client key") .public_key(); app.add_client( Client::new( deleted_key, "deleted reservation fixture", ClientAddresses { ipv4: "10.20.0.4".parse().expect("synthetic IPv4"), ipv6: Some("fd20::4".parse().expect("synthetic IPv6")), }, ) .expect("synthetic managed client"), None, ) .expect("stage managed client"); app.commit().expect("save second managed client"); app.delete_client(deleted_key) .expect("delete managed peer in draft"); app.commit() .expect("save deletion and reserve its addresses"); let mut saved = clone_saved(app.saved_bytes()); saved.server_config.extend_from_slice( format!( "\n[Peer]\nPublicKey = {}\nAllowedIPs = 192.0.2.0/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.20.0.99/32\n", PublicKey::from_bytes([19; 32]), PublicKey::from_bytes([20; 32]), ) .as_bytes(), ); app.store .install(&saved, BackupComplete(())) .expect("install synthetic legacy saved generation"); let mut app = Application::open_from_saved_store(app.store.clone()) .expect("open metadata-free legacy peers"); assert!(!app.states().reserved_addresses().is_empty()); let original_config = app.saved_bytes().server_config.clone(); let preview = app .preview_restart() .expect("valid legacy peers are eligible"); let observation = observed_runtime(&app); let report = app.restart_with_adapters( preview, RestartChoice::RestartSavedState, |unit, _| { assert_eq!(unit, "wg-quick@wg0.service"); RestartExecution { outcome: RestartOutcome::Succeeded, submitted: true, phase: crate::service::RestartPhase::WaitForJob, } }, |_, _| Ok(observation), |store, saved, backup| store.install(saved, backup), ); assert_eq!(report.stage, RestartStage::Complete); assert_eq!(app.states().applied(), &ApplyStatus::Verified); assert!(app.states().applied_snapshot().is_none()); assert!(app.states().reserved_addresses().is_empty()); assert_eq!(app.saved_bytes().server_config, original_config); assert!(app.saved_generation_is_verified_applied(app.saved_bytes(), app.document())); let mut different_generation = clone_saved(app.saved_bytes()); different_generation.settings.push(b'\n'); assert!(!app.saved_generation_is_verified_applied(&different_generation, app.document())); let historical_apply = app.historical_apply.clone(); let reopened = Application::open_from_saved_store(app.store.clone()) .expect("reopen historically verified saved state"); assert_eq!(reopened.historical_apply, historical_apply); assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); assert!(reopened.states().applied_snapshot().is_none()); assert!(reopened.states().reserved_addresses().is_empty()); assert!( !reopened .saved_generation_is_verified_applied(reopened.saved_bytes(), reopened.document()) ); } #[test] fn unsafe_saved_peer_diagnostics_block_preview_before_backup_with_safe_location() { let root = tempfile::tempdir().expect("temp directory"); let (app, _) = prepare_restartable(root.path()); let key = PublicKey::from_bytes([21; 32]); let mut saved = clone_saved(app.saved_bytes()); saved.server_config.extend_from_slice( format!( "\n[Peer]\nPublicKey = {key}\nPresharedKey = synthetic-peer-secret\nAllowedIPs = malformed-route\n" ) .as_bytes(), ); app.store .install(&saved, BackupComplete(())) .expect("install malformed synthetic peer"); let app = Application::open_from_saved_store(app.store.clone()) .expect("open malformed peer for diagnostics"); let backup_count = app.list_backups().expect("list existing archives").len(); let error = match app.preview_restart() { Ok(_) => panic!("unsafe peer must block preview"), Err(error) => error, }; let AppError::UnsafeSavedPeers(diagnostics) = error else { panic!("expected actionable unsafe peer diagnostics"); }; assert!(diagnostics.iter().any(|diagnostic| { diagnostic.identity == Some(key) && diagnostic.section_line.is_some() && diagnostic.reason == "peer has a malformed AllowedIPs item" })); let rendered = format!("{diagnostics:?}"); assert!(!rendered.contains("synthetic-peer-secret")); assert!(!rendered.contains("malformed-route")); assert_eq!( app.list_backups().expect("backup listing").len(), backup_count, "unsafe preview must not create a restart archive" ); } #[test] fn incomplete_legacy_runtime_match_keeps_deleted_addresses_reserved() { let root = tempfile::tempdir().expect("temp directory"); let (mut app, _) = prepare_restartable(root.path()); let deleted_key = WireGuardPrivateKey::generate() .expect("synthetic deleted-client key") .public_key(); app.add_client( Client::new( deleted_key, "deleted reservation fixture", ClientAddresses { ipv4: "10.20.0.4".parse().expect("synthetic IPv4"), ipv6: Some("fd20::4".parse().expect("synthetic IPv6")), }, ) .expect("synthetic managed client"), None, ) .expect("stage managed client"); app.commit().expect("save second managed client"); app.delete_client(deleted_key) .expect("delete managed peer in draft"); app.commit() .expect("save deletion and reserve its addresses"); let reserved_before = app.states().reserved_addresses().clone(); let mut saved = clone_saved(app.saved_bytes()); let legacy_key = PublicKey::from_bytes([22; 32]); saved.server_config.extend_from_slice( format!("\n[Peer]\nPublicKey = {legacy_key}\nAllowedIPs = 10.20.0.99/32\n").as_bytes(), ); app.store .install(&saved, BackupComplete(())) .expect("install synthetic legacy peer"); let mut app = Application::open_from_saved_store(app.store.clone()).expect("reopen with legacy peer"); let preview = app.preview_restart().expect("legacy peer preview succeeds"); let mut incomplete = observed_runtime(&app); let InterfaceObservation::Present { peers, .. } = &mut incomplete.interface else { panic!("fixture interface is present"); }; peers.peers.remove(&legacy_key); let report = app.restart_with_adapters( preview, RestartChoice::RestartSavedState, |_, _| RestartExecution { outcome: RestartOutcome::Succeeded, submitted: true, phase: crate::service::RestartPhase::WaitForJob, }, |_, _| Ok(incomplete), |store, saved, backup| store.install(saved, backup), ); assert_eq!(report.stage, RestartStage::Verification); assert_eq!(report.failure, Some(RestartFailureKind::RuntimeMismatch)); assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert_eq!(app.states().reserved_addresses(), &reserved_before); let reopened = Application::open_from_saved_store(app.store.clone()) .expect("failed verification leaves saved reservations durable"); assert_eq!(reopened.states().reserved_addresses(), &reserved_before); assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); } #[test] fn ambiguous_identity_and_malformed_structure_block_restart_preview() { for malformed in [false, true] { let root = tempfile::tempdir().expect("temp directory"); let (app, managed_identity) = prepare_restartable(root.path()); let mut saved = clone_saved(app.saved_bytes()); if malformed { saved.server_config.extend_from_slice( b"\n[Peer\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n", ); } else { saved.server_config.extend_from_slice( format!( "\n[Peer]\nPublicKey = {managed_identity}\nAllowedIPs = 10.20.0.8/32\n" ) .as_bytes(), ); } app.store .install(&saved, BackupComplete(())) .expect("install unsafe synthetic saved bytes"); let app = Application::open_from_saved_store(app.store.clone()) .expect("open unsafe saved bytes for preview"); let error = match app.preview_restart() { Ok(_) => panic!("unsafe saved bytes must block restart preview"), Err(error) => error, }; let AppError::UnsafeSavedPeers(diagnostics) = error else { panic!("expected safe structured diagnostics"); }; let expected_reason = if malformed { "malformed section header" } else { "public key is used by multiple peer sections" }; assert!(diagnostics.iter().any(|diagnostic| { diagnostic.reason == expected_reason && diagnostic.section_line.is_some() })); } } #[test] fn service_timeout_reports_submission_and_may_still_complete() { let root = tempfile::tempdir().expect("temp directory"); let (mut app, _) = prepare_restartable(root.path()); let preview = app.preview_restart().expect("preview saved generation"); let runtime_called = std::cell::Cell::new(false); let report = app.restart_with_adapters( preview, RestartChoice::RestartSavedState, |_, _| RestartExecution { outcome: RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete), submitted: true, phase: crate::service::RestartPhase::WaitForJob, }, |_, _| { runtime_called.set(true); Err(TransportError::Transport) }, |_, _, _| Ok(()), ); assert_eq!(report.stage, RestartStage::Service); assert!(report.restart_submitted); assert!(report.restart_may_still_complete); assert!(report.manual_recovery_required); assert!(!runtime_called.get()); } #[test] fn shared_deadline_expired_after_successful_job_stops_before_runtime_read() { let root = tempfile::tempdir().expect("temp directory"); let (mut app, key) = prepare_restartable(root.path()); app.delete_client(key).expect("delete saved peer in draft"); app.commit().expect("save deletion and reservation"); let preview = app.preview_restart().expect("preview saved generation"); let started_at = Instant::now(); let clock_values = [started_at, started_at + Duration::from_secs(30)]; let clock_index = std::cell::Cell::new(0); let runtime_called = std::cell::Cell::new(false); let report = app.restart_with_adapters_and_clock( preview, RestartChoice::RestartSavedState, |_, deadline| { assert_eq!(deadline.started_at(), started_at); assert_eq!(deadline.timeout(), Duration::from_secs(30)); RestartExecution { outcome: RestartOutcome::Succeeded, submitted: true, phase: crate::service::RestartPhase::WaitForJob, } }, |_, _| { runtime_called.set(true); Err(TransportError::Transport) }, |store, saved, backup| store.install(saved, backup), || { let index = clock_index.get(); clock_index.set(index + 1); clock_values[index] }, ); assert_eq!(report.stage, RestartStage::RuntimeRead); assert_eq!(report.failure, Some(RestartFailureKind::RuntimeTimeout)); assert!(report.backup_path.is_some()); assert!(report.restart_submitted); assert!(!report.restart_may_still_complete); assert!(report.manual_recovery_required); assert!(!runtime_called.get()); assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert!(!app.states().reserved_addresses().is_empty()); } #[test] fn shared_deadline_expired_after_runtime_read_reports_timeout_before_comparison() { let root = tempfile::tempdir().expect("temp directory"); let (mut app, key) = prepare_restartable(root.path()); app.delete_client(key).expect("delete saved peer in draft"); app.commit().expect("save deletion and reservation"); let preview = app.preview_restart().expect("preview saved generation"); let started_at = Instant::now(); let clock_values = [started_at, started_at, started_at + Duration::from_secs(30)]; let clock_index = std::cell::Cell::new(0); let install_called = std::cell::Cell::new(false); let observation = observed_runtime(&app); let report = app.restart_with_adapters_and_clock( preview, RestartChoice::RestartSavedState, |_, deadline| { assert_eq!(deadline.started_at(), started_at); assert_eq!(deadline.timeout(), Duration::from_secs(30)); RestartExecution { outcome: RestartOutcome::Succeeded, submitted: true, phase: crate::service::RestartPhase::WaitForJob, } }, |_, remaining| { assert_eq!(remaining, Duration::from_secs(30)); Ok(observation) }, |store, saved, backup| { install_called.set(true); store.install(saved, backup) }, || { let index = clock_index.get(); clock_index.set(index + 1); clock_values[index] }, ); assert_eq!(report.stage, RestartStage::Verification); assert_eq!(report.failure, Some(RestartFailureKind::RuntimeTimeout)); assert!(report.backup_path.is_some()); assert!(report.restart_submitted); assert!(!report.restart_may_still_complete); assert!(report.manual_recovery_required); assert!(!install_called.get()); assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert!(!app.states().reserved_addresses().is_empty()); } #[test] fn runtime_transport_failures_remain_distinct_in_public_reports() { let cases = [ ( TransportError::NoDevice, RestartFailureKind::RuntimeNoDevice, ), ( TransportError::PermissionDenied, RestartFailureKind::RuntimePermissionDenied, ), ( TransportError::Malformed, RestartFailureKind::RuntimeMalformed, ), ( TransportError::Transport, RestartFailureKind::RuntimeTransport, ), (TransportError::Timeout, RestartFailureKind::RuntimeTimeout), ( TransportError::LimitExceeded, RestartFailureKind::RuntimeLimitExceeded, ), ]; for (error, expected) in cases { let root = tempfile::tempdir().expect("temp directory"); let (mut app, _) = prepare_restartable(root.path()); let preview = app.preview_restart().expect("preview saved generation"); let install_called = std::cell::Cell::new(false); let report = app.restart_with_adapters( preview, RestartChoice::RestartSavedState, |_, _| RestartExecution { outcome: RestartOutcome::Succeeded, submitted: true, phase: crate::service::RestartPhase::WaitForJob, }, |_, _| Err(error), |store, saved, backup| { install_called.set(true); store.install(saved, backup) }, ); assert_eq!(report.stage, RestartStage::RuntimeRead); assert_eq!(report.failure, Some(expected)); assert!(report.restart_submitted); assert!(!report.restart_may_still_complete); assert!(report.manual_recovery_required); assert!(!install_called.get()); assert_eq!(app.states().applied(), &ApplyStatus::Unknown); } } #[test] fn submitted_terminal_job_failures_cannot_still_complete() { let root = tempfile::tempdir().expect("temp directory"); let (mut app, _) = prepare_restartable(root.path()); for failure in [ RestartFailure::JobFailed, RestartFailure::JobDependency, RestartFailure::JobTimedOut, RestartFailure::JobCanceled, RestartFailure::JobSkipped, RestartFailure::JobResultUnknown, ] { let preview = app.preview_restart().expect("preview saved generation"); let report = app.restart_with_adapters( preview, RestartChoice::RestartSavedState, |_, _| RestartExecution { outcome: RestartOutcome::Failed(failure), submitted: true, phase: crate::service::RestartPhase::WaitForJob, }, |_, _| unreachable!("terminal service failure must stop before runtime read"), |_, _, _| unreachable!("terminal service failure must stop before persistence"), ); assert_eq!(report.stage, RestartStage::Service); assert!(report.restart_submitted); assert!(!report.restart_may_still_complete); assert!(report.manual_recovery_required); } } #[test] fn application_report_preserves_every_service_failure_and_operation_phase() { use crate::service::RestartPhase; let cases = [ (RestartFailure::JobFailed, RestartPhase::WaitForJob, true), ( RestartFailure::JobDependency, RestartPhase::WaitForJob, true, ), (RestartFailure::JobTimedOut, RestartPhase::WaitForJob, true), (RestartFailure::JobCanceled, RestartPhase::WaitForJob, true), (RestartFailure::JobSkipped, RestartPhase::WaitForJob, true), ( RestartFailure::JobResultUnknown, RestartPhase::WaitForJob, true, ), ( RestartFailure::BusDisconnected, RestartPhase::Connect, false, ), ( RestartFailure::ManagerOwnerChanged, RestartPhase::WaitForJob, true, ), ( RestartFailure::ManagerOwnerChanged, RestartPhase::RequestRestart, true, ), ( RestartFailure::SubscriptionFailed, RestartPhase::Subscribe, false, ), ( RestartFailure::UnitResolutionFailed, RestartPhase::ResolveUnit, false, ), ( RestartFailure::PermissionDenied, RestartPhase::ResolveUnit, false, ), ( RestartFailure::RequestFailed, RestartPhase::RequestRestart, false, ), ( RestartFailure::WaitTimedOutMayStillComplete, RestartPhase::WaitForJob, true, ), ( RestartFailure::EarlyEventBufferFull, RestartPhase::WaitForJob, true, ), ( RestartFailure::InvalidJobPath, RestartPhase::WaitForJob, true, ), ]; let root = tempfile::tempdir().expect("temp directory"); let (mut app, _) = prepare_restartable(root.path()); for (failure, phase, submitted) in cases { let preview = app.preview_restart().expect("preview saved generation"); let report = app.restart_with_adapters( preview, RestartChoice::RestartSavedState, |_, _| RestartExecution { outcome: RestartOutcome::Failed(failure), submitted, phase, }, |_, _| unreachable!("service failure must stop before runtime read"), |_, _, _| unreachable!("service failure must stop before persistence"), ); assert_eq!(report.failure, Some(RestartFailureKind::ServiceFailed)); assert_eq!(report.service_failure, Some(failure)); assert_eq!(report.service_phase, Some(phase)); assert_eq!(report.systemd_unit.as_deref(), Some("wg-quick@wg0.service")); assert_eq!(report.restart_submitted, submitted); if failure == RestartFailure::ManagerOwnerChanged && phase == RestartPhase::RequestRestart { assert!(report.restart_may_still_complete); assert!(report.manual_recovery_required); } assert_eq!( report.restart_may_still_complete, submitted && !matches!( failure, RestartFailure::JobFailed | RestartFailure::JobDependency | RestartFailure::JobTimedOut | RestartFailure::JobCanceled | RestartFailure::JobSkipped | RestartFailure::JobResultUnknown ) ); assert_eq!(app.states().applied(), &ApplyStatus::Unknown); } } #[test] fn restart_mismatch_and_post_restart_write_failure_never_release_addresses() { let root = tempfile::tempdir().expect("temp directory"); let (mut app, key) = prepare_restartable(root.path()); app.delete_client(key).expect("delete saved peer in draft"); app.commit().expect("save deletion and reservation"); assert!(!app.states().reserved_addresses().is_empty()); let preview = app.preview_restart().expect("preview empty saved peer set"); let mismatch = app.restart_with_adapters( preview, RestartChoice::RestartSavedState, |_, _| RestartExecution { outcome: RestartOutcome::Succeeded, submitted: true, phase: crate::service::RestartPhase::WaitForJob, }, |_, _| { Ok(RuntimeObservation { observed_at: None, interface: InterfaceObservation::Missing, }) }, |store, saved, backup| store.install(saved, backup), ); assert_eq!(mismatch.stage, RestartStage::Verification); assert!(!app.states().reserved_addresses().is_empty()); let preview = app.preview_restart().expect("preview remains available"); let observation = observed_runtime(&app); let failed_write = app.restart_with_adapters( preview, RestartChoice::RestartSavedState, |_, _| RestartExecution { outcome: RestartOutcome::Succeeded, submitted: true, phase: crate::service::RestartPhase::WaitForJob, }, |_, _| Ok(observation), |_, _, _| Err(PersistenceError::RecoveryRequired), ); assert_eq!(failed_write.stage, RestartStage::Persistence); assert!(failed_write.manual_recovery_required); assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert!(!app.states().reserved_addresses().is_empty()); } #[test] fn restart_rejects_unsafe_saveconfig_before_backup_or_submission() { let root = tempfile::tempdir().expect("temp directory"); let (mut app, key) = prepare_restartable(root.path()); let mut saved = clone_saved(app.saved_bytes()); let server = String::from_utf8(saved.server_config.clone()).expect("synthetic config"); saved.server_config = server .replace( "Address = 10.20.0.1/24", "Address = 10.20.0.1/24\nSaveConfig = true", ) .into_bytes(); app.store .install(&saved, BackupComplete(())) .expect("install synthetic unsafe generation"); let reopened_store = app.store.clone(); let states = app.draft.states.clone(); app = Application::open(reopened_store, states).expect("reopen fixture"); app.rename_client(key, "draft") .expect("stage a draft for preservation"); assert!(matches!( app.preview_restart(), Err(AppError::UnsafeSaveConfig) )); } #[test] fn restart_confirmation_rejects_a_generation_changed_after_preview() { let root = tempfile::tempdir().expect("temp directory"); let (mut app, _) = prepare_restartable(root.path()); let preview = app.preview_restart().expect("preview saved generation"); let mut externally_replaced = clone_saved(app.saved_bytes()); let settings = String::from_utf8(externally_replaced.settings.clone()).expect("synthetic settings"); externally_replaced.settings = settings .replace("wg-quick@wg0.service", "wg-quick@wg1.service") .into_bytes(); app.store .install(&externally_replaced, BackupComplete(())) .expect("install changed synthetic generation"); let called = std::cell::Cell::new(false); let report = app.restart_with_adapters( preview, RestartChoice::RestartSavedState, |_, _| { called.set(true); RestartExecution { outcome: RestartOutcome::Succeeded, submitted: true, phase: crate::service::RestartPhase::WaitForJob, } }, |_, _| Err(TransportError::Transport), |_, _, _| Ok(()), ); assert_eq!(report.stage, RestartStage::Preparation); assert_eq!( report.failure, Some(RestartFailureKind::InvalidOrStaleSavedGeneration) ); assert!(!called.get()); assert!(report.backup_path.is_none()); } #[test] fn verified_history_persists_across_reopen_without_marking_runtime_applied() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, _, _) = setup(root.path(), false); let mut app = Application::open(store.clone(), states.clone()).expect("application opens"); let verified_at = "2026-10-20T08:15:00Z"; let observed = app.states().saved().clone(); test_mark_verified(&mut app, observed, verified_at.to_owned()) .expect("verified generation persists"); let saved = store.read_saved().expect("verified saved bytes"); assert_eq!( PersistedState::from_toml(saved.internal_state.as_deref().unwrap()) .unwrap() .last_verified_at_utc .as_deref(), Some(verified_at) ); let reopened = Application::open(store, states).expect("application reopens"); assert_eq!( reopened.historical_apply, HistoricalApplyStatus::Verified { verified_at_utc: verified_at.to_owned() } ); assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); } #[test] fn open_does_not_treat_caller_seeded_verified_state_as_runtime_evidence() { let root = tempfile::tempdir().expect("temp directory"); let (store, seeded, _, _) = setup(root.path(), false); assert_eq!(seeded.applied(), &ApplyStatus::Verified); let app = Application::open(store, seeded).expect("application opens"); assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert!(app.states().applied_snapshot().is_some()); assert!(!app.saved_generation_is_verified_applied(app.saved_bytes(), app.document())); } #[test] fn failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, _, _) = setup(root.path(), false); let mut app = Application::open(store.clone(), states.clone()).expect("application opens"); let before = store.read_saved().expect("original saved bytes"); let observed = app.states().saved().clone(); app.create_pre_restart_backup() .expect("pre-restart backup completes"); let id = app.next_restart_attempt; app.next_restart_attempt += 1; app.active_restart_attempt = Some(id); let attempt = super::RestartAttempt { id, saved: clone_saved(&app.saved), }; let result = app.mark_verified_with_attempt( attempt, Some(observed), "2026-10-20T08:30:00Z".to_owned(), |store, saved, _backup| { store.install_with_hook(saved, |point| { if point == InstallCheckpoint::Replaced(3) { Err(std::io::Error::other("history-state install interruption")) } else { Ok(()) } }) }, ); assert!(matches!( result, Err(AppError::Persistence(PersistenceError::RecoveryRequired)) )); assert_eq!(app.historical_apply, HistoricalApplyStatus::Unknown); assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert_eq!(app.saved_bytes().internal_state, before.internal_state); let resolved = SettingsFile::parse_toml(SETTINGS) .unwrap() .validate() .unwrap() .resolve_paths(root.path()); let reopened_store = SavedStore::new(root.path(), resolved).expect("store reopens"); let recovered = reopened_store .read_saved() .expect("interrupted history write rolls back"); assert_eq!(recovered.internal_state, before.internal_state); let reopened = Application::open(reopened_store, states).expect("application reopens"); assert_eq!(reopened.historical_apply, HistoricalApplyStatus::Unknown); assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); } #[test] fn precommit_archive_records_verified_installed_generation_and_success_clears_it() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, key, _) = setup(root.path(), false); let mut app = Application::open(store.clone(), states.clone()).expect("application opens"); let verified_at = "2026-10-20T09:00:00Z"; let observed = app.states().saved().clone(); test_mark_verified(&mut app, observed, verified_at.to_owned()) .expect("verified generation persists"); app.rename_client(key, "next saved alias") .expect("draft alias"); let archive = app.commit().expect("commit succeeds"); let archived = preview_archive(&archive).expect("pre-commit archive is valid"); assert_eq!( archived.historical_apply, HistoricalApplyStatus::Verified { verified_at_utc: verified_at.to_owned() } ); assert_eq!(app.historical_apply, HistoricalApplyStatus::Unknown); assert_eq!( PersistedState::from_toml( store .read_saved() .unwrap() .internal_state .as_deref() .unwrap() ) .unwrap() .last_verified_at_utc, None ); let reopened = Application::open(store, states).expect("committed state reopens"); assert_eq!(reopened.historical_apply, HistoricalApplyStatus::Unknown); assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); } #[test] fn failed_commit_retains_verified_history_and_retryable_draft() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, key, _) = setup(root.path(), false); let mut app = Application::open(store.clone(), states).expect("application opens"); let verified_at = "2026-10-20T10:00:00Z"; let observed = app.states().saved().clone(); test_mark_verified(&mut app, observed, verified_at.to_owned()) .expect("verified generation persists"); let before = store.read_saved().expect("verified saved bytes"); app.rename_client(key, "retryable draft") .expect("draft alias"); let archives = store.archives_directory().to_path_buf(); fs::remove_dir_all(&archives).expect("remove synthetic archive directory"); fs::write(&archives, b"block commit backup").expect("install backup blocker"); assert!(matches!(app.commit(), Err(AppError::Backup(_)))); let persisted = PersistedState::from_toml(before.internal_state.as_deref().unwrap()) .expect("prior state parses"); assert_eq!(persisted.last_verified_at_utc.as_deref(), Some(verified_at)); assert_eq!( app.historical_apply, HistoricalApplyStatus::Verified { verified_at_utc: verified_at.to_owned() } ); assert_eq!(app.saved_bytes().server_config, before.server_config); assert_eq!( app.states().draft().get(&key).unwrap().alias, "retryable draft" ); assert_eq!(app.states().saved().get(&key).unwrap().alias, "legacy-view"); } #[test] fn successful_restore_clears_verified_current_history_but_backup_retains_it() { let root = tempfile::tempdir().expect("temp directory"); let (store, states, _, _) = setup(root.path(), false); let mut app = Application::open(store.clone(), states.clone()).expect("application opens"); let selected = app.create_manual_backup().expect("select initial archive"); let verified_at = "2026-10-20T11:00:00Z"; let observed = app.states().saved().clone(); test_mark_verified(&mut app, observed, verified_at.to_owned()) .expect("verified generation persists"); let preview = app .preview_restore(selected.file_name().unwrap().to_str().unwrap()) .expect("restore preview validates"); let pre_restore = app.confirm_restore(preview).expect("restore succeeds"); assert_eq!(app.historical_apply, HistoricalApplyStatus::Unknown); assert_eq!(app.states().applied(), &ApplyStatus::Unknown); let archived = preview_archive(&pre_restore).expect("pre-restore backup validates"); assert_eq!( archived.historical_apply, HistoricalApplyStatus::Verified { verified_at_utc: verified_at.to_owned() } ); let restored = store.read_saved().expect("restored saved bytes parse"); assert!( restored.internal_state.is_none(), "the selected archive had no state and restored routes need no reservation" ); let restored_document = ConfigDocument::parse(&restored.server_config).unwrap(); let reopened_states = ClientStates::new( snapshot_from_document(&restored_document), None, ApplyStatus::Unknown, ) .unwrap(); let reopened = Application::open(store, reopened_states).expect("restored state reopens"); assert_eq!(reopened.historical_apply, HistoricalApplyStatus::Unknown); assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); } }