Files
wireguard-admin/src/app.rs
T

5339 lines
210 KiB
Rust

//! 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<PublicKey>,
pub label: String,
pub allowed_ips: Vec<String>,
pub has_subnet_routes: bool,
pub issues: Vec<crate::wireguard::PeerIssue>,
pub mutable_identity: Option<PublicKey>,
}
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<Item = &crate::wireguard::PeerIssue> {
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<ClientDisplay>,
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
}
/// 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<SavedPeerProjection>,
active_restart_attempt: Option<u64>,
next_restart_attempt: u64,
next_issue_authorization: u64,
initial_issues: BTreeMap<PublicKey, InitialIssue>,
}
#[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<T, E> {
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<QrCodeOutput, QrError>,
}
/// 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<Result<QrCodeOutput, QrError>>,
}
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<Snapshot>,
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<PublicKey>,
pub section_line: Option<usize>,
pub reason: &'static str,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RestartReport {
pub stage: RestartStage,
pub failure: Option<RestartFailureKind>,
pub service_failure: Option<service::RestartFailure>,
pub service_phase: Option<service::RestartPhase>,
pub systemd_unit: Option<String>,
pub backup_path: Option<std::path::PathBuf>,
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::<Vec<_>>(),
)
})
})
.collect::<BTreeMap<_, _>>()
})
.unwrap_or_default()
};
let current_peer_map = peer_map(&current.server_config);
let proposed_peer_map = peer_map(&self.saved_bytes.server_config);
let current_peers = current_peer_map.keys().copied().collect::<BTreeSet<_>>();
let proposed_peers = proposed_peer_map.keys().copied().collect::<BTreeSet<_>>();
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(
&current.settings,
&self.saved_bytes.settings,
),
added_clients: proposed_peers.difference(&current_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<PublicKey>,
pub removed_clients: Vec<PublicKey>,
pub changed_clients: Vec<PublicKey>,
}
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<Self, AppError> {
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<Self, AppError> {
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<Self, AppError> {
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::<String>()),
)
});
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<ClientAddresses>,
created_utc: Option<&str>,
) -> Result<PublicKey, AppError> {
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::<Vec<_>>();
occupied.extend(document_host_addresses(&self.draft.document));
let reserved = self
.draft
.states
.address_reservations()
.all()
.collect::<Vec<_>>();
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<T, E>(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
publish: impl FnOnce(&ClientConfigPayload) -> Result<T, (E, FirstIssueDeliveryFailure)>,
) -> Result<FirstIssueDeliveryOutcome<T, E>, AppError> {
self.deliver_first_issue_with_warning(identity, attempt, |payload, _| publish(payload))
}
fn deliver_first_issue_with_warning<T, E>(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
publish: impl FnOnce(&ClientConfigPayload, bool) -> Result<T, (E, FirstIssueDeliveryFailure)>,
) -> Result<FirstIssueDeliveryOutcome<T, E>, 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<std::path::Path>,
) -> Result<PreparedDestination, FileOutputError> {
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<FirstIssueDeliveryOutcome<FirstIssueFileDelivery, FileOutputError>, 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<FirstIssueDeliveryOutcome<FirstIssueFileDelivery, FileOutputError>, 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<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, 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<QrCodeOutput, QrError>,
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, 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<QrCodeOutput, QrError>,
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, 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<ReissueDelivery, ReissueError> {
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<bool, 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,
)?;
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<FirstIssueAuthorization, 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 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<FirstIssueRetry, 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 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<SelectedFileImportReport, AppError> {
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<bool, AppError> {
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<SourceImportReport, AppError> {
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<SourceImportReport, AppError> {
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<std::path::PathBuf, AppError> {
backups::create_backup(self, reason)
}
pub fn create_manual_backup(&self) -> Result<std::path::PathBuf, AppError> {
self.create_backup(BackupReason::Manual)
}
pub fn list_backups(&self) -> Result<Vec<backup::ArchiveSummary>, 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<std::path::PathBuf, AppError> {
backups::create_pre_restart_backup(self)
}
/// Prepare a read-only preview bound to the exact currently installed generation.
pub fn preview_restart(&self) -> Result<RestartPreview, AppError> {
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<RuntimeObservation, TransportError>,
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<RuntimeObservation, TransportError>,
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<RestorePreview, AppError> {
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<std::path::PathBuf, AppError> {
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<std::path::PathBuf, AppError> {
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<Snapshot>,
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(&current.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<std::path::PathBuf, AppError> {
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<std::path::PathBuf, BackupError>,
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
) -> Result<std::path::PathBuf, AppError> {
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<HistoricalApplyStatus, AppError> {
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<PublicKey, InitialIssue>,
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<PublicKey, InitialIssue>,
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<IssueContext, AppError> {
let matching = document
.peers()
.iter()
.filter(|peer| {
peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity)
})
.collect::<Vec<_>>();
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::<Vec<_>>()
.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<Vec<IpAddr>, AppError> {
let interfaces = document
.sections()
.iter()
.filter(|section| section.kind == SectionKind::Interface)
.collect::<Vec<_>>();
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::<ipnet::IpNet>()
.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<IpAddr> {
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<Snapshot>), Vec<RestartDiagnostic>> {
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<RestartFailureKind>,
backup_path: Option<std::path::PathBuf>,
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<RestartDiagnostic>),
#[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::<Ipv6Addr>().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::<Vec<_>>();
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(&current, 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::<Vec<_>>();
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(&current, BackupComplete(()))
.expect("install synthetic unprojectable peers");
let archive = create_archive(
store.archives_directory(),
BackupReason::Manual,
HistoricalApplyStatus::Unknown,
&current,
)
.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(&current, 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);
}
}