5339 lines
210 KiB
Rust
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(¤t.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(
|
|
¤t.settings,
|
|
&self.saved_bytes.settings,
|
|
),
|
|
added_clients: proposed_peers.difference(¤t_peers).copied().collect(),
|
|
removed_clients: current_peers.difference(&proposed_peers).copied().collect(),
|
|
changed_clients,
|
|
server_config_changed: current.server_config != self.saved_bytes.server_config,
|
|
vault_changed: current.vault != self.saved_bytes.vault,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct RestoreChangeSummary {
|
|
pub settings_changed: bool,
|
|
pub settings_fields_changed: Vec<&'static str>,
|
|
pub server_config_changed: bool,
|
|
pub vault_changed: bool,
|
|
pub added_clients: Vec<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(¤t.server_config).ok();
|
|
let vault = current
|
|
.vault
|
|
.as_deref()
|
|
.and_then(|bytes| std::str::from_utf8(bytes).ok())
|
|
.and_then(|text| VaultBundle::from_toml(text).ok());
|
|
if let Some(document) = document {
|
|
retain_matching_issue_map(
|
|
&mut self.initial_issues,
|
|
&document,
|
|
vault.as_ref(),
|
|
);
|
|
} else {
|
|
self.initial_issues.clear();
|
|
}
|
|
}
|
|
Err(_) => self.initial_issues.clear(),
|
|
}
|
|
return Err(error.into());
|
|
}
|
|
self.saved = proposed;
|
|
self.draft.states = next_states;
|
|
self.verified_saved_projection =
|
|
Some(SavedPeerProjection::from_document(&self.draft.document));
|
|
self.historical_apply = historical_apply;
|
|
self.active_restart_attempt = None;
|
|
Ok(())
|
|
}
|
|
|
|
/// Commit current configuration, vault, and reservations. The archive is
|
|
/// complete before `SavedStore::install` receives its capability. In-memory
|
|
/// saved state advances only after the adapter reports full success.
|
|
pub fn commit(&mut self) -> Result<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(¤t, BackupComplete(()))
|
|
.expect("install synthetic current generation");
|
|
let mut app = Application::open(store.clone(), states).expect("open with peer routes");
|
|
|
|
// Missing managed metadata makes this peer unprojectable. Deleting it
|
|
// stages its exact addresses without requiring a client snapshot.
|
|
assert!(
|
|
!app.delete_client(pending_key)
|
|
.expect("stage exact-host deletion")
|
|
);
|
|
let preview = app
|
|
.preview_restore(selected.file_name().unwrap().to_str().unwrap())
|
|
.expect("preview combines current and pending reservations");
|
|
app.confirm_restore(preview).expect("restore succeeds");
|
|
|
|
let reopened = Application::open_from_saved_store(store.clone())
|
|
.expect("reservations survive a fresh application session");
|
|
let reserved = reopened
|
|
.states()
|
|
.reserved_addresses()
|
|
.iter()
|
|
.copied()
|
|
.collect::<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(¤t, BackupComplete(()))
|
|
.expect("install synthetic unprojectable peers");
|
|
let archive = create_archive(
|
|
store.archives_directory(),
|
|
BackupReason::Manual,
|
|
HistoricalApplyStatus::Unknown,
|
|
¤t,
|
|
)
|
|
.expect("archive restored peer routes");
|
|
let mut app = Application::open_from_saved_store(store.clone())
|
|
.expect("application opens with unprojectable peers");
|
|
|
|
assert!(
|
|
!app.delete_client(deleted_key)
|
|
.expect("stage pending peer deletion")
|
|
);
|
|
assert_eq!(
|
|
app.states()
|
|
.address_reservations()
|
|
.pending_host_routes()
|
|
.count(),
|
|
2
|
|
);
|
|
let preview = app
|
|
.preview_restore(archive.file_name().unwrap().to_str().unwrap())
|
|
.expect("preview archive restoring exact routes");
|
|
app.confirm_restore(preview).expect("restore succeeds");
|
|
|
|
assert_eq!(app.states().reserved_addresses().len(), 1);
|
|
assert!(
|
|
app.states()
|
|
.reserved_addresses()
|
|
.contains(&"10.20.0.88".parse().expect("archived reservation"))
|
|
);
|
|
assert!(
|
|
app.states()
|
|
.address_reservations()
|
|
.pending_host_routes()
|
|
.next()
|
|
.is_none()
|
|
);
|
|
let restored_state = PersistedState::from_toml(
|
|
app.saved_bytes()
|
|
.internal_state
|
|
.as_deref()
|
|
.expect("archived reservation remains persisted"),
|
|
)
|
|
.expect("restored state remains valid");
|
|
assert_eq!(restored_state.reserved_addresses, ["10.20.0.88"]);
|
|
}
|
|
|
|
#[test]
|
|
fn restoring_absent_optional_components_removes_installed_vault_and_state() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (store, states, _, _) = setup(root.path(), false);
|
|
let mut app = Application::open(store.clone(), states).expect("application opens");
|
|
let initial = store.read_saved().expect("initial bytes");
|
|
let selected = create_archive(
|
|
store.archives_directory(),
|
|
BackupReason::Manual,
|
|
HistoricalApplyStatus::Unknown,
|
|
&initial,
|
|
)
|
|
.expect("initial-state archive");
|
|
app.prepare_initial_vault(VaultBundle::create("synthetic password").expect("vault"))
|
|
.expect("vault staged");
|
|
app.commit().expect("vault installed");
|
|
assert!(store.read_saved().expect("installed").vault.is_some());
|
|
let mut current = store.read_saved().expect("current saved bytes");
|
|
let removed_key = WireGuardPrivateKey::generate()
|
|
.expect("synthetic removed-peer key")
|
|
.public_key();
|
|
current.server_config.extend_from_slice(
|
|
format!(
|
|
"\n[Peer]\nPublicKey = {removed_key}\nAllowedIPs = 10.20.0.9/32, fd20::9/128\n"
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
store
|
|
.install(¤t, BackupComplete(()))
|
|
.expect("install current peer removed by archive");
|
|
|
|
let preview = app
|
|
.preview_restore(selected.file_name().unwrap().to_str().unwrap())
|
|
.expect("preview initial state");
|
|
app.confirm_restore(preview).expect("restore initial state");
|
|
let restored = store.read_saved().expect("restored state");
|
|
assert!(restored.vault.is_none());
|
|
let state = PersistedState::from_toml(
|
|
restored
|
|
.internal_state
|
|
.as_deref()
|
|
.expect("removed current routes are retained"),
|
|
)
|
|
.expect("restored reservation state parses");
|
|
assert!(state.reserved_addresses.contains(&"10.20.0.9".to_owned()));
|
|
assert!(state.reserved_addresses.contains(&"fd20::9".to_owned()));
|
|
assert!(app.vault().is_none());
|
|
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
|
}
|
|
|
|
#[test]
|
|
fn failed_pre_restore_backup_preserves_installed_files_and_draft() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (store, states, key, _) = setup(root.path(), false);
|
|
let mut app = Application::open(store.clone(), states).expect("application opens");
|
|
let saved = store.read_saved().expect("saved");
|
|
let selected = create_archive(
|
|
store.archives_directory(),
|
|
BackupReason::Manual,
|
|
HistoricalApplyStatus::Unknown,
|
|
&saved,
|
|
)
|
|
.expect("archive");
|
|
app.rename_client(key, "draft retained")
|
|
.expect("draft rename");
|
|
let installed = store.read_saved().expect("installed state");
|
|
let preview = app
|
|
.preview_restore(selected.file_name().unwrap().to_str().unwrap())
|
|
.expect("preview");
|
|
|
|
let archive_dir = store.archives_directory();
|
|
fs::remove_dir_all(archive_dir).expect("remove synthetic archive directory");
|
|
fs::write(archive_dir, b"cannot create a backup here").expect("replace with file");
|
|
assert!(app.confirm_restore(preview).is_err());
|
|
assert_eq!(
|
|
store.read_saved().expect("files preserved").server_config,
|
|
installed.server_config
|
|
);
|
|
assert_eq!(
|
|
app.states().draft().get(&key).expect("draft client").alias,
|
|
"draft retained"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_and_pre_restart_backups_are_listed_without_runtime_calls() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (store, states, _, _) = setup(root.path(), false);
|
|
let mut app = Application::open(store, states).expect("application opens");
|
|
app.create_manual_backup().expect("manual backup");
|
|
app.create_pre_restart_backup().expect("pre-restart backup");
|
|
let listed = app.list_backups().expect("backup listing");
|
|
assert_eq!(listed.len(), 2);
|
|
assert!(
|
|
listed
|
|
.iter()
|
|
.any(|item| item.reason == BackupReason::Manual)
|
|
);
|
|
assert!(
|
|
listed
|
|
.iter()
|
|
.any(|item| item.reason == BackupReason::PreRestart)
|
|
);
|
|
assert!(
|
|
listed
|
|
.iter()
|
|
.all(|item| item.historical_apply == HistoricalApplyStatus::Unknown)
|
|
);
|
|
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
|
}
|
|
|
|
#[test]
|
|
fn restart_cancel_preserves_draft_and_never_calls_adapters() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (mut app, key) = prepare_restartable(root.path());
|
|
app.rename_client(key, "pending draft")
|
|
.expect("stage draft alias");
|
|
let preview = app.preview_restart().expect("preview saved generation");
|
|
let called = std::cell::Cell::new(false);
|
|
let report = app.restart_with_adapters(
|
|
preview,
|
|
RestartChoice::Cancel,
|
|
|_, _| {
|
|
called.set(true);
|
|
RestartExecution {
|
|
outcome: RestartOutcome::Succeeded,
|
|
submitted: true,
|
|
phase: crate::service::RestartPhase::WaitForJob,
|
|
}
|
|
},
|
|
|_, _| {
|
|
called.set(true);
|
|
Err(TransportError::Transport)
|
|
},
|
|
|_, _, _| {
|
|
called.set(true);
|
|
Ok(())
|
|
},
|
|
);
|
|
assert_eq!(report.stage, RestartStage::Cancelled);
|
|
assert!(!called.get());
|
|
assert_eq!(
|
|
app.states().draft().get(&key).unwrap().alias,
|
|
"pending draft"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn restart_backup_failure_preserves_draft_and_submits_no_job() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (mut app, key) = prepare_restartable(root.path());
|
|
app.rename_client(key, "pending draft")
|
|
.expect("stage draft alias");
|
|
let preview = app.preview_restart().expect("preview saved generation");
|
|
let archives = root.path().join("Backup/archives");
|
|
fs::remove_dir_all(&archives).expect("remove archive directory");
|
|
fs::write(&archives, b"synthetic archive blocker").expect("install archive blocker");
|
|
let submitted = std::cell::Cell::new(false);
|
|
let report = app.restart_with_adapters(
|
|
preview,
|
|
RestartChoice::DiscardDraftAndRestart,
|
|
|_, _| {
|
|
submitted.set(true);
|
|
RestartExecution {
|
|
outcome: RestartOutcome::Succeeded,
|
|
submitted: true,
|
|
phase: crate::service::RestartPhase::WaitForJob,
|
|
}
|
|
},
|
|
|_, _| Err(TransportError::Transport),
|
|
|_, _, _| Ok(()),
|
|
);
|
|
assert_eq!(report.stage, RestartStage::Backup);
|
|
assert_eq!(report.failure, Some(RestartFailureKind::BackupFailed));
|
|
assert!(!report.restart_submitted);
|
|
assert!(!submitted.get());
|
|
assert_eq!(
|
|
app.states().draft().get(&key).unwrap().alias,
|
|
"pending draft"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn confirmed_restart_orders_backup_job_read_and_durable_publication() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (mut app, key) = prepare_restartable(root.path());
|
|
app.rename_client(key, "discard me")
|
|
.expect("stage draft alias");
|
|
let preview = app.preview_restart().expect("preview saved generation");
|
|
let observation = observed_runtime(&app);
|
|
let archives = root.path().join("Backup/archives");
|
|
let events = std::cell::RefCell::new(Vec::new());
|
|
let report = app.restart_with_adapters(
|
|
preview,
|
|
RestartChoice::DiscardDraftAndRestart,
|
|
|unit, _| {
|
|
assert_eq!(unit, "wg-quick@wg0.service");
|
|
assert!(
|
|
crate::backup::list_archives(&archives)
|
|
.expect("archive listing")
|
|
.iter()
|
|
.any(|archive| archive.reason == BackupReason::PreRestart)
|
|
);
|
|
events.borrow_mut().push("service");
|
|
RestartExecution {
|
|
outcome: RestartOutcome::Succeeded,
|
|
submitted: true,
|
|
phase: crate::service::RestartPhase::WaitForJob,
|
|
}
|
|
},
|
|
|interface, remaining| {
|
|
assert_eq!(interface, "wg0");
|
|
assert!(!remaining.is_zero());
|
|
events.borrow_mut().push("runtime");
|
|
Ok(observation)
|
|
},
|
|
|store, saved, backup| {
|
|
events.borrow_mut().push("persist");
|
|
store.install(saved, backup)
|
|
},
|
|
);
|
|
assert_eq!(report.stage, RestartStage::Complete, "{report:?}");
|
|
assert!(report.backup_path.is_some());
|
|
assert!(report.restart_submitted);
|
|
assert_eq!(*events.borrow(), vec!["service", "runtime", "persist"]);
|
|
assert_eq!(app.states().applied(), &ApplyStatus::Verified);
|
|
assert_eq!(
|
|
app.states().draft().get(&key).unwrap().alias,
|
|
"restart fixture"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn restart_verifies_metadata_free_legacy_routes_without_partial_applied_snapshot() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (mut app, _) = prepare_restartable(root.path());
|
|
let deleted_key = WireGuardPrivateKey::generate()
|
|
.expect("synthetic deleted-client key")
|
|
.public_key();
|
|
app.add_client(
|
|
Client::new(
|
|
deleted_key,
|
|
"deleted reservation fixture",
|
|
ClientAddresses {
|
|
ipv4: "10.20.0.4".parse().expect("synthetic IPv4"),
|
|
ipv6: Some("fd20::4".parse().expect("synthetic IPv6")),
|
|
},
|
|
)
|
|
.expect("synthetic managed client"),
|
|
None,
|
|
)
|
|
.expect("stage managed client");
|
|
app.commit().expect("save second managed client");
|
|
app.delete_client(deleted_key)
|
|
.expect("delete managed peer in draft");
|
|
app.commit()
|
|
.expect("save deletion and reserve its addresses");
|
|
let mut saved = clone_saved(app.saved_bytes());
|
|
saved.server_config.extend_from_slice(
|
|
format!(
|
|
"\n[Peer]\nPublicKey = {}\nAllowedIPs = 192.0.2.0/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.20.0.99/32\n",
|
|
PublicKey::from_bytes([19; 32]),
|
|
PublicKey::from_bytes([20; 32]),
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
app.store
|
|
.install(&saved, BackupComplete(()))
|
|
.expect("install synthetic legacy saved generation");
|
|
let mut app = Application::open_from_saved_store(app.store.clone())
|
|
.expect("open metadata-free legacy peers");
|
|
assert!(!app.states().reserved_addresses().is_empty());
|
|
let original_config = app.saved_bytes().server_config.clone();
|
|
let preview = app
|
|
.preview_restart()
|
|
.expect("valid legacy peers are eligible");
|
|
let observation = observed_runtime(&app);
|
|
let report = app.restart_with_adapters(
|
|
preview,
|
|
RestartChoice::RestartSavedState,
|
|
|unit, _| {
|
|
assert_eq!(unit, "wg-quick@wg0.service");
|
|
RestartExecution {
|
|
outcome: RestartOutcome::Succeeded,
|
|
submitted: true,
|
|
phase: crate::service::RestartPhase::WaitForJob,
|
|
}
|
|
},
|
|
|_, _| Ok(observation),
|
|
|store, saved, backup| store.install(saved, backup),
|
|
);
|
|
|
|
assert_eq!(report.stage, RestartStage::Complete);
|
|
assert_eq!(app.states().applied(), &ApplyStatus::Verified);
|
|
assert!(app.states().applied_snapshot().is_none());
|
|
assert!(app.states().reserved_addresses().is_empty());
|
|
assert_eq!(app.saved_bytes().server_config, original_config);
|
|
assert!(app.saved_generation_is_verified_applied(app.saved_bytes(), app.document()));
|
|
let mut different_generation = clone_saved(app.saved_bytes());
|
|
different_generation.settings.push(b'\n');
|
|
assert!(!app.saved_generation_is_verified_applied(&different_generation, app.document()));
|
|
let historical_apply = app.historical_apply.clone();
|
|
let reopened = Application::open_from_saved_store(app.store.clone())
|
|
.expect("reopen historically verified saved state");
|
|
assert_eq!(reopened.historical_apply, historical_apply);
|
|
assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown);
|
|
assert!(reopened.states().applied_snapshot().is_none());
|
|
assert!(reopened.states().reserved_addresses().is_empty());
|
|
assert!(
|
|
!reopened
|
|
.saved_generation_is_verified_applied(reopened.saved_bytes(), reopened.document())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unsafe_saved_peer_diagnostics_block_preview_before_backup_with_safe_location() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (app, _) = prepare_restartable(root.path());
|
|
let key = PublicKey::from_bytes([21; 32]);
|
|
let mut saved = clone_saved(app.saved_bytes());
|
|
saved.server_config.extend_from_slice(
|
|
format!(
|
|
"\n[Peer]\nPublicKey = {key}\nPresharedKey = synthetic-peer-secret\nAllowedIPs = malformed-route\n"
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
app.store
|
|
.install(&saved, BackupComplete(()))
|
|
.expect("install malformed synthetic peer");
|
|
let app = Application::open_from_saved_store(app.store.clone())
|
|
.expect("open malformed peer for diagnostics");
|
|
let backup_count = app.list_backups().expect("list existing archives").len();
|
|
|
|
let error = match app.preview_restart() {
|
|
Ok(_) => panic!("unsafe peer must block preview"),
|
|
Err(error) => error,
|
|
};
|
|
let AppError::UnsafeSavedPeers(diagnostics) = error else {
|
|
panic!("expected actionable unsafe peer diagnostics");
|
|
};
|
|
assert!(diagnostics.iter().any(|diagnostic| {
|
|
diagnostic.identity == Some(key)
|
|
&& diagnostic.section_line.is_some()
|
|
&& diagnostic.reason == "peer has a malformed AllowedIPs item"
|
|
}));
|
|
let rendered = format!("{diagnostics:?}");
|
|
assert!(!rendered.contains("synthetic-peer-secret"));
|
|
assert!(!rendered.contains("malformed-route"));
|
|
assert_eq!(
|
|
app.list_backups().expect("backup listing").len(),
|
|
backup_count,
|
|
"unsafe preview must not create a restart archive"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn incomplete_legacy_runtime_match_keeps_deleted_addresses_reserved() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (mut app, _) = prepare_restartable(root.path());
|
|
let deleted_key = WireGuardPrivateKey::generate()
|
|
.expect("synthetic deleted-client key")
|
|
.public_key();
|
|
app.add_client(
|
|
Client::new(
|
|
deleted_key,
|
|
"deleted reservation fixture",
|
|
ClientAddresses {
|
|
ipv4: "10.20.0.4".parse().expect("synthetic IPv4"),
|
|
ipv6: Some("fd20::4".parse().expect("synthetic IPv6")),
|
|
},
|
|
)
|
|
.expect("synthetic managed client"),
|
|
None,
|
|
)
|
|
.expect("stage managed client");
|
|
app.commit().expect("save second managed client");
|
|
app.delete_client(deleted_key)
|
|
.expect("delete managed peer in draft");
|
|
app.commit()
|
|
.expect("save deletion and reserve its addresses");
|
|
let reserved_before = app.states().reserved_addresses().clone();
|
|
let mut saved = clone_saved(app.saved_bytes());
|
|
let legacy_key = PublicKey::from_bytes([22; 32]);
|
|
saved.server_config.extend_from_slice(
|
|
format!("\n[Peer]\nPublicKey = {legacy_key}\nAllowedIPs = 10.20.0.99/32\n").as_bytes(),
|
|
);
|
|
app.store
|
|
.install(&saved, BackupComplete(()))
|
|
.expect("install synthetic legacy peer");
|
|
let mut app =
|
|
Application::open_from_saved_store(app.store.clone()).expect("reopen with legacy peer");
|
|
let preview = app.preview_restart().expect("legacy peer preview succeeds");
|
|
let mut incomplete = observed_runtime(&app);
|
|
let InterfaceObservation::Present { peers, .. } = &mut incomplete.interface else {
|
|
panic!("fixture interface is present");
|
|
};
|
|
peers.peers.remove(&legacy_key);
|
|
|
|
let report = app.restart_with_adapters(
|
|
preview,
|
|
RestartChoice::RestartSavedState,
|
|
|_, _| RestartExecution {
|
|
outcome: RestartOutcome::Succeeded,
|
|
submitted: true,
|
|
phase: crate::service::RestartPhase::WaitForJob,
|
|
},
|
|
|_, _| Ok(incomplete),
|
|
|store, saved, backup| store.install(saved, backup),
|
|
);
|
|
|
|
assert_eq!(report.stage, RestartStage::Verification);
|
|
assert_eq!(report.failure, Some(RestartFailureKind::RuntimeMismatch));
|
|
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
|
assert_eq!(app.states().reserved_addresses(), &reserved_before);
|
|
let reopened = Application::open_from_saved_store(app.store.clone())
|
|
.expect("failed verification leaves saved reservations durable");
|
|
assert_eq!(reopened.states().reserved_addresses(), &reserved_before);
|
|
assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown);
|
|
}
|
|
|
|
#[test]
|
|
fn ambiguous_identity_and_malformed_structure_block_restart_preview() {
|
|
for malformed in [false, true] {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (app, managed_identity) = prepare_restartable(root.path());
|
|
let mut saved = clone_saved(app.saved_bytes());
|
|
if malformed {
|
|
saved.server_config.extend_from_slice(
|
|
b"\n[Peer\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n",
|
|
);
|
|
} else {
|
|
saved.server_config.extend_from_slice(
|
|
format!(
|
|
"\n[Peer]\nPublicKey = {managed_identity}\nAllowedIPs = 10.20.0.8/32\n"
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
}
|
|
app.store
|
|
.install(&saved, BackupComplete(()))
|
|
.expect("install unsafe synthetic saved bytes");
|
|
let app = Application::open_from_saved_store(app.store.clone())
|
|
.expect("open unsafe saved bytes for preview");
|
|
let error = match app.preview_restart() {
|
|
Ok(_) => panic!("unsafe saved bytes must block restart preview"),
|
|
Err(error) => error,
|
|
};
|
|
let AppError::UnsafeSavedPeers(diagnostics) = error else {
|
|
panic!("expected safe structured diagnostics");
|
|
};
|
|
let expected_reason = if malformed {
|
|
"malformed section header"
|
|
} else {
|
|
"public key is used by multiple peer sections"
|
|
};
|
|
assert!(diagnostics.iter().any(|diagnostic| {
|
|
diagnostic.reason == expected_reason && diagnostic.section_line.is_some()
|
|
}));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn service_timeout_reports_submission_and_may_still_complete() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (mut app, _) = prepare_restartable(root.path());
|
|
let preview = app.preview_restart().expect("preview saved generation");
|
|
let runtime_called = std::cell::Cell::new(false);
|
|
let report = app.restart_with_adapters(
|
|
preview,
|
|
RestartChoice::RestartSavedState,
|
|
|_, _| RestartExecution {
|
|
outcome: RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete),
|
|
submitted: true,
|
|
phase: crate::service::RestartPhase::WaitForJob,
|
|
},
|
|
|_, _| {
|
|
runtime_called.set(true);
|
|
Err(TransportError::Transport)
|
|
},
|
|
|_, _, _| Ok(()),
|
|
);
|
|
assert_eq!(report.stage, RestartStage::Service);
|
|
assert!(report.restart_submitted);
|
|
assert!(report.restart_may_still_complete);
|
|
assert!(report.manual_recovery_required);
|
|
assert!(!runtime_called.get());
|
|
}
|
|
|
|
#[test]
|
|
fn shared_deadline_expired_after_successful_job_stops_before_runtime_read() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (mut app, key) = prepare_restartable(root.path());
|
|
app.delete_client(key).expect("delete saved peer in draft");
|
|
app.commit().expect("save deletion and reservation");
|
|
let preview = app.preview_restart().expect("preview saved generation");
|
|
let started_at = Instant::now();
|
|
let clock_values = [started_at, started_at + Duration::from_secs(30)];
|
|
let clock_index = std::cell::Cell::new(0);
|
|
let runtime_called = std::cell::Cell::new(false);
|
|
|
|
let report = app.restart_with_adapters_and_clock(
|
|
preview,
|
|
RestartChoice::RestartSavedState,
|
|
|_, deadline| {
|
|
assert_eq!(deadline.started_at(), started_at);
|
|
assert_eq!(deadline.timeout(), Duration::from_secs(30));
|
|
RestartExecution {
|
|
outcome: RestartOutcome::Succeeded,
|
|
submitted: true,
|
|
phase: crate::service::RestartPhase::WaitForJob,
|
|
}
|
|
},
|
|
|_, _| {
|
|
runtime_called.set(true);
|
|
Err(TransportError::Transport)
|
|
},
|
|
|store, saved, backup| store.install(saved, backup),
|
|
|| {
|
|
let index = clock_index.get();
|
|
clock_index.set(index + 1);
|
|
clock_values[index]
|
|
},
|
|
);
|
|
|
|
assert_eq!(report.stage, RestartStage::RuntimeRead);
|
|
assert_eq!(report.failure, Some(RestartFailureKind::RuntimeTimeout));
|
|
assert!(report.backup_path.is_some());
|
|
assert!(report.restart_submitted);
|
|
assert!(!report.restart_may_still_complete);
|
|
assert!(report.manual_recovery_required);
|
|
assert!(!runtime_called.get());
|
|
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
|
assert!(!app.states().reserved_addresses().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn shared_deadline_expired_after_runtime_read_reports_timeout_before_comparison() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (mut app, key) = prepare_restartable(root.path());
|
|
app.delete_client(key).expect("delete saved peer in draft");
|
|
app.commit().expect("save deletion and reservation");
|
|
let preview = app.preview_restart().expect("preview saved generation");
|
|
let started_at = Instant::now();
|
|
let clock_values = [started_at, started_at, started_at + Duration::from_secs(30)];
|
|
let clock_index = std::cell::Cell::new(0);
|
|
let install_called = std::cell::Cell::new(false);
|
|
let observation = observed_runtime(&app);
|
|
|
|
let report = app.restart_with_adapters_and_clock(
|
|
preview,
|
|
RestartChoice::RestartSavedState,
|
|
|_, deadline| {
|
|
assert_eq!(deadline.started_at(), started_at);
|
|
assert_eq!(deadline.timeout(), Duration::from_secs(30));
|
|
RestartExecution {
|
|
outcome: RestartOutcome::Succeeded,
|
|
submitted: true,
|
|
phase: crate::service::RestartPhase::WaitForJob,
|
|
}
|
|
},
|
|
|_, remaining| {
|
|
assert_eq!(remaining, Duration::from_secs(30));
|
|
Ok(observation)
|
|
},
|
|
|store, saved, backup| {
|
|
install_called.set(true);
|
|
store.install(saved, backup)
|
|
},
|
|
|| {
|
|
let index = clock_index.get();
|
|
clock_index.set(index + 1);
|
|
clock_values[index]
|
|
},
|
|
);
|
|
|
|
assert_eq!(report.stage, RestartStage::Verification);
|
|
assert_eq!(report.failure, Some(RestartFailureKind::RuntimeTimeout));
|
|
assert!(report.backup_path.is_some());
|
|
assert!(report.restart_submitted);
|
|
assert!(!report.restart_may_still_complete);
|
|
assert!(report.manual_recovery_required);
|
|
assert!(!install_called.get());
|
|
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
|
assert!(!app.states().reserved_addresses().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_transport_failures_remain_distinct_in_public_reports() {
|
|
let cases = [
|
|
(
|
|
TransportError::NoDevice,
|
|
RestartFailureKind::RuntimeNoDevice,
|
|
),
|
|
(
|
|
TransportError::PermissionDenied,
|
|
RestartFailureKind::RuntimePermissionDenied,
|
|
),
|
|
(
|
|
TransportError::Malformed,
|
|
RestartFailureKind::RuntimeMalformed,
|
|
),
|
|
(
|
|
TransportError::Transport,
|
|
RestartFailureKind::RuntimeTransport,
|
|
),
|
|
(TransportError::Timeout, RestartFailureKind::RuntimeTimeout),
|
|
(
|
|
TransportError::LimitExceeded,
|
|
RestartFailureKind::RuntimeLimitExceeded,
|
|
),
|
|
];
|
|
|
|
for (error, expected) in cases {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (mut app, _) = prepare_restartable(root.path());
|
|
let preview = app.preview_restart().expect("preview saved generation");
|
|
let install_called = std::cell::Cell::new(false);
|
|
let report = app.restart_with_adapters(
|
|
preview,
|
|
RestartChoice::RestartSavedState,
|
|
|_, _| RestartExecution {
|
|
outcome: RestartOutcome::Succeeded,
|
|
submitted: true,
|
|
phase: crate::service::RestartPhase::WaitForJob,
|
|
},
|
|
|_, _| Err(error),
|
|
|store, saved, backup| {
|
|
install_called.set(true);
|
|
store.install(saved, backup)
|
|
},
|
|
);
|
|
|
|
assert_eq!(report.stage, RestartStage::RuntimeRead);
|
|
assert_eq!(report.failure, Some(expected));
|
|
assert!(report.restart_submitted);
|
|
assert!(!report.restart_may_still_complete);
|
|
assert!(report.manual_recovery_required);
|
|
assert!(!install_called.get());
|
|
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn submitted_terminal_job_failures_cannot_still_complete() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (mut app, _) = prepare_restartable(root.path());
|
|
|
|
for failure in [
|
|
RestartFailure::JobFailed,
|
|
RestartFailure::JobDependency,
|
|
RestartFailure::JobTimedOut,
|
|
RestartFailure::JobCanceled,
|
|
RestartFailure::JobSkipped,
|
|
RestartFailure::JobResultUnknown,
|
|
] {
|
|
let preview = app.preview_restart().expect("preview saved generation");
|
|
let report = app.restart_with_adapters(
|
|
preview,
|
|
RestartChoice::RestartSavedState,
|
|
|_, _| RestartExecution {
|
|
outcome: RestartOutcome::Failed(failure),
|
|
submitted: true,
|
|
phase: crate::service::RestartPhase::WaitForJob,
|
|
},
|
|
|_, _| unreachable!("terminal service failure must stop before runtime read"),
|
|
|_, _, _| unreachable!("terminal service failure must stop before persistence"),
|
|
);
|
|
|
|
assert_eq!(report.stage, RestartStage::Service);
|
|
assert!(report.restart_submitted);
|
|
assert!(!report.restart_may_still_complete);
|
|
assert!(report.manual_recovery_required);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn application_report_preserves_every_service_failure_and_operation_phase() {
|
|
use crate::service::RestartPhase;
|
|
|
|
let cases = [
|
|
(RestartFailure::JobFailed, RestartPhase::WaitForJob, true),
|
|
(
|
|
RestartFailure::JobDependency,
|
|
RestartPhase::WaitForJob,
|
|
true,
|
|
),
|
|
(RestartFailure::JobTimedOut, RestartPhase::WaitForJob, true),
|
|
(RestartFailure::JobCanceled, RestartPhase::WaitForJob, true),
|
|
(RestartFailure::JobSkipped, RestartPhase::WaitForJob, true),
|
|
(
|
|
RestartFailure::JobResultUnknown,
|
|
RestartPhase::WaitForJob,
|
|
true,
|
|
),
|
|
(
|
|
RestartFailure::BusDisconnected,
|
|
RestartPhase::Connect,
|
|
false,
|
|
),
|
|
(
|
|
RestartFailure::ManagerOwnerChanged,
|
|
RestartPhase::WaitForJob,
|
|
true,
|
|
),
|
|
(
|
|
RestartFailure::ManagerOwnerChanged,
|
|
RestartPhase::RequestRestart,
|
|
true,
|
|
),
|
|
(
|
|
RestartFailure::SubscriptionFailed,
|
|
RestartPhase::Subscribe,
|
|
false,
|
|
),
|
|
(
|
|
RestartFailure::UnitResolutionFailed,
|
|
RestartPhase::ResolveUnit,
|
|
false,
|
|
),
|
|
(
|
|
RestartFailure::PermissionDenied,
|
|
RestartPhase::ResolveUnit,
|
|
false,
|
|
),
|
|
(
|
|
RestartFailure::RequestFailed,
|
|
RestartPhase::RequestRestart,
|
|
false,
|
|
),
|
|
(
|
|
RestartFailure::WaitTimedOutMayStillComplete,
|
|
RestartPhase::WaitForJob,
|
|
true,
|
|
),
|
|
(
|
|
RestartFailure::EarlyEventBufferFull,
|
|
RestartPhase::WaitForJob,
|
|
true,
|
|
),
|
|
(
|
|
RestartFailure::InvalidJobPath,
|
|
RestartPhase::WaitForJob,
|
|
true,
|
|
),
|
|
];
|
|
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (mut app, _) = prepare_restartable(root.path());
|
|
for (failure, phase, submitted) in cases {
|
|
let preview = app.preview_restart().expect("preview saved generation");
|
|
let report = app.restart_with_adapters(
|
|
preview,
|
|
RestartChoice::RestartSavedState,
|
|
|_, _| RestartExecution {
|
|
outcome: RestartOutcome::Failed(failure),
|
|
submitted,
|
|
phase,
|
|
},
|
|
|_, _| unreachable!("service failure must stop before runtime read"),
|
|
|_, _, _| unreachable!("service failure must stop before persistence"),
|
|
);
|
|
assert_eq!(report.failure, Some(RestartFailureKind::ServiceFailed));
|
|
assert_eq!(report.service_failure, Some(failure));
|
|
assert_eq!(report.service_phase, Some(phase));
|
|
assert_eq!(report.systemd_unit.as_deref(), Some("wg-quick@wg0.service"));
|
|
assert_eq!(report.restart_submitted, submitted);
|
|
if failure == RestartFailure::ManagerOwnerChanged
|
|
&& phase == RestartPhase::RequestRestart
|
|
{
|
|
assert!(report.restart_may_still_complete);
|
|
assert!(report.manual_recovery_required);
|
|
}
|
|
assert_eq!(
|
|
report.restart_may_still_complete,
|
|
submitted
|
|
&& !matches!(
|
|
failure,
|
|
RestartFailure::JobFailed
|
|
| RestartFailure::JobDependency
|
|
| RestartFailure::JobTimedOut
|
|
| RestartFailure::JobCanceled
|
|
| RestartFailure::JobSkipped
|
|
| RestartFailure::JobResultUnknown
|
|
)
|
|
);
|
|
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn restart_mismatch_and_post_restart_write_failure_never_release_addresses() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (mut app, key) = prepare_restartable(root.path());
|
|
app.delete_client(key).expect("delete saved peer in draft");
|
|
app.commit().expect("save deletion and reservation");
|
|
assert!(!app.states().reserved_addresses().is_empty());
|
|
let preview = app.preview_restart().expect("preview empty saved peer set");
|
|
let mismatch = app.restart_with_adapters(
|
|
preview,
|
|
RestartChoice::RestartSavedState,
|
|
|_, _| RestartExecution {
|
|
outcome: RestartOutcome::Succeeded,
|
|
submitted: true,
|
|
phase: crate::service::RestartPhase::WaitForJob,
|
|
},
|
|
|_, _| {
|
|
Ok(RuntimeObservation {
|
|
observed_at: None,
|
|
interface: InterfaceObservation::Missing,
|
|
})
|
|
},
|
|
|store, saved, backup| store.install(saved, backup),
|
|
);
|
|
assert_eq!(mismatch.stage, RestartStage::Verification);
|
|
assert!(!app.states().reserved_addresses().is_empty());
|
|
|
|
let preview = app.preview_restart().expect("preview remains available");
|
|
let observation = observed_runtime(&app);
|
|
let failed_write = app.restart_with_adapters(
|
|
preview,
|
|
RestartChoice::RestartSavedState,
|
|
|_, _| RestartExecution {
|
|
outcome: RestartOutcome::Succeeded,
|
|
submitted: true,
|
|
phase: crate::service::RestartPhase::WaitForJob,
|
|
},
|
|
|_, _| Ok(observation),
|
|
|_, _, _| Err(PersistenceError::RecoveryRequired),
|
|
);
|
|
assert_eq!(failed_write.stage, RestartStage::Persistence);
|
|
assert!(failed_write.manual_recovery_required);
|
|
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
|
assert!(!app.states().reserved_addresses().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn restart_rejects_unsafe_saveconfig_before_backup_or_submission() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (mut app, key) = prepare_restartable(root.path());
|
|
let mut saved = clone_saved(app.saved_bytes());
|
|
let server = String::from_utf8(saved.server_config.clone()).expect("synthetic config");
|
|
saved.server_config = server
|
|
.replace(
|
|
"Address = 10.20.0.1/24",
|
|
"Address = 10.20.0.1/24\nSaveConfig = true",
|
|
)
|
|
.into_bytes();
|
|
app.store
|
|
.install(&saved, BackupComplete(()))
|
|
.expect("install synthetic unsafe generation");
|
|
let reopened_store = app.store.clone();
|
|
let states = app.draft.states.clone();
|
|
app = Application::open(reopened_store, states).expect("reopen fixture");
|
|
app.rename_client(key, "draft")
|
|
.expect("stage a draft for preservation");
|
|
assert!(matches!(
|
|
app.preview_restart(),
|
|
Err(AppError::UnsafeSaveConfig)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn restart_confirmation_rejects_a_generation_changed_after_preview() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (mut app, _) = prepare_restartable(root.path());
|
|
let preview = app.preview_restart().expect("preview saved generation");
|
|
let mut externally_replaced = clone_saved(app.saved_bytes());
|
|
let settings =
|
|
String::from_utf8(externally_replaced.settings.clone()).expect("synthetic settings");
|
|
externally_replaced.settings = settings
|
|
.replace("wg-quick@wg0.service", "wg-quick@wg1.service")
|
|
.into_bytes();
|
|
app.store
|
|
.install(&externally_replaced, BackupComplete(()))
|
|
.expect("install changed synthetic generation");
|
|
let called = std::cell::Cell::new(false);
|
|
let report = app.restart_with_adapters(
|
|
preview,
|
|
RestartChoice::RestartSavedState,
|
|
|_, _| {
|
|
called.set(true);
|
|
RestartExecution {
|
|
outcome: RestartOutcome::Succeeded,
|
|
submitted: true,
|
|
phase: crate::service::RestartPhase::WaitForJob,
|
|
}
|
|
},
|
|
|_, _| Err(TransportError::Transport),
|
|
|_, _, _| Ok(()),
|
|
);
|
|
assert_eq!(report.stage, RestartStage::Preparation);
|
|
assert_eq!(
|
|
report.failure,
|
|
Some(RestartFailureKind::InvalidOrStaleSavedGeneration)
|
|
);
|
|
assert!(!called.get());
|
|
assert!(report.backup_path.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn verified_history_persists_across_reopen_without_marking_runtime_applied() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (store, states, _, _) = setup(root.path(), false);
|
|
let mut app = Application::open(store.clone(), states.clone()).expect("application opens");
|
|
let verified_at = "2026-10-20T08:15:00Z";
|
|
let observed = app.states().saved().clone();
|
|
test_mark_verified(&mut app, observed, verified_at.to_owned())
|
|
.expect("verified generation persists");
|
|
|
|
let saved = store.read_saved().expect("verified saved bytes");
|
|
assert_eq!(
|
|
PersistedState::from_toml(saved.internal_state.as_deref().unwrap())
|
|
.unwrap()
|
|
.last_verified_at_utc
|
|
.as_deref(),
|
|
Some(verified_at)
|
|
);
|
|
let reopened = Application::open(store, states).expect("application reopens");
|
|
assert_eq!(
|
|
reopened.historical_apply,
|
|
HistoricalApplyStatus::Verified {
|
|
verified_at_utc: verified_at.to_owned()
|
|
}
|
|
);
|
|
assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown);
|
|
}
|
|
|
|
#[test]
|
|
fn open_does_not_treat_caller_seeded_verified_state_as_runtime_evidence() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (store, seeded, _, _) = setup(root.path(), false);
|
|
assert_eq!(seeded.applied(), &ApplyStatus::Verified);
|
|
let app = Application::open(store, seeded).expect("application opens");
|
|
|
|
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
|
assert!(app.states().applied_snapshot().is_some());
|
|
assert!(!app.saved_generation_is_verified_applied(app.saved_bytes(), app.document()));
|
|
}
|
|
|
|
#[test]
|
|
fn failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (store, states, _, _) = setup(root.path(), false);
|
|
let mut app = Application::open(store.clone(), states.clone()).expect("application opens");
|
|
let before = store.read_saved().expect("original saved bytes");
|
|
let observed = app.states().saved().clone();
|
|
app.create_pre_restart_backup()
|
|
.expect("pre-restart backup completes");
|
|
let id = app.next_restart_attempt;
|
|
app.next_restart_attempt += 1;
|
|
app.active_restart_attempt = Some(id);
|
|
let attempt = super::RestartAttempt {
|
|
id,
|
|
saved: clone_saved(&app.saved),
|
|
};
|
|
let result = app.mark_verified_with_attempt(
|
|
attempt,
|
|
Some(observed),
|
|
"2026-10-20T08:30:00Z".to_owned(),
|
|
|store, saved, _backup| {
|
|
store.install_with_hook(saved, |point| {
|
|
if point == InstallCheckpoint::Replaced(3) {
|
|
Err(std::io::Error::other("history-state install interruption"))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
})
|
|
},
|
|
);
|
|
assert!(matches!(
|
|
result,
|
|
Err(AppError::Persistence(PersistenceError::RecoveryRequired))
|
|
));
|
|
assert_eq!(app.historical_apply, HistoricalApplyStatus::Unknown);
|
|
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
|
assert_eq!(app.saved_bytes().internal_state, before.internal_state);
|
|
|
|
let resolved = SettingsFile::parse_toml(SETTINGS)
|
|
.unwrap()
|
|
.validate()
|
|
.unwrap()
|
|
.resolve_paths(root.path());
|
|
let reopened_store = SavedStore::new(root.path(), resolved).expect("store reopens");
|
|
let recovered = reopened_store
|
|
.read_saved()
|
|
.expect("interrupted history write rolls back");
|
|
assert_eq!(recovered.internal_state, before.internal_state);
|
|
let reopened = Application::open(reopened_store, states).expect("application reopens");
|
|
assert_eq!(reopened.historical_apply, HistoricalApplyStatus::Unknown);
|
|
assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown);
|
|
}
|
|
|
|
#[test]
|
|
fn precommit_archive_records_verified_installed_generation_and_success_clears_it() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (store, states, key, _) = setup(root.path(), false);
|
|
let mut app = Application::open(store.clone(), states.clone()).expect("application opens");
|
|
let verified_at = "2026-10-20T09:00:00Z";
|
|
let observed = app.states().saved().clone();
|
|
test_mark_verified(&mut app, observed, verified_at.to_owned())
|
|
.expect("verified generation persists");
|
|
app.rename_client(key, "next saved alias")
|
|
.expect("draft alias");
|
|
let archive = app.commit().expect("commit succeeds");
|
|
let archived = preview_archive(&archive).expect("pre-commit archive is valid");
|
|
assert_eq!(
|
|
archived.historical_apply,
|
|
HistoricalApplyStatus::Verified {
|
|
verified_at_utc: verified_at.to_owned()
|
|
}
|
|
);
|
|
assert_eq!(app.historical_apply, HistoricalApplyStatus::Unknown);
|
|
assert_eq!(
|
|
PersistedState::from_toml(
|
|
store
|
|
.read_saved()
|
|
.unwrap()
|
|
.internal_state
|
|
.as_deref()
|
|
.unwrap()
|
|
)
|
|
.unwrap()
|
|
.last_verified_at_utc,
|
|
None
|
|
);
|
|
let reopened = Application::open(store, states).expect("committed state reopens");
|
|
assert_eq!(reopened.historical_apply, HistoricalApplyStatus::Unknown);
|
|
assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown);
|
|
}
|
|
|
|
#[test]
|
|
fn failed_commit_retains_verified_history_and_retryable_draft() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (store, states, key, _) = setup(root.path(), false);
|
|
let mut app = Application::open(store.clone(), states).expect("application opens");
|
|
let verified_at = "2026-10-20T10:00:00Z";
|
|
let observed = app.states().saved().clone();
|
|
test_mark_verified(&mut app, observed, verified_at.to_owned())
|
|
.expect("verified generation persists");
|
|
let before = store.read_saved().expect("verified saved bytes");
|
|
app.rename_client(key, "retryable draft")
|
|
.expect("draft alias");
|
|
|
|
let archives = store.archives_directory().to_path_buf();
|
|
fs::remove_dir_all(&archives).expect("remove synthetic archive directory");
|
|
fs::write(&archives, b"block commit backup").expect("install backup blocker");
|
|
assert!(matches!(app.commit(), Err(AppError::Backup(_))));
|
|
let persisted = PersistedState::from_toml(before.internal_state.as_deref().unwrap())
|
|
.expect("prior state parses");
|
|
assert_eq!(persisted.last_verified_at_utc.as_deref(), Some(verified_at));
|
|
assert_eq!(
|
|
app.historical_apply,
|
|
HistoricalApplyStatus::Verified {
|
|
verified_at_utc: verified_at.to_owned()
|
|
}
|
|
);
|
|
assert_eq!(app.saved_bytes().server_config, before.server_config);
|
|
assert_eq!(
|
|
app.states().draft().get(&key).unwrap().alias,
|
|
"retryable draft"
|
|
);
|
|
assert_eq!(app.states().saved().get(&key).unwrap().alias, "legacy-view");
|
|
}
|
|
|
|
#[test]
|
|
fn successful_restore_clears_verified_current_history_but_backup_retains_it() {
|
|
let root = tempfile::tempdir().expect("temp directory");
|
|
let (store, states, _, _) = setup(root.path(), false);
|
|
let mut app = Application::open(store.clone(), states.clone()).expect("application opens");
|
|
let selected = app.create_manual_backup().expect("select initial archive");
|
|
let verified_at = "2026-10-20T11:00:00Z";
|
|
let observed = app.states().saved().clone();
|
|
test_mark_verified(&mut app, observed, verified_at.to_owned())
|
|
.expect("verified generation persists");
|
|
let preview = app
|
|
.preview_restore(selected.file_name().unwrap().to_str().unwrap())
|
|
.expect("restore preview validates");
|
|
let pre_restore = app.confirm_restore(preview).expect("restore succeeds");
|
|
|
|
assert_eq!(app.historical_apply, HistoricalApplyStatus::Unknown);
|
|
assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
|
|
let archived = preview_archive(&pre_restore).expect("pre-restore backup validates");
|
|
assert_eq!(
|
|
archived.historical_apply,
|
|
HistoricalApplyStatus::Verified {
|
|
verified_at_utc: verified_at.to_owned()
|
|
}
|
|
);
|
|
let restored = store.read_saved().expect("restored saved bytes parse");
|
|
assert!(
|
|
restored.internal_state.is_none(),
|
|
"the selected archive had no state and restored routes need no reservation"
|
|
);
|
|
let restored_document = ConfigDocument::parse(&restored.server_config).unwrap();
|
|
let reopened_states = ClientStates::new(
|
|
snapshot_from_document(&restored_document),
|
|
None,
|
|
ApplyStatus::Unknown,
|
|
)
|
|
.unwrap();
|
|
let reopened = Application::open(store, reopened_states).expect("restored state reopens");
|
|
assert_eq!(reopened.historical_apply, HistoricalApplyStatus::Unknown);
|
|
assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown);
|
|
}
|
|
}
|