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
+11 -11
View File
@@ -132,7 +132,7 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo
| 20 | Explicit vocabulary and named operation contexts | 19 | accepted | | 20 | Explicit vocabulary and named operation contexts | 19 | accepted |
| 21 | Domain ownership of reservations and apply knowledge | 20 | accepted | | 21 | Domain ownership of reservations and apply knowledge | 20 | accepted |
| 22 | Transactional draft editing and client creation vertical slice | 21 | accepted | | 22 | Transactional draft editing and client creation vertical slice | 21 | accepted |
| 23 | Readable saved-state Commit and restore scenarios | 22 | pending | | 23 | Readable saved-state Commit and restore scenarios | 22 | accepted |
| 24 | Session-owned first delivery and password reissue scenarios | 23 | pending | | 24 | Session-owned first delivery and password reissue scenarios | 23 | pending |
| 25 | Explicit restart verification scenario | 24 | pending | | 25 | Explicit restart verification scenario | 24 | pending |
| 26 | Application read models and thin TUI command adapter | 25 | pending | | 26 | Application read models and thin TUI command adapter | 25 | pending |
@@ -4015,7 +4015,7 @@ M22.1 added the internal owner and staged replacement API; M22.2 migrated assign
### Milestone 23 — Readable saved-state Commit and restore scenarios ### Milestone 23 — Readable saved-state Commit and restore scenarios
Status: pending. Prerequisite: M22 accepted. Status: accepted (2026-10-29T23:33:11+02:00; independent M23-R1 re-review, M23-R2 evidence verification and integrated Rust gates passed). Prerequisite: M22 accepted (2026-10-29T23:14:00+02:00). The user previously confirmed this session's coordinator GPT-6-Sol/medium setting; project configuration and explicit worker dispatch selected GPT-6-Luna/high. Initial tree clean at `c1d4f93`; pinned Rust/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 verified. M9 is retired/unaccepted by user direction and is not an M23 prerequisite.
#### Outcome and scope #### Outcome and scope
@@ -4023,9 +4023,9 @@ Extract explicit saved-state scenarios with visible ordering and honest failure
| Task | Owner | State | Bounded deliverable | | Task | Owner | State | Bounded deliverable |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| M23.1 | implementer | pending | Commit preparation, backup/install, in-memory publication | | M23.1 | implementer `/root/m23_commit` | complete | Commit prepare/validate -> backup -> install -> publish; focused checks passed |
| M23.2 | implementer | pending | Backup listing/creation and preview-confirm restore scenario | | M23.2 | implementer `/root/m23_commit` | complete | Backup listing/creation and preview-confirm restore extraction; exact-host reservation gap test/repair; focused checks passed |
| M23.3 | reviewer; repairer; coordinator | pending | Ordering/recovery/compatibility review, repair and RUST acceptance | | M23.3 | reviewer `/root/m23_review`; repairer `/root/m23_repair`; coordinator | complete | M23-R1 closed on independent re-review; M23-R2 evidence verified; integrated RUST passed |
1. Extract Commit as readable preparation -> validate proposed saved bytes -> completed pre-Commit backup of installed state -> journaled install -> publish new session state. Keep a concrete prepared result if it clarifies which fallible work precedes mutation. Do not turn backup confirmation into a public forgeable permission or allow callers to skip it. 1. Extract Commit as readable preparation -> validate proposed saved bytes -> completed pre-Commit backup of installed state -> journaled install -> publish new session state. Keep a concrete prepared result if it clarifies which fallible work precedes mutation. Do not turn backup confirmation into a public forgeable permission or allow callers to skip it.
2. Preserve failure behavior: draft remains retryable, readback after failed install conservatively retains/revokes first-issue rights, saved/application history is not advanced on failure, and recovery on reopen follows the existing journal. Avoid an apparently infallible final publication that hides parsing or state-install errors. 2. Preserve failure behavior: draft remains retryable, readback after failed install conservatively retains/revokes first-issue rights, saved/application history is not advanced on failure, and recovery on reopen follows the existing journal. Avoid an apparently infallible final publication that hides parsing or state-install errors.
@@ -4035,11 +4035,11 @@ Extract explicit saved-state scenarios with visible ordering and honest failure
#### Acceptance and validation #### Acceptance and validation
- [ ] M23-A1: completed backup precedes protected mutation for Commit/restore; failures and cancel retain documented draft/saved/runtime behavior. - [x] M23-A1: completed backup precedes protected mutation for Commit/restore; failures and cancel retain documented draft/saved/runtime behavior.
- [ ] M23-A2: restore compatibility, path restrictions, absent components, recovery and historical status tests retain their assertions. - [x] M23-A2: restore compatibility, path restrictions, absent components, recovery and historical status tests retain their assertions.
- [ ] M23-A3: fixture old-state backups and state/vault/archive compatibility checks pass; no runtime/service call in saved-only paths; independent review and RUST pass. - [x] M23-A3: fixture old-state backups and state/vault/archive compatibility checks pass; no runtime/service call in saved-only paths; independent review and RUST pass.
Focused commands: `cargo test --locked app::`, `cargo test --locked persistence::`, `cargo test --locked backup::`, `cargo test --locked --test commit_restore`, `cargo test --locked --test client_delivery`. Evidence: docs/evidence/milestone-23.md. Outcome: not accepted. Handoff: M24 after acceptance. M23.1 and M23.2 focused checks passed. Fresh reviewer found M23-R1: over-reservation of exact routes retained by an unprojectable restored peer. Repair separated archived from newly carried reservations; independent re-review closed M23-R1. M23-R2, the missing evidence artifact, was closed after the reviewer verified the report and source/artifact hashes. Final coordinator commands `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` (404 passed, 3 ignored) and `cargo build --locked --release` exited 0; `git diff --check` passed. [M23 evidence](docs/evidence/milestone-23.md) records focused runs, final source, review and limits. Outcome: accepted for host/synthetic scope; stop after M23. Next action only on a separate request is M24.1; no fresh Alpine/VM result is claimed.
### Milestone 24 — Session-owned first delivery and password reissue scenarios ### Milestone 24 — Session-owned first delivery and password reissue scenarios
@@ -4159,7 +4159,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af
## Current progress and resume point ## Current progress and resume point
- M22 is accepted for host/synthetic scope: coordinated draft ownership, edit/setup/import migration, failure tests, fresh independent review and required Rust gates passed. [M22 evidence](docs/evidence/milestone-22.md) records final inputs and limits. M23–M27 remain pending; M9 remains retired/unaccepted and its real-system limitations remain unchanged. - M23 is accepted for host/synthetic scope: saved-only Commit/backup/restore extraction, exact-host reservation repair, independent M23-R1/R2 closure and required Rust gates passed. [M23 evidence](docs/evidence/milestone-23.md) records final inputs and limits. M24–M27 remain pending; M9 remains retired/unaccepted and its real-system limitations remain unchanged.
- M18 was accepted on 2026-10-29: structured, accurate in-app Help, responsive wrapping and bounded scrolling passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M18 evidence](docs/evidence/milestone-18.md) records final inputs and limits. At that handoff, M19 had not started. - M18 was accepted on 2026-10-29: structured, accurate in-app Help, responsive wrapping and bounded scrolling passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M18 evidence](docs/evidence/milestone-18.md) records final inputs and limits. At that handoff, M19 had not started.
@@ -4188,7 +4188,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af
- Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope. - Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope.
- Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked. - Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked.
- M18–M22 handoffs remain complete. Stop after M22; begin M23.1 only on a separate request. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work. - M18–M23 handoffs remain complete. Stop after M23; begin M24.1 only on a separate request. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work.
## Plan maintenance ## Plan maintenance
+3
View File
@@ -32,6 +32,9 @@ src/
app.rs # Application operations and orchestration app.rs # Application operations and orchestration
app/ app/
draft.rs # Coordinated in-memory document, client-state, and vault drafts draft.rs # Coordinated in-memory document, client-state, and vault drafts
commit.rs # Prepare, backup, install, and publish saved-state Commit
restore.rs # Bound preview and confirmed saved-state replacement
backups.rs # Manual/pre-operation backup and archive-list scenarios
wireguard.rs # Server configuration module boundary wireguard.rs # Server configuration module boundary
wireguard/ wireguard/
document.rs # Parsing and edits preserving original text document.rs # Parsing and edits preserving original text
+37
View File
@@ -0,0 +1,37 @@
# Milestone 23 evidence — saved-state Commit, backup, and restore scenarios
Run: 2026-10-29T23:32:25+02:00. Base commit: `c1d4f93` (clean at start). Final dirty source inputs are `src/app.rs`, new `src/app/commit.rs`, `src/app/restore.rs`, `src/app/backups.rs`, and `docs/PROJECT_LAYOUT.md`. `PLANS.md` and this report are coordinator records. Reproduce identity with `sha256sum`:
| Input | SHA-256 |
| --- | --- |
| `src/app.rs` | `9fe74372a68182dc24d7061b4409d3dd6970bc77e3528560da2cacd9d70876b2` |
| `src/app/commit.rs` | `e8af86c36613d91605c81c3dca82fb3d1b3b0dd9b41fdbfeaba9e84984fc091b` |
| `src/app/restore.rs` | `5dd96ee9650ce66e17378eebea2adf18da746264814f0f0d6258a6ebbda651e4` |
| `src/app/backups.rs` | `d837fb0dd29cd3affd9ae12233ecb0337e9a308d730b24a4c892c7bc2605e6ef` |
| `docs/PROJECT_LAYOUT.md` | `c9d873caf3c46d48392e6352b5aed036ce3fdaa471c66b4df8d92994c1a2f5ef` |
| `target/release/wireguard-admin` | `44444967d552bbb7bac2d36423cc943cde2ced783190946aae67f12e0283541e` |
Native host toolchain: rustc/Cargo 1.97.1, rustfmt 1.9.0, Clippy 0.1.97. `Cargo.toml`, `Cargo.lock`, `src/persistence.rs`, and `src/backup.rs` have no diff.
## Implementation and review
- M23.1 extracted Commit into a private scenario: prepare and validate proposed bytes, parsed document, and next client state; complete a pre-Commit archive of the installed generation; perform journaled install; then publish the prepared in-memory state. A failed install reads back conservatively to retain or revoke session-first-delivery rights. The public `Application` facade and narrow test callback remain. No service or runtime call is made by Commit.
- M23.2 extracted manual/pre-restart backup listing/creation and previewed restore. Preview is read-only and holds the exact installed generation and selected archive bytes. Confirmation rejects a stale generation before backup, prepares path-compatible settings/document/vault/state, completes a pre-restore archive, performs journaled full replacement, then publishes. An install failure clears session-only delivery rights and reports recovery uncertainty. Absent optional components and archive/historical labels retain their documented semantics. No vault password or runtime call is used.
- The M19 exact-host restore gap is repaired: removed unprojectable peers' exact IPv4 `/32` and IPv6 `/128` routes are retained as reservations through reopen and allocation. Broad routes are not expanded into guessed addresses.
- Fresh GPT-6-Luna/high reviewer `/root/m23_review`, separate from implementer `/root/m23_commit`, found M23-R1: newly carried routes were reserved even when still present in the restored document. GPT-6-Luna/high repairer `/root/m23_repair` separated archived reservations from carried current/pending addresses and excluded carried exact routes still owned by the restored document. Focused tests cover retained, removed, pending-deletion and archived-reservation cases. Independent re-review closed M23-R1 with no remaining code finding. M23-R2 identified that this evidence file was pending during the first review; it is now present for final verification.
## Integrated validation
| Exact command | Result |
| --- | --- |
| `cargo fmt --all -- --check` | exit 0 |
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 |
| `cargo test --locked` | exit 0; 404 passed, 3 ignored opt-in Alpine tests, 0 failed, including 2 doctests |
| `cargo build --locked --release` | exit 0 |
| `git diff --check` | exit 0 |
Focused implementer/repairer/reviewer runs covered `cargo test --locked app::` (final 67), `cargo test --locked persistence::` (15), `cargo test --locked backup::` (8), `cargo test --locked --test commit_restore` (9), and `cargo test --locked --test client_delivery` (5), all passed on the final code. The full test command supersedes those focused runs as integrated host evidence. The independent review inspected backup-before-install order, saved-only dependencies, path/generation binding, optional components, secret handling, recovery and preserved formats.
## Acceptance scope and limits
M23-A1 is supported by reviewed operation ordering and backup/install/cancel failure tests. M23-A2 is supported by restored settings/path, optional-component, recovery, historical-status and old-archive tests. M23-A3 is supported by fixture old-state backups, compatibility tests, the saved-only dependency audit, independent re-review and required Rust gates. This is host/synthetic evidence. No opt-in Alpine or disposable systemd VM run was made for M23; no real systemd, live VPN, production or IPv6 integration behavior is claimed. M9 remains retired/unaccepted.
+241 -200
View File
@@ -4,7 +4,10 @@
//! encrypted vault data, backups, the recoverable saved-file adapter, and the //! encrypted vault data, backups, the recoverable saved-file adapter, and the
//! explicit runtime-restart confirmation boundary. //! explicit runtime-restart confirmation boundary.
mod backups;
mod commit;
mod draft; mod draft;
mod restore;
use draft::DraftChanges; use draft::DraftChanges;
@@ -334,6 +337,7 @@ pub struct RestorePreview {
created_at_utc: String, created_at_utc: String,
reason: BackupReason, reason: BackupReason,
historical_apply: HistoricalApplyStatus, historical_apply: HistoricalApplyStatus,
installed_generation: SavedBytes,
saved_bytes: SavedBytes, saved_bytes: SavedBytes,
} }
@@ -1424,14 +1428,7 @@ impl Application {
/// Create the mandatory archive from current installed bytes without /// Create the mandatory archive from current installed bytes without
/// changing the current session or saved files. /// changing the current session or saved files.
pub fn create_backup(&self, reason: BackupReason) -> Result<std::path::PathBuf, AppError> { pub fn create_backup(&self, reason: BackupReason) -> Result<std::path::PathBuf, AppError> {
let installed = self.store.read_saved()?; backups::create_backup(self, reason)
let historical_apply = historical_apply_from_saved(&installed)?;
Ok(backup::create_archive(
self.store.archives_directory(),
reason,
historical_apply,
&installed,
)?)
} }
pub fn create_manual_backup(&self) -> Result<std::path::PathBuf, AppError> { 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> { pub fn list_backups(&self) -> Result<Vec<backup::ArchiveSummary>, AppError> {
match backup::list_archives(self.store.archives_directory()) { backups::list_backups(self)
Ok(archives) => Ok(archives),
Err(BackupError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
Ok(Vec::new())
}
Err(error) => Err(error.into()),
}
} }
/// Reusable mandatory backup boundary for M5 before it invokes restart. /// Reusable mandatory backup boundary for M5 before it invokes restart.
/// This method only archives saved bytes; it never calls a runtime adapter. /// 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> { pub fn create_pre_restart_backup(&mut self) -> Result<std::path::PathBuf, AppError> {
self.active_restart_attempt = None; backups::create_pre_restart_backup(self)
self.create_backup(BackupReason::PreRestart)
} }
/// Prepare a read-only preview bound to the exact currently installed generation. /// 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. /// Validate a selected archive without changing saved files or the draft.
/// The returned value carries the exact validated bytes used at confirmation. /// The returned value carries the exact validated bytes used at confirmation.
pub fn preview_restore(&self, file_name: &str) -> Result<RestorePreview, AppError> { pub fn preview_restore(&self, file_name: &str) -> Result<RestorePreview, AppError> {
if !safe_archive_name(file_name) { restore::preview_restore(self, 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,
})
} }
/// Explicitly confirm a previously previewed restore. The pre-restore /// Explicitly confirm a previously previewed restore. The pre-restore
@@ -1958,54 +1894,18 @@ impl Application {
&mut self, &mut self,
preview: RestorePreview, preview: RestorePreview,
) -> Result<std::path::PathBuf, AppError> { ) -> 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(())) store.install(saved, BackupComplete(()))
}) })
} }
#[cfg(test)]
fn confirm_restore_with_install( fn confirm_restore_with_install(
&mut self, &mut self,
preview: RestorePreview, preview: RestorePreview,
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
) -> Result<std::path::PathBuf, AppError> { ) -> Result<std::path::PathBuf, AppError> {
self.store.validate_saved(&preview.saved_bytes)?; restore::confirm_restore(self, preview, install)
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)
} }
/// Persist applied state only with the one-use authorization produced by /// Persist applied state only with the one-use authorization produced by
@@ -2102,87 +2002,7 @@ impl Application {
) -> Result<std::path::PathBuf, BackupError>, ) -> Result<std::path::PathBuf, BackupError>,
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
) -> Result<std::path::PathBuf, AppError> { ) -> Result<std::path::PathBuf, AppError> {
let installed = self.store.read_saved()?; commit::commit(self, create_backup, install)
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)
} }
} }
@@ -2555,7 +2375,10 @@ mod tests {
use crate::backup::{BackupReason, HistoricalApplyStatus, create_archive, preview_archive}; use crate::backup::{BackupReason, HistoricalApplyStatus, create_archive, preview_archive};
use crate::config::SettingsFile; use crate::config::SettingsFile;
use crate::delivery::qr::QrError; 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::{ use crate::persistence::{
BackupComplete, InstallCheckpoint, PersistedState, PersistenceError, SavedStore, 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] #[test]
fn restoring_absent_optional_components_removes_installed_vault_and_state() { fn restoring_absent_optional_components_removes_installed_vault_and_state() {
let root = tempfile::tempdir().expect("temp directory"); let root = tempfile::tempdir().expect("temp directory");
@@ -4219,6 +4248,19 @@ restart_timeout_seconds = 30
.expect("vault staged"); .expect("vault staged");
app.commit().expect("vault installed"); app.commit().expect("vault installed");
assert!(store.read_saved().expect("installed").vault.is_some()); 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 let preview = app
.preview_restore(selected.file_name().unwrap().to_str().unwrap()) .preview_restore(selected.file_name().unwrap().to_str().unwrap())
@@ -4230,10 +4272,11 @@ restart_timeout_seconds = 30
restored restored
.internal_state .internal_state
.as_deref() .as_deref()
.expect("pending current address is retained"), .expect("removed current routes are retained"),
) )
.expect("restored reservation state parses"); .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!(app.vault().is_none());
assert_eq!(app.states().applied(), &ApplyStatus::Unknown); 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"); let restored = store.read_saved().expect("restored saved bytes parse");
assert_eq!( assert!(
PersistedState::from_toml(restored.internal_state.as_deref().unwrap()) restored.internal_state.is_none(),
.unwrap() "the selected archive had no state and restored routes need no reservation"
.last_verified_at_utc,
None
); );
let restored_document = ConfigDocument::parse(&restored.server_config).unwrap(); let restored_document = ConfigDocument::parse(&restored.server_config).unwrap();
let reopened_states = ClientStates::new( let reopened_states = ClientStates::new(
+38
View File
@@ -0,0 +1,38 @@
//! Application-owned backup listing and creation scenarios.
use crate::backup::{self, ArchiveSummary, BackupReason};
use super::{AppError, Application, historical_apply_from_saved};
pub(super) fn create_backup(
application: &Application,
reason: BackupReason,
) -> Result<std::path::PathBuf, AppError> {
let installed = application.store.read_saved()?;
let historical_apply = historical_apply_from_saved(&installed)?;
Ok(backup::create_archive(
application.store.archives_directory(),
reason,
historical_apply,
&installed,
)?)
}
pub(super) fn list_backups(application: &Application) -> Result<Vec<ArchiveSummary>, AppError> {
match backup::list_archives(application.store.archives_directory()) {
Ok(archives) => Ok(archives),
Err(crate::backup::BackupError::Io(error))
if error.kind() == std::io::ErrorKind::NotFound =>
{
Ok(Vec::new())
}
Err(error) => Err(error.into()),
}
}
pub(super) fn create_pre_restart_backup(
application: &mut Application,
) -> Result<std::path::PathBuf, AppError> {
application.active_restart_attempt = None;
create_backup(application, BackupReason::PreRestart)
}
+145
View File
@@ -0,0 +1,145 @@
//! Saved-state Commit scenario.
//!
//! The ordering is deliberately explicit: prepare and validate the complete
//! candidate, archive the installed generation, install it through the
//! journaled store, then publish only already-prepared in-memory values.
use std::net::IpAddr;
use crate::backup::{BackupError, BackupReason, HistoricalApplyStatus, SavedBytes};
use crate::domain::ClientStates;
use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore};
use crate::vault::VaultBundle;
use crate::wireguard::ConfigDocument;
use super::{
AppError, Application, historical_apply_from_saved, promote_matching_issue_map,
retain_matching_issue_map,
};
struct PreparedCommit {
proposed: SavedBytes,
document: ConfigDocument,
states: ClientStates,
}
impl Application {
/// Prepare a candidate without changing the live session or installed
/// files. All parsing, serialization and store validation finish here.
fn prepare_commit(
&self,
installed: &SavedBytes,
) -> Result<(SavedBytes, ConfigDocument, ClientStates), AppError> {
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 states = self.draft.states.clone();
states.commit_draft();
let reservations = states
.reserved_addresses()
.iter()
.map(IpAddr::to_string)
.collect::<Vec<_>>();
let internal_state = Some(PersistedState::new(reservations, None)?.to_toml()?);
let vault = 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,
};
self.store.validate_saved(&proposed)?;
// Reuse this parsed document after install; publication must not have a
// fallible parse step after the filesystem has already advanced.
let document = ConfigDocument::parse(&proposed.server_config)?;
Ok((proposed, document, states))
}
}
pub(super) fn commit(
application: &mut Application,
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> {
let installed = application.store.read_saved()?;
let historical_apply = historical_apply_from_saved(&installed)?;
let (proposed, document, states) = application.prepare_commit(&installed)?;
let prepared = PreparedCommit {
proposed,
document,
states,
};
// Passing BackupComplete is the only path to installation, and this point
// is reached only after the full archive has been published successfully.
let archive = create_backup(
application.store.archives_directory(),
BackupReason::PreCommit,
historical_apply,
&installed,
)?;
if let Err(error) = install(&application.store, &prepared.proposed, BackupComplete(())) {
retain_first_issue_rights_after_failed_install(application);
return Err(error.into());
}
application.saved = prepared.proposed;
application.draft.states = prepared.states;
application.draft.document = prepared.document;
application.invalidate_active_issue_authorizations();
promote_matching_issue_map(
&mut application.initial_issues,
&application.draft.document,
application.draft.vault.as_ref(),
);
application.historical_apply = HistoricalApplyStatus::Unknown;
application.verified_saved_projection = None;
application.active_restart_attempt = None;
Ok(archive)
}
fn retain_first_issue_rights_after_failed_install(application: &mut Application) {
match application.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 application.initial_issues,
&document,
vault.as_ref(),
);
} else {
application.initial_issues.clear();
}
}
Err(_) => application.initial_issues.clear(),
}
}
+221
View File
@@ -0,0 +1,221 @@
//! Previewed, saved-state-only restore scenario.
use std::collections::BTreeSet;
use std::net::IpAddr;
use crate::backup::{self, BackupReason, SavedBytes};
use crate::domain::{ApplyStatus, ClientStates};
use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore};
use crate::vault::VaultBundle;
use crate::wireguard::{ConfigDocument, PeerInspection};
use super::{
AppError, Application, RestorePreview, historical_apply_from_saved, safe_archive_name,
same_saved_bytes, snapshot_from_document,
};
struct PreparedRestore {
proposed: SavedBytes,
resolved_settings: crate::config::ResolvedSettings,
document: ConfigDocument,
vault: Option<VaultBundle>,
states: ClientStates,
}
pub(super) fn preview_restore(
application: &Application,
file_name: &str,
) -> Result<RestorePreview, AppError> {
if !safe_archive_name(file_name) {
return Err(AppError::InvalidArchiveSelection);
}
let installed_generation = application.store.read_saved()?;
let current_document = ConfigDocument::parse(&installed_generation.server_config)?;
let selected_path = application.store.archives_directory().join(file_name);
let archive = backup::preview_archive(&selected_path)?;
application.store.validate_saved(&archive.saved_bytes)?;
let mut proposed = archive.saved_bytes;
let document = ConfigDocument::parse(&proposed.server_config)?;
let mut states = ClientStates::new(
snapshot_from_document(&document),
None,
ApplyStatus::Unknown,
)?;
let mut carried_reservations = application
.draft
.states
.address_reservations()
.all()
.collect::<BTreeSet<_>>();
carried_reservations.extend(
snapshot_from_document(&current_document)
.clients()
.flat_map(|client| client.addresses.iter()),
);
// Saved peers outside the manageable client projection can still own
// exact host routes. Keep those addresses if this restore removes them;
// never enumerate broad routes or infer other addresses from a subnet.
carried_reservations.extend(exact_host_route_reservations(&current_document));
let archived_had_state = proposed.internal_state.is_some();
let mut archived_reservations = BTreeSet::new();
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<_>, _>>()?;
}
let restored_exact_hosts = exact_host_routes(&document);
let mut reservations = carried_reservations.clone();
reservations.extend(archived_reservations.iter().copied());
reservations.retain(|address| {
let assigned_to_restored_client = states
.saved()
.clients()
.any(|client| client.addresses.iter().any(|saved| saved == *address));
!assigned_to_restored_client
&& (archived_reservations.contains(address)
|| !carried_reservations.contains(address)
|| !restored_exact_hosts.contains(address))
});
if !reservations.is_empty() || archived_had_state {
let state = PersistedState::new(reservations.iter().map(ToString::to_string), None)?;
state.apply_to(&mut states)?;
proposed.internal_state = Some(state.to_toml()?);
}
application.store.validate_saved(&proposed)?;
Ok(RestorePreview {
created_at_utc: archive.created_at_utc,
reason: archive.reason,
historical_apply: archive.historical_apply,
installed_generation,
saved_bytes: proposed,
})
}
pub(super) fn confirm_restore(
application: &mut Application,
preview: RestorePreview,
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
) -> Result<std::path::PathBuf, AppError> {
let current = application.store.read_saved()?;
if !same_saved_bytes(&current, &preview.installed_generation) {
return Err(AppError::SavedGenerationChanged);
}
let prepared = prepare_restore(application, preview.saved_bytes)?;
let historical_apply = historical_apply_from_saved(&current)?;
let archive = backup::create_archive(
application.store.archives_directory(),
BackupReason::PreRestore,
historical_apply,
&current,
)?;
if let Err(error) = install(&application.store, &prepared.proposed, BackupComplete(())) {
// A partial journaled install may leave disk in a recovery-required
// state. Revoke session-only delivery rights until the store is read
// again and the actual installed generation is known.
application.initial_issues.clear();
return Err(error.into());
}
application.saved = prepared.proposed;
application
.store
.set_resolved_settings(prepared.resolved_settings);
application.draft.document = prepared.document;
application.draft.vault = prepared.vault;
application.draft.states = prepared.states;
application.initial_issues.clear();
application.historical_apply = crate::backup::HistoricalApplyStatus::Unknown;
application.verified_saved_projection = None;
application.active_restart_attempt = None;
Ok(archive)
}
fn prepare_restore(
application: &Application,
proposed: SavedBytes,
) -> Result<PreparedRestore, AppError> {
application.store.validate_saved(&proposed)?;
// Check installation path compatibility and parse every in-memory value
// before the precautionary archive or file replacement begins.
let resolved_settings = application.store.resolved_settings_for(&proposed)?;
let document = ConfigDocument::parse(&proposed.server_config)?;
let vault = proposed
.vault
.as_deref()
.map(std::str::from_utf8)
.transpose()
.map_err(|_| AppError::InvalidSavedState)?
.map(VaultBundle::from_toml)
.transpose()?;
let mut states = ClientStates::new(
snapshot_from_document(&document),
None,
ApplyStatus::Unknown,
)?;
if let Some(bytes) = &proposed.internal_state {
PersistedState::from_toml(bytes)?.apply_to(&mut states)?;
}
Ok(PreparedRestore {
proposed,
resolved_settings,
document,
vault,
states,
})
}
fn exact_host_route_reservations(document: &ConfigDocument) -> BTreeSet<IpAddr> {
let mut reservations = BTreeSet::new();
for peer in document.peers() {
if manageable_peer(document, peer) {
continue;
}
for route in &peer.allowed_ips {
match route {
ipnet::IpNet::V4(network) if network.prefix_len() == 32 => {
reservations.insert(IpAddr::V4(network.addr()));
}
ipnet::IpNet::V6(network) if network.prefix_len() == 128 => {
reservations.insert(IpAddr::V6(network.addr()));
}
_ => {}
}
}
}
reservations
}
fn exact_host_routes(document: &ConfigDocument) -> BTreeSet<IpAddr> {
document
.peers()
.iter()
.flat_map(|peer| peer.allowed_ips.iter())
.filter_map(|route| match route {
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 manageable_peer(document: &ConfigDocument, peer: &PeerInspection) -> bool {
peer.identity
.is_some_and(|identity| document.project_client(&identity).is_ok())
}