Implement transactional draft editing and client creation vertical slice
This commit is contained in:
+244
-130
@@ -4,6 +4,10 @@
|
||||
//! encrypted vault data, backups, the recoverable saved-file adapter, and the
|
||||
//! explicit runtime-restart confirmation boundary.
|
||||
|
||||
mod draft;
|
||||
|
||||
use draft::DraftChanges;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt;
|
||||
@@ -78,9 +82,7 @@ pub struct SessionDisplay {
|
||||
pub struct Application {
|
||||
store: SavedStore,
|
||||
saved: SavedBytes,
|
||||
document: ConfigDocument,
|
||||
states: ClientStates,
|
||||
vault: Option<VaultBundle>,
|
||||
draft: DraftChanges,
|
||||
historical_apply: HistoricalApplyStatus,
|
||||
verified_saved_projection: Option<SavedPeerProjection>,
|
||||
active_restart_attempt: Option<u64>,
|
||||
@@ -466,9 +468,9 @@ impl fmt::Debug for Application {
|
||||
formatter
|
||||
.debug_struct("Application")
|
||||
.field("saved", &self.saved)
|
||||
.field("document", &self.document)
|
||||
.field("states", &self.states)
|
||||
.field("vault_present", &self.vault.is_some())
|
||||
.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",
|
||||
@@ -477,6 +479,7 @@ impl fmt::Debug for Application {
|
||||
.field(
|
||||
"pending_reservation_count",
|
||||
&self
|
||||
.draft
|
||||
.states
|
||||
.address_reservations()
|
||||
.pending_host_routes()
|
||||
@@ -531,9 +534,7 @@ impl Application {
|
||||
Ok(Self {
|
||||
store,
|
||||
saved,
|
||||
document,
|
||||
states,
|
||||
vault,
|
||||
draft: DraftChanges::new(document, states, vault),
|
||||
historical_apply,
|
||||
verified_saved_projection: None,
|
||||
active_restart_attempt: None,
|
||||
@@ -548,24 +549,25 @@ impl Application {
|
||||
}
|
||||
|
||||
pub fn document(&self) -> &ConfigDocument {
|
||||
&self.document
|
||||
&self.draft.document
|
||||
}
|
||||
|
||||
pub fn states(&self) -> &ClientStates {
|
||||
&self.states
|
||||
&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.document.project_client(&identity).ok());
|
||||
.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())
|
||||
@@ -588,14 +590,14 @@ impl Application {
|
||||
has_subnet_routes: peer.has_subnet_routes,
|
||||
issues: peer.issues.clone(),
|
||||
mutable_identity: projected
|
||||
.filter(|client| self.states.draft().get(&client.identity).is_some())
|
||||
.filter(|client| self.draft.states.draft().get(&client.identity).is_some())
|
||||
.map(|client| client.identity),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
SessionDisplay {
|
||||
clients,
|
||||
document_issues: self.document.issues().to_vec(),
|
||||
document_issues: self.draft.document.issues().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,10 +608,18 @@ impl Application {
|
||||
client: Client,
|
||||
created_utc: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
let edited = self.document.add_client(&client, created_utc)?;
|
||||
self.states.draft_mut().insert(client)?;
|
||||
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();
|
||||
self.document = edited;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -628,14 +638,20 @@ impl Application {
|
||||
let key = WireGuardPrivateKey::generate().map_err(|_| AppError::KeyGenerationFailed)?;
|
||||
let identity = key.public_key();
|
||||
|
||||
let server_addresses = server_interface_addresses(&self.document)?;
|
||||
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.document));
|
||||
let reserved = self.states.address_reservations().all().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,
|
||||
@@ -668,10 +684,11 @@ impl Application {
|
||||
// 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 mut next_document = self.document.add_client(&client, created_utc)?;
|
||||
let mut next_states = self.states.clone();
|
||||
next_states.draft_mut().insert(client)?;
|
||||
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)?;
|
||||
@@ -680,12 +697,14 @@ impl Application {
|
||||
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.
|
||||
next_document = ConfigDocument::parse(next_document.render())?;
|
||||
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.document = next_document;
|
||||
self.states = next_states;
|
||||
self.vault = Some(next_vault);
|
||||
self.draft.publish_replacement(replacement);
|
||||
self.initial_issues.insert(
|
||||
identity,
|
||||
InitialIssue {
|
||||
@@ -977,7 +996,7 @@ impl Application {
|
||||
let Ok((projection, _)) = restart_projection_from_document(document) else {
|
||||
return false;
|
||||
};
|
||||
self.states.applied() == &ApplyStatus::Verified
|
||||
self.draft.states.applied() == &ApplyStatus::Verified
|
||||
&& self.verified_saved_projection.as_ref() == Some(&projection)
|
||||
}
|
||||
|
||||
@@ -1213,13 +1232,13 @@ impl Application {
|
||||
}
|
||||
|
||||
pub fn vault(&self) -> Option<&VaultBundle> {
|
||||
self.vault.as_ref()
|
||||
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.vault.is_some()
|
||||
self.draft.vault.is_some()
|
||||
}
|
||||
|
||||
/// Whether the in-memory client/document/vault draft differs from saved.
|
||||
@@ -1235,24 +1254,44 @@ impl Application {
|
||||
selected_name: &OsStr,
|
||||
) -> Result<SelectedFileImportReport, AppError> {
|
||||
let settings = self.store.resolved_settings_for(&self.saved)?;
|
||||
let vault = self
|
||||
let existing_vault = self
|
||||
.draft
|
||||
.vault
|
||||
.as_mut()
|
||||
.as_ref()
|
||||
.ok_or(AppError::VaultMissingForCreation)?;
|
||||
crate::vault::source::import_selected_file(vault, &settings, &self.document, selected_name)
|
||||
.map_err(AppError::SourceImport)
|
||||
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 = self.document.rename_client(&identity, alias)?;
|
||||
if let Some(client) = self.states.draft().get(&identity).cloned() {
|
||||
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)?;
|
||||
self.states.draft_mut().insert(replacement)?;
|
||||
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();
|
||||
self.document = edited;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1264,8 +1303,8 @@ impl Application {
|
||||
.peers()
|
||||
.iter()
|
||||
.any(|peer| peer.identity == Some(identity));
|
||||
let mut next_states = self.states.clone();
|
||||
if saved_peer_exists && self.states.saved().get(&identity).is_none() {
|
||||
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()
|
||||
@@ -1275,33 +1314,42 @@ impl Application {
|
||||
.stage_exact_host_routes(peer.allowed_ips.iter().copied())
|
||||
.map_err(|_| AppError::DeletedPeerAddressUnknown)?;
|
||||
}
|
||||
let edited = self.document.delete_client(&identity)?;
|
||||
let removed_record = match self.vault.as_mut() {
|
||||
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,
|
||||
};
|
||||
next_states.draft_mut().remove(&identity);
|
||||
self.states = next_states;
|
||||
self.document = edited;
|
||||
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)
|
||||
}
|
||||
|
||||
/// Edit the in-memory vault draft without exposing or replacing its identity.
|
||||
/// The next commit validates that an installed identity is not changed.
|
||||
pub fn vault_draft_mut(&mut self) -> Option<&mut VaultBundle> {
|
||||
self.vault.as_mut()
|
||||
}
|
||||
|
||||
/// 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.vault.is_some() || self.saved.vault.is_some() {
|
||||
if self.draft.vault.is_some() || self.saved.vault.is_some() {
|
||||
return Err(AppError::VaultAlreadyInstalled);
|
||||
}
|
||||
bundle.to_toml()?;
|
||||
self.vault = Some(bundle);
|
||||
let replacement = DraftChanges::stage_vault(Some(bundle));
|
||||
self.draft.publish_replacement(replacement);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1324,9 +1372,12 @@ impl Application {
|
||||
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.document, vault_password)
|
||||
.map_err(AppError::SourceImport)?;
|
||||
let prepared = crate::vault::source::prepare_initial_setup(
|
||||
&settings,
|
||||
&self.draft.document,
|
||||
vault_password,
|
||||
)
|
||||
.map_err(AppError::SourceImport)?;
|
||||
self.prepare_initial_setup(prepared)
|
||||
}
|
||||
|
||||
@@ -1334,13 +1385,8 @@ impl Application {
|
||||
/// successfully installed session snapshot. No files are written.
|
||||
pub fn discard_draft(&mut self) -> Result<(), AppError> {
|
||||
let installed = self.store.read_saved()?;
|
||||
if !same_saved_bytes(&self.saved, &installed) {
|
||||
self.verified_saved_projection = None;
|
||||
}
|
||||
self.saved = installed;
|
||||
self.document = ConfigDocument::parse(&self.saved.server_config)?;
|
||||
self.vault = self
|
||||
.saved
|
||||
let next_document = ConfigDocument::parse(&installed.server_config)?;
|
||||
let next_vault = installed
|
||||
.vault
|
||||
.as_deref()
|
||||
.map(std::str::from_utf8)
|
||||
@@ -1348,19 +1394,29 @@ impl Application {
|
||||
.map_err(|_| AppError::InvalidSavedState)?
|
||||
.map(VaultBundle::from_toml)
|
||||
.transpose()?;
|
||||
self.states.discard_draft();
|
||||
if let Some(bytes) = &self.saved.internal_state {
|
||||
PersistedState::from_toml(bytes)?.apply_to(&mut self.states)?;
|
||||
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 {
|
||||
self.states.runtime_knowledge_unknown();
|
||||
next_states.runtime_knowledge_unknown();
|
||||
}
|
||||
self.historical_apply = historical_apply_from_saved(&self.saved)?;
|
||||
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.document,
|
||||
self.vault.as_ref(),
|
||||
&self.draft.document,
|
||||
self.draft.vault.as_ref(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1421,7 +1477,8 @@ impl Application {
|
||||
}
|
||||
let (projection, expected_clients) =
|
||||
restart_projection_from_document(&document).map_err(AppError::UnsafeSavedPeers)?;
|
||||
let expected_clients = expected_clients.filter(|snapshot| snapshot == self.states.saved());
|
||||
let expected_clients =
|
||||
expected_clients.filter(|snapshot| snapshot == self.draft.states.saved());
|
||||
Ok(RestartPreview {
|
||||
saved: clone_saved(&installed),
|
||||
interface: settings.interface_name,
|
||||
@@ -1434,13 +1491,14 @@ impl Application {
|
||||
}
|
||||
|
||||
fn has_draft(&self) -> bool {
|
||||
let vault_differs = match &self.vault {
|
||||
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.states.draft() != self.states.saved()
|
||||
|| self.document.render() != self.saved.server_config
|
||||
self.draft.states.draft() != self.draft.states.saved()
|
||||
|| self.draft.document.render() != self.saved.server_config
|
||||
|| self
|
||||
.draft
|
||||
.states
|
||||
.address_reservations()
|
||||
.pending_host_routes()
|
||||
@@ -1622,7 +1680,8 @@ impl Application {
|
||||
);
|
||||
}
|
||||
};
|
||||
let expected_clients = expected_clients.filter(|snapshot| snapshot == self.states.saved());
|
||||
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
|
||||
@@ -1696,7 +1755,7 @@ impl Application {
|
||||
);
|
||||
}
|
||||
|
||||
self.states.runtime_knowledge_unknown();
|
||||
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;
|
||||
@@ -1847,12 +1906,13 @@ impl Application {
|
||||
let snapshot = snapshot_from_document(&document);
|
||||
let mut restored_states = ClientStates::new(snapshot, None, ApplyStatus::Unknown)?;
|
||||
let mut archived_reservations = BTreeSet::new();
|
||||
let mut reservations = self.states.reserved_addresses().clone();
|
||||
let mut reservations = self.draft.states.reserved_addresses().clone();
|
||||
// A restore can remove clients that are currently saved even when their
|
||||
// addresses were not yet in the persisted reservation set. Runtime state
|
||||
// remains unchanged, so carry those addresses across the replacement.
|
||||
reservations.extend(
|
||||
self.states
|
||||
self.draft
|
||||
.states
|
||||
.saved()
|
||||
.clients()
|
||||
.flat_map(|client| client.addresses.iter()),
|
||||
@@ -1938,9 +1998,9 @@ impl Application {
|
||||
}
|
||||
self.saved = preview.saved_bytes;
|
||||
self.store.set_resolved_settings(resolved);
|
||||
self.document = document;
|
||||
self.vault = vault;
|
||||
self.states = states;
|
||||
self.draft.document = document;
|
||||
self.draft.vault = vault;
|
||||
self.draft.states = states;
|
||||
self.initial_issues.clear();
|
||||
self.historical_apply = HistoricalApplyStatus::Unknown;
|
||||
self.verified_saved_projection = None;
|
||||
@@ -1966,7 +2026,7 @@ impl Application {
|
||||
{
|
||||
return Err(AppError::SavedGenerationChanged);
|
||||
}
|
||||
let mut next_states = self.states.clone();
|
||||
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)?,
|
||||
@@ -2015,8 +2075,9 @@ impl Application {
|
||||
return Err(error.into());
|
||||
}
|
||||
self.saved = proposed;
|
||||
self.states = next_states;
|
||||
self.verified_saved_projection = Some(SavedPeerProjection::from_document(&self.document));
|
||||
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(())
|
||||
@@ -2047,6 +2108,7 @@ impl Application {
|
||||
std::str::from_utf8(existing_bytes).map_err(|_| AppError::InvalidSavedState)?;
|
||||
let existing = VaultBundle::from_toml(existing_text)?;
|
||||
if self
|
||||
.draft
|
||||
.vault
|
||||
.as_ref()
|
||||
.is_none_or(|vault| existing.recipient() != vault.recipient())
|
||||
@@ -2055,7 +2117,7 @@ impl Application {
|
||||
}
|
||||
}
|
||||
|
||||
let mut next_states = self.states.clone();
|
||||
let mut next_states = self.draft.states.clone();
|
||||
next_states.commit_draft();
|
||||
let reservations = next_states
|
||||
.reserved_addresses()
|
||||
@@ -2064,6 +2126,7 @@ impl Application {
|
||||
.collect::<Vec<_>>();
|
||||
let internal_state = Some(PersistedState::new(reservations, None)?.to_toml()?);
|
||||
let vault_bytes = self
|
||||
.draft
|
||||
.vault
|
||||
.as_ref()
|
||||
.map(VaultBundle::to_toml)
|
||||
@@ -2071,7 +2134,7 @@ impl Application {
|
||||
.map(String::into_bytes);
|
||||
let proposed = SavedBytes {
|
||||
settings: installed.settings.clone(),
|
||||
server_config: self.document.render().to_vec(),
|
||||
server_config: self.draft.document.render().to_vec(),
|
||||
internal_state,
|
||||
vault: vault_bytes,
|
||||
};
|
||||
@@ -2108,13 +2171,13 @@ impl Application {
|
||||
}
|
||||
|
||||
self.saved = proposed;
|
||||
self.states = next_states;
|
||||
self.document = ConfigDocument::parse(&self.saved.server_config)?;
|
||||
self.draft.states = next_states;
|
||||
self.draft.document = ConfigDocument::parse(&self.saved.server_config)?;
|
||||
self.invalidate_active_issue_authorizations();
|
||||
promote_matching_issue_map(
|
||||
&mut self.initial_issues,
|
||||
&self.document,
|
||||
self.vault.as_ref(),
|
||||
&self.draft.document,
|
||||
self.draft.vault.as_ref(),
|
||||
);
|
||||
self.historical_apply = HistoricalApplyStatus::Unknown;
|
||||
self.verified_saved_projection = None;
|
||||
@@ -2415,6 +2478,13 @@ fn snapshot_from_document(document: &ConfigDocument) -> Snapshot {
|
||||
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")]
|
||||
@@ -2476,6 +2546,11 @@ mod tests {
|
||||
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;
|
||||
@@ -2492,13 +2567,6 @@ mod tests {
|
||||
use crate::service::{RestartExecution, RestartFailure, RestartOutcome};
|
||||
use crate::vault::{VaultBundle, WireGuardPrivateKey};
|
||||
use crate::wireguard::ConfigDocument;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::{
|
||||
AppError, Application, FileOutputError, FirstIssueAttempt, FirstIssueDeliveryFailure,
|
||||
FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartFailureKind,
|
||||
RestartStage, clone_saved, snapshot_from_document,
|
||||
};
|
||||
|
||||
const SETTINGS: &str = r#"
|
||||
interface_name = "wg0"
|
||||
@@ -2687,6 +2755,25 @@ restart_timeout_seconds = 30
|
||||
));
|
||||
}
|
||||
|
||||
#[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");
|
||||
@@ -2717,6 +2804,36 @@ restart_timeout_seconds = 30
|
||||
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");
|
||||
@@ -3471,37 +3588,6 @@ restart_timeout_seconds = 30
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_the_encrypted_record_invalidates_committed_first_issue() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
let mut app = setup_with_vault(root.path());
|
||||
let identity = app
|
||||
.prepare_new_client("replacement", None, None)
|
||||
.expect("client stages");
|
||||
app.commit().expect("client commits");
|
||||
let replacement_bytes = Zeroizing::new(
|
||||
app.initial_issues
|
||||
.get(&identity)
|
||||
.expect("session key retained")
|
||||
.private_key
|
||||
.to_base64(),
|
||||
);
|
||||
let replacement_key = WireGuardPrivateKey::parse_base64(&replacement_bytes)
|
||||
.expect("same synthetic key parses");
|
||||
let vault = app.vault_draft_mut().expect("vault exists");
|
||||
assert!(vault.remove_record(identity).expect("record removed"));
|
||||
vault
|
||||
.encrypt_record(identity, &replacement_key)
|
||||
.expect("replacement record encrypted");
|
||||
app.commit().expect("replacement commits");
|
||||
assert!(matches!(
|
||||
app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>(
|
||||
()
|
||||
)),
|
||||
Err(AppError::FirstIssueUnavailable)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_and_discard_revoke_only_session_bound_initial_material() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
@@ -3677,7 +3763,7 @@ restart_timeout_seconds = 30
|
||||
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.states.runtime_knowledge_unavailable();
|
||||
app.draft.states.runtime_knowledge_unavailable();
|
||||
|
||||
app.commit().expect("unchanged commit completes");
|
||||
|
||||
@@ -3724,6 +3810,34 @@ restart_timeout_seconds = 30
|
||||
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");
|
||||
@@ -4925,7 +5039,7 @@ restart_timeout_seconds = 30
|
||||
.install(&saved, BackupComplete(()))
|
||||
.expect("install synthetic unsafe generation");
|
||||
let reopened_store = app.store.clone();
|
||||
let states = app.states.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");
|
||||
|
||||
Reference in New Issue
Block a user