diff --git a/PLANS.md b/PLANS.md index 17147a4..2e18f7a 100644 --- a/PLANS.md +++ b/PLANS.md @@ -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 | | 21 | Domain ownership of reservations and apply knowledge | 20 | 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 | | 25 | Explicit restart verification scenario | 24 | 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 -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 @@ -4023,9 +4023,9 @@ Extract explicit saved-state scenarios with visible ordering and honest failure | Task | Owner | State | Bounded deliverable | | --- | --- | --- | --- | -| M23.1 | implementer | pending | Commit preparation, backup/install, in-memory publication | -| M23.2 | implementer | pending | Backup listing/creation and preview-confirm restore scenario | -| M23.3 | reviewer; repairer; coordinator | pending | Ordering/recovery/compatibility review, repair and RUST acceptance | +| M23.1 | implementer `/root/m23_commit` | complete | Commit prepare/validate -> backup -> install -> publish; focused checks passed | +| 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 `/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. 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 -- [ ] 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. -- [ ] 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-A1: completed backup precedes protected mutation for Commit/restore; failures and cancel retain documented draft/saved/runtime behavior. +- [x] M23-A2: restore compatibility, path restrictions, absent components, recovery and historical status tests retain their assertions. +- [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 @@ -4159,7 +4159,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af ## 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. @@ -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 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 diff --git a/docs/PROJECT_LAYOUT.md b/docs/PROJECT_LAYOUT.md index dc3c6c1..962dd26 100644 --- a/docs/PROJECT_LAYOUT.md +++ b/docs/PROJECT_LAYOUT.md @@ -32,6 +32,9 @@ src/ app.rs # Application operations and orchestration app/ 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/ document.rs # Parsing and edits preserving original text diff --git a/docs/evidence/milestone-23.md b/docs/evidence/milestone-23.md new file mode 100644 index 0000000..46b9ee6 --- /dev/null +++ b/docs/evidence/milestone-23.md @@ -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. diff --git a/src/app.rs b/src/app.rs index d0fff98..e421552 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 { - 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 { @@ -1439,20 +1436,13 @@ impl Application { } pub fn list_backups(&self) -> Result, 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 { - 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 { - 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::() - .map_err(|_| AppError::InvalidSavedState) - }) - .collect::, _>>()?; - 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 { - 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 { - 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(¤t)?, - ¤t, - )?; - 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, install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, ) -> Result { - 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::>(); - 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(¤t.server_config).ok(); - let vault = current - .vault - .as_deref() - .and_then(|bytes| std::str::from_utf8(bytes).ok()) - .and_then(|text| VaultBundle::from_toml(text).ok()); - if let Some(document) = document { - retain_matching_issue_map( - &mut self.initial_issues, - &document, - vault.as_ref(), - ); - } else { - self.initial_issues.clear(); - } - } - Err(_) => self.initial_issues.clear(), - } - return Err(error.into()); - } - - self.saved = proposed; - self.draft.states = next_states; - self.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(¤t, BackupComplete(())) + .expect("install synthetic current generation"); + let mut app = Application::open(store.clone(), states).expect("open with peer routes"); + + // Missing managed metadata makes this peer unprojectable. Deleting it + // stages its exact addresses without requiring a client snapshot. + assert!( + !app.delete_client(pending_key) + .expect("stage exact-host deletion") + ); + let preview = app + .preview_restore(selected.file_name().unwrap().to_str().unwrap()) + .expect("preview combines current and pending reservations"); + app.confirm_restore(preview).expect("restore succeeds"); + + let reopened = Application::open_from_saved_store(store.clone()) + .expect("reservations survive a fresh application session"); + let reserved = reopened + .states() + .reserved_addresses() + .iter() + .copied() + .collect::>(); + for address in ["10.20.0.77", "fd20::77", "10.20.0.79", "fd20::79"] { + assert!(reserved.contains(&address.parse().unwrap()), "{address}"); + } + assert!( + !reserved.contains(&"10.20.0.1".parse().unwrap()), + "broad subnet routes must not be expanded into guessed reservations" + ); + + assert_eq!( + allocate_ipv4( + "10.20.0.77/32".parse().unwrap(), + &AddressAvailability { + server: &[], + occupied: &[], + reserved: &reserved, + }, + ), + Err(DomainError::AddressExhausted(AddressFamily::Ipv4)) + ); + assert_eq!( + allocate_ipv6( + "fd20::77/128".parse().unwrap(), + &AddressAvailability { + server: &[], + occupied: &[], + reserved: &reserved, + }, + ), + Err(DomainError::AddressExhausted(AddressFamily::Ipv6)) + ); + } + + #[test] + fn restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, _, _, _) = setup(root.path(), false); + let mut current = store.read_saved().expect("initial saved bytes"); + let retained_key = WireGuardPrivateKey::generate() + .expect("synthetic retained-peer key") + .public_key(); + let deleted_key = WireGuardPrivateKey::generate() + .expect("synthetic pending-deletion key") + .public_key(); + current.server_config.extend_from_slice( + format!( + "\n[Peer]\nPublicKey = {retained_key}\nAllowedIPs = 10.20.0.77/32, fd20::77/128\n\n[Peer]\nPublicKey = {deleted_key}\nAllowedIPs = 10.20.0.79/32, fd20::79/128\n" + ) + .as_bytes(), + ); + current.internal_state = Some( + PersistedState::new(["10.20.0.88".to_owned()], None) + .expect("synthetic archived reservation") + .to_toml() + .expect("archived reservation serializes"), + ); + store + .install(¤t, BackupComplete(())) + .expect("install synthetic unprojectable peers"); + let archive = create_archive( + store.archives_directory(), + BackupReason::Manual, + HistoricalApplyStatus::Unknown, + ¤t, + ) + .expect("archive restored peer routes"); + let mut app = Application::open_from_saved_store(store.clone()) + .expect("application opens with unprojectable peers"); + + assert!( + !app.delete_client(deleted_key) + .expect("stage pending peer deletion") + ); + assert_eq!( + app.states() + .address_reservations() + .pending_host_routes() + .count(), + 2 + ); + let preview = app + .preview_restore(archive.file_name().unwrap().to_str().unwrap()) + .expect("preview archive restoring exact routes"); + app.confirm_restore(preview).expect("restore succeeds"); + + assert_eq!(app.states().reserved_addresses().len(), 1); + assert!( + app.states() + .reserved_addresses() + .contains(&"10.20.0.88".parse().expect("archived reservation")) + ); + assert!( + app.states() + .address_reservations() + .pending_host_routes() + .next() + .is_none() + ); + let restored_state = PersistedState::from_toml( + app.saved_bytes() + .internal_state + .as_deref() + .expect("archived reservation remains persisted"), + ) + .expect("restored state remains valid"); + assert_eq!(restored_state.reserved_addresses, ["10.20.0.88"]); + } + #[test] fn restoring_absent_optional_components_removes_installed_vault_and_state() { let root = tempfile::tempdir().expect("temp directory"); @@ -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(¤t, 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( diff --git a/src/app/backups.rs b/src/app/backups.rs new file mode 100644 index 0000000..b1c100c --- /dev/null +++ b/src/app/backups.rs @@ -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 { + 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, 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 { + application.active_restart_attempt = None; + create_backup(application, BackupReason::PreRestart) +} diff --git a/src/app/commit.rs b/src/app/commit.rs new file mode 100644 index 0000000..ce8ae3d --- /dev/null +++ b/src/app/commit.rs @@ -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::>(); + 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, + install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, +) -> Result { + 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(¤t.server_config).ok(); + let vault = current + .vault + .as_deref() + .and_then(|bytes| std::str::from_utf8(bytes).ok()) + .and_then(|text| VaultBundle::from_toml(text).ok()); + if let Some(document) = document { + retain_matching_issue_map( + &mut application.initial_issues, + &document, + vault.as_ref(), + ); + } else { + application.initial_issues.clear(); + } + } + Err(_) => application.initial_issues.clear(), + } +} diff --git a/src/app/restore.rs b/src/app/restore.rs new file mode 100644 index 0000000..c75729a --- /dev/null +++ b/src/app/restore.rs @@ -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, + states: ClientStates, +} + +pub(super) fn preview_restore( + application: &Application, + file_name: &str, +) -> Result { + 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::>(); + carried_reservations.extend( + snapshot_from_document(¤t_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(¤t_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::() + .map_err(|_| AppError::InvalidSavedState) + }) + .collect::, _>>()?; + } + 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 { + let current = application.store.read_saved()?; + if !same_saved_bytes(¤t, &preview.installed_generation) { + return Err(AppError::SavedGenerationChanged); + } + + let prepared = prepare_restore(application, preview.saved_bytes)?; + let historical_apply = historical_apply_from_saved(¤t)?; + let archive = backup::create_archive( + application.store.archives_directory(), + BackupReason::PreRestore, + historical_apply, + ¤t, + )?; + + 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 { + 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 { + 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 { + 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()) +}