From 7123197ec4a3fad74b71f5c7b598f1d6fd74d3a1 Mon Sep 17 00:00:00 2001 From: sasa Date: Fri, 30 Oct 2026 23:02:35 +0200 Subject: [PATCH] Add application read models and thin TUI command adapter --- PLANS.md | 26 +- docs/PROJECT_LAYOUT.md | 5 +- docs/evidence/milestone-26.md | 46 +++ src/app.rs | 553 +++++++++++++++++++++++----- src/app/first_delivery.rs | 41 +++ src/app/read_model.rs | 585 ++++++++++++++++++++++++++++++ src/tui/events.rs | 442 +++-------------------- src/tui/events/executor.rs | 404 +++++++++++++++++++++ src/tui/render.rs | 652 ++++++++++++++++++++++++++-------- src/tui/state.rs | 547 +++++++++++++++++++++++++--- src/tui/terminal.rs | 35 +- tests/tui_workflow.rs | 36 ++ 12 files changed, 2669 insertions(+), 703 deletions(-) create mode 100644 docs/evidence/milestone-26.md create mode 100644 src/app/read_model.rs create mode 100644 src/tui/events/executor.rs diff --git a/PLANS.md b/PLANS.md index 2ff0cec..db1ae57 100644 --- a/PLANS.md +++ b/PLANS.md @@ -135,7 +135,7 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo | 23 | Readable saved-state Commit and restore scenarios | 22 | accepted | | 24 | Session-owned first delivery and password reissue scenarios | 23 | accepted | | 25 | Explicit restart verification scenario | 24 | accepted | -| 26 | Application read models and thin TUI command adapter | 25 | pending | +| 26 | Application read models and thin TUI command adapter | 25 | accepted | | 27 | Integrated refactoring acceptance and architecture handoff | 26 | pending | ### Milestone 1 — Rust foundation, settings, and domain states @@ -4099,7 +4099,7 @@ Focused commands `cargo test --locked app::` (67), `runtime::` (25), `service::` ### Milestone 26 — Application read models and thin TUI command adapter -Status: pending. Prerequisite: M25 accepted. +Status: accepted (2026-10-30T23:01:09+02:00; independent R1–R4 re-review, evidence verification and integrated Rust gates passed). Prerequisite: M25 accepted. M1–8 and M10–25 are accepted; M9 is retired and not a prerequisite. This session's coordinator GPT-6-Sol/medium was user-confirmed; `.codex/config.toml` and explicit worker dispatch use GPT-6-Luna/high. Initial tree clean at `9a143e9`; pinned Rust/Cargo 1.97.1 verified. TUI previously reparsed saved config for peer count; app deletion already supports some exact-host-route peers outside manageable `ClientStates`. #### Outcome and scope @@ -4107,10 +4107,10 @@ TUI consumes public application facts and action eligibility without parsing sav | Task | Owner | State | Bounded deliverable | | --- | --- | --- | --- | -| M26.1 | implementer | pending | Secret-free application view and per-action eligibility | -| M26.2 | implementer | pending | Migrate TUI projection and command execution adapter | -| M26.3 | implementer | pending | Split command/result/navigation responsibilities where justified | -| M26.4 | reviewer; repairer; coordinator | pending | UI parity/authorization/lifecycle review, repair and RUST acceptance | +| M26.1 | implementer `/root/m26_read_model` | complete | `ApplicationView` and per-action eligibility built from authoritative peer and session facts; app/TUI/workflow focused checks, fmt and Clippy passed | +| M26.2 | implementer `/root/m26_tui_projection` | complete | TUI view consumes app read model; selection-bound guards/hints use per-action eligibility; TUI/workflow/client-delivery focused checks, fmt and Clippy passed | +| M26.3 | implementer `/root/m26_command_adapter` | complete | Application command/prepared-context adapter extracted under events; TUI/workflow/client-delivery focused checks, fmt and Clippy passed; broad terminal/render split deferred as no cohesive gain | +| M26.4 | reviewer `/root/m26_review`; repairer `/root/m26_repair`; coordinator | complete | M26-R1 removed unprojectable saved peer, R2 unrelated issue overblock, R3 false Removed row and R4 capability-issue reissue mismatch closed on re-review; evidence and integrated RUST verified | 1. Move ClientDisplay/SessionDisplay projection responsibilities to an application-owned read model. Include saved peer count, draft/saved public client differences, issue classification, reservation/apply facts and vault-initialized state. Keep UI-only selection, scroll, dialog focus, rendered strings/styles and worker generation in TUI. Raw ConfigDocument/SavedBytes/vault/password/payload must not be fields of the public view. 2. Represent action eligibility explicitly per action with typed reasons where useful. Audit current guards first: rename, delete, first delivery and restart do not necessarily have identical eligibility. In particular inspection-only management projection must not accidentally prohibit an already-supported deletion by public key. Never silently enable actions for malformed/ambiguous peers. Eligibility is guidance for presentation; application execution revalidates its actual preconditions. @@ -4121,12 +4121,12 @@ TUI consumes public application facts and action eligibility without parsing sav #### Acceptance and validation -- [ ] M26-A1: TUI no longer parses saved server bytes to obtain application facts; read model is secret-free and includes all authoritative records. -- [ ] M26-A2: per-action availability matches existing handlers, with authoritative revalidation; selection/cancel/draft/QR behavior preserved. -- [ ] M26-A3: worker generation/continuation/shutdown tests, responsive help/list/footer buffer tests and synthetic PTY workflows pass without weakened assertions. -- [ ] M26-A4: reviewer checks UI parity and boundary direction; RUST passes; actual module layout documented. +- [x] M26-A1: TUI no longer parses saved server bytes to obtain application facts; read model is secret-free and includes all authoritative records. +- [x] M26-A2: per-action availability matches existing handlers, with authoritative revalidation; selection/cancel/draft/QR behavior preserved. +- [x] M26-A3: worker generation/continuation/shutdown tests, responsive help/list/footer buffer tests and synthetic PTY workflows pass without weakened assertions. +- [x] M26-A4: reviewer checks UI parity and boundary direction; RUST passes; actual module layout documented. -Focused commands: `cargo test --locked tui::`, `cargo test --locked --test tui_workflow`, `cargo test --locked --test client_delivery`. Evidence: docs/evidence/milestone-26.md. Outcome: not accepted. Handoff: M27 after acceptance. +Focused commands `cargo test --locked tui::` (88), `--test tui_workflow` (9), `--test client_delivery` (5), and affected `app::` (74) exited 0 on the integrated tree. Final coordinator `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` (library 352 passed, 2 ignored; integration/doc tests passed), `cargo build --locked --release`, and `git diff --check` exited 0. Independent reviewer closed M26-R1 (removed unprojectable peer omission), R2 (unrelated issue reissue overblock), R3 (false Removed row from repair) and R4 (capability-issue reissue mismatch); all 11 evidence hashes and final gate results were rechecked. [M26 evidence](docs/evidence/milestone-26.md) identifies inputs, fixes and host/synthetic limits. No fresh Alpine or VM/systemd run is claimed. Outcome: accepted for M26 host/synthetic scope. Stop after M26; M27 starts only on a separate request. ### Milestone 27 — Integrated refactoring acceptance and architecture handoff @@ -4159,7 +4159,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af ## Current progress and resume point -- M25 is accepted for host/fixture scope: bound preview/preparation and ordered restart verification moved into one scenario; independent review and required Rust gates passed. M25-R1 evidence gap was closed on re-review. [M25 evidence](docs/evidence/milestone-25.md) records final inputs and limits. M26–M27 remain pending; M9 remains retired/unaccepted and its real-system limitations remain unchanged. +- M26 is accepted for host/synthetic scope: app-owned secret-free facts and per-action eligibility drive TUI presentation, while the command adapter owns prepared contexts and the worker keeps generation/lifecycle control. Independent R1–R4 closure and required Rust gates passed. [M26 evidence](docs/evidence/milestone-26.md) records final inputs and limits. M27 remains 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–M25 handoffs remain complete. Stop after M25; begin M26.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–M26 handoffs remain complete. Stop after M26; begin M27.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 d82500b..628f2cd 100644 --- a/docs/PROJECT_LAYOUT.md +++ b/docs/PROJECT_LAYOUT.md @@ -34,6 +34,7 @@ src/ draft.rs # Coordinated in-memory document, client-state, and vault drafts first_delivery.rs # Session-local first-issue authorization and retry state first_file.rs # Password-free first-file and optional QR scenario + read_model.rs # Secret-free application facts and per-action eligibility reissue.rs # Per-action vault authentication and saved-client delivery commit.rs # Prepare, backup, install, and publish saved-state Commit restore.rs # Bound preview and confirmed saved-state replacement @@ -51,7 +52,9 @@ src/ tui.rs # Terminal UI entry point tui/ state.rs # Screen state and navigation - events.rs # Keyboard and background events + events.rs # Commands, worker lifecycle, and background events + events/ + executor.rs # Application command adaptation and prepared contexts forms.rs # Short-lived forms and secret owners render.rs # Rendering terminal.rs # Terminal setup and cleanup diff --git a/docs/evidence/milestone-26.md b/docs/evidence/milestone-26.md new file mode 100644 index 0000000..48107d7 --- /dev/null +++ b/docs/evidence/milestone-26.md @@ -0,0 +1,46 @@ +# Milestone 26 evidence — application read model and TUI command adapter + +Run date: 2026-10-30T22:59:07+02:00. Baseline commit: `9a143e9`; the working tree contains uncommitted M26 source, test and layout changes below, plus coordinator-owned `PLANS.md`. Rust/Cargo: 1.97.1, native host target. Tests used synthetic fixtures, buffer rendering and PTY workflows only. No production configuration, keys, VPN, host service, Docker environment or VM was accessed. + +## Integrated inputs + +| Input | SHA-256 | +| --- | --- | +| `src/app.rs` | `5aeab061f45a75f3b4f4ec41b078cbecce3cbdd5a49ec2c47c0a98f062747023` | +| `src/app/read_model.rs` | `92a1945ca0ad0d4f6dc7aad255643d3d515712a1750c466738d29655f69d282d` | +| `src/app/first_delivery.rs` | `b9e80c7802b083f0ffa6e1e99898e9be9459e9eb9f3ea415b00e5592020a6ef3` | +| `src/tui/state.rs` | `da429e61bf918bf8524b7d131bc553460eb260f76f39d3763824e399764dcfac` | +| `src/tui/terminal.rs` | `9131ef9ae1e4d6f8a84715cdcdfcdd528262930dc95834991916484ff5ce4e21` | +| `src/tui/render.rs` | `3fa3b6496b792f3d7d3db16405a847c91736f2e975e5dba892d6921ca367df19` | +| `src/tui/events.rs` | `114c9505e69a1e9ec0782b149daf2ff9dcdc1a29af99abdb28df91fee70b758c` | +| `src/tui/events/executor.rs` | `4a46a8b03b1f708ab89d2e50ceceb60674c30e3bc1bcbb4c87af93b57815cd57` | +| `tests/tui_workflow.rs` | `8050c2e7887366ca6145c62fa8ded25b9dcd36f01062afa85ce85518ce64733f` | +| `docs/PROJECT_LAYOUT.md` | `06b83f3c0a26de4a7c3f8ccbaf1d37a697afb17cf67c297b69326018d144262c` | +| `target/release/wireguard-admin` | `70b45b51e2e345e1d914b1196355fde9f5688f123bed47332690d56752ca5cbe` | + +No dependency, manifest or lockfile changed. The release artifact hash identifies the binary built from this integrated source; no deployment occurred. + +## Checks on integrated tree + +| Command | Result | +| --- | --- | +| `cargo test --locked app::` | Exit 0; 74 app tests passed. | +| `cargo test --locked tui::` | Exit 0; 88 TUI tests passed. | +| `cargo test --locked --test tui_workflow` | Exit 0; 9 tests passed. | +| `cargo test --locked --test client_delivery` | Exit 0; 5 tests passed. | +| `cargo fmt --all -- --check` | Exit 0. | +| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. | +| `cargo test --locked` | Exit 0; library 352 passed, 2 ignored; integration and doc tests passed. Opt-in Alpine scenarios stayed ignored. | +| `cargo build --locked --release` | Exit 0. | +| `git diff --check` | Exit 0. | + +## Acceptance mapping and review + +- **M26-A1:** `Application::read_model` owns the secret-free peer rows, saved peer count, draft/saved public client changes, issue classes, reservations, applied knowledge, vault/draft flags and per-action eligibility. `SessionView::from_application` consumes those facts and maps them into compatibility UI fields without accessing saved server bytes or `ClientStates`. Malformed, duplicate and no-key draft peer rows remain available for inspection, and removed saved peers outside manageable `ClientStates` remain visible. +- **M26-A2:** Selection binds action presentation to public identity and peer index. App eligibility separates rename, deletion, first delivery, reissue and restart; execution still checks its own preconditions. Exact-host-route deletion remains available even for a peer outside the manageable client projection, and the removed row is inspection-only. First-delivery session availability is queried without secret disclosure. Unrelated saved-document issues do not suppress a valid selected reissue; selected-peer issues and vault conflicts do. +- **M26-A3:** TUI, workflow and delivery suites cover worker generations, stale continuations and statistics, shutdown, QR, selection, list/help/footer buffers and synthetic PTY behavior. Existing dimensioned tests include 34x8, 40x9, 60x16, 80x24, 100x30 and 180x50 cases. No assertions were weakened to pass the refactor. +- **M26-A4:** `src/tui/events/executor.rs` owns command adaptation and prepared destination/restore contexts; `events.rs` keeps the single-owner worker queue and generation lifecycle. `docs/PROJECT_LAYOUT.md` matches the integrated modules. Broader terminal/render splitting was deferred because it would broaden visibility across shared lifecycle and layout helpers without a cohesive ownership gain in M26. + +Fresh independent reviewer `/root/m26_review` reviewed the integrated diff. M26-R1 (removed unprojectable peer omitted) and M26-R2 (unrelated issue overblocked reissue) were fixed by repairer `/root/m26_repair`. Coordinator found M26-R3 (false Removed row for unchanged saved client) in the repair; it was fixed with a one-row regression. Reviewer found M26-R4 (reissue shown for selected capability-issue peer); the repair aligned eligibility with delivery validation and added a failing-before/passing-after regression. The reviewer independently re-reviewed and closed R1–R4 with no remaining source finding, then verified all 11 evidence hashes, reran the full test suite, app tests and release build, and found no evidence issue. + +This is host/synthetic evidence only. No fresh Alpine Testcontainers scenario or disposable systemd VM test was run; prior Alpine results do not identify this M26 source, and Alpine does not prove systemd behavior. Real systemd/D-Bus and live IPv6 behavior remain unverified. The retired M9 real-system limitation remains outside M26. diff --git a/src/app.rs b/src/app.rs index f424980..957e0af 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,10 +9,16 @@ mod commit; mod draft; mod first_delivery; mod first_file; +mod read_model; mod reissue; mod restart; mod restore; +pub use read_model::{ + ActionEligibility, ActionUnavailableReason, ApplicationView, ClassifiedPeerIssue, + ClientActions, ClientDisplay, ClientStateChange, FirstDeliveryEligibility, PeerIssueClass, + PeerRecord, ReservationFacts, SessionDisplay, +}; pub use restart::{ RestartChoice, RestartDiagnostic, RestartFailureKind, RestartPreview, RestartReport, RestartStage, @@ -47,46 +53,6 @@ use crate::vault::{VaultBundle, VaultError, WireGuardPrivateKey}; use crate::wireguard::SectionKind; use crate::wireguard::{ConfigDocument, DocumentError, EditError}; -/// Secret-free row for displaying every authoritative peer section. -/// `mutable_identity` is present only when the peer has an unambiguous, -/// supported projection into the client domain model. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ClientDisplay { - pub peer_index: usize, - pub line: usize, - pub identity: Option, - pub label: String, - pub allowed_ips: Vec, - pub has_subnet_routes: bool, - pub issues: Vec, - pub mutable_identity: Option, -} - -impl ClientDisplay { - /// Whether a retained parser issue describes a valid address shape the - /// client model cannot manage, rather than a malformed configuration. - pub fn is_capability_limitation_issue(&self, issue: &crate::wireguard::PeerIssue) -> bool { - matches!( - issue, - crate::wireguard::PeerIssue::UnsupportedAllowedIpShape { .. } - | crate::wireguard::PeerIssue::MissingIpv4HostRoute - ) - } - - pub fn presented_issues(&self) -> impl Iterator { - self.issues - .iter() - .filter(|issue| !self.is_capability_limitation_issue(issue)) - } -} - -/// Secret-free display projection of the current document and client state. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SessionDisplay { - pub clients: Vec, - pub document_issues: Vec, -} - /// An open, in-memory management session. The configuration and state drafts /// change independently of installed bytes until [`Self::commit`] succeeds. pub struct Application { @@ -446,46 +412,13 @@ impl Application { /// Return a secret-free projection that includes unsupported and /// ambiguous peers instead of silently omitting them from the client list. pub fn display_projection(&self) -> SessionDisplay { - let clients = self - .draft - .document - .peers() - .iter() - .map(|peer| { - let projected = peer - .identity - .and_then(|identity| self.draft.document.project_client(&identity).ok()); - let metadata_label = match &peer.metadata { - crate::wireguard::MetadataAttachment::Valid { metadata, .. } => { - Some(metadata.alias.clone()) - } - _ => None, - }; - let identity = peer.identity; - let label = metadata_label.unwrap_or_else(|| { - identity.map_or_else( - || format!("Peer at line {}", peer.section_line), - |key| format!("{}…", key.to_string().chars().take(8).collect::()), - ) - }); - ClientDisplay { - peer_index: peer.index, - line: peer.section_line, - identity, - label, - allowed_ips: peer.allowed_ips.iter().map(ToString::to_string).collect(), - has_subnet_routes: peer.has_subnet_routes, - issues: peer.issues.clone(), - mutable_identity: projected - .filter(|client| self.draft.states.draft().get(&client.identity).is_some()) - .map(|client| client.identity), - } - }) - .collect(); - SessionDisplay { - clients, - document_issues: self.draft.document.issues().to_vec(), - } + read_model::legacy_session_display(self) + } + + /// Return secret-free application facts for presentation and per-action + /// availability. Operations still revalidate their preconditions when run. + pub fn read_model(&self) -> ApplicationView { + read_model::session_display(self) } /// Add one prepared public-key client to the byte-preserving server draft @@ -1271,7 +1204,8 @@ mod tests { use std::time::{Duration, Instant}; use super::{ - AppError, Application, FileOutputError, FirstIssueAttempt, FirstIssueDeliveryFailure, + ActionUnavailableReason, AppError, Application, ClientStateChange, FileOutputError, + FirstDeliveryEligibility, FirstIssueAttempt, FirstIssueDeliveryFailure, FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartFailureKind, RestartStage, clone_saved, snapshot_from_document, }; @@ -2678,6 +2612,93 @@ restart_timeout_seconds = 30 assert!(!String::from_utf8_lossy(&saved.server_config).contains(&unsupported.to_string())); } + #[test] + fn read_model_keeps_removed_unprojectable_peer_and_reserved_routes_visible() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, _, _) = setup(root.path(), false); + let unsupported = PublicKey::from_bytes([10; 32]); + let mut server = + fs::read_to_string(root.path().join("server.conf")).expect("server fixture reads"); + server.push_str(&format!( + "\n[Peer]\nPublicKey = {unsupported}\nAllowedIPs = 10.20.0.10/32, fd20::10/128\n" + )); + fs::write(root.path().join("server.conf"), server).expect("server fixture updated"); + let mut app = Application::open(store, states).expect("application opens"); + + assert!( + !app.delete_client(unsupported) + .expect("unprojectable exact-host peer is deleted") + ); + let view = app.read_model(); + let removed = view + .clients + .iter() + .find(|peer| peer.identity == Some(unsupported)) + .expect("removed authoritative peer remains in the read model"); + assert_eq!(removed.state_change, ClientStateChange::Removed); + assert_eq!(removed.saved_client, None); + assert_eq!(removed.peer_index, None); + assert_eq!(removed.saved_peer_index, Some(1)); + assert_eq!( + removed.label, + format!("{}…", unsupported.to_string()[..8].to_owned()) + ); + assert_eq!(removed.allowed_ips, ["10.20.0.10/32", "fd20::10/128"]); + assert!(!removed.actions.rename.available); + assert!(!removed.actions.delete.available); + assert!( + view.reservations + .pending_host_routes + .contains(&"10.20.0.10".parse().unwrap()) + ); + assert!( + view.reservations + .pending_host_routes + .contains(&"fd20::10".parse().unwrap()) + ); + } + + #[test] + fn unchanged_saved_client_has_one_read_model_and_tui_row() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, identity, _) = setup(root.path(), false); + let app = Application::open(store, states).expect("application opens"); + + let application_view = app.read_model(); + let matching_rows = application_view + .clients + .iter() + .filter(|peer| peer.identity == Some(identity)) + .collect::>(); + assert_eq!(matching_rows.len(), 1); + assert_eq!(matching_rows[0].state_change, ClientStateChange::Unchanged); + + let tui_view = crate::tui::state::SessionView::from_application(&app, 0); + assert_eq!( + tui_view + .visible_records() + .iter() + .filter(|record| record.identity() == Some(identity)) + .count(), + 1, + "the unchanged saved client must not be appended as a removed row" + ); + assert_eq!( + tui_view + .application + .as_ref() + .unwrap() + .clients + .iter() + .filter(|peer| { + peer.identity == Some(identity) + && peer.state_change == ClientStateChange::Removed + }) + .count(), + 0 + ); + } + #[test] fn deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change() { let root = tempfile::tempdir().expect("temp directory"); @@ -4241,4 +4262,374 @@ restart_timeout_seconds = 30 assert_eq!(reopened.historical_apply, HistoricalApplyStatus::Unknown); assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); } + + #[test] + fn application_read_model_keeps_malformed_and_duplicate_authoritative_peer_rows() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, key, _) = setup(root.path(), false); + let duplicate = WireGuardPrivateKey::generate() + .expect("synthetic duplicate identity") + .public_key(); + let additional = format!( + "\n[Peer]\nAllowedIPs = 10.20.0.9/32\n\n[Peer]\nPublicKey = malformed\nAllowedIPs = 10.20.0.10/32\n\n[Peer]\nPublicKey = {duplicate}\nAllowedIPs = 10.20.0.11/32\n\n[Peer]\nPublicKey = {duplicate}\nAllowedIPs = 10.20.0.12/32\n" + ); + let path = root.path().join("server.conf"); + let mut server = fs::read(&path).expect("synthetic server fixture"); + server.extend_from_slice(additional.as_bytes()); + fs::write(&path, server).expect("extend synthetic saved peers"); + let mut app = Application::open(store, states).expect("application opens"); + app.prepare_initial_vault( + VaultBundle::create("synthetic read model password").expect("synthetic vault"), + ) + .expect("vault stages"); + app.commit().expect("vault is committed"); + let created = app + .prepare_new_client("draft-only", None, None) + .expect("new client is staged"); + app.delete_client(key) + .expect("saved client is removed from draft"); + + let view = app.read_model(); + assert_eq!(view.saved_peer_count, 5); + assert_eq!( + view.clients.len(), + 6, + "draft peers and the removed saved row remain visible" + ); + assert_eq!( + view.clients + .iter() + .filter(|peer| peer.draft_peer_index.is_some()) + .count(), + 5, + "all current authoritative peer sections remain visible" + ); + assert!(view.clients.iter().any(|peer| { + peer.draft_peer_index.is_some() + && peer.identity.is_none() + && peer + .issues + .iter() + .any(|issue| matches!(issue, crate::wireguard::PeerIssue::MissingPublicKey)) + })); + assert!(view.clients.iter().any(|peer| { + peer.identity.is_none() + && peer.issues.iter().any(|issue| { + matches!( + issue, + crate::wireguard::PeerIssue::MalformedPublicKey { .. } + ) + }) + })); + assert_eq!( + view.clients + .iter() + .filter(|peer| peer.issues.iter().any(|issue| matches!( + issue, + crate::wireguard::PeerIssue::DuplicatePeerIdentity { .. } + ))) + .count(), + 2, + "both sides of a duplicate identity stay visible" + ); + assert!( + view.clients + .iter() + .filter(|peer| peer.issues.iter().any(|issue| matches!( + issue, + crate::wireguard::PeerIssue::DuplicatePeerIdentity { .. } + ))) + .all(|peer| { + !peer.actions.rename.available + && !peer.actions.delete.available + && !peer.actions.reissue.available + && peer.mutable_identity.is_none() + }) + ); + let created_row = view + .clients + .iter() + .find(|peer| peer.identity == Some(created)) + .expect("created record represented"); + assert_eq!(created_row.state_change, ClientStateChange::Added); + let removed_row = view + .clients + .iter() + .find(|peer| peer.identity == Some(key)) + .expect("removed saved client is represented"); + assert_eq!(removed_row.state_change, ClientStateChange::Removed); + assert_eq!(removed_row.draft_peer_index, None); + assert_eq!(view.draft_client_count, 1); + assert_eq!(view.saved_client_count, 1); + } + + #[test] + fn application_read_model_reports_distinct_delivery_and_exact_route_actions() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, manageable, _) = setup(root.path(), false); + let exact_key = WireGuardPrivateKey::generate() + .expect("synthetic exact-route peer") + .public_key(); + let subnet_key = WireGuardPrivateKey::generate() + .expect("synthetic subnet peer") + .public_key(); + let path = root.path().join("server.conf"); + let mut server = fs::read(&path).expect("synthetic server fixture"); + server.extend_from_slice( + format!( + "\n[Peer]\nPublicKey = {exact_key}\nAllowedIPs = 10.20.0.20/32\n\n[Peer]\nPublicKey = {subnet_key}\nAllowedIPs = 10.20.1.0/24\n" + ) + .as_bytes(), + ); + fs::write(&path, server).expect("add synthetic unsupported peers"); + let mut app = Application::open(store, states).expect("application opens"); + app.prepare_initial_vault( + VaultBundle::create("synthetic read model password").expect("synthetic vault"), + ) + .expect("vault stages"); + app.commit().expect("vault is committed"); + let created = app + .prepare_new_client("first-file", None, None) + .expect("new client is staged"); + + let pending = app.read_model(); + assert_eq!( + pending + .clients + .iter() + .find(|peer| peer.identity == Some(created)) + .unwrap() + .actions + .first_delivery, + FirstDeliveryEligibility::Unavailable, + "draft-only first delivery is not yet eligible" + ); + assert!(pending.vault_initialized); + assert!(pending.has_draft); + let exact = pending + .clients + .iter() + .find(|peer| peer.identity == Some(exact_key)) + .expect("exact-host peer remains represented"); + assert!(exact.actions.delete.available); + assert_eq!(exact.mutable_identity, None); + let subnet = pending + .clients + .iter() + .find(|peer| peer.identity == Some(subnet_key)) + .expect("subnet peer remains represented"); + assert_eq!( + subnet.actions.delete.reason, + Some(ActionUnavailableReason::ExactHostRoutesRequired) + ); + let manageable_row = pending + .clients + .iter() + .find(|peer| peer.identity == Some(manageable)) + .expect("managed saved peer represented"); + assert_eq!( + manageable_row.actions.reissue.reason, + Some(ActionUnavailableReason::VaultRecordMissing) + ); + + app.commit().expect("created client is saved"); + let committed = app.read_model(); + let created_row = committed + .clients + .iter() + .find(|peer| peer.identity == Some(created)) + .expect("committed client represented"); + assert_eq!( + created_row.actions.first_delivery, + FirstDeliveryEligibility::InitialFile + ); + assert!(created_row.actions.reissue.available); + assert!(created_row.actions.rename.available); + assert!(created_row.actions.delete.available); + + let failed = app + .deliver_first_issue(created, FirstIssueAttempt::Initial, |_| { + Err::<(), _>(( + "cleanup required", + FirstIssueDeliveryFailure::CleanupRequired, + )) + }) + .expect("synthetic publication failure is represented"); + assert!(matches!( + failed, + FirstIssueDeliveryOutcome::Failed { + retry: FirstIssueRetry::ManualCleanupRequired, + .. + } + )); + let retry = app.read_model(); + assert_eq!( + retry + .clients + .iter() + .find(|peer| peer.identity == Some(created)) + .unwrap() + .actions + .first_delivery, + FirstDeliveryEligibility::RetryNeedsManualCleanup + ); + app.deliver_first_issue( + created, + FirstIssueAttempt::Retry { + confirm_possible_overwrite: false, + confirm_manual_cleanup: true, + }, + |_| Ok::<_, ((), FirstIssueDeliveryFailure)>(()), + ) + .expect("acknowledged first-file retry succeeds in synthetic adapter"); + assert_eq!( + app.read_model() + .clients + .iter() + .find(|peer| peer.identity == Some(created)) + .unwrap() + .actions + .first_delivery, + FirstDeliveryEligibility::Unavailable + ); + } + + #[test] + fn reissue_eligibility_ignores_unrelated_document_issue_but_rejects_ambiguous_peer() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, identity, private_key) = setup(root.path(), false); + let mut server = + fs::read_to_string(root.path().join("server.conf")).expect("server fixture reads"); + server.push_str("\nmalformed unrelated directive\n"); + fs::write(root.path().join("server.conf"), server).expect("fixture issue installed"); + let mut app = Application::open(store, states).expect("application opens"); + let mut vault = VaultBundle::create("synthetic reissue password").expect("synthetic vault"); + vault + .encrypt_record(identity, &private_key) + .expect("saved peer record is encrypted"); + app.prepare_initial_vault(vault).expect("vault staged"); + app.commit().expect("vault committed"); + + let valid = app.read_model(); + assert!(!valid.document_issues.is_empty()); + assert!( + valid + .clients + .iter() + .find(|peer| peer.identity == Some(identity)) + .expect("valid selected peer represented") + .actions + .reissue + .available, + "an unrelated document issue must not block the selected safe peer" + ); + + let saved_vault = app.saved.vault.clone(); + let mut conflicted_vault = + VaultBundle::create("synthetic conflict password").expect("conflict vault"); + conflicted_vault + .encrypt_record_with_status(identity, &private_key, true) + .expect("conflicted saved record is encrypted"); + app.saved.vault = Some( + conflicted_vault + .to_toml() + .expect("conflict vault serializes") + .into_bytes(), + ); + let conflicted = app.read_model(); + assert_eq!( + conflicted + .clients + .iter() + .find(|peer| peer.identity == Some(identity)) + .expect("conflicted peer represented") + .actions + .reissue + .reason, + Some(ActionUnavailableReason::VaultRecordConflict), + "a selected server/client parameter mismatch remains blocked" + ); + app.saved.vault = saved_vault; + + app.saved.server_config.extend_from_slice( + format!("\n[Peer]\nPublicKey = {identity}\nAllowedIPs = 10.20.0.99/32\n").as_bytes(), + ); + let ambiguous = app.read_model(); + let selected = ambiguous + .clients + .iter() + .find(|peer| peer.identity == Some(identity)) + .expect("selected peer remains represented"); + assert_eq!( + selected.actions.reissue.reason, + Some(ActionUnavailableReason::UnsafePeerOrDocument), + "duplicate selected identity remains blocked" + ); + } + + #[test] + fn reissue_eligibility_blocks_selected_peer_capability_issues() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, identity, private_key) = setup(root.path(), false); + let mut app = Application::open(store.clone(), states.clone()).expect("application opens"); + let mut vault = + VaultBundle::create("synthetic capability password").expect("synthetic vault"); + vault + .encrypt_record(identity, &private_key) + .expect("saved peer record is encrypted"); + app.prepare_initial_vault(vault).expect("vault staged"); + app.commit().expect("vault committed"); + + let mut server = fs::read_to_string(root.path().join("server.conf")) + .expect("saved server configuration reads"); + server = server.replace( + "AllowedIPs = 10.20.0.2/32, fd20::2/128", + "AllowedIPs = 10.20.0.2/32, 10.20.0.0/24", + ); + fs::write(root.path().join("server.conf"), server) + .expect("install synthetic selected-peer capability issue"); + let reopened = Application::open(store, app.states().clone()) + .expect("application opens with synthetic route issue"); + + let selected = reopened + .read_model() + .clients + .into_iter() + .find(|peer| peer.identity == Some(identity)) + .expect("selected saved peer remains represented"); + assert!(selected.issues.iter().any(|issue| matches!( + issue, + crate::wireguard::PeerIssue::UnsupportedAllowedIpShape { .. } + ))); + assert!(!selected.actions.reissue.available); + assert_eq!( + selected.actions.reissue.reason, + Some(ActionUnavailableReason::UnsafePeerOrDocument) + ); + } + + #[test] + fn application_read_model_debug_does_not_expose_saved_config_or_vault_password() { + let root = tempfile::tempdir().expect("temp directory"); + let mut app = setup_with_vault(root.path()); + app.prepare_new_client("redaction-check", None, None) + .expect("client is staged"); + let view = app.read_model(); + let debug = format!("{view:?}"); + let saved_text = String::from_utf8(app.saved_bytes().server_config.clone()) + .expect("synthetic server configuration is UTF-8"); + assert!(saved_text.contains("PrivateKey = ")); + assert!(!debug.contains("PrivateKey = ")); + assert!(!debug.contains("synthetic vault password")); + assert!(!debug.contains("vault_secret")); + let saved_vault = String::from_utf8( + app.saved_bytes() + .vault + .as_ref() + .expect("synthetic vault is saved") + .clone(), + ) + .expect("synthetic vault is UTF-8"); + assert!(!debug.contains(&saved_vault)); + } } diff --git a/src/app/first_delivery.rs b/src/app/first_delivery.rs index ad4d4ef..0e3e3a8 100644 --- a/src/app/first_delivery.rs +++ b/src/app/first_delivery.rs @@ -39,6 +39,17 @@ enum IssueStage { RetryNeedsManualCleanup, } +/// Read-only first-file availability with no access to the held private key, +/// issue context, or payload. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum SessionFirstDeliveryAvailability { + InitialFile, + Retry, + RetryNeedsOverwriteConfirmation, + RetryNeedsManualCleanup, + Unavailable, +} + pub(super) struct FirstIssueAuthorization { identity: PublicKey, context: IssueContext, @@ -64,6 +75,36 @@ impl FirstDeliverySession { self.issues.len() } + pub(super) fn read_model_availability( + &self, + identity: PublicKey, + document: &ConfigDocument, + vault: Option<&VaultBundle>, + ) -> SessionFirstDeliveryAvailability { + let Some(issue) = self.issues.get(&identity) else { + return SessionFirstDeliveryAvailability::Unavailable; + }; + let matching = vault + .and_then(|vault| context_for(document, vault, identity).ok()) + .is_some_and(|actual| actual == issue.context); + if !matching { + return SessionFirstDeliveryAvailability::Unavailable; + } + match issue.stage { + IssueStage::AwaitingFirstFile => SessionFirstDeliveryAvailability::InitialFile, + IssueStage::RetryAvailable => SessionFirstDeliveryAvailability::Retry, + IssueStage::RetryNeedsOverwriteConfirmation => { + SessionFirstDeliveryAvailability::RetryNeedsOverwriteConfirmation + } + IssueStage::RetryNeedsManualCleanup => { + SessionFirstDeliveryAvailability::RetryNeedsManualCleanup + } + IssueStage::PendingDraft | IssueStage::Authorizing(_) => { + SessionFirstDeliveryAvailability::Unavailable + } + } + } + #[cfg(test)] pub(super) fn is_empty(&self) -> bool { self.issues.is_empty() diff --git a/src/app/read_model.rs b/src/app/read_model.rs new file mode 100644 index 0000000..653ddfe --- /dev/null +++ b/src/app/read_model.rs @@ -0,0 +1,585 @@ +//! Secret-free application facts prepared for the terminal and other readers. + +use std::net::IpAddr; + +use crate::domain::{ApplyStatus, Client, PublicKey}; +use crate::wireguard::{ConfigDocument, PeerInspection, PeerIssue}; + +use super::Application; +use super::first_delivery::SessionFirstDeliveryAvailability; + +/// A presentation action is available only as far as the current application +/// facts can establish. The operation itself remains the final authority. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActionEligibility { + pub available: bool, + pub reason: Option, +} + +impl ActionEligibility { + fn available() -> Self { + Self { + available: true, + reason: None, + } + } + + fn unavailable(reason: ActionUnavailableReason) -> Self { + Self { + available: false, + reason: Some(reason), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ActionUnavailableReason { + NoUniquePeer, + PeerNotInDraft, + PeerNotInSavedState, + UnsafePeerOrDocument, + ExactHostRoutesRequired, + VaultNotInitialized, + VaultRecordMissing, + VaultRecordConflict, + FirstDeliveryNotAvailable, + RestartPreviewRejected, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FirstDeliveryEligibility { + InitialFile, + Retry, + RetryNeedsOverwriteConfirmation, + RetryNeedsManualCleanup, + Unavailable, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClientActions { + pub rename: ActionEligibility, + pub delete: ActionEligibility, + pub first_delivery: FirstDeliveryEligibility, + pub reissue: ActionEligibility, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PeerIssueClass { + MalformedOrAmbiguous, + UnsupportedCapability, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClassifiedPeerIssue { + pub issue: PeerIssue, + pub class: PeerIssueClass, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClientStateChange { + Added, + Removed, + Changed, + Unchanged, + UnsupportedOrUnmanaged, +} + +/// Compatibility row for the original `display_projection` API. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClientDisplay { + pub peer_index: usize, + pub line: usize, + pub identity: Option, + pub label: String, + pub allowed_ips: Vec, + pub has_subnet_routes: bool, + pub issues: Vec, + pub mutable_identity: Option, +} + +impl ClientDisplay { + /// Whether an issue is a valid configuration shape outside the current + /// client model's capabilities. + pub fn is_capability_limitation_issue(&self, issue: &PeerIssue) -> bool { + classify_issue(issue) == PeerIssueClass::UnsupportedCapability + } + + pub fn presented_issues(&self) -> impl Iterator { + self.issues + .iter() + .filter(|issue| !self.is_capability_limitation_issue(issue)) + } +} + +/// Secret-free authoritative peer record used by the application read model. +/// Saved-only rows have no draft peer index and retain the former saved index. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PeerRecord { + pub peer_index: Option, + pub draft_peer_index: Option, + pub saved_peer_index: Option, + pub line: usize, + pub identity: Option, + pub label: String, + pub allowed_ips: Vec, + pub has_subnet_routes: bool, + pub issues: Vec, + pub classified_issues: Vec, + pub mutable_identity: Option, + pub draft_client: Option, + pub saved_client: Option, + pub state_change: ClientStateChange, + pub actions: ClientActions, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ReservationFacts { + pub committed: Vec, + pub pending_host_routes: Vec, +} + +/// Compatibility projection for existing TUI callers. New consumers should +/// use [`ApplicationView`] for saved-state and action facts. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionDisplay { + pub clients: Vec, + pub document_issues: Vec, +} + +/// Public projection of application facts. It intentionally has no document, +/// saved-byte, vault, password, or client-payload owner. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ApplicationView { + pub clients: Vec, + pub document_issues: Vec, + pub saved_peer_count: usize, + pub draft_client_count: usize, + pub saved_client_count: usize, + pub reservations: ReservationFacts, + pub applied: ApplyStatus, + pub vault_initialized: bool, + pub has_draft: bool, + pub restart: ActionEligibility, +} + +pub(super) fn legacy_session_display(application: &Application) -> SessionDisplay { + SessionDisplay { + clients: application + .draft + .document + .peers() + .iter() + .map(|peer| { + let projected = peer + .identity + .and_then(|identity| application.draft.document.project_client(&identity).ok()); + let label = match &peer.metadata { + crate::wireguard::MetadataAttachment::Valid { metadata, .. } => { + Some(metadata.alias.clone()) + } + _ => None, + } + .unwrap_or_else(|| { + peer.identity.map_or_else( + || format!("Peer at line {}", peer.section_line), + |key| format!("{}…", key.to_string().chars().take(8).collect::()), + ) + }); + ClientDisplay { + peer_index: peer.index, + line: peer.section_line, + identity: peer.identity, + label, + allowed_ips: peer.allowed_ips.iter().map(ToString::to_string).collect(), + has_subnet_routes: peer.has_subnet_routes, + issues: peer.issues.clone(), + mutable_identity: projected + .filter(|client| { + application + .draft + .states + .draft() + .get(&client.identity) + .is_some() + }) + .map(|client| client.identity), + } + }) + .collect(), + document_issues: application.draft.document.issues().to_vec(), + } +} + +pub(super) fn session_display(application: &Application) -> ApplicationView { + let draft_document = &application.draft.document; + // The saved document was parsed when the session opened and every saved + // replacement is parsed before publication. Keep this projection total if + // that internal invariant is ever violated: no saved records are inferred. + let saved_document = ConfigDocument::parse(&application.saved.server_config).ok(); + let saved_peer_count = saved_document.as_ref().map_or(0, |doc| doc.peers().len()); + let mut clients = draft_document + .peers() + .iter() + .map(|peer| display_peer(application, peer, saved_document.as_ref())) + .collect::>(); + + let draft_peer_identities = draft_document + .peers() + .iter() + .flat_map(|peer| peer.public_key_candidates.iter().copied()) + .collect::>(); + let mut saved_only_identities = std::collections::BTreeSet::new(); + if let Some(document) = saved_document.as_ref() { + for saved_peer in document.peers() { + let Some(identity) = saved_peer.identity else { + continue; + }; + if draft_peer_identities.contains(&identity) + || !saved_only_identities.insert(identity) + || document + .peers() + .iter() + .filter(|peer| peer.identity == Some(identity)) + .count() + != 1 + { + continue; + } + let saved_client = application.draft.states.saved().get(&identity).cloned(); + let label = saved_client.as_ref().map_or_else( + || match &saved_peer.metadata { + crate::wireguard::MetadataAttachment::Valid { metadata, .. } => { + metadata.alias.clone() + } + _ => format!( + "{}…", + identity.to_string().chars().take(8).collect::() + ), + }, + |client| client.alias.clone(), + ); + clients.push(PeerRecord { + peer_index: None, + draft_peer_index: None, + saved_peer_index: Some(saved_peer.index), + line: saved_peer.section_line, + identity: Some(identity), + label, + allowed_ips: saved_peer + .allowed_ips + .iter() + .map(ToString::to_string) + .collect(), + has_subnet_routes: saved_peer.has_subnet_routes, + issues: saved_peer.issues.clone(), + classified_issues: classify_issues(&saved_peer.issues), + mutable_identity: None, + draft_client: None, + saved_client, + state_change: ClientStateChange::Removed, + actions: actions_for(application, Some(identity), false), + }); + } + } + + // A saved client can be absent from a malformed or unsupported saved + // document projection. Retain the state-backed row only when no + // authoritative peer row represented that identity above. + for client in application.draft.states.saved().clients() { + if draft_peer_identities.contains(&client.identity) + || clients.iter().any(|peer| { + peer.state_change == ClientStateChange::Removed + && peer.identity == Some(client.identity) + }) + { + continue; + } + clients.push(PeerRecord { + peer_index: None, + draft_peer_index: None, + saved_peer_index: None, + line: 0, + identity: Some(client.identity), + label: client.alias.clone(), + allowed_ips: Vec::new(), + has_subnet_routes: false, + issues: Vec::new(), + classified_issues: Vec::new(), + mutable_identity: None, + draft_client: None, + saved_client: Some(client.clone()), + state_change: ClientStateChange::Removed, + actions: actions_for(application, Some(client.identity), false), + }); + } + + let reservations = application.draft.states.address_reservations(); + let restart = if restart_is_eligible(application) { + ActionEligibility::available() + } else { + ActionEligibility::unavailable(ActionUnavailableReason::RestartPreviewRejected) + }; + ApplicationView { + clients, + document_issues: draft_document.issues().to_vec(), + saved_peer_count, + draft_client_count: application.draft.states.draft().clients().count(), + saved_client_count: application.draft.states.saved().clients().count(), + reservations: ReservationFacts { + committed: reservations.committed().iter().copied().collect(), + pending_host_routes: reservations.pending_host_routes().collect(), + }, + applied: *application.draft.states.applied(), + vault_initialized: application.draft.vault.is_some(), + has_draft: application.has_draft(), + restart, + } +} + +fn display_peer( + application: &Application, + peer: &PeerInspection, + saved_document: Option<&ConfigDocument>, +) -> PeerRecord { + let identity = peer.identity; + let draft_client = identity.and_then(|key| application.draft.states.draft().get(&key).cloned()); + let saved_client = identity.and_then(|key| application.draft.states.saved().get(&key).cloned()); + let projected = identity.and_then(|key| application.draft.document.project_client(&key).ok()); + let metadata_label = match &peer.metadata { + crate::wireguard::MetadataAttachment::Valid { metadata, .. } => { + Some(metadata.alias.clone()) + } + _ => None, + }; + let label = metadata_label.unwrap_or_else(|| { + identity.map_or_else( + || format!("Peer at line {}", peer.section_line), + |key| format!("{}…", key.to_string().chars().take(8).collect::()), + ) + }); + let saved_peer_index = identity.and_then(|key| { + saved_document.and_then(|document| { + document + .peers() + .iter() + .find(|saved_peer| saved_peer.identity == Some(key)) + .map(|saved_peer| saved_peer.index) + }) + }); + let mutable_identity = projected + .filter(|client| { + draft_client + .as_ref() + .is_some_and(|draft| draft.identity == client.identity) + }) + .map(|client| client.identity); + let state_change = match (&draft_client, &saved_client) { + (Some(_), None) => ClientStateChange::Added, + (None, Some(_)) => ClientStateChange::UnsupportedOrUnmanaged, + (Some(draft), Some(saved)) if draft == saved => ClientStateChange::Unchanged, + (Some(_), Some(_)) => ClientStateChange::Changed, + (None, None) => ClientStateChange::UnsupportedOrUnmanaged, + }; + PeerRecord { + peer_index: Some(peer.index), + draft_peer_index: Some(peer.index), + saved_peer_index, + line: peer.section_line, + identity, + label, + allowed_ips: peer.allowed_ips.iter().map(ToString::to_string).collect(), + has_subnet_routes: peer.has_subnet_routes, + issues: peer.issues.clone(), + classified_issues: classify_issues(&peer.issues), + mutable_identity, + draft_client, + saved_client, + state_change, + actions: actions_for(application, identity, true), + } +} + +fn actions_for( + application: &Application, + identity: Option, + peer_is_in_draft: bool, +) -> ClientActions { + let (rename, delete) = match identity { + None => { + let reason = ActionUnavailableReason::NoUniquePeer; + ( + ActionEligibility::unavailable(reason), + ActionEligibility::unavailable(reason), + ) + } + Some(_identity) if !peer_is_in_draft => { + let reason = ActionUnavailableReason::PeerNotInDraft; + ( + ActionEligibility::unavailable(reason), + ActionEligibility::unavailable(reason), + ) + } + Some(identity) => ( + if application + .draft + .document + .rename_client(&identity, "eligibility probe") + .is_ok() + { + ActionEligibility::available() + } else { + ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument) + }, + delete_eligibility(application, identity), + ), + }; + + let first_delivery = + identity.map_or( + FirstDeliveryEligibility::Unavailable, + |identity| match application.first_delivery.read_model_availability( + identity, + &application.draft.document, + application.draft.vault.as_ref(), + ) { + SessionFirstDeliveryAvailability::InitialFile => { + FirstDeliveryEligibility::InitialFile + } + SessionFirstDeliveryAvailability::Retry => FirstDeliveryEligibility::Retry, + SessionFirstDeliveryAvailability::RetryNeedsOverwriteConfirmation => { + FirstDeliveryEligibility::RetryNeedsOverwriteConfirmation + } + SessionFirstDeliveryAvailability::RetryNeedsManualCleanup => { + FirstDeliveryEligibility::RetryNeedsManualCleanup + } + SessionFirstDeliveryAvailability::Unavailable => { + FirstDeliveryEligibility::Unavailable + } + }, + ); + + let reissue = match identity { + None => ActionEligibility::unavailable(ActionUnavailableReason::NoUniquePeer), + Some(identity) => reissue_eligibility(application, identity), + }; + ClientActions { + rename, + delete, + first_delivery, + reissue, + } +} + +fn delete_eligibility(application: &Application, identity: PublicKey) -> ActionEligibility { + if application.draft.document.delete_client(&identity).is_err() { + return ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument); + } + let saved_peer = ConfigDocument::parse(&application.saved.server_config) + .ok() + .and_then(|document| { + document + .peers() + .iter() + .find(|peer| peer.identity == Some(identity)) + .cloned() + }); + if application.draft.states.saved().get(&identity).is_some() || saved_peer.is_none() { + return ActionEligibility::available(); + } + let Some(peer) = saved_peer else { + return ActionEligibility::available(); + }; + let exact_host_routes = !peer.allowed_ips.is_empty() + && peer.allowed_ips.iter().all(|route| match route { + ipnet::IpNet::V4(net) => net.prefix_len() == 32, + ipnet::IpNet::V6(net) => net.prefix_len() == 128, + }); + if exact_host_routes { + ActionEligibility::available() + } else { + ActionEligibility::unavailable(ActionUnavailableReason::ExactHostRoutesRequired) + } +} + +fn reissue_eligibility(application: &Application, identity: PublicKey) -> ActionEligibility { + let saved_document = match ConfigDocument::parse(&application.saved.server_config) { + Ok(document) => document, + Err(_) => { + return ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument); + } + }; + let matching_peers = saved_document + .peers() + .iter() + .filter(|peer| peer.identity == Some(identity)) + .collect::>(); + let Some(peer) = matching_peers.first() else { + return ActionEligibility::unavailable(ActionUnavailableReason::PeerNotInSavedState); + }; + if matching_peers.len() != 1 + || peer.identity != peer.public_key_candidates.first().copied() + || peer.public_key_candidates.len() != 1 + || !peer.issues.is_empty() + || peer.allowed_ips.is_empty() + { + return ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument); + } + let Some(bytes) = application.saved.vault.as_deref() else { + return ActionEligibility::unavailable(ActionUnavailableReason::VaultNotInitialized); + }; + let vault_text = match std::str::from_utf8(bytes) { + Ok(text) => text, + Err(_) => { + return ActionEligibility::unavailable(ActionUnavailableReason::VaultNotInitialized); + } + }; + let vault = match crate::vault::VaultBundle::from_toml(vault_text) { + Ok(vault) => vault, + Err(_) => { + return ActionEligibility::unavailable(ActionUnavailableReason::VaultNotInitialized); + } + }; + if vault.contains_record(identity) { + if vault.reissue_blocked(identity).unwrap_or(true) { + ActionEligibility::unavailable(ActionUnavailableReason::VaultRecordConflict) + } else { + ActionEligibility::available() + } + } else { + ActionEligibility::unavailable(ActionUnavailableReason::VaultRecordMissing) + } +} + +fn restart_is_eligible(application: &Application) -> bool { + let Ok(document) = ConfigDocument::parse(&application.saved.server_config) else { + return false; + }; + document + .sections() + .iter() + .filter(|section| section.kind == crate::wireguard::SectionKind::Interface) + .count() + == 1 + && !document.has_unsafe_save_config() + && super::restart::restart_projection_from_document(&document).is_ok() +} + +fn classify_issues(issues: &[PeerIssue]) -> Vec { + issues + .iter() + .cloned() + .map(|issue| ClassifiedPeerIssue { + class: classify_issue(&issue), + issue, + }) + .collect() +} + +fn classify_issue(issue: &PeerIssue) -> PeerIssueClass { + match issue { + PeerIssue::UnsupportedAllowedIpShape { .. } | PeerIssue::MissingIpv4HostRoute => { + PeerIssueClass::UnsupportedCapability + } + _ => PeerIssueClass::MalformedOrAmbiguous, + } +} diff --git a/src/tui/events.rs b/src/tui/events.rs index 8a220c4..b6a8cd6 100644 --- a/src/tui/events.rs +++ b/src/tui/events.rs @@ -10,13 +10,10 @@ use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; use crate::app::Application; -use crate::app::{ - FirstIssueAttempt, FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartReport, - RestorePreview, -}; +use crate::app::{FirstIssueAttempt, FirstIssueRetry, RestartChoice, RestartReport}; use crate::backup::{ArchiveSummary, BackupReason, HistoricalApplyStatus}; use crate::config::SettingsFile; -use crate::delivery::files::{FileOutputError, PreparedDestination}; +use crate::delivery::files::FileOutputError; use crate::delivery::{QrCodeOutput, QrError}; use crate::domain::{ClientAddresses, PublicKey}; use crate::runtime::RuntimeObservation; @@ -287,396 +284,10 @@ pub trait OperationExecutor: Send + 'static { } } -enum DeliveryMode { - FirstIssue, - Reissue, -} - -struct PreparedDestinationContext { - identity: PublicKey, - include_qr: bool, - mode: DeliveryMode, - attempt: FirstIssueAttempt, - generation: u64, - destination: PreparedDestination, -} - -struct RestorePreviewContext { - _archive_name: String, - generation: u64, - preview: RestorePreview, -} - -#[derive(Default)] -pub struct ApplicationExecutor { - prepared_destination: Option, - restore_preview: Option, -} - -impl OperationExecutor for ApplicationExecutor { - fn execute( - &mut self, - app: &mut Application, - command: UiCommand, - ) -> Result { - self.execute_at(app, command, 0) - } - - fn execute_at( - &mut self, - app: &mut Application, - command: UiCommand, - generation: u64, - ) -> Result { - let delivery_continuation = matches!(command, UiCommand::DeliverFirstIssue { .. }); - let restore_continuation = matches!( - command, - UiCommand::ConfirmRestore { .. } | UiCommand::CancelRestorePreview - ); - if !delivery_continuation && !matches!(&command, UiCommand::CancelPreparedDelivery) { - self.prepared_destination = None; - } - if !restore_continuation && !matches!(&command, UiCommand::PreviewRestore { .. }) { - self.restore_preview = None; - } - let result = match command { - UiCommand::CreateClient { - alias, - manual_addresses, - created_utc, - } => app - .prepare_new_client(&alias, manual_addresses, created_utc.as_deref()) - .map(CommandResult::ClientCreated), - UiCommand::RenameClient { identity, alias } => app - .rename_client(identity, &alias) - .map(|()| CommandResult::ClientRenamed), - UiCommand::DeleteClient { identity } => app - .delete_client(identity) - .map(|record_removed| CommandResult::ClientDeleted { record_removed }), - UiCommand::DiscardDraft => app.discard_draft().map(|()| CommandResult::DraftDiscarded), - UiCommand::Commit => app.commit().map(CommandResult::Committed), - UiCommand::CreateBackup => app.create_manual_backup().map(CommandResult::BackupCreated), - UiCommand::ListBackups => app.list_backups().map(CommandResult::Backups), - UiCommand::ImportSelectedFile { file_name } => app - .import_selected_file(&file_name) - .map(CommandResult::SelectedFileImported), - UiCommand::PreviewRestart => app.preview_restart().map(|preview| { - CommandResult::RestartPreview(RestartPreviewSummary { - has_draft: preview.has_draft(), - interface: preview.interface().to_owned(), - systemd_unit: preview.systemd_unit().to_owned(), - }) - }), - UiCommand::Restart(choice) => { - let result = app - .preview_restart() - .map(|preview| app.restart(preview, choice)); - result.map(CommandResult::Restarted) - } - UiCommand::PrepareDelivery { - identity, - path, - include_qr, - reissue, - attempt, - } => app - .prepare_first_issue_destination(path) - .map_err(crate::app::AppError::FileOutput) - .map(|destination| { - let summary = DeliveryDestinationSummary { - identity, - path: destination.path().to_path_buf(), - overwrite_required: destination.overwrite_confirmation_required(), - include_qr, - reissue, - attempt, - prepared_generation: generation, - }; - self.prepared_destination = Some(PreparedDestinationContext { - identity, - include_qr, - mode: if reissue { - DeliveryMode::Reissue - } else { - DeliveryMode::FirstIssue - }, - attempt, - generation, - destination, - }); - CommandResult::DeliveryDestination(summary) - }), - UiCommand::DeliverFirstIssue { - attempt, - overwrite_confirmed, - prepared_generation, - } => { - let prepared = self - .prepared_destination - .take() - .ok_or_else(|| OperationError { - message: "No prepared output destination is available.".to_owned(), - })?; - if !matches!(prepared.mode, DeliveryMode::FirstIssue) - || prepared.attempt != attempt - || !destination_generation_matches( - prepared.generation, - prepared_generation, - generation, - ) - { - return Err(OperationError { - message: "Prepared output destination is stale; select it again." - .to_owned(), - }); - } - let identity = prepared.identity; - let include_qr = prepared.include_qr; - let mut destination = prepared.destination; - if destination.overwrite_confirmation_required() { - if !overwrite_confirmed { - return Err(OperationError { - message: "Overwrite confirmation was not supplied.".to_owned(), - }); - } - destination = destination.confirm_overwrite().map_err(operation_error)?; - } - if include_qr { - app.deliver_first_issue_file_and_qr(identity, attempt, destination) - .map(|outcome| match outcome { - FirstIssueDeliveryOutcome::Delivered(delivery) => { - let (qr, qr_output) = qr_result(delivery.qr); - CommandResult::FirstIssue(FirstIssueSummary { - identity, - status: FirstIssueStatus::Delivered, - retry: None, - activation_warning: delivery.activation_warning, - qr, - qr_output, - }) - } - FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain { - value, - .. - } => { - let (qr, qr_output) = qr_result(value.qr); - CommandResult::FirstIssue(FirstIssueSummary { - identity, - status: FirstIssueStatus::PublishedButBookkeepingUncertain, - retry: None, - activation_warning: value.activation_warning, - qr, - qr_output, - }) - } - FirstIssueDeliveryOutcome::Failed { - error, - retry, - activation_warning, - } => CommandResult::FirstIssue(FirstIssueSummary { - identity, - status: FirstIssueStatus::Failed(error), - retry: Some(retry), - activation_warning, - qr: QrStatus::NotRequested, - qr_output: None, - }), - }) - } else { - app.deliver_first_issue_file(identity, attempt, destination) - .map(|outcome| match outcome { - FirstIssueDeliveryOutcome::Delivered(delivery) => { - CommandResult::FirstIssue(FirstIssueSummary { - identity, - status: FirstIssueStatus::Delivered, - retry: None, - activation_warning: delivery.activation_warning, - qr: QrStatus::NotRequested, - qr_output: None, - }) - } - FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain { - value, - .. - } => CommandResult::FirstIssue(FirstIssueSummary { - identity, - status: FirstIssueStatus::PublishedButBookkeepingUncertain, - retry: None, - activation_warning: value.activation_warning, - qr: QrStatus::NotRequested, - qr_output: None, - }), - FirstIssueDeliveryOutcome::Failed { - error, - retry, - activation_warning, - } => CommandResult::FirstIssue(FirstIssueSummary { - identity, - status: FirstIssueStatus::Failed(error), - retry: Some(retry), - activation_warning, - qr: QrStatus::NotRequested, - qr_output: None, - }), - }) - } - } - UiCommand::PreviewRestore { file_name } => { - app.preview_restore(&file_name).map(|preview| { - let changes = preview.change_summary(app.saved_bytes()); - let summary = RestorePreviewSummary { - file_name: file_name.clone(), - created_at_utc: preview.created_at_utc().to_owned(), - reason: preview.reason(), - historical_apply: preview.historical_apply().clone(), - changes, - preview_generation: generation, - }; - self.restore_preview = Some(RestorePreviewContext { - _archive_name: file_name, - generation, - preview, - }); - CommandResult::RestorePreview(summary) - }) - } - UiCommand::CancelPreparedDelivery => { - self.prepared_destination = None; - Ok(CommandResult::Cancelled) - } - UiCommand::CancelRestorePreview => { - self.restore_preview = None; - Ok(CommandResult::Cancelled) - } - UiCommand::ConfirmRestore { preview_generation } => { - let context = self.restore_preview.take().ok_or_else(|| OperationError { - message: "Restore preview is no longer available; select the archive again." - .to_owned(), - })?; - if !restore_preview_generation_matches( - context.generation, - preview_generation, - generation, - ) { - return Err(OperationError { - message: "Restore preview is stale; select the archive again.".to_owned(), - }); - } - app.confirm_restore(context.preview) - .map(CommandResult::Restored) - } - }; - result.map_err(|error| OperationError { - message: error.to_string(), - }) - } - - fn execute_sensitive( - &mut self, - app: &mut Application, - command: SensitiveCommand, - ) -> Result { - match command { - SensitiveCommand::PrepareInitialSetup(password) => app - .prepare_initial_setup_from_sources(password.expose()) - .map(CommandResult::InitialSetupPrepared) - .map_err(|error| OperationError { - message: error.to_string(), - }), - SensitiveCommand::ReissueClient { - identity, - password, - overwrite_confirmed, - prepared_generation, - } => { - let prepared = self - .prepared_destination - .take() - .ok_or_else(|| OperationError { - message: "No prepared output destination is available.".to_owned(), - })?; - if prepared.identity != identity - || !matches!(prepared.mode, DeliveryMode::Reissue) - || prepared.generation != prepared_generation - { - return Err(OperationError { - message: "Prepared output destination is stale; select it again." - .to_owned(), - }); - } - let include_qr = prepared.include_qr; - let mut destination = prepared.destination; - if destination.overwrite_confirmation_required() { - if !overwrite_confirmed { - return Err(OperationError { - message: "Overwrite confirmation was not supplied.".to_owned(), - }); - } - destination = destination.confirm_overwrite().map_err(operation_error)?; - } - let delivery = app - .reissue_client_file(identity, password.expose(), destination, include_qr) - .map_err(|error| OperationError { - message: error.to_string(), - })?; - let (qr, qr_output) = match delivery.qr { - None => (QrStatus::NotRequested, None), - Some(result) => qr_result(result), - }; - Ok(CommandResult::Reissued(ReissueSummary { - activation_warning: delivery.activation_warning, - qr, - qr_output, - })) - } - } - } - - fn execute_sensitive_at( - &mut self, - app: &mut Application, - command: SensitiveCommand, - generation: u64, - ) -> Result { - match &command { - SensitiveCommand::ReissueClient { - prepared_generation, - .. - } if *prepared_generation != generation => { - return Err(OperationError { - message: "Prepared output destination is stale; select it again.".to_owned(), - }); - } - _ => {} - } - if matches!(command, SensitiveCommand::PrepareInitialSetup(_)) { - self.prepared_destination = None; - self.restore_preview = None; - } - self.execute_sensitive(app, command) - } -} - -fn operation_error(error: impl std::fmt::Display) -> OperationError { - OperationError { - message: error.to_string(), - } -} - -fn destination_generation_matches(stored: u64, requested: u64, current: u64) -> bool { - stored == requested && stored == current -} - -fn restore_preview_generation_matches(stored: u64, requested: u64, current: u64) -> bool { - stored == requested && current == requested.saturating_add(1) -} - -fn qr_result(result: Result) -> (QrStatus, Option) { - match result { - Ok(output) => (QrStatus::Ready, Some(output)), - Err(error) => (QrStatus::Failed(error), None), - } -} +mod executor; +pub use executor::ApplicationExecutor; +#[cfg(test)] +use executor::{destination_generation_matches, restore_preview_generation_matches}; pub trait StatisticsReader: Send + 'static { /// Implementations must return within `timeout`; unavailable runtime data @@ -1151,6 +762,47 @@ mod tests { ); } + #[test] + fn stale_delivery_confirmation_consumes_prepared_destination_without_delivery() { + let (root, mut app) = application(); + let identity = PublicKey::from_bytes([45; 32]); + let path = root.path().join("prepared.conf"); + let mut executor = ApplicationExecutor::default(); + + assert!(matches!( + executor.execute_at( + &mut app, + UiCommand::PrepareDelivery { + identity, + path, + include_qr: false, + reissue: false, + attempt: FirstIssueAttempt::Initial, + }, + 7, + ), + Ok(CommandResult::DeliveryDestination(_)) + )); + + let stale = UiCommand::DeliverFirstIssue { + attempt: FirstIssueAttempt::Initial, + overwrite_confirmed: false, + prepared_generation: 7, + }; + assert_eq!( + executor + .execute_at(&mut app, stale.clone(), 8) + .unwrap_err() + .message, + "Prepared output destination is stale; select it again." + ); + assert_eq!( + executor.execute_at(&mut app, stale, 7).unwrap_err().message, + "No prepared output destination is available." + ); + assert!(!root.path().join("prepared.conf").exists()); + } + struct FakeStatistics { started: Option>, release: Option>, diff --git a/src/tui/events/executor.rs b/src/tui/events/executor.rs new file mode 100644 index 0000000..8680f59 --- /dev/null +++ b/src/tui/events/executor.rs @@ -0,0 +1,404 @@ +use super::{ + CommandResult, DeliveryDestinationSummary, FirstIssueAttempt, FirstIssueStatus, + FirstIssueSummary, OperationError, OperationExecutor, QrStatus, ReissueSummary, + RestartPreviewSummary, RestorePreviewSummary, SensitiveCommand, UiCommand, +}; +use crate::app::{Application, FirstIssueDeliveryOutcome, RestorePreview}; +use crate::delivery::files::PreparedDestination; +use crate::delivery::{QrCodeOutput, QrError}; +use crate::domain::PublicKey; + +enum DeliveryMode { + FirstIssue, + Reissue, +} + +struct PreparedDestinationContext { + identity: PublicKey, + include_qr: bool, + mode: DeliveryMode, + attempt: FirstIssueAttempt, + generation: u64, + destination: PreparedDestination, +} + +struct RestorePreviewContext { + _archive_name: String, + generation: u64, + preview: RestorePreview, +} + +#[derive(Default)] +pub struct ApplicationExecutor { + prepared_destination: Option, + restore_preview: Option, +} + +impl OperationExecutor for ApplicationExecutor { + fn execute( + &mut self, + app: &mut Application, + command: UiCommand, + ) -> Result { + self.execute_at(app, command, 0) + } + + fn execute_at( + &mut self, + app: &mut Application, + command: UiCommand, + generation: u64, + ) -> Result { + let delivery_continuation = matches!(command, UiCommand::DeliverFirstIssue { .. }); + let restore_continuation = matches!( + command, + UiCommand::ConfirmRestore { .. } | UiCommand::CancelRestorePreview + ); + if !delivery_continuation && !matches!(&command, UiCommand::CancelPreparedDelivery) { + self.prepared_destination = None; + } + if !restore_continuation && !matches!(&command, UiCommand::PreviewRestore { .. }) { + self.restore_preview = None; + } + let result = match command { + UiCommand::CreateClient { + alias, + manual_addresses, + created_utc, + } => app + .prepare_new_client(&alias, manual_addresses, created_utc.as_deref()) + .map(CommandResult::ClientCreated), + UiCommand::RenameClient { identity, alias } => app + .rename_client(identity, &alias) + .map(|()| CommandResult::ClientRenamed), + UiCommand::DeleteClient { identity } => app + .delete_client(identity) + .map(|record_removed| CommandResult::ClientDeleted { record_removed }), + UiCommand::DiscardDraft => app.discard_draft().map(|()| CommandResult::DraftDiscarded), + UiCommand::Commit => app.commit().map(CommandResult::Committed), + UiCommand::CreateBackup => app.create_manual_backup().map(CommandResult::BackupCreated), + UiCommand::ListBackups => app.list_backups().map(CommandResult::Backups), + UiCommand::ImportSelectedFile { file_name } => app + .import_selected_file(&file_name) + .map(CommandResult::SelectedFileImported), + UiCommand::PreviewRestart => app.preview_restart().map(|preview| { + CommandResult::RestartPreview(RestartPreviewSummary { + has_draft: preview.has_draft(), + interface: preview.interface().to_owned(), + systemd_unit: preview.systemd_unit().to_owned(), + }) + }), + UiCommand::Restart(choice) => { + let result = app + .preview_restart() + .map(|preview| app.restart(preview, choice)); + result.map(CommandResult::Restarted) + } + UiCommand::PrepareDelivery { + identity, + path, + include_qr, + reissue, + attempt, + } => app + .prepare_first_issue_destination(path) + .map_err(crate::app::AppError::FileOutput) + .map(|destination| { + let summary = DeliveryDestinationSummary { + identity, + path: destination.path().to_path_buf(), + overwrite_required: destination.overwrite_confirmation_required(), + include_qr, + reissue, + attempt, + prepared_generation: generation, + }; + self.prepared_destination = Some(PreparedDestinationContext { + identity, + include_qr, + mode: if reissue { + DeliveryMode::Reissue + } else { + DeliveryMode::FirstIssue + }, + attempt, + generation, + destination, + }); + CommandResult::DeliveryDestination(summary) + }), + UiCommand::DeliverFirstIssue { + attempt, + overwrite_confirmed, + prepared_generation, + } => { + let prepared = self + .prepared_destination + .take() + .ok_or_else(|| OperationError { + message: "No prepared output destination is available.".to_owned(), + })?; + if !matches!(prepared.mode, DeliveryMode::FirstIssue) + || prepared.attempt != attempt + || !destination_generation_matches( + prepared.generation, + prepared_generation, + generation, + ) + { + return Err(OperationError { + message: "Prepared output destination is stale; select it again." + .to_owned(), + }); + } + let identity = prepared.identity; + let include_qr = prepared.include_qr; + let mut destination = prepared.destination; + if destination.overwrite_confirmation_required() { + if !overwrite_confirmed { + return Err(OperationError { + message: "Overwrite confirmation was not supplied.".to_owned(), + }); + } + destination = destination.confirm_overwrite().map_err(operation_error)?; + } + if include_qr { + app.deliver_first_issue_file_and_qr(identity, attempt, destination) + .map(|outcome| match outcome { + FirstIssueDeliveryOutcome::Delivered(delivery) => { + let (qr, qr_output) = qr_result(delivery.qr); + CommandResult::FirstIssue(FirstIssueSummary { + identity, + status: FirstIssueStatus::Delivered, + retry: None, + activation_warning: delivery.activation_warning, + qr, + qr_output, + }) + } + FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain { + value, + .. + } => { + let (qr, qr_output) = qr_result(value.qr); + CommandResult::FirstIssue(FirstIssueSummary { + identity, + status: FirstIssueStatus::PublishedButBookkeepingUncertain, + retry: None, + activation_warning: value.activation_warning, + qr, + qr_output, + }) + } + FirstIssueDeliveryOutcome::Failed { + error, + retry, + activation_warning, + } => CommandResult::FirstIssue(FirstIssueSummary { + identity, + status: FirstIssueStatus::Failed(error), + retry: Some(retry), + activation_warning, + qr: QrStatus::NotRequested, + qr_output: None, + }), + }) + } else { + app.deliver_first_issue_file(identity, attempt, destination) + .map(|outcome| match outcome { + FirstIssueDeliveryOutcome::Delivered(delivery) => { + CommandResult::FirstIssue(FirstIssueSummary { + identity, + status: FirstIssueStatus::Delivered, + retry: None, + activation_warning: delivery.activation_warning, + qr: QrStatus::NotRequested, + qr_output: None, + }) + } + FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain { + value, + .. + } => CommandResult::FirstIssue(FirstIssueSummary { + identity, + status: FirstIssueStatus::PublishedButBookkeepingUncertain, + retry: None, + activation_warning: value.activation_warning, + qr: QrStatus::NotRequested, + qr_output: None, + }), + FirstIssueDeliveryOutcome::Failed { + error, + retry, + activation_warning, + } => CommandResult::FirstIssue(FirstIssueSummary { + identity, + status: FirstIssueStatus::Failed(error), + retry: Some(retry), + activation_warning, + qr: QrStatus::NotRequested, + qr_output: None, + }), + }) + } + } + UiCommand::PreviewRestore { file_name } => { + app.preview_restore(&file_name).map(|preview| { + let changes = preview.change_summary(app.saved_bytes()); + let summary = RestorePreviewSummary { + file_name: file_name.clone(), + created_at_utc: preview.created_at_utc().to_owned(), + reason: preview.reason(), + historical_apply: preview.historical_apply().clone(), + changes, + preview_generation: generation, + }; + self.restore_preview = Some(RestorePreviewContext { + _archive_name: file_name, + generation, + preview, + }); + CommandResult::RestorePreview(summary) + }) + } + UiCommand::CancelPreparedDelivery => { + self.prepared_destination = None; + Ok(CommandResult::Cancelled) + } + UiCommand::CancelRestorePreview => { + self.restore_preview = None; + Ok(CommandResult::Cancelled) + } + UiCommand::ConfirmRestore { preview_generation } => { + let context = self.restore_preview.take().ok_or_else(|| OperationError { + message: "Restore preview is no longer available; select the archive again." + .to_owned(), + })?; + if !restore_preview_generation_matches( + context.generation, + preview_generation, + generation, + ) { + return Err(OperationError { + message: "Restore preview is stale; select the archive again.".to_owned(), + }); + } + app.confirm_restore(context.preview) + .map(CommandResult::Restored) + } + }; + result.map_err(|error| OperationError { + message: error.to_string(), + }) + } + + fn execute_sensitive( + &mut self, + app: &mut Application, + command: SensitiveCommand, + ) -> Result { + match command { + SensitiveCommand::PrepareInitialSetup(password) => app + .prepare_initial_setup_from_sources(password.expose()) + .map(CommandResult::InitialSetupPrepared) + .map_err(|error| OperationError { + message: error.to_string(), + }), + SensitiveCommand::ReissueClient { + identity, + password, + overwrite_confirmed, + prepared_generation, + } => { + let prepared = self + .prepared_destination + .take() + .ok_or_else(|| OperationError { + message: "No prepared output destination is available.".to_owned(), + })?; + if prepared.identity != identity + || !matches!(prepared.mode, DeliveryMode::Reissue) + || prepared.generation != prepared_generation + { + return Err(OperationError { + message: "Prepared output destination is stale; select it again." + .to_owned(), + }); + } + let include_qr = prepared.include_qr; + let mut destination = prepared.destination; + if destination.overwrite_confirmation_required() { + if !overwrite_confirmed { + return Err(OperationError { + message: "Overwrite confirmation was not supplied.".to_owned(), + }); + } + destination = destination.confirm_overwrite().map_err(operation_error)?; + } + let delivery = app + .reissue_client_file(identity, password.expose(), destination, include_qr) + .map_err(|error| OperationError { + message: error.to_string(), + })?; + let (qr, qr_output) = match delivery.qr { + None => (QrStatus::NotRequested, None), + Some(result) => qr_result(result), + }; + Ok(CommandResult::Reissued(ReissueSummary { + activation_warning: delivery.activation_warning, + qr, + qr_output, + })) + } + } + } + + fn execute_sensitive_at( + &mut self, + app: &mut Application, + command: SensitiveCommand, + generation: u64, + ) -> Result { + match &command { + SensitiveCommand::ReissueClient { + prepared_generation, + .. + } if *prepared_generation != generation => { + return Err(OperationError { + message: "Prepared output destination is stale; select it again.".to_owned(), + }); + } + _ => {} + } + if matches!(command, SensitiveCommand::PrepareInitialSetup(_)) { + self.prepared_destination = None; + self.restore_preview = None; + } + self.execute_sensitive(app, command) + } +} + +fn operation_error(error: impl std::fmt::Display) -> OperationError { + OperationError { + message: error.to_string(), + } +} + +pub(super) fn destination_generation_matches(stored: u64, requested: u64, current: u64) -> bool { + stored == requested && stored == current +} + +pub(super) fn restore_preview_generation_matches( + stored: u64, + requested: u64, + current: u64, +) -> bool { + stored == requested && current == requested.saturating_add(1) +} + +fn qr_result(result: Result) -> (QrStatus, Option) { + match result { + Ok(output) => (QrStatus::Ready, Some(output)), + Err(error) => (QrStatus::Failed(error), None), + } +} diff --git a/src/tui/render.rs b/src/tui/render.rs index a07e6f5..34efec0 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -208,7 +208,9 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec { add("p", "preview", HintKind::Action); add("c", "Commit", HintKind::Action); add("x", "discard", HintKind::Destructive); - add("R", "restart", HintKind::Destructive); + if restart_available(state) { + add("R", "restart", HintKind::Destructive); + } add("b", "backups / restore", HintKind::Action); add("B", "backup", HintKind::Action); add("!", "issues", HintKind::Action); @@ -219,22 +221,21 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec { { add("s", "setup", HintKind::Action); } - if state.selected_client_mutable() { + if state.selected_rename_available() { add("r", "rename", HintKind::Action); + } + if state.selected_delete_available() { add("d", "delete", HintKind::Destructive); - let saved = state - .selected_identity - .and_then(|id| state.view.as_ref()?.client_states.get(&id)) - .is_some_and(|row| row.saved.is_some()); - if saved { - add("o", "first file", HintKind::Action); - add("O", "password reissue", HintKind::Action); - } + } + if state.selected_first_delivery_available() { + add("o", "first file", HintKind::Action); + } + if state.selected_reissue_available() { + add("O", "password reissue", HintKind::Action); } } Screen::ClientList => { let has_view = state.view.is_some(); - let selected = state.selected_client_mutable(); add("↑/↓", "select", HintKind::Navigation); if state .view @@ -246,23 +247,22 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec { add("?", "Help", HintKind::Navigation); if has_view { add("n", "new", HintKind::Action); - if selected { + if state.selected_rename_available() { add("r", "rename", HintKind::Action); + } + if state.selected_delete_available() { add("d", "delete", HintKind::Destructive); } add("p", "preview", HintKind::Action); add("c", "Commit", HintKind::Action); add("x", "discard", HintKind::Destructive); - add("R", "restart", HintKind::Destructive); - if let Some(id) = state.selected_identity - && state - .view - .as_ref() - .and_then(|view| view.client_states.get(&id)) - .is_some_and(|row| row.saved.is_some()) - && selected - { + if restart_available(state) { + add("R", "restart", HintKind::Destructive); + } + if state.selected_first_delivery_available() { add("o", "first file", HintKind::Action); + } + if state.selected_reissue_available() { add("O", "password reissue", HintKind::Action); } add("b", "backups / restore", HintKind::Action); @@ -286,6 +286,14 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec { hints } +fn restart_available(state: &UiState) -> bool { + state + .view + .as_ref() + .and_then(SessionView::restart_eligibility) + .is_none_or(|eligibility| eligibility.available) +} + fn pack_hints(hints: &[Hint], width: usize) -> Vec> { let mut rows = Vec::>::new(); let mut used = 0; @@ -649,10 +657,7 @@ fn qr_state_summary(state: &UiState, width: usize) -> Option { }, view.saved_peer_count .map_or_else(|| "unknown".to_owned(), |count| count.to_string()), - view.client_states - .values() - .filter(|row| row.saved.is_some()) - .count(), + view.saved_managed_client_count(), match view.applied { crate::domain::ApplyStatus::Verified => "verified", crate::domain::ApplyStatus::Unknown => "not verified", @@ -827,37 +832,83 @@ fn draft_review_lines(state: &UiState) -> Vec { if view.has_draft { "yes" } else { "no" } ), ]; - for (identity, row) in &view.client_states { - match (&row.saved, &row.draft) { - (None, Some(draft)) => lines.push(format!( - "Added {identity}: {} (IPv4 {}, IPv6 {})", - safe_text(&draft.alias), - draft.addresses.ipv4, - draft - .addresses - .ipv6 - .map_or_else(|| "none".to_owned(), |ip| ip.to_string()) - )), - (Some(saved), None) => lines.push(format!( - "Removed {identity}: {} (saved removal remains unapplied until restart)", - safe_text(&saved.alias) - )), - (Some(saved), Some(draft)) if saved != draft => lines.push(format!( - "Changed {identity}: alias {} → {}; addresses {} / {} → {} / {}", - safe_text(&saved.alias), - safe_text(&draft.alias), - saved.addresses.ipv4, - saved - .addresses - .ipv6 - .map_or_else(|| "none".to_owned(), |ip| ip.to_string()), - draft.addresses.ipv4, - draft - .addresses - .ipv6 - .map_or_else(|| "none".to_owned(), |ip| ip.to_string()) - )), - _ => {} + if let Some(application) = &view.application { + for peer in &application.clients { + let identity = peer.identity; + match ( + peer.state_change, + peer.saved_client.as_ref(), + peer.draft_client.as_ref(), + ) { + (crate::app::ClientStateChange::Added, _, Some(draft)) => lines.push(format!( + "Added {}: {} (IPv4 {}, IPv6 {})", + identity.map_or_else(|| "unknown key".to_owned(), |key| key.to_string()), + safe_text(&draft.alias), + draft.addresses.ipv4, + draft + .addresses + .ipv6 + .map_or_else(|| "none".to_owned(), |ip| ip.to_string()) + )), + (crate::app::ClientStateChange::Removed, _, _) => lines.push(format!( + "Removed {}: {} (saved removal remains unapplied until restart)", + identity.map_or_else(|| "unknown key".to_owned(), |key| key.to_string()), + safe_text(&peer.label) + )), + (crate::app::ClientStateChange::Changed, Some(saved), Some(draft)) => { + lines.push(format!( + "Changed {}: alias {} → {}; addresses {} / {} → {} / {}", + identity.map_or_else(|| "unknown key".to_owned(), |key| key.to_string()), + safe_text(&saved.alias), + safe_text(&draft.alias), + saved.addresses.ipv4, + saved + .addresses + .ipv6 + .map_or_else(|| "none".to_owned(), |ip| ip.to_string()), + draft.addresses.ipv4, + draft + .addresses + .ipv6 + .map_or_else(|| "none".to_owned(), |ip| ip.to_string()) + )) + } + _ => {} + } + } + } else { + for (identity, row) in &view.client_states { + match (&row.saved, &row.draft) { + (None, Some(draft)) => lines.push(format!( + "Added {identity}: {} (IPv4 {}, IPv6 {})", + safe_text(&draft.alias), + draft.addresses.ipv4, + draft + .addresses + .ipv6 + .map_or_else(|| "none".to_owned(), |ip| ip.to_string()) + )), + (Some(saved), None) => lines.push(format!( + "Removed {identity}: {} (saved removal remains unapplied until restart)", + safe_text(&saved.alias) + )), + (Some(saved), Some(draft)) if saved != draft => lines.push(format!( + "Changed {identity}: alias {} → {}; addresses {} / {} → {} / {}", + safe_text(&saved.alias), + safe_text(&draft.alias), + saved.addresses.ipv4, + saved + .addresses + .ipv6 + .map_or_else(|| "none".to_owned(), |ip| ip.to_string()), + draft.addresses.ipv4, + draft + .addresses + .ipv6 + .map_or_else(|| "none".to_owned(), |ip| ip.to_string()) + )), + _ => {} + } } } if lines.len() == 2 && view.has_draft { @@ -884,25 +935,38 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta .clients .iter() .map(|client| { - let visible_issues: Vec<_> = client.presented_issues().collect(); + let visible_issues = view.presented_issues(client); let identity = client .identity .map(|key| short_key(&key)) .unwrap_or_else(|| format!("peer at line {}", client.line)); let (ipv4, ipv6, route_marker) = client_address_cells(client); - let row_state = client - .identity - .and_then(|identity| view.client_states.get(&identity)) - .map_or_else( - || "Saved ?".to_owned(), - |row| match (&row.saved, &row.draft) { - (None, Some(_)) => "Draft added".to_owned(), - (Some(saved), Some(draft)) if saved != draft => "Draft changed".to_owned(), - (Some(_), None) => "Draft removed".to_owned(), - (Some(_), Some(_)) => "Saved".to_owned(), - (None, None) => "State unavailable".to_owned(), - }, - ); + let row_state = view.client_state_change(client).map_or_else( + || { + client + .identity + .and_then(|identity| view.client_states.get(&identity)) + .map_or_else( + || "Saved ?".to_owned(), + |row| match (&row.saved, &row.draft) { + (None, Some(_)) => "Draft added".to_owned(), + (Some(saved), Some(draft)) if saved != draft => { + "Draft changed".to_owned() + } + (Some(_), None) => "Draft removed".to_owned(), + (Some(_), Some(_)) => "Saved".to_owned(), + (None, None) => "State unavailable".to_owned(), + }, + ) + }, + |change| match change { + crate::app::ClientStateChange::Added => "Draft added".to_owned(), + crate::app::ClientStateChange::Removed => "Draft removed".to_owned(), + crate::app::ClientStateChange::Changed => "Draft changed".to_owned(), + crate::app::ClientStateChange::Unchanged => "Saved".to_owned(), + crate::app::ClientStateChange::UnsupportedOrUnmanaged => "Saved ?".to_owned(), + }, + ); let row_state = if layout.compact { compact_state_label(&row_state).to_owned() } else { @@ -935,46 +999,94 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta ) }) .collect(); - rows.extend(view.client_states.iter().filter_map(|(identity, row)| { - let saved = row.saved.as_ref()?; - if row.draft.is_some() - || view - .clients - .iter() - .any(|peer| peer.identity == Some(*identity)) - { - return None; - } - let ipv6 = saved - .addresses - .ipv6 - .map_or_else(|| "none".to_owned(), |address| address.to_string()); - Some(client_table_row( - &layout.columns, - [ - if layout.compact { - "Removed".to_owned() - } else { - "Draft removed".to_owned() - }, - safe_text(&saved.alias), - identity.to_string(), - saved.addresses.ipv4.to_string(), - ipv6, - String::new(), - "Managed".to_owned(), - address_summary( - &saved.addresses.ipv4.to_string(), - &saved + let removed_clients = if let Some(application) = &view.application { + application + .clients + .iter() + .filter_map(|peer| { + if peer.state_change != crate::app::ClientStateChange::Removed { + return None; + } + let identity = peer.identity?; + let (ipv4, ipv6) = peer.saved_client.as_ref().map_or_else( + || removed_peer_addresses(&peer.allowed_ips), + |saved| { + ( + saved.addresses.ipv4.to_string(), + saved + .addresses + .ipv6 + .map_or_else(String::new, |ip| ip.to_string()), + ) + }, + ); + Some(( + identity, + peer.label.clone(), + ipv4, + ipv6, + if peer.saved_client.is_some() { + "Managed" + } else { + "Inspect only" + }, + )) + }) + .collect::>() + } else { + view.client_states + .iter() + .filter_map(|(identity, row)| { + let saved = row.saved.as_ref()?; + if row.draft.is_some() + || view + .clients + .iter() + .any(|peer| peer.identity == Some(*identity)) + { + return None; + } + Some(( + *identity, + saved.alias.clone(), + saved.addresses.ipv4.to_string(), + saved .addresses .ipv6 - .map_or_else(String::new, |address| address.to_string()), - "", - ), - ], - false, - )) - })); + .map_or_else(String::new, |ip| ip.to_string()), + "Managed", + )) + }) + .collect::>() + }; + rows.extend( + removed_clients + .iter() + .map(|(identity, label, ipv4, ipv6, access)| { + client_table_row( + &layout.columns, + [ + if layout.compact { + "Removed".to_owned() + } else { + "Draft removed".to_owned() + }, + safe_text(label), + identity.to_string(), + ipv4.clone(), + if ipv6.is_empty() { + "none".to_owned() + } else { + ipv6.clone() + }, + String::new(), + (*access).to_owned(), + address_summary(ipv4, ipv6, ""), + ], + false, + ) + }), + ); rows.extend(view.document_issues.iter().map(|issue| { client_table_row( &layout.columns, @@ -1110,23 +1222,60 @@ fn render_client_screen(frame: &mut Frame<'_>, area: Rect, state: &UiState) { fn selected_display(state: &UiState) -> Option { match state.selected_record()? { VisibleRecord::Peer(client) => Some(client.clone()), - VisibleRecord::DraftRemoved { identity, client } => { - let mut allowed_ips = vec![format!("{}/32", client.addresses.ipv4)]; - if let Some(ipv6) = client.addresses.ipv6 { - allowed_ips.push(format!("{ipv6}/128")); - } - Some(ClientDisplay { - peer_index: usize::MAX, - line: 0, - identity: Some(identity), - label: client.alias.clone(), - allowed_ips, - has_subnet_routes: false, - issues: Vec::new(), - mutable_identity: None, - }) + VisibleRecord::DraftRemoved { + identity, + peer: Some(peer), + .. + } => Some(ClientDisplay { + peer_index: usize::MAX, + line: peer.line, + identity: Some(identity), + label: peer.label.clone(), + allowed_ips: peer.allowed_ips.clone(), + has_subnet_routes: peer.has_subnet_routes, + issues: peer.issues.clone(), + mutable_identity: None, + }), + VisibleRecord::DraftRemoved { + identity, + client: Some(client), + .. + } => Some(ClientDisplay { + peer_index: usize::MAX, + line: 0, + identity: Some(identity), + label: client.alias.clone(), + allowed_ips: client_routes(client), + has_subnet_routes: false, + issues: Vec::new(), + mutable_identity: None, + }), + VisibleRecord::DraftRemoved { .. } => None, + } +} + +fn client_routes(client: &crate::domain::Client) -> Vec { + let mut routes = vec![format!("{}/32", client.addresses.ipv4)]; + if let Some(ipv6) = client.addresses.ipv6 { + routes.push(format!("{ipv6}/128")); + } + routes +} + +fn removed_peer_addresses(allowed_ips: &[String]) -> (String, String) { + let mut ipv4 = String::new(); + let mut ipv6 = String::new(); + for route in allowed_ips { + let Some((address, _)) = route.split_once('/') else { + continue; + }; + match address.parse::() { + Ok(std::net::IpAddr::V4(address)) if ipv4.is_empty() => ipv4 = address.to_string(), + Ok(std::net::IpAddr::V6(address)) if ipv6.is_empty() => ipv6 = address.to_string(), + _ => {} } } + (ipv4, ipv6) } fn persistent_detail_lines( @@ -1204,11 +1353,7 @@ fn persistent_state_line(view: &SessionView) -> String { let saved_peers = view .saved_peer_count .map_or_else(|| "unknown".to_owned(), |count| count.to_string()); - let saved_managed = view - .client_states - .values() - .filter(|row| row.saved.is_some()) - .count(); + let saved_managed = view.saved_managed_client_count(); let applied = match view.applied { crate::domain::ApplyStatus::Verified => "verified", crate::domain::ApplyStatus::Unknown => "not verified", @@ -1254,7 +1399,7 @@ fn inspection_summary(client: &ClientDisplay, view: &SessionView) -> String { } else if client .issues .iter() - .any(|issue| client.is_capability_limitation_issue(issue)) + .any(|issue| view.is_capability_limitation_issue(client, issue)) { "Inspect: address shape; Enter for details".to_owned() } else if !client.issues.is_empty() { @@ -1629,7 +1774,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec let has_capability_limitation = client .issues .iter() - .any(|issue| client.is_capability_limitation_issue(issue)); + .any(|issue| view.is_capability_limitation_issue(client, issue)); if client.has_subnet_routes { lines.push(Line::from( "This peer contains subnet routes. Managing this configuration is not supported yet.", @@ -1640,18 +1785,26 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec )); } if let Some(identity) = client.identity { - if let Some(row) = view.client_states.get(&identity) { + let managed_state = view + .application_record_for_client(client) + .map(|peer| (&peer.draft_client, &peer.saved_client)) + .or_else(|| { + view.client_states + .get(&identity) + .map(|row| (&row.draft, &row.saved)) + }); + if let Some((draft, saved)) = managed_state { lines.push(Line::from(format!( "Draft managed record: {}", - safe_text(row.draft.as_ref().map_or("absent", |c| c.alias.as_str())) + safe_text(draft.as_ref().map_or("absent", |c| c.alias.as_str())) ))); lines.push(Line::from(format!( "Saved managed record: {}", - safe_text(row.saved.as_ref().map_or("absent", |c| c.alias.as_str())) + safe_text(saved.as_ref().map_or("absent", |c| c.alias.as_str())) ))); - if row.draft != row.saved { + if draft != saved { lines.push(Line::from("Draft differs from saved state.")); - if let Some(client) = &row.draft { + if let Some(client) = draft { lines.push(Line::from(format!( "Draft addresses: IPv4 {}, IPv6 {}", client.addresses.ipv4, @@ -1661,7 +1814,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec .map_or_else(|| "none".to_owned(), |ip| ip.to_string()) ))); } - if let Some(client) = &row.saved { + if let Some(client) = saved { lines.push(Line::from(format!( "Saved addresses: IPv4 {}, IPv6 {}", client.addresses.ipv4, @@ -1703,7 +1856,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec Line::from(issue_summary(view)), Line::from("Handshake and counters are observations, not apply verification."), ]); - for issue in client.presented_issues() { + for issue in view.presented_issues(client) { lines.push(Line::from(format!( "Peer issue: {}", safe_text(&format!("{issue:?}")) @@ -1767,10 +1920,7 @@ fn state_summary(view: &SessionView) -> String { } fn managed_change_count(view: &SessionView) -> usize { - view.client_states - .values() - .filter(|row| row.draft != row.saved) - .count() + view.managed_change_count() } fn draft_status(view: &SessionView) -> String { @@ -1784,11 +1934,7 @@ fn draft_status(view: &SessionView) -> String { } fn saved_status(view: &SessionView) -> String { - let managed = view - .client_states - .values() - .filter(|row| row.saved.is_some()) - .count(); + let managed = view.saved_managed_client_count(); match view.saved_peer_count { Some(peers) => format!("Saved: {peers} peer sections; {managed} managed clients"), None => format!("Saved: peer count unavailable; {managed} managed clients"), @@ -1814,7 +1960,7 @@ fn issue_summary(view: &SessionView) -> String { let peer_problems = view .clients .iter() - .filter(|peer| peer.presented_issues().next().is_some()) + .filter(|peer| !view.presented_issues(peer).is_empty()) .count(); let capability_limits = view .clients @@ -1824,7 +1970,7 @@ fn issue_summary(view: &SessionView) -> String { || peer .issues .iter() - .any(|issue| peer.is_capability_limitation_issue(issue)) + .any(|issue| view.is_capability_limitation_issue(peer, issue)) }) .count(); format!( @@ -2334,9 +2480,145 @@ mod tests { statistics: RuntimeObservation::unknown(), has_draft: true, vault_initialized: false, + application: None, } } + #[test] + fn draft_review_shows_removed_unmanaged_authoritative_peer() { + let identity = PublicKey::from_bytes([45; 32]); + let mut view = empty_view(); + view.application = Some(crate::app::ApplicationView { + clients: vec![crate::app::PeerRecord { + peer_index: None, + draft_peer_index: None, + saved_peer_index: Some(2), + line: 8, + identity: Some(identity), + label: "legacy route device".to_owned(), + allowed_ips: vec!["10.0.0.45/32".to_owned(), "fd00::45/128".to_owned()], + has_subnet_routes: false, + issues: Vec::new(), + classified_issues: Vec::new(), + mutable_identity: None, + draft_client: None, + saved_client: None, + state_change: crate::app::ClientStateChange::Removed, + actions: crate::app::ClientActions { + rename: crate::app::ActionEligibility { + available: false, + reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft), + }, + delete: crate::app::ActionEligibility { + available: false, + reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft), + }, + first_delivery: crate::app::FirstDeliveryEligibility::Unavailable, + reissue: crate::app::ActionEligibility { + available: false, + reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft), + }, + }, + }], + document_issues: Vec::new(), + saved_peer_count: 1, + draft_client_count: 0, + saved_client_count: 0, + reservations: crate::app::ReservationFacts::default(), + applied: crate::domain::ApplyStatus::Unknown, + vault_initialized: false, + has_draft: true, + restart: crate::app::ActionEligibility { + available: true, + reason: None, + }, + }); + let state = UiState { + view: Some(view), + ..UiState::default() + }; + + let lines = draft_review_lines(&state); + let removed = lines + .iter() + .find(|line| line.starts_with("Removed ")) + .expect("removed peer appears in draft review"); + assert!(removed.contains(&identity.to_string())); + assert!(removed.contains("legacy route device")); + assert!(removed.contains("unapplied until restart")); + } + + #[test] + fn list_shows_removed_unprojectable_peer_as_inspection_only() { + let identity = PublicKey::from_bytes([46; 32]); + let mut view = empty_view(); + view.application = Some(crate::app::ApplicationView { + clients: vec![crate::app::PeerRecord { + peer_index: None, + draft_peer_index: None, + saved_peer_index: Some(3), + line: 10, + identity: Some(identity), + label: "legacy route device".to_owned(), + allowed_ips: vec!["10.0.0.46/32".to_owned(), "fd00::46/128".to_owned()], + has_subnet_routes: false, + issues: Vec::new(), + classified_issues: Vec::new(), + mutable_identity: None, + draft_client: None, + saved_client: None, + state_change: crate::app::ClientStateChange::Removed, + actions: crate::app::ClientActions { + rename: crate::app::ActionEligibility { + available: false, + reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft), + }, + delete: crate::app::ActionEligibility { + available: false, + reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft), + }, + first_delivery: crate::app::FirstDeliveryEligibility::Unavailable, + reissue: crate::app::ActionEligibility { + available: false, + reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft), + }, + }, + }], + document_issues: Vec::new(), + saved_peer_count: 1, + draft_client_count: 0, + saved_client_count: 0, + reservations: crate::app::ReservationFacts::default(), + applied: crate::domain::ApplyStatus::Unknown, + vault_initialized: false, + has_draft: true, + restart: crate::app::ActionEligibility { + available: true, + reason: None, + }, + }); + let state = UiState { + view: Some(view), + ..UiState::default() + }; + let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); + terminal + .draw(|frame| draw(frame, &state, &FormController::default())) + .unwrap(); + + let text = rendered_text(&terminal); + for expected in [ + "Draft removed", + "legacy route device", + "10.0.0.46", + "fd00::46", + "Inspect only", + ] { + assert!(text.contains(expected), "missing {expected:?}:\n{text}"); + } + assert!(text.contains(&identity.to_string())); + } + #[test] fn help_document_covers_nine_sections_and_distinguishes_case_sensitive_actions() { let rows = help_document(178); @@ -2550,6 +2832,7 @@ mod tests { statistics: RuntimeObservation::unknown(), has_draft: false, vault_initialized: true, + application: None, } } @@ -2838,6 +3121,77 @@ mod tests { ); } + #[test] + fn inspection_row_hints_follow_each_application_action_eligibility() { + let identity = PublicKey::from_bytes([44; 32]); + let mut view = empty_view(); + let unavailable = crate::app::ActionEligibility { + available: false, + reason: Some(crate::app::ActionUnavailableReason::UnsafePeerOrDocument), + }; + view.clients.push(ClientDisplay { + peer_index: 3, + line: 4, + identity: Some(identity), + label: "legacy peer".to_owned(), + allowed_ips: vec!["10.0.0.2/32".to_owned()], + has_subnet_routes: false, + issues: vec![crate::wireguard::PeerIssue::MissingIpv4HostRoute], + mutable_identity: None, + }); + view.application = Some(crate::app::ApplicationView { + clients: vec![crate::app::PeerRecord { + peer_index: Some(3), + draft_peer_index: Some(3), + saved_peer_index: Some(3), + line: 4, + identity: Some(identity), + label: "legacy peer".to_owned(), + allowed_ips: vec!["10.0.0.2/32".to_owned()], + has_subnet_routes: false, + issues: vec![crate::wireguard::PeerIssue::MissingIpv4HostRoute], + classified_issues: vec![crate::app::ClassifiedPeerIssue { + issue: crate::wireguard::PeerIssue::MissingIpv4HostRoute, + class: crate::app::PeerIssueClass::UnsupportedCapability, + }], + mutable_identity: None, + draft_client: None, + saved_client: None, + state_change: crate::app::ClientStateChange::UnsupportedOrUnmanaged, + actions: crate::app::ClientActions { + rename: unavailable.clone(), + delete: crate::app::ActionEligibility { + available: true, + reason: None, + }, + first_delivery: crate::app::FirstDeliveryEligibility::Unavailable, + reissue: unavailable.clone(), + }, + }], + document_issues: Vec::new(), + saved_peer_count: 1, + draft_client_count: 0, + saved_client_count: 0, + reservations: Default::default(), + applied: crate::domain::ApplyStatus::Unknown, + vault_initialized: true, + has_draft: false, + restart: unavailable, + }); + let state = UiState { + selected_identity: Some(identity), + selected_peer_index: Some(3), + view: Some(view), + ..UiState::default() + }; + let hints = keyboard_hints(&state, &FormController::default()); + let available_keys = hints.iter().map(|hint| hint.key).collect::>(); + assert!(available_keys.contains(&"d")); + for unavailable_key in ["r", "o", "O", "R"] { + assert!(!available_keys.contains(&unavailable_key)); + } + } + #[test] fn standard_footer_is_four_rows_and_list_height_is_stable_across_states() { let identity = PublicKey::from_bytes([4; 32]); @@ -3937,6 +4291,7 @@ mod tests { statistics: RuntimeObservation::unknown(), has_draft: false, vault_initialized: true, + application: None, }; let state = UiState { screen: Screen::ClientDetails, @@ -4060,6 +4415,7 @@ mod tests { statistics: RuntimeObservation::unknown(), has_draft: true, vault_initialized: true, + application: None, }; let mut state = UiState { view: Some(view), diff --git a/src/tui/state.rs b/src/tui/state.rs index dbb941e..29ecf6a 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -3,7 +3,10 @@ use std::collections::BTreeSet; use std::net::IpAddr; -use crate::app::{Application, ClientDisplay}; +use crate::app::{ + ActionEligibility, Application, ApplicationView, ClientDisplay, FirstDeliveryEligibility, + PeerRecord, +}; use crate::domain::{ApplyStatus, Client, PublicKey}; use crate::runtime::RuntimeObservation; @@ -56,45 +59,191 @@ pub struct SessionView { pub statistics: RuntimeObservation, pub has_draft: bool, pub vault_initialized: bool, + /// Authoritative application facts used to present action availability. + /// The parallel fields above are the navigation/rendering projection. + pub application: Option, } impl SessionView { pub fn from_application(app: &Application, generation: u64) -> Self { - let states = app.states(); - let mut identities = BTreeSet::new(); - identities.extend(states.saved().clients().map(|client| client.identity)); - identities.extend(states.draft().clients().map(|client| client.identity)); - let client_states = identities - .into_iter() - .map(|identity| { - ( - identity, - ClientStateRow { - saved: states.saved().get(&identity).cloned(), - draft: states.draft().get(&identity).cloned(), - }, - ) - }) + let application = app.read_model(); + let mut client_states = std::collections::BTreeMap::new(); + for row in &application.clients { + if let Some(identity) = row.identity { + if row.saved_client.is_none() && row.draft_client.is_none() { + continue; + } + let state = client_states + .entry(identity) + .or_insert_with(|| ClientStateRow { + saved: None, + draft: None, + }); + if state.saved.is_none() { + state.saved.clone_from(&row.saved_client); + } + if state.draft.is_none() { + state.draft.clone_from(&row.draft_client); + } + } + } + let clients = application + .clients + .iter() + .filter_map(navigation_peer) .collect(); - let projection = app.display_projection(); - let saved_peer_count = - crate::wireguard::ConfigDocument::parse(&app.saved_bytes().server_config) - .ok() - .map(|document| document.peers().len()); Self { generation, - clients: projection.clients, + clients, client_states, - saved_peer_count, - document_issues: projection.document_issues, - applied: *states.applied(), - reserved_addresses: states.reserved_addresses().clone(), + saved_peer_count: Some(application.saved_peer_count), + document_issues: application.document_issues.clone(), + applied: application.applied, + reserved_addresses: application.reservations.committed.iter().copied().collect(), statistics: RuntimeObservation::unknown(), - has_draft: app.has_draft_changes(), - vault_initialized: app.vault_initialized(), + has_draft: application.has_draft, + vault_initialized: application.vault_initialized, + application: Some(application), } } + fn application_peer( + &self, + identity: PublicKey, + peer_index: Option, + ) -> Option<&PeerRecord> { + let application = self.application.as_ref()?; + let mut matching = application + .clients + .iter() + .filter(|peer| peer.identity == Some(identity)); + let peer = matching.next()?; + if matching.next().is_some() || peer.peer_index != peer_index { + return None; + } + Some(peer) + } + + pub fn application_record_for_client(&self, client: &ClientDisplay) -> Option<&PeerRecord> { + let identity = client.identity?; + self.application_peer(identity, Some(client.peer_index)) + } + + pub fn selected_rename_eligibility(&self, identity: PublicKey, peer_index: usize) -> bool { + self.application_peer(identity, Some(peer_index)) + .is_some_and(|peer| peer.actions.rename.available) + } + + pub fn selected_delete_eligibility(&self, identity: PublicKey, peer_index: usize) -> bool { + self.application_peer(identity, Some(peer_index)) + .is_some_and(|peer| peer.actions.delete.available) + } + + pub fn selected_first_delivery_eligibility( + &self, + identity: PublicKey, + peer_index: usize, + ) -> bool { + self.application_peer(identity, Some(peer_index)) + .is_some_and(|peer| { + peer.actions.first_delivery != FirstDeliveryEligibility::Unavailable + }) + } + + pub fn selected_reissue_eligibility(&self, identity: PublicKey, peer_index: usize) -> bool { + self.application_peer(identity, Some(peer_index)) + .is_some_and(|peer| peer.actions.reissue.available) + } + + pub fn restart_eligibility(&self) -> Option<&ActionEligibility> { + self.application.as_ref().map(|view| &view.restart) + } + + pub fn is_capability_limitation_issue( + &self, + client: &ClientDisplay, + issue: &crate::wireguard::PeerIssue, + ) -> bool { + self.application + .as_ref() + .and_then(|application| { + application.clients.iter().find(|peer| { + peer.peer_index == Some(client.peer_index) && peer.identity == client.identity + }) + }) + .and_then(|peer| { + peer.classified_issues + .iter() + .find(|classified| &classified.issue == issue) + }) + .map_or_else( + || client.is_capability_limitation_issue(issue), + |classified| classified.class == crate::app::PeerIssueClass::UnsupportedCapability, + ) + } + + pub fn presented_issues<'a>( + &'a self, + client: &'a ClientDisplay, + ) -> Vec<&'a crate::wireguard::PeerIssue> { + client + .issues + .iter() + .filter(|issue| !self.is_capability_limitation_issue(client, issue)) + .collect() + } + + pub fn client_state_change( + &self, + client: &ClientDisplay, + ) -> Option { + self.application + .as_ref()? + .clients + .iter() + .find(|peer| { + peer.peer_index == Some(client.peer_index) && peer.identity == client.identity + }) + .map(|peer| peer.state_change) + } + + pub fn saved_managed_client_count(&self) -> usize { + self.application.as_ref().map_or_else( + || { + self.client_states + .values() + .filter(|row| row.saved.is_some()) + .count() + }, + |application| application.saved_client_count, + ) + } + + pub fn managed_change_count(&self) -> usize { + self.application.as_ref().map_or_else( + || { + self.client_states + .values() + .filter(|row| row.draft != row.saved) + .count() + }, + |application| { + application + .clients + .iter() + .filter(|peer| { + matches!( + peer.state_change, + crate::app::ClientStateChange::Added + | crate::app::ClientStateChange::Removed + | crate::app::ClientStateChange::Changed + ) + }) + .count() + }, + ) + } + pub fn contains_identity(&self, identity: &PublicKey) -> bool { self.clients .iter() @@ -109,31 +258,60 @@ impl SessionView { .iter() .map(VisibleRecord::Peer) .collect::>(); - records.extend(self.client_states.iter().filter_map(|(identity, row)| { - let client = row.saved.as_ref()?; - if row.draft.is_some() - || self - .clients - .iter() - .any(|peer| peer.identity == Some(*identity)) - { - return None; - } - Some(VisibleRecord::DraftRemoved { - identity: *identity, - client, - }) - })); + if let Some(application) = &self.application { + records.extend(application.clients.iter().filter_map(|peer| { + if peer.state_change != crate::app::ClientStateChange::Removed { + return None; + } + Some(VisibleRecord::DraftRemoved { + identity: peer.identity?, + peer: Some(peer), + client: peer.saved_client.as_ref(), + }) + })); + } else { + records.extend(self.client_states.iter().filter_map(|(identity, row)| { + let client = row.saved.as_ref()?; + if row.draft.is_some() + || self + .clients + .iter() + .any(|peer| peer.identity == Some(*identity)) + { + return None; + } + Some(VisibleRecord::DraftRemoved { + identity: *identity, + peer: None, + client: Some(client), + }) + })); + } records } } +fn navigation_peer(peer: &PeerRecord) -> Option { + let peer_index = peer.peer_index?; + Some(ClientDisplay { + peer_index, + line: peer.line, + identity: peer.identity, + label: peer.label.clone(), + allowed_ips: peer.allowed_ips.clone(), + has_subnet_routes: peer.has_subnet_routes, + issues: peer.issues.clone(), + mutable_identity: peer.mutable_identity, + }) +} + #[derive(Clone, Copy, Debug)] pub enum VisibleRecord<'a> { Peer(&'a ClientDisplay), DraftRemoved { identity: PublicKey, - client: &'a Client, + peer: Option<&'a PeerRecord>, + client: Option<&'a Client>, }, } @@ -248,13 +426,8 @@ impl UiState { self.select_first_record(view); return; } - let removed = view.client_states.get(&identity).is_some_and(|row| { - row.saved.is_some() - && row.draft.is_none() - && !view - .clients - .iter() - .any(|client| client.identity == Some(identity)) + let removed = view.visible_records().iter().any(|record| { + matches!(record, VisibleRecord::DraftRemoved { identity: removed, .. } if *removed == identity) }); if removed { self.selected_peer_index = None; @@ -277,9 +450,9 @@ impl UiState { } if self.selected_identity.is_some_and(|identity| { - view.client_states - .get(&identity) - .is_some_and(|row| row.saved.is_some() && row.draft.is_none()) + view.visible_records().iter().any(|record| { + matches!(record, VisibleRecord::DraftRemoved { identity: removed, .. } if *removed == identity) + }) }) { return; } @@ -477,11 +650,75 @@ impl UiState { && client.mutable_identity == Some(identity) }) } + + pub fn selected_rename_available(&self) -> bool { + self.view.as_ref().is_some_and(|view| { + view.application.as_ref().map_or_else( + || self.selected_client_mutable(), + |_| self.selected_peer_action(|peer| peer.actions.rename.available), + ) + }) + } + + pub fn selected_delete_available(&self) -> bool { + self.view.as_ref().is_some_and(|view| { + view.application.as_ref().map_or_else( + || self.selected_client_mutable(), + |_| self.selected_peer_action(|peer| peer.actions.delete.available), + ) + }) + } + + pub fn selected_first_delivery_available(&self) -> bool { + self.view.as_ref().is_some_and(|view| { + view.application.as_ref().map_or_else( + || self.selected_client_is_saved(), + |_| { + self.selected_peer_action(|peer| { + peer.actions.first_delivery != FirstDeliveryEligibility::Unavailable + }) + }, + ) + }) + } + + pub fn selected_reissue_available(&self) -> bool { + self.view.as_ref().is_some_and(|view| { + view.application.as_ref().map_or_else( + || self.selected_client_is_saved(), + |_| self.selected_peer_action(|peer| peer.actions.reissue.available), + ) + }) + } + + fn selected_client_is_saved(&self) -> bool { + self.selected_identity.is_some_and(|identity| { + self.view + .as_ref() + .and_then(|view| view.client_states.get(&identity)) + .is_some_and(|row| row.saved.is_some()) + }) + } + + fn selected_peer_action(&self, action: impl FnOnce(&PeerRecord) -> bool) -> bool { + let (Some(identity), Some(peer_index)) = (self.selected_identity, self.selected_peer_index) + else { + return false; + }; + self.view + .as_ref() + .and_then(|view| view.application_peer(identity, Some(peer_index))) + .is_some_and(action) + } } #[cfg(test)] mod tests { use super::*; + use crate::app::{ + ActionEligibility, ActionUnavailableReason, ClientActions, ClientStateChange, + FirstDeliveryEligibility, PeerIssueClass, ReservationFacts, + }; use crate::domain::PublicKey; use crate::wireguard::PeerIssue; @@ -514,6 +751,72 @@ mod tests { statistics: RuntimeObservation::unknown(), has_draft: false, vault_initialized: true, + application: None, + } + } + + fn application_peer( + identity: PublicKey, + peer_index: usize, + actions: ClientActions, + ) -> PeerRecord { + PeerRecord { + peer_index: Some(peer_index), + draft_peer_index: Some(peer_index), + saved_peer_index: Some(peer_index), + line: peer_index + 1, + identity: Some(identity), + label: "inspect-only peer".to_owned(), + allowed_ips: vec!["10.0.0.2/32".to_owned()], + has_subnet_routes: false, + issues: vec![PeerIssue::MissingIpv4HostRoute], + classified_issues: vec![crate::app::ClassifiedPeerIssue { + issue: PeerIssue::MissingIpv4HostRoute, + class: PeerIssueClass::MalformedOrAmbiguous, + }], + mutable_identity: None, + draft_client: None, + saved_client: None, + state_change: ClientStateChange::UnsupportedOrUnmanaged, + actions, + } + } + + fn available_actions(delete: bool) -> ClientActions { + let unavailable = ActionEligibility { + available: false, + reason: Some(ActionUnavailableReason::UnsafePeerOrDocument), + }; + ClientActions { + rename: unavailable.clone(), + delete: if delete { + ActionEligibility { + available: true, + reason: None, + } + } else { + unavailable.clone() + }, + first_delivery: FirstDeliveryEligibility::Unavailable, + reissue: unavailable, + } + } + + fn application_view(records: Vec) -> ApplicationView { + ApplicationView { + clients: records, + document_issues: Vec::new(), + saved_peer_count: 1, + draft_client_count: 0, + saved_client_count: 0, + reservations: ReservationFacts::default(), + applied: ApplyStatus::Unknown, + vault_initialized: false, + has_draft: false, + restart: ActionEligibility { + available: true, + reason: None, + }, } } @@ -598,6 +901,72 @@ mod tests { assert!(!state.selected_client_mutable()); } + #[test] + fn action_eligibility_is_per_action_and_bound_to_the_selected_peer_position() { + let identity = PublicKey::from_bytes([40; 32]); + let mut peer = application_peer(identity, 6, available_actions(true)); + let mut session = view( + 0, + vec![ClientDisplay { + peer_index: 6, + line: 7, + identity: Some(identity), + label: peer.label.clone(), + allowed_ips: peer.allowed_ips.clone(), + has_subnet_routes: false, + issues: peer.issues.clone(), + mutable_identity: None, + }], + ); + session.application = Some(application_view(vec![peer.clone()])); + let mut state = UiState { + view: Some(session), + ..UiState::default() + }; + assert!(state.select_peer_index(6)); + assert!(!state.selected_client_mutable()); + assert!(!state.selected_rename_available()); + assert!(state.selected_delete_available()); + assert!(!state.selected_first_delivery_available()); + assert!(!state.selected_reissue_available()); + + state.selected_peer_index = Some(7); + assert!(!state.selected_delete_available()); + + peer.peer_index = Some(7); + if let Some(application) = state + .view + .as_mut() + .and_then(|view| view.application.as_mut()) + { + application.clients.push(peer); + } + state.selected_peer_index = Some(6); + assert!(!state.selected_delete_available()); + } + + #[test] + fn malformed_peer_inspection_never_inherits_keyed_action_eligibility() { + let identity = PublicKey::from_bytes([41; 32]); + let keyed = application_peer(identity, 2, available_actions(true)); + let mut malformed = keyed.clone(); + malformed.peer_index = Some(9); + malformed.draft_peer_index = Some(9); + malformed.identity = None; + let mut inspection = row(identity, "peer", false); + inspection.peer_index = 9; + inspection.identity = None; + let mut state_view = view(0, vec![inspection]); + state_view.application = Some(application_view(vec![keyed.clone(), malformed])); + let mut state = UiState { + view: Some(state_view), + ..UiState::default() + }; + assert!(state.select_peer_index(9)); + assert_eq!(state.selected_identity, None); + assert!(!state.selected_delete_available()); + } + #[test] fn removed_saved_client_is_visible_and_never_mutable() { let key = PublicKey::from_bytes([13; 32]); @@ -635,6 +1004,76 @@ mod tests { assert_eq!(state.view.as_ref().unwrap().visible_records().len(), 1); } + #[test] + fn read_model_removed_rows_remain_visible_without_state_map_reconstruction() { + let identity = PublicKey::from_bytes([42; 32]); + let removed = Client::new( + identity, + "removed by draft", + crate::domain::ClientAddresses { + ipv4: "10.0.0.42".parse().unwrap(), + ipv6: None, + }, + ) + .unwrap(); + let mut peer = application_peer(identity, 0, available_actions(false)); + peer.peer_index = None; + peer.draft_peer_index = None; + peer.saved_client = Some(removed.clone()); + peer.state_change = ClientStateChange::Removed; + let mut session = view(0, Vec::new()); + session.application = Some(application_view(vec![peer])); + + let mut state = UiState::default(); + assert!(state.apply_view(session)); + assert_eq!(state.selected_identity, Some(identity)); + assert!(matches!( + state.selected_record(), + Some(VisibleRecord::DraftRemoved { + identity: selected, + client: Some(client), + .. + }) if selected == identity && client == &removed + )); + assert!(state.view.as_ref().unwrap().client_states.is_empty()); + assert!(!state.selected_delete_available()); + } + + #[test] + fn removed_unprojectable_authoritative_peer_stays_selectable_without_actions() { + let identity = PublicKey::from_bytes([43; 32]); + let mut peer = application_peer(identity, 0, available_actions(true)); + peer.peer_index = None; + peer.draft_peer_index = None; + peer.saved_peer_index = Some(4); + peer.label = "legacy route peer".to_owned(); + peer.allowed_ips = vec!["10.0.0.43/32".to_owned()]; + peer.saved_client = None; + peer.state_change = ClientStateChange::Removed; + let mut session = view(0, Vec::new()); + session.application = Some(application_view(vec![peer])); + + let mut state = UiState::default(); + assert!(state.apply_view(session)); + assert_eq!(state.selected_identity, Some(identity)); + assert_eq!(state.selected_peer_index, None); + assert!(matches!( + state.selected_record(), + Some(VisibleRecord::DraftRemoved { + identity: selected, + peer: Some(record), + client: None, + }) if selected == identity && record.label == "legacy route peer" + )); + assert!(!state.selected_client_mutable()); + assert!(!state.selected_rename_available()); + assert!(!state.selected_delete_available()); + assert!(!state.selected_first_delivery_available()); + assert!(!state.selected_reissue_available()); + assert!(state.open_selected_record()); + assert_eq!(state.screen, Screen::ClientDetails); + } + #[test] fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() { let mut state = UiState { diff --git a/src/tui/terminal.rs b/src/tui/terminal.rs index aa5b4e6..cdbb794 100644 --- a/src/tui/terminal.rs +++ b/src/tui/terminal.rs @@ -771,7 +771,7 @@ fn handle_key( KeyCode::Char('n') if state.view.is_some() => forms.open_create(), KeyCode::Char('i') => forms.open_import(), KeyCode::Char('r') => { - if state.selected_client_mutable() { + if state.selected_rename_available() { let selected = state.selected_identity.and_then(|id| { state .view @@ -788,7 +788,7 @@ fn handle_key( } } KeyCode::Char('d') => { - if state.selected_client_mutable() { + if state.selected_delete_available() { if let (Some(id), Some(view)) = (state.selected_identity, state.view.as_ref()) { let label = view .clients @@ -806,16 +806,25 @@ fn handle_key( KeyCode::Char('p') => forms.open_draft_review(), KeyCode::Char('c') => forms.open_commit(), KeyCode::Char('x') => forms.open_discard(), - KeyCode::Char('R') => submit_command(worker, UiCommand::PreviewRestart, state), - KeyCode::Char('o') | KeyCode::Char('O') => { - if state.selected_client_mutable() - && let Some(identity) = state.selected_identity - && state.view.as_ref().is_some_and(|view| { - view.client_states - .get(&identity) - .is_some_and(|row| row.saved.is_some()) - }) + KeyCode::Char('R') => { + if state + .view + .as_ref() + .is_some_and(|view| view.restart_eligibility().is_none_or(|item| item.available)) { + submit_command(worker, UiCommand::PreviewRestart, state); + } else { + state.status_message = + Some("Restart preview is unavailable for the saved configuration.".to_owned()); + } + } + KeyCode::Char('o') | KeyCode::Char('O') => { + let eligible = if code == KeyCode::Char('o') { + state.selected_first_delivery_available() + } else { + state.selected_reissue_available() + }; + if eligible && let Some(identity) = state.selected_identity { forms.open_delivery(identity, code == KeyCode::Char('O')); } else { state.status_message = @@ -1038,6 +1047,7 @@ mod tests { statistics: RuntimeObservation::unknown(), has_draft: false, vault_initialized: true, + application: None, } } @@ -1319,6 +1329,7 @@ mod tests { statistics: RuntimeObservation::unknown(), has_draft: false, vault_initialized: true, + application: None, }), ..UiState::default() }; @@ -1350,6 +1361,7 @@ mod tests { statistics: RuntimeObservation::unknown(), has_draft: false, vault_initialized: true, + application: None, }), ..UiState::default() }; @@ -1584,6 +1596,7 @@ mod tests { statistics: RuntimeObservation::unknown(), has_draft: false, vault_initialized: true, + application: None, }; let mut worker = FakeWorker { shutdown_calls: 0, diff --git a/tests/tui_workflow.rs b/tests/tui_workflow.rs index 01e011a..180dd9a 100644 --- a/tests/tui_workflow.rs +++ b/tests/tui_workflow.rs @@ -102,6 +102,42 @@ fn saved_peer_projection_counts_legacy_peers_without_inventing_managed_state() { assert!(!view.has_draft); } +#[test] +fn read_model_exposes_supported_exact_host_peer_actions_individually() { + let fixture = fixture(); + let legacy_key = WireGuardPrivateKey::generate() + .expect("synthetic legacy peer key") + .public_key(); + let mut server_config = + fs::read_to_string(&fixture.settings.server_config_path).expect("synthetic server config"); + server_config.push_str(&format!( + "\n[Peer]\nPublicKey = {legacy_key}\nAllowedIPs = 10.20.0.2/32\n" + )); + fs::write(&fixture.settings.server_config_path, server_config) + .expect("write synthetic exact-host peer"); + + let app = wireguard_admin::app::Application::open_from_saved_store(fixture.store.clone()) + .expect("application opens with an inspect-only legacy peer"); + let mut view = SessionView::from_application(&app, 0); + assert_eq!(view.clients.len(), 1); + assert_eq!(view.clients[0].mutable_identity, None); + assert_eq!(view.application.as_ref().unwrap().clients.len(), 1); + + let mut state = wireguard_admin::tui::state::UiState { + view: Some(view), + ..Default::default() + }; + assert!(state.select_peer_index(0)); + assert!(state.selected_rename_available()); + assert!(state.selected_delete_available()); + assert!(!state.selected_first_delivery_available()); + assert!(!state.selected_reissue_available()); + + view = SessionView::from_application(&app, 0); + assert_eq!(view.saved_peer_count, Some(1)); + assert!(view.client_states.is_empty()); +} + #[derive(Clone, Default)] struct CountingStatistics(Arc);