Add application read models and thin TUI command adapter

This commit is contained in:
2026-10-30 23:02:35 +02:00
parent 9a143e975e
commit 7123197ec4
12 changed files with 2669 additions and 703 deletions
+13 -13
View File
@@ -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 | | 23 | Readable saved-state Commit and restore scenarios | 22 | accepted |
| 24 | Session-owned first delivery and password reissue scenarios | 23 | accepted | | 24 | Session-owned first delivery and password reissue scenarios | 23 | accepted |
| 25 | Explicit restart verification scenario | 24 | 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 | | 27 | Integrated refactoring acceptance and architecture handoff | 26 | pending |
### Milestone 1 — Rust foundation, settings, and domain states ### 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 ### 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 #### Outcome and scope
@@ -4107,10 +4107,10 @@ TUI consumes public application facts and action eligibility without parsing sav
| Task | Owner | State | Bounded deliverable | | Task | Owner | State | Bounded deliverable |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| M26.1 | implementer | pending | Secret-free application view and per-action eligibility | | 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 | pending | Migrate TUI projection and command execution adapter | | 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 | pending | Split command/result/navigation responsibilities where justified | | 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; repairer; coordinator | pending | UI parity/authorization/lifecycle review, repair and RUST acceptance | | 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. 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. 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 #### 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. - [x] 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. - [x] 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. - [x] 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-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 ### 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 ## 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. - M18 was accepted on 2026-10-29: structured, accurate in-app Help, responsive wrapping and bounded scrolling passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M18 evidence](docs/evidence/milestone-18.md) records final inputs and limits. At that handoff, M19 had not started.
@@ -4188,7 +4188,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af
- Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope. - Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope.
- Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked. - Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked.
- M18–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 ## Plan maintenance
+4 -1
View File
@@ -34,6 +34,7 @@ src/
draft.rs # Coordinated in-memory document, client-state, and vault drafts draft.rs # Coordinated in-memory document, client-state, and vault drafts
first_delivery.rs # Session-local first-issue authorization and retry state first_delivery.rs # Session-local first-issue authorization and retry state
first_file.rs # Password-free first-file and optional QR scenario 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 reissue.rs # Per-action vault authentication and saved-client delivery
commit.rs # Prepare, backup, install, and publish saved-state Commit commit.rs # Prepare, backup, install, and publish saved-state Commit
restore.rs # Bound preview and confirmed saved-state replacement restore.rs # Bound preview and confirmed saved-state replacement
@@ -51,7 +52,9 @@ src/
tui.rs # Terminal UI entry point tui.rs # Terminal UI entry point
tui/ tui/
state.rs # Screen state and navigation 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 forms.rs # Short-lived forms and secret owners
render.rs # Rendering render.rs # Rendering
terminal.rs # Terminal setup and cleanup terminal.rs # Terminal setup and cleanup
+46
View File
@@ -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.
+472 -81
View File
@@ -9,10 +9,16 @@ mod commit;
mod draft; mod draft;
mod first_delivery; mod first_delivery;
mod first_file; mod first_file;
mod read_model;
mod reissue; mod reissue;
mod restart; mod restart;
mod restore; mod restore;
pub use read_model::{
ActionEligibility, ActionUnavailableReason, ApplicationView, ClassifiedPeerIssue,
ClientActions, ClientDisplay, ClientStateChange, FirstDeliveryEligibility, PeerIssueClass,
PeerRecord, ReservationFacts, SessionDisplay,
};
pub use restart::{ pub use restart::{
RestartChoice, RestartDiagnostic, RestartFailureKind, RestartPreview, RestartReport, RestartChoice, RestartDiagnostic, RestartFailureKind, RestartPreview, RestartReport,
RestartStage, RestartStage,
@@ -47,46 +53,6 @@ use crate::vault::{VaultBundle, VaultError, WireGuardPrivateKey};
use crate::wireguard::SectionKind; use crate::wireguard::SectionKind;
use crate::wireguard::{ConfigDocument, DocumentError, EditError}; 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<PublicKey>,
pub label: String,
pub allowed_ips: Vec<String>,
pub has_subnet_routes: bool,
pub issues: Vec<crate::wireguard::PeerIssue>,
pub mutable_identity: Option<PublicKey>,
}
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<Item = &crate::wireguard::PeerIssue> {
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<ClientDisplay>,
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
}
/// An open, in-memory management session. The configuration and state drafts /// An open, in-memory management session. The configuration and state drafts
/// change independently of installed bytes until [`Self::commit`] succeeds. /// change independently of installed bytes until [`Self::commit`] succeeds.
pub struct Application { pub struct Application {
@@ -446,46 +412,13 @@ impl Application {
/// Return a secret-free projection that includes unsupported and /// Return a secret-free projection that includes unsupported and
/// ambiguous peers instead of silently omitting them from the client list. /// ambiguous peers instead of silently omitting them from the client list.
pub fn display_projection(&self) -> SessionDisplay { pub fn display_projection(&self) -> SessionDisplay {
let clients = self read_model::legacy_session_display(self)
.draft }
.document
.peers() /// Return secret-free application facts for presentation and per-action
.iter() /// availability. Operations still revalidate their preconditions when run.
.map(|peer| { pub fn read_model(&self) -> ApplicationView {
let projected = peer read_model::session_display(self)
.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::<String>()),
)
});
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(),
}
} }
/// Add one prepared public-key client to the byte-preserving server draft /// Add one prepared public-key client to the byte-preserving server draft
@@ -1271,7 +1204,8 @@ mod tests {
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use super::{ use super::{
AppError, Application, FileOutputError, FirstIssueAttempt, FirstIssueDeliveryFailure, ActionUnavailableReason, AppError, Application, ClientStateChange, FileOutputError,
FirstDeliveryEligibility, FirstIssueAttempt, FirstIssueDeliveryFailure,
FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartFailureKind, FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartFailureKind,
RestartStage, clone_saved, snapshot_from_document, 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())); 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::<Vec<_>>();
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] #[test]
fn deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change() { fn deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change() {
let root = tempfile::tempdir().expect("temp directory"); 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.historical_apply, HistoricalApplyStatus::Unknown);
assert_eq!(reopened.states().applied(), &ApplyStatus::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));
}
} }
+41
View File
@@ -39,6 +39,17 @@ enum IssueStage {
RetryNeedsManualCleanup, 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 { pub(super) struct FirstIssueAuthorization {
identity: PublicKey, identity: PublicKey,
context: IssueContext, context: IssueContext,
@@ -64,6 +75,36 @@ impl FirstDeliverySession {
self.issues.len() 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)] #[cfg(test)]
pub(super) fn is_empty(&self) -> bool { pub(super) fn is_empty(&self) -> bool {
self.issues.is_empty() self.issues.is_empty()
+585
View File
@@ -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<ActionUnavailableReason>,
}
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<PublicKey>,
pub label: String,
pub allowed_ips: Vec<String>,
pub has_subnet_routes: bool,
pub issues: Vec<PeerIssue>,
pub mutable_identity: Option<PublicKey>,
}
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<Item = &PeerIssue> {
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<usize>,
pub draft_peer_index: Option<usize>,
pub saved_peer_index: Option<usize>,
pub line: usize,
pub identity: Option<PublicKey>,
pub label: String,
pub allowed_ips: Vec<String>,
pub has_subnet_routes: bool,
pub issues: Vec<PeerIssue>,
pub classified_issues: Vec<ClassifiedPeerIssue>,
pub mutable_identity: Option<PublicKey>,
pub draft_client: Option<Client>,
pub saved_client: Option<Client>,
pub state_change: ClientStateChange,
pub actions: ClientActions,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ReservationFacts {
pub committed: Vec<IpAddr>,
pub pending_host_routes: Vec<IpAddr>,
}
/// 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<ClientDisplay>,
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
}
/// 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<PeerRecord>,
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
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::<String>()),
)
});
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::<Vec<_>>();
let draft_peer_identities = draft_document
.peers()
.iter()
.flat_map(|peer| peer.public_key_candidates.iter().copied())
.collect::<std::collections::BTreeSet<_>>();
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::<String>()
),
},
|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::<String>()),
)
});
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<PublicKey>,
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::<Vec<_>>();
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<ClassifiedPeerIssue> {
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,
}
}
+47 -395
View File
@@ -10,13 +10,10 @@ use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::app::Application; use crate::app::Application;
use crate::app::{ use crate::app::{FirstIssueAttempt, FirstIssueRetry, RestartChoice, RestartReport};
FirstIssueAttempt, FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartReport,
RestorePreview,
};
use crate::backup::{ArchiveSummary, BackupReason, HistoricalApplyStatus}; use crate::backup::{ArchiveSummary, BackupReason, HistoricalApplyStatus};
use crate::config::SettingsFile; use crate::config::SettingsFile;
use crate::delivery::files::{FileOutputError, PreparedDestination}; use crate::delivery::files::FileOutputError;
use crate::delivery::{QrCodeOutput, QrError}; use crate::delivery::{QrCodeOutput, QrError};
use crate::domain::{ClientAddresses, PublicKey}; use crate::domain::{ClientAddresses, PublicKey};
use crate::runtime::RuntimeObservation; use crate::runtime::RuntimeObservation;
@@ -287,396 +284,10 @@ pub trait OperationExecutor: Send + 'static {
} }
} }
enum DeliveryMode { mod executor;
FirstIssue, pub use executor::ApplicationExecutor;
Reissue, #[cfg(test)]
} use executor::{destination_generation_matches, restore_preview_generation_matches};
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<PreparedDestinationContext>,
restore_preview: Option<RestorePreviewContext>,
}
impl OperationExecutor for ApplicationExecutor {
fn execute(
&mut self,
app: &mut Application,
command: UiCommand,
) -> Result<CommandResult, OperationError> {
self.execute_at(app, command, 0)
}
fn execute_at(
&mut self,
app: &mut Application,
command: UiCommand,
generation: u64,
) -> Result<CommandResult, OperationError> {
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<CommandResult, OperationError> {
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<CommandResult, OperationError> {
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<QrCodeOutput, QrError>) -> (QrStatus, Option<QrCodeOutput>) {
match result {
Ok(output) => (QrStatus::Ready, Some(output)),
Err(error) => (QrStatus::Failed(error), None),
}
}
pub trait StatisticsReader: Send + 'static { pub trait StatisticsReader: Send + 'static {
/// Implementations must return within `timeout`; unavailable runtime data /// 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 { struct FakeStatistics {
started: Option<mpsc::SyncSender<()>>, started: Option<mpsc::SyncSender<()>>,
release: Option<Receiver<()>>, release: Option<Receiver<()>>,
+404
View File
@@ -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<PreparedDestinationContext>,
restore_preview: Option<RestorePreviewContext>,
}
impl OperationExecutor for ApplicationExecutor {
fn execute(
&mut self,
app: &mut Application,
command: UiCommand,
) -> Result<CommandResult, OperationError> {
self.execute_at(app, command, 0)
}
fn execute_at(
&mut self,
app: &mut Application,
command: UiCommand,
generation: u64,
) -> Result<CommandResult, OperationError> {
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<CommandResult, OperationError> {
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<CommandResult, OperationError> {
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<QrCodeOutput, QrError>) -> (QrStatus, Option<QrCodeOutput>) {
match result {
Ok(output) => (QrStatus::Ready, Some(output)),
Err(error) => (QrStatus::Failed(error), None),
}
}
+504 -148
View File
@@ -208,7 +208,9 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
add("p", "preview", HintKind::Action); add("p", "preview", HintKind::Action);
add("c", "Commit", HintKind::Action); add("c", "Commit", HintKind::Action);
add("x", "discard", HintKind::Destructive); 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", "backups / restore", HintKind::Action);
add("B", "backup", HintKind::Action); add("B", "backup", HintKind::Action);
add("!", "issues", HintKind::Action); add("!", "issues", HintKind::Action);
@@ -219,22 +221,21 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
{ {
add("s", "setup", HintKind::Action); add("s", "setup", HintKind::Action);
} }
if state.selected_client_mutable() { if state.selected_rename_available() {
add("r", "rename", HintKind::Action); add("r", "rename", HintKind::Action);
}
if state.selected_delete_available() {
add("d", "delete", HintKind::Destructive); add("d", "delete", HintKind::Destructive);
let saved = state }
.selected_identity if state.selected_first_delivery_available() {
.and_then(|id| state.view.as_ref()?.client_states.get(&id)) add("o", "first file", HintKind::Action);
.is_some_and(|row| row.saved.is_some()); }
if saved { if state.selected_reissue_available() {
add("o", "first file", HintKind::Action); add("O", "password reissue", HintKind::Action);
add("O", "password reissue", HintKind::Action);
}
} }
} }
Screen::ClientList => { Screen::ClientList => {
let has_view = state.view.is_some(); let has_view = state.view.is_some();
let selected = state.selected_client_mutable();
add("↑/↓", "select", HintKind::Navigation); add("↑/↓", "select", HintKind::Navigation);
if state if state
.view .view
@@ -246,23 +247,22 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
add("?", "Help", HintKind::Navigation); add("?", "Help", HintKind::Navigation);
if has_view { if has_view {
add("n", "new", HintKind::Action); add("n", "new", HintKind::Action);
if selected { if state.selected_rename_available() {
add("r", "rename", HintKind::Action); add("r", "rename", HintKind::Action);
}
if state.selected_delete_available() {
add("d", "delete", HintKind::Destructive); add("d", "delete", HintKind::Destructive);
} }
add("p", "preview", HintKind::Action); add("p", "preview", HintKind::Action);
add("c", "Commit", HintKind::Action); add("c", "Commit", HintKind::Action);
add("x", "discard", HintKind::Destructive); add("x", "discard", HintKind::Destructive);
add("R", "restart", HintKind::Destructive); if restart_available(state) {
if let Some(id) = state.selected_identity add("R", "restart", HintKind::Destructive);
&& state }
.view if state.selected_first_delivery_available() {
.as_ref()
.and_then(|view| view.client_states.get(&id))
.is_some_and(|row| row.saved.is_some())
&& selected
{
add("o", "first file", HintKind::Action); add("o", "first file", HintKind::Action);
}
if state.selected_reissue_available() {
add("O", "password reissue", HintKind::Action); add("O", "password reissue", HintKind::Action);
} }
add("b", "backups / restore", HintKind::Action); add("b", "backups / restore", HintKind::Action);
@@ -286,6 +286,14 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
hints 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<Vec<Hint>> { fn pack_hints(hints: &[Hint], width: usize) -> Vec<Vec<Hint>> {
let mut rows = Vec::<Vec<Hint>>::new(); let mut rows = Vec::<Vec<Hint>>::new();
let mut used = 0; let mut used = 0;
@@ -649,10 +657,7 @@ fn qr_state_summary(state: &UiState, width: usize) -> Option<String> {
}, },
view.saved_peer_count view.saved_peer_count
.map_or_else(|| "unknown".to_owned(), |count| count.to_string()), .map_or_else(|| "unknown".to_owned(), |count| count.to_string()),
view.client_states view.saved_managed_client_count(),
.values()
.filter(|row| row.saved.is_some())
.count(),
match view.applied { match view.applied {
crate::domain::ApplyStatus::Verified => "verified", crate::domain::ApplyStatus::Verified => "verified",
crate::domain::ApplyStatus::Unknown => "not verified", crate::domain::ApplyStatus::Unknown => "not verified",
@@ -827,37 +832,83 @@ fn draft_review_lines(state: &UiState) -> Vec<String> {
if view.has_draft { "yes" } else { "no" } if view.has_draft { "yes" } else { "no" }
), ),
]; ];
for (identity, row) in &view.client_states { if let Some(application) = &view.application {
match (&row.saved, &row.draft) { for peer in &application.clients {
(None, Some(draft)) => lines.push(format!( let identity = peer.identity;
"Added {identity}: {} (IPv4 {}, IPv6 {})", match (
safe_text(&draft.alias), peer.state_change,
draft.addresses.ipv4, peer.saved_client.as_ref(),
draft peer.draft_client.as_ref(),
.addresses ) {
.ipv6 (crate::app::ClientStateChange::Added, _, Some(draft)) => lines.push(format!(
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()) "Added {}: {} (IPv4 {}, IPv6 {})",
)), identity.map_or_else(|| "unknown key".to_owned(), |key| key.to_string()),
(Some(saved), None) => lines.push(format!( safe_text(&draft.alias),
"Removed {identity}: {} (saved removal remains unapplied until restart)", draft.addresses.ipv4,
safe_text(&saved.alias) draft
)), .addresses
(Some(saved), Some(draft)) if saved != draft => lines.push(format!( .ipv6
"Changed {identity}: alias {} → {}; addresses {} / {} → {} / {}", .map_or_else(|| "none".to_owned(), |ip| ip.to_string())
safe_text(&saved.alias), )),
safe_text(&draft.alias), (crate::app::ClientStateChange::Removed, _, _) => lines.push(format!(
saved.addresses.ipv4, "Removed {}: {} (saved removal remains unapplied until restart)",
saved identity.map_or_else(|| "unknown key".to_owned(), |key| key.to_string()),
.addresses safe_text(&peer.label)
.ipv6 )),
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()), (crate::app::ClientStateChange::Changed, Some(saved), Some(draft)) => {
draft.addresses.ipv4, lines.push(format!(
draft "Changed {}: alias {} → {}; addresses {} / {} → {} / {}",
.addresses identity.map_or_else(|| "unknown key".to_owned(), |key| key.to_string()),
.ipv6 safe_text(&saved.alias),
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()) 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 { if lines.len() == 2 && view.has_draft {
@@ -884,25 +935,38 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta
.clients .clients
.iter() .iter()
.map(|client| { .map(|client| {
let visible_issues: Vec<_> = client.presented_issues().collect(); let visible_issues = view.presented_issues(client);
let identity = client let identity = client
.identity .identity
.map(|key| short_key(&key)) .map(|key| short_key(&key))
.unwrap_or_else(|| format!("peer at line {}", client.line)); .unwrap_or_else(|| format!("peer at line {}", client.line));
let (ipv4, ipv6, route_marker) = client_address_cells(client); let (ipv4, ipv6, route_marker) = client_address_cells(client);
let row_state = client let row_state = view.client_state_change(client).map_or_else(
.identity || {
.and_then(|identity| view.client_states.get(&identity)) client
.map_or_else( .identity
|| "Saved ?".to_owned(), .and_then(|identity| view.client_states.get(&identity))
|row| match (&row.saved, &row.draft) { .map_or_else(
(None, Some(_)) => "Draft added".to_owned(), || "Saved ?".to_owned(),
(Some(saved), Some(draft)) if saved != draft => "Draft changed".to_owned(), |row| match (&row.saved, &row.draft) {
(Some(_), None) => "Draft removed".to_owned(), (None, Some(_)) => "Draft added".to_owned(),
(Some(_), Some(_)) => "Saved".to_owned(), (Some(saved), Some(draft)) if saved != draft => {
(None, None) => "State unavailable".to_owned(), "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 { let row_state = if layout.compact {
compact_state_label(&row_state).to_owned() compact_state_label(&row_state).to_owned()
} else { } else {
@@ -935,46 +999,94 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta
) )
}) })
.collect(); .collect();
rows.extend(view.client_states.iter().filter_map(|(identity, row)| { let removed_clients = if let Some(application) = &view.application {
let saved = row.saved.as_ref()?; application
if row.draft.is_some() .clients
|| view .iter()
.clients .filter_map(|peer| {
.iter() if peer.state_change != crate::app::ClientStateChange::Removed {
.any(|peer| peer.identity == Some(*identity)) return None;
{ }
return None; let identity = peer.identity?;
} let (ipv4, ipv6) = peer.saved_client.as_ref().map_or_else(
let ipv6 = saved || removed_peer_addresses(&peer.allowed_ips),
.addresses |saved| {
.ipv6 (
.map_or_else(|| "none".to_owned(), |address| address.to_string()); saved.addresses.ipv4.to_string(),
Some(client_table_row( saved
&layout.columns, .addresses
[ .ipv6
if layout.compact { .map_or_else(String::new, |ip| ip.to_string()),
"Removed".to_owned() )
} else { },
"Draft removed".to_owned() );
}, Some((
safe_text(&saved.alias), identity,
identity.to_string(), peer.label.clone(),
saved.addresses.ipv4.to_string(), ipv4,
ipv6, ipv6,
String::new(), if peer.saved_client.is_some() {
"Managed".to_owned(), "Managed"
address_summary( } else {
&saved.addresses.ipv4.to_string(), "Inspect only"
&saved },
))
})
.collect::<Vec<_>>()
} 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 .addresses
.ipv6 .ipv6
.map_or_else(String::new, |address| address.to_string()), .map_or_else(String::new, |ip| ip.to_string()),
"", "Managed",
), ))
], })
false, .collect::<Vec<_>>()
)) };
})); 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| { rows.extend(view.document_issues.iter().map(|issue| {
client_table_row( client_table_row(
&layout.columns, &layout.columns,
@@ -1110,23 +1222,60 @@ fn render_client_screen(frame: &mut Frame<'_>, area: Rect, state: &UiState) {
fn selected_display(state: &UiState) -> Option<ClientDisplay> { fn selected_display(state: &UiState) -> Option<ClientDisplay> {
match state.selected_record()? { match state.selected_record()? {
VisibleRecord::Peer(client) => Some(client.clone()), VisibleRecord::Peer(client) => Some(client.clone()),
VisibleRecord::DraftRemoved { identity, client } => { VisibleRecord::DraftRemoved {
let mut allowed_ips = vec![format!("{}/32", client.addresses.ipv4)]; identity,
if let Some(ipv6) = client.addresses.ipv6 { peer: Some(peer),
allowed_ips.push(format!("{ipv6}/128")); ..
} } => Some(ClientDisplay {
Some(ClientDisplay { peer_index: usize::MAX,
peer_index: usize::MAX, line: peer.line,
line: 0, identity: Some(identity),
identity: Some(identity), label: peer.label.clone(),
label: client.alias.clone(), allowed_ips: peer.allowed_ips.clone(),
allowed_ips, has_subnet_routes: peer.has_subnet_routes,
has_subnet_routes: false, issues: peer.issues.clone(),
issues: Vec::new(), mutable_identity: None,
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<String> {
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::<std::net::IpAddr>() {
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( fn persistent_detail_lines(
@@ -1204,11 +1353,7 @@ fn persistent_state_line(view: &SessionView) -> String {
let saved_peers = view let saved_peers = view
.saved_peer_count .saved_peer_count
.map_or_else(|| "unknown".to_owned(), |count| count.to_string()); .map_or_else(|| "unknown".to_owned(), |count| count.to_string());
let saved_managed = view let saved_managed = view.saved_managed_client_count();
.client_states
.values()
.filter(|row| row.saved.is_some())
.count();
let applied = match view.applied { let applied = match view.applied {
crate::domain::ApplyStatus::Verified => "verified", crate::domain::ApplyStatus::Verified => "verified",
crate::domain::ApplyStatus::Unknown => "not verified", crate::domain::ApplyStatus::Unknown => "not verified",
@@ -1254,7 +1399,7 @@ fn inspection_summary(client: &ClientDisplay, view: &SessionView) -> String {
} else if client } else if client
.issues .issues
.iter() .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() "Inspect: address shape; Enter for details".to_owned()
} else if !client.issues.is_empty() { } else if !client.issues.is_empty() {
@@ -1629,7 +1774,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
let has_capability_limitation = client let has_capability_limitation = client
.issues .issues
.iter() .iter()
.any(|issue| client.is_capability_limitation_issue(issue)); .any(|issue| view.is_capability_limitation_issue(client, issue));
if client.has_subnet_routes { if client.has_subnet_routes {
lines.push(Line::from( lines.push(Line::from(
"This peer contains subnet routes. Managing this configuration is not supported yet.", "This peer contains subnet routes. Managing this configuration is not supported yet.",
@@ -1640,18 +1785,26 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
)); ));
} }
if let Some(identity) = client.identity { 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!( lines.push(Line::from(format!(
"Draft managed record: {}", "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!( lines.push(Line::from(format!(
"Saved managed record: {}", "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.")); 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!( lines.push(Line::from(format!(
"Draft addresses: IPv4 {}, IPv6 {}", "Draft addresses: IPv4 {}, IPv6 {}",
client.addresses.ipv4, client.addresses.ipv4,
@@ -1661,7 +1814,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()) .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!( lines.push(Line::from(format!(
"Saved addresses: IPv4 {}, IPv6 {}", "Saved addresses: IPv4 {}, IPv6 {}",
client.addresses.ipv4, client.addresses.ipv4,
@@ -1703,7 +1856,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
Line::from(issue_summary(view)), Line::from(issue_summary(view)),
Line::from("Handshake and counters are observations, not apply verification."), 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!( lines.push(Line::from(format!(
"Peer issue: {}", "Peer issue: {}",
safe_text(&format!("{issue:?}")) safe_text(&format!("{issue:?}"))
@@ -1767,10 +1920,7 @@ fn state_summary(view: &SessionView) -> String {
} }
fn managed_change_count(view: &SessionView) -> usize { fn managed_change_count(view: &SessionView) -> usize {
view.client_states view.managed_change_count()
.values()
.filter(|row| row.draft != row.saved)
.count()
} }
fn draft_status(view: &SessionView) -> String { fn draft_status(view: &SessionView) -> String {
@@ -1784,11 +1934,7 @@ fn draft_status(view: &SessionView) -> String {
} }
fn saved_status(view: &SessionView) -> String { fn saved_status(view: &SessionView) -> String {
let managed = view let managed = view.saved_managed_client_count();
.client_states
.values()
.filter(|row| row.saved.is_some())
.count();
match view.saved_peer_count { match view.saved_peer_count {
Some(peers) => format!("Saved: {peers} peer sections; {managed} managed clients"), Some(peers) => format!("Saved: {peers} peer sections; {managed} managed clients"),
None => format!("Saved: peer count unavailable; {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 let peer_problems = view
.clients .clients
.iter() .iter()
.filter(|peer| peer.presented_issues().next().is_some()) .filter(|peer| !view.presented_issues(peer).is_empty())
.count(); .count();
let capability_limits = view let capability_limits = view
.clients .clients
@@ -1824,7 +1970,7 @@ fn issue_summary(view: &SessionView) -> String {
|| peer || peer
.issues .issues
.iter() .iter()
.any(|issue| peer.is_capability_limitation_issue(issue)) .any(|issue| view.is_capability_limitation_issue(peer, issue))
}) })
.count(); .count();
format!( format!(
@@ -2334,9 +2480,145 @@ mod tests {
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: true, has_draft: true,
vault_initialized: false, 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] #[test]
fn help_document_covers_nine_sections_and_distinguishes_case_sensitive_actions() { fn help_document_covers_nine_sections_and_distinguishes_case_sensitive_actions() {
let rows = help_document(178); let rows = help_document(178);
@@ -2550,6 +2832,7 @@ mod tests {
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: false, has_draft: false,
vault_initialized: true, 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::<Vec<_>>();
assert!(available_keys.contains(&"d"));
for unavailable_key in ["r", "o", "O", "R"] {
assert!(!available_keys.contains(&unavailable_key));
}
}
#[test] #[test]
fn standard_footer_is_four_rows_and_list_height_is_stable_across_states() { fn standard_footer_is_four_rows_and_list_height_is_stable_across_states() {
let identity = PublicKey::from_bytes([4; 32]); let identity = PublicKey::from_bytes([4; 32]);
@@ -3937,6 +4291,7 @@ mod tests {
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: false, has_draft: false,
vault_initialized: true, vault_initialized: true,
application: None,
}; };
let state = UiState { let state = UiState {
screen: Screen::ClientDetails, screen: Screen::ClientDetails,
@@ -4060,6 +4415,7 @@ mod tests {
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: true, has_draft: true,
vault_initialized: true, vault_initialized: true,
application: None,
}; };
let mut state = UiState { let mut state = UiState {
view: Some(view), view: Some(view),
+493 -54
View File
@@ -3,7 +3,10 @@
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::net::IpAddr; 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::domain::{ApplyStatus, Client, PublicKey};
use crate::runtime::RuntimeObservation; use crate::runtime::RuntimeObservation;
@@ -56,45 +59,191 @@ pub struct SessionView {
pub statistics: RuntimeObservation, pub statistics: RuntimeObservation,
pub has_draft: bool, pub has_draft: bool,
pub vault_initialized: 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<ApplicationView>,
} }
impl SessionView { impl SessionView {
pub fn from_application(app: &Application, generation: u64) -> Self { pub fn from_application(app: &Application, generation: u64) -> Self {
let states = app.states(); let application = app.read_model();
let mut identities = BTreeSet::new(); let mut client_states = std::collections::BTreeMap::new();
identities.extend(states.saved().clients().map(|client| client.identity)); for row in &application.clients {
identities.extend(states.draft().clients().map(|client| client.identity)); if let Some(identity) = row.identity {
let client_states = identities if row.saved_client.is_none() && row.draft_client.is_none() {
.into_iter() continue;
.map(|identity| { }
( let state = client_states
identity, .entry(identity)
ClientStateRow { .or_insert_with(|| ClientStateRow {
saved: states.saved().get(&identity).cloned(), saved: None,
draft: states.draft().get(&identity).cloned(), 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(); .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 { Self {
generation, generation,
clients: projection.clients, clients,
client_states, client_states,
saved_peer_count, saved_peer_count: Some(application.saved_peer_count),
document_issues: projection.document_issues, document_issues: application.document_issues.clone(),
applied: *states.applied(), applied: application.applied,
reserved_addresses: states.reserved_addresses().clone(), reserved_addresses: application.reservations.committed.iter().copied().collect(),
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: app.has_draft_changes(), has_draft: application.has_draft,
vault_initialized: app.vault_initialized(), vault_initialized: application.vault_initialized,
application: Some(application),
} }
} }
fn application_peer(
&self,
identity: PublicKey,
peer_index: Option<usize>,
) -> 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<crate::app::ClientStateChange> {
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 { pub fn contains_identity(&self, identity: &PublicKey) -> bool {
self.clients self.clients
.iter() .iter()
@@ -109,31 +258,60 @@ impl SessionView {
.iter() .iter()
.map(VisibleRecord::Peer) .map(VisibleRecord::Peer)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
records.extend(self.client_states.iter().filter_map(|(identity, row)| { if let Some(application) = &self.application {
let client = row.saved.as_ref()?; records.extend(application.clients.iter().filter_map(|peer| {
if row.draft.is_some() if peer.state_change != crate::app::ClientStateChange::Removed {
|| self return None;
.clients }
.iter() Some(VisibleRecord::DraftRemoved {
.any(|peer| peer.identity == Some(*identity)) identity: peer.identity?,
{ peer: Some(peer),
return None; client: peer.saved_client.as_ref(),
} })
Some(VisibleRecord::DraftRemoved { }));
identity: *identity, } else {
client, 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 records
} }
} }
fn navigation_peer(peer: &PeerRecord) -> Option<ClientDisplay> {
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)] #[derive(Clone, Copy, Debug)]
pub enum VisibleRecord<'a> { pub enum VisibleRecord<'a> {
Peer(&'a ClientDisplay), Peer(&'a ClientDisplay),
DraftRemoved { DraftRemoved {
identity: PublicKey, identity: PublicKey,
client: &'a Client, peer: Option<&'a PeerRecord>,
client: Option<&'a Client>,
}, },
} }
@@ -248,13 +426,8 @@ impl UiState {
self.select_first_record(view); self.select_first_record(view);
return; return;
} }
let removed = view.client_states.get(&identity).is_some_and(|row| { let removed = view.visible_records().iter().any(|record| {
row.saved.is_some() matches!(record, VisibleRecord::DraftRemoved { identity: removed, .. } if *removed == identity)
&& row.draft.is_none()
&& !view
.clients
.iter()
.any(|client| client.identity == Some(identity))
}); });
if removed { if removed {
self.selected_peer_index = None; self.selected_peer_index = None;
@@ -277,9 +450,9 @@ impl UiState {
} }
if self.selected_identity.is_some_and(|identity| { if self.selected_identity.is_some_and(|identity| {
view.client_states view.visible_records().iter().any(|record| {
.get(&identity) matches!(record, VisibleRecord::DraftRemoved { identity: removed, .. } if *removed == identity)
.is_some_and(|row| row.saved.is_some() && row.draft.is_none()) })
}) { }) {
return; return;
} }
@@ -477,11 +650,75 @@ impl UiState {
&& client.mutable_identity == Some(identity) && 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::app::{
ActionEligibility, ActionUnavailableReason, ClientActions, ClientStateChange,
FirstDeliveryEligibility, PeerIssueClass, ReservationFacts,
};
use crate::domain::PublicKey; use crate::domain::PublicKey;
use crate::wireguard::PeerIssue; use crate::wireguard::PeerIssue;
@@ -514,6 +751,72 @@ mod tests {
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: false, has_draft: false,
vault_initialized: true, 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<PeerRecord>) -> 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()); 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] #[test]
fn removed_saved_client_is_visible_and_never_mutable() { fn removed_saved_client_is_visible_and_never_mutable() {
let key = PublicKey::from_bytes([13; 32]); let key = PublicKey::from_bytes([13; 32]);
@@ -635,6 +1004,76 @@ mod tests {
assert_eq!(state.view.as_ref().unwrap().visible_records().len(), 1); 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] #[test]
fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() { fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() {
let mut state = UiState { let mut state = UiState {
+24 -11
View File
@@ -771,7 +771,7 @@ fn handle_key(
KeyCode::Char('n') if state.view.is_some() => forms.open_create(), KeyCode::Char('n') if state.view.is_some() => forms.open_create(),
KeyCode::Char('i') => forms.open_import(), KeyCode::Char('i') => forms.open_import(),
KeyCode::Char('r') => { KeyCode::Char('r') => {
if state.selected_client_mutable() { if state.selected_rename_available() {
let selected = state.selected_identity.and_then(|id| { let selected = state.selected_identity.and_then(|id| {
state state
.view .view
@@ -788,7 +788,7 @@ fn handle_key(
} }
} }
KeyCode::Char('d') => { 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()) { if let (Some(id), Some(view)) = (state.selected_identity, state.view.as_ref()) {
let label = view let label = view
.clients .clients
@@ -806,16 +806,25 @@ fn handle_key(
KeyCode::Char('p') => forms.open_draft_review(), KeyCode::Char('p') => forms.open_draft_review(),
KeyCode::Char('c') => forms.open_commit(), KeyCode::Char('c') => forms.open_commit(),
KeyCode::Char('x') => forms.open_discard(), KeyCode::Char('x') => forms.open_discard(),
KeyCode::Char('R') => submit_command(worker, UiCommand::PreviewRestart, state), KeyCode::Char('R') => {
KeyCode::Char('o') | KeyCode::Char('O') => { if state
if state.selected_client_mutable() .view
&& let Some(identity) = state.selected_identity .as_ref()
&& state.view.as_ref().is_some_and(|view| { .is_some_and(|view| view.restart_eligibility().is_none_or(|item| item.available))
view.client_states
.get(&identity)
.is_some_and(|row| row.saved.is_some())
})
{ {
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')); forms.open_delivery(identity, code == KeyCode::Char('O'));
} else { } else {
state.status_message = state.status_message =
@@ -1038,6 +1047,7 @@ mod tests {
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: false, has_draft: false,
vault_initialized: true, vault_initialized: true,
application: None,
} }
} }
@@ -1319,6 +1329,7 @@ mod tests {
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: false, has_draft: false,
vault_initialized: true, vault_initialized: true,
application: None,
}), }),
..UiState::default() ..UiState::default()
}; };
@@ -1350,6 +1361,7 @@ mod tests {
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: false, has_draft: false,
vault_initialized: true, vault_initialized: true,
application: None,
}), }),
..UiState::default() ..UiState::default()
}; };
@@ -1584,6 +1596,7 @@ mod tests {
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: false, has_draft: false,
vault_initialized: true, vault_initialized: true,
application: None,
}; };
let mut worker = FakeWorker { let mut worker = FakeWorker {
shutdown_calls: 0, shutdown_calls: 0,
+36
View File
@@ -102,6 +102,42 @@ fn saved_peer_projection_counts_legacy_peers_without_inventing_managed_state() {
assert!(!view.has_draft); 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)] #[derive(Clone, Default)]
struct CountingStatistics(Arc<AtomicUsize>); struct CountingStatistics(Arc<AtomicUsize>);