Implement readable saved-state Commit and restore scenarios

This commit is contained in:
2026-10-29 23:36:28 +02:00
parent c1d4f935c8
commit ed602f0596
7 changed files with 696 additions and 211 deletions
+241 -200
View File
@@ -4,7 +4,10 @@
//! 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;
@@ -334,6 +337,7 @@ pub struct RestorePreview {
created_at_utc: String,
reason: BackupReason,
historical_apply: HistoricalApplyStatus,
installed_generation: SavedBytes,
saved_bytes: SavedBytes,
}
@@ -1424,14 +1428,7 @@ impl Application {
/// 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> {
let installed = self.store.read_saved()?;
let historical_apply = historical_apply_from_saved(&installed)?;
Ok(backup::create_archive(
self.store.archives_directory(),
reason,
historical_apply,
&installed,
)?)
backups::create_backup(self, reason)
}
pub fn create_manual_backup(&self) -> Result<std::path::PathBuf, AppError> {
@@ -1439,20 +1436,13 @@ impl Application {
}
pub fn list_backups(&self) -> Result<Vec<backup::ArchiveSummary>, AppError> {
match backup::list_archives(self.store.archives_directory()) {
Ok(archives) => Ok(archives),
Err(BackupError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
Ok(Vec::new())
}
Err(error) => Err(error.into()),
}
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> {
self.active_restart_attempt = None;
self.create_backup(BackupReason::PreRestart)
backups::create_pre_restart_backup(self)
}
/// Prepare a read-only preview bound to the exact currently installed generation.
@@ -1895,61 +1885,7 @@ impl Application {
/// 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> {
if !safe_archive_name(file_name) {
return Err(AppError::InvalidArchiveSelection);
}
let path = self.store.archives_directory().join(file_name);
let preview = backup::preview_archive(&path)?;
self.store.validate_saved(&preview.saved_bytes)?;
let mut proposed = preview.saved_bytes;
let document = ConfigDocument::parse(&proposed.server_config)?;
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.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.draft
.states
.saved()
.clients()
.flat_map(|client| client.addresses.iter()),
);
if let Some(state_bytes) = &proposed.internal_state {
let archived = PersistedState::from_toml(state_bytes)?;
archived_reservations = archived
.reserved_addresses
.iter()
.map(|address| {
address
.parse::<IpAddr>()
.map_err(|_| AppError::InvalidSavedState)
})
.collect::<Result<BTreeSet<_>, _>>()?;
PersistedState::new(archived_reservations.iter().map(ToString::to_string), None)?
.apply_to(&mut restored_states)?;
}
reservations.extend(archived_reservations);
reservations.retain(|address| {
!restored_states
.saved()
.clients()
.any(|client| client.addresses.iter().any(|saved| saved == *address))
});
if !reservations.is_empty() || proposed.internal_state.is_some() {
let state = PersistedState::new(reservations.iter().map(ToString::to_string), None)?;
state.apply_to(&mut restored_states)?;
proposed.internal_state = Some(state.to_toml()?);
}
self.store.validate_saved(&proposed)?;
Ok(RestorePreview {
created_at_utc: preview.created_at_utc,
reason: preview.reason,
historical_apply: preview.historical_apply,
saved_bytes: proposed,
})
restore::preview_restore(self, file_name)
}
/// Explicitly confirm a previously previewed restore. The pre-restore
@@ -1958,54 +1894,18 @@ impl Application {
&mut self,
preview: RestorePreview,
) -> Result<std::path::PathBuf, AppError> {
self.confirm_restore_with_install(preview, |store, saved, _backup| {
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> {
self.store.validate_saved(&preview.saved_bytes)?;
let current = self.store.read_saved()?;
let archive = backup::create_archive(
self.store.archives_directory(),
BackupReason::PreRestore,
historical_apply_from_saved(&current)?,
&current,
)?;
let resolved = self.store.resolved_settings_for(&preview.saved_bytes)?;
if let Err(error) = install(&self.store, &preview.saved_bytes, BackupComplete(())) {
self.initial_issues.clear();
return Err(error.into());
}
let document = ConfigDocument::parse(&preview.saved_bytes.server_config)?;
let vault = preview
.saved_bytes
.vault
.as_deref()
.map(std::str::from_utf8)
.transpose()
.map_err(|_| AppError::InvalidSavedState)?
.map(VaultBundle::from_toml)
.transpose()?;
let snapshot = snapshot_from_document(&document);
let mut states = ClientStates::new(snapshot, None, ApplyStatus::Unknown)?;
if let Some(bytes) = &preview.saved_bytes.internal_state {
PersistedState::from_toml(bytes)?.apply_to(&mut states)?;
}
self.saved = preview.saved_bytes;
self.store.set_resolved_settings(resolved);
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;
self.active_restart_attempt = None;
Ok(archive)
restore::confirm_restore(self, preview, install)
}
/// Persist applied state only with the one-use authorization produced by
@@ -2102,87 +2002,7 @@ impl Application {
) -> Result<std::path::PathBuf, BackupError>,
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
) -> Result<std::path::PathBuf, AppError> {
let installed = self.store.read_saved()?;
if let Some(existing_bytes) = &installed.vault {
let existing_text =
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())
{
return Err(AppError::VaultIdentityChange);
}
}
let mut next_states = self.draft.states.clone();
next_states.commit_draft();
let reservations = next_states
.reserved_addresses()
.iter()
.map(IpAddr::to_string)
.collect::<Vec<_>>();
let internal_state = Some(PersistedState::new(reservations, None)?.to_toml()?);
let vault_bytes = self
.draft
.vault
.as_ref()
.map(VaultBundle::to_toml)
.transpose()?
.map(String::into_bytes);
let proposed = SavedBytes {
settings: installed.settings.clone(),
server_config: self.draft.document.render().to_vec(),
internal_state,
vault: vault_bytes,
};
self.store.validate_saved(&proposed)?;
let archive = create_backup(
self.store.archives_directory(),
BackupReason::PreCommit,
historical_apply_from_saved(&installed)?,
&installed,
)?;
if let Err(error) = install(&self.store, &proposed, BackupComplete(())) {
match self.store.read_saved() {
Ok(current) => {
let document = ConfigDocument::parse(&current.server_config).ok();
let vault = current
.vault
.as_deref()
.and_then(|bytes| std::str::from_utf8(bytes).ok())
.and_then(|text| VaultBundle::from_toml(text).ok());
if let Some(document) = document {
retain_matching_issue_map(
&mut self.initial_issues,
&document,
vault.as_ref(),
);
} else {
self.initial_issues.clear();
}
}
Err(_) => self.initial_issues.clear(),
}
return Err(error.into());
}
self.saved = proposed;
self.draft.states = next_states;
self.draft.document = ConfigDocument::parse(&self.saved.server_config)?;
self.invalidate_active_issue_authorizations();
promote_matching_issue_map(
&mut self.initial_issues,
&self.draft.document,
self.draft.vault.as_ref(),
);
self.historical_apply = HistoricalApplyStatus::Unknown;
self.verified_saved_projection = None;
self.active_restart_attempt = None;
Ok(archive)
commit::commit(self, create_backup, install)
}
}
@@ -2555,7 +2375,10 @@ mod tests {
use crate::backup::{BackupReason, HistoricalApplyStatus, create_archive, preview_archive};
use crate::config::SettingsFile;
use crate::delivery::qr::QrError;
use crate::domain::{ApplyStatus, Client, ClientAddresses, ClientStates, PublicKey, Snapshot};
use crate::domain::{
AddressAvailability, AddressFamily, ApplyStatus, Client, ClientAddresses, ClientStates,
DomainError, PublicKey, Snapshot, allocate_ipv4, allocate_ipv6,
};
use crate::persistence::{
BackupComplete, InstallCheckpoint, PersistedState, PersistenceError, SavedStore,
};
@@ -4202,6 +4025,212 @@ restart_timeout_seconds = 30
));
}
#[test]
fn stale_restore_preview_is_rejected_before_backup_and_keeps_the_draft() {
let root = tempfile::tempdir().expect("temp directory");
let (store, states, key, _) = setup(root.path(), false);
let mut app = Application::open(store.clone(), states).expect("application opens");
let saved = store.read_saved().expect("saved bytes");
let selected = create_archive(
store.archives_directory(),
BackupReason::Manual,
HistoricalApplyStatus::Unknown,
&saved,
)
.expect("restore archive");
let preview = app
.preview_restore(selected.file_name().unwrap().to_str().unwrap())
.expect("preview is prepared");
app.rename_client(key, "committed after preview")
.expect("stage new saved generation");
app.commit().expect("save new generation");
app.rename_client(key, "draft retained after stale preview")
.expect("stage follow-up draft");
let backup_count = app.list_backups().expect("list backups").len();
assert!(matches!(
app.confirm_restore(preview),
Err(AppError::SavedGenerationChanged)
));
assert_eq!(
app.list_backups().expect("no precautionary backup").len(),
backup_count
);
assert_eq!(
app.states()
.draft()
.get(&key)
.expect("draft survives")
.alias,
"draft retained after stale preview"
);
assert_eq!(
store
.read_saved()
.expect("current generation remains installed")
.server_config,
app.saved_bytes().server_config
);
}
#[test]
fn restore_retains_removed_unprojectable_exact_hosts_through_reopen_and_allocation() {
let root = tempfile::tempdir().expect("temp directory");
let (store, states, _, _) = setup(root.path(), false);
let original = store.read_saved().expect("initial saved bytes");
let selected = create_archive(
store.archives_directory(),
BackupReason::Manual,
HistoricalApplyStatus::Unknown,
&original,
)
.expect("archive without unsupported peers");
let broad_key = WireGuardPrivateKey::generate()
.expect("synthetic broad-route peer key")
.public_key();
let pending_key = WireGuardPrivateKey::generate()
.expect("synthetic exact-route peer key")
.public_key();
let mut current = clone_saved(&original);
current.server_config.extend_from_slice(
format!(
"\n[Peer]\nPublicKey = {broad_key}\nAllowedIPs = 10.20.0.77/32, 10.20.0.0/24, fd20::77/128\n\n[Peer]\nPublicKey = {pending_key}\nAllowedIPs = 10.20.0.79/32, fd20::79/128\n"
)
.as_bytes(),
);
store
.install(&current, BackupComplete(()))
.expect("install synthetic current generation");
let mut app = Application::open(store.clone(), states).expect("open with peer routes");
// Missing managed metadata makes this peer unprojectable. Deleting it
// stages its exact addresses without requiring a client snapshot.
assert!(
!app.delete_client(pending_key)
.expect("stage exact-host deletion")
);
let preview = app
.preview_restore(selected.file_name().unwrap().to_str().unwrap())
.expect("preview combines current and pending reservations");
app.confirm_restore(preview).expect("restore succeeds");
let reopened = Application::open_from_saved_store(store.clone())
.expect("reservations survive a fresh application session");
let reserved = reopened
.states()
.reserved_addresses()
.iter()
.copied()
.collect::<Vec<_>>();
for address in ["10.20.0.77", "fd20::77", "10.20.0.79", "fd20::79"] {
assert!(reserved.contains(&address.parse().unwrap()), "{address}");
}
assert!(
!reserved.contains(&"10.20.0.1".parse().unwrap()),
"broad subnet routes must not be expanded into guessed reservations"
);
assert_eq!(
allocate_ipv4(
"10.20.0.77/32".parse().unwrap(),
&AddressAvailability {
server: &[],
occupied: &[],
reserved: &reserved,
},
),
Err(DomainError::AddressExhausted(AddressFamily::Ipv4))
);
assert_eq!(
allocate_ipv6(
"fd20::77/128".parse().unwrap(),
&AddressAvailability {
server: &[],
occupied: &[],
reserved: &reserved,
},
),
Err(DomainError::AddressExhausted(AddressFamily::Ipv6))
);
}
#[test]
fn restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document() {
let root = tempfile::tempdir().expect("temp directory");
let (store, _, _, _) = setup(root.path(), false);
let mut current = store.read_saved().expect("initial saved bytes");
let retained_key = WireGuardPrivateKey::generate()
.expect("synthetic retained-peer key")
.public_key();
let deleted_key = WireGuardPrivateKey::generate()
.expect("synthetic pending-deletion key")
.public_key();
current.server_config.extend_from_slice(
format!(
"\n[Peer]\nPublicKey = {retained_key}\nAllowedIPs = 10.20.0.77/32, fd20::77/128\n\n[Peer]\nPublicKey = {deleted_key}\nAllowedIPs = 10.20.0.79/32, fd20::79/128\n"
)
.as_bytes(),
);
current.internal_state = Some(
PersistedState::new(["10.20.0.88".to_owned()], None)
.expect("synthetic archived reservation")
.to_toml()
.expect("archived reservation serializes"),
);
store
.install(&current, BackupComplete(()))
.expect("install synthetic unprojectable peers");
let archive = create_archive(
store.archives_directory(),
BackupReason::Manual,
HistoricalApplyStatus::Unknown,
&current,
)
.expect("archive restored peer routes");
let mut app = Application::open_from_saved_store(store.clone())
.expect("application opens with unprojectable peers");
assert!(
!app.delete_client(deleted_key)
.expect("stage pending peer deletion")
);
assert_eq!(
app.states()
.address_reservations()
.pending_host_routes()
.count(),
2
);
let preview = app
.preview_restore(archive.file_name().unwrap().to_str().unwrap())
.expect("preview archive restoring exact routes");
app.confirm_restore(preview).expect("restore succeeds");
assert_eq!(app.states().reserved_addresses().len(), 1);
assert!(
app.states()
.reserved_addresses()
.contains(&"10.20.0.88".parse().expect("archived reservation"))
);
assert!(
app.states()
.address_reservations()
.pending_host_routes()
.next()
.is_none()
);
let restored_state = PersistedState::from_toml(
app.saved_bytes()
.internal_state
.as_deref()
.expect("archived reservation remains persisted"),
)
.expect("restored state remains valid");
assert_eq!(restored_state.reserved_addresses, ["10.20.0.88"]);
}
#[test]
fn restoring_absent_optional_components_removes_installed_vault_and_state() {
let root = tempfile::tempdir().expect("temp directory");
@@ -4219,6 +4248,19 @@ restart_timeout_seconds = 30
.expect("vault staged");
app.commit().expect("vault installed");
assert!(store.read_saved().expect("installed").vault.is_some());
let mut current = store.read_saved().expect("current saved bytes");
let removed_key = WireGuardPrivateKey::generate()
.expect("synthetic removed-peer key")
.public_key();
current.server_config.extend_from_slice(
format!(
"\n[Peer]\nPublicKey = {removed_key}\nAllowedIPs = 10.20.0.9/32, fd20::9/128\n"
)
.as_bytes(),
);
store
.install(&current, BackupComplete(()))
.expect("install current peer removed by archive");
let preview = app
.preview_restore(selected.file_name().unwrap().to_str().unwrap())
@@ -4230,10 +4272,11 @@ restart_timeout_seconds = 30
restored
.internal_state
.as_deref()
.expect("pending current address is retained"),
.expect("removed current routes are retained"),
)
.expect("restored reservation state parses");
assert!(state.reserved_addresses.contains(&"fd20::2".to_owned()));
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);
}
@@ -5277,11 +5320,9 @@ restart_timeout_seconds = 30
}
);
let restored = store.read_saved().expect("restored saved bytes parse");
assert_eq!(
PersistedState::from_toml(restored.internal_state.as_deref().unwrap())
.unwrap()
.last_verified_at_utc,
None
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(