diff --git a/PLANS.md b/PLANS.md index fc1b372..ee647e0 100644 --- a/PLANS.md +++ b/PLANS.md @@ -3279,9 +3279,9 @@ Real IPv6 remains separately unverified by the previously accepted test scope. ### Milestone 10 — Visible styled keyboard quick reference in bottom panel -Status: in_progress (2026-10-26T20:52:01Z). The user retired M9 and explicitly +Status: accepted (2026-10-26T21:07:24Z). The user retired M9 and explicitly directed development to continue. M1–M8 are accepted; M9 is no longer a -prerequisite. M10 implementation, review, and validation are starting now. +prerequisite. M10's scoped TUI checks and independent review passed. #### Outcome and scope @@ -3301,10 +3301,10 @@ paragraph cannot meet the requirement. | Task | Owner | Scope and expected result | State | | --- | --- | --- | --- | -| M10.1 | implementer | Inspect `src/tui/render.rs`, `state.rs`, `forms.rs` and `terminal.rs`; derive hints from actual supported keys and screen/form state. Implement the bottom panel with sufficient interior height, styled key/action groups and width-aware packing. | needs_changes: initial 7 render tests passed; independent R1/R2 found compact/form key-hint mismatches; targeted repair pending | -| M10.2 | implementer | Cover resize, narrow/minimum terminals, list/details, forms, progress and QR disclosure with synthetic render/navigation checks. Update README shortcut guidance if needed. | pending | -| M10.3 | reviewer / repairer | Independent review of actual layout, hints versus handlers, readability, clipping and state correctness; repair and re-review actionable findings. | pending | -| M10.4 | coordinator | Run integrated gates, record source/artifact identity and actual results, maintain this plan and make the scoped acceptance decision. | pending | +| M10.1 | implementer | Inspect `src/tui/render.rs`, `state.rs`, `forms.rs` and `terminal.rs`; derive hints from actual supported keys and screen/form state. Implement the bottom panel with sufficient interior height, styled key/action groups and width-aware packing. | complete for task scope: R1/R2 repaired and independently re-reviewed; render 9/9 and forms 7/7 focused tests passed on integrated HEAD | +| M10.2 | implementer | Cover resize, narrow/minimum terminals, list/details, forms, progress and QR disclosure with synthetic render/navigation checks. Update README shortcut guidance if needed. | complete: render 13/13, TUI workflow 6/6 including PTY smoke; integrated review closed | +| M10.3 | reviewer / repairer | Independent review of actual layout, hints versus handlers, readability, clipping and state correctness; repair and re-review actionable findings. | complete: R1 Clippy and R2 README precision repaired and independently re-reviewed with no remaining finding | +| M10.4 | coordinator | Run integrated gates, record source/artifact identity and actual results, maintain this plan and make the scoped acceptance decision. | complete: integrated gates passed; source fingerprints and results in [M10 evidence](docs/evidence/milestone-10.md) | List/details quick reference must include navigation (`Up/Down`, `Enter`, `Esc` as applicable), `?` Help and `q` Exit, plus the implemented management keys: @@ -3326,19 +3326,19 @@ Keep Help as the complete reference and preserve all existing operation semantic #### Acceptance and validation -- [ ] M10-A1: the lowest bordered panel has visible interior text; at 80x24 all +- [x] M10-A1: the lowest bordered panel has visible interior text; at 80x24 all applicable list actions are displayed in readable packed rows. -- [ ] M10-A2: key labels are visually distinct with bold/color styling; action +- [x] M10-A2: key labels are visually distinct with bold/color styling; action text is readable, and meaning remains understandable without color. -- [ ] M10-A3: hints match actual key handlers, uppercase/lowercase and active +- [x] M10-A3: hints match actual key handlers, uppercase/lowercase and active screen/form prerequisites; no hint implies a disabled operation can run. -- [ ] M10-A4: resizing through 120x30, 80x24, 40x8 and the supported minimum +- [x] M10-A4: resizing through 120x30, 80x24, 40x8 and the supported minimum 34x8 causes no panic, overlapping borders or invisible footer text; compact layouts retain navigation/Help and indicate omitted commands. Below-minimum sizes retain a readable resize message. -- [ ] M10-A5: list/detail navigation, forms, progress and QR remain usable; +- [x] M10-A5: list/detail navigation, forms, progress and QR remain usable; footer rendering exposes no secrets and performs no application mutations. -- [ ] M10-A6: focused synthetic rendering checks and all required Rust gates +- [x] M10-A6: focused synthetic rendering checks and all required Rust gates pass; independent review closes actionable findings with recorded evidence. Planned validation: `cargo test --locked tui::`, @@ -3346,37 +3346,131 @@ Planned validation: `cargo test --locked tui::`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked`, `cargo build --locked --release` and `git diff --check`. Use synthetic fixtures and Ratatui test buffers; inspect styles as well as rendered text and geometry. -No production or VM operations are needed for this presentation change. These -checks have not been run for M10; no implementation or acceptance is claimed. +No production or VM operations are needed for this presentation change. Actual +integrated results and source identity are in [M10 evidence](docs/evidence/milestone-10.md). #### Review, evidence and handoff M10.1 initial implementation added visible styled footer rows and passed `cargo test --locked tui::render` (7/7), `cargo fmt --all -- --check`, and -`git diff --check`. Independent review found **M10.1-R1 (P2, open)**: compact -rows advertise list navigation/Help during forms, QR, and pending work; -**M10.1-R2 (P2, open)**: generic form Enter is false for draft review and Tab -is missing from tabbed forms. Targeted repair/re-review is next; no acceptance -item is closed yet. +`git diff --check`. Independent review found **M10.1-R1 (P2, closed)**: compact +rows advertised list navigation/Help during forms, QR, and pending work; and +**M10.1-R2 (P2, closed)**: generic form Enter was false for draft review and +Tab was missing from tabbed forms. Targeted repair added secret-free form key +capabilities and context-specific compact rows; independent re-review closed +both findings on current integrated HEAD `e141c03`. Render tests passed 9/9, +forms tests 7/7, format and diff checks passed. M10.2 coverage remains; no +milestone acceptance is claimed yet. -The user removed M9 as -a prerequisite without accepting its unchecked M9-A1–A9 criteria. The project -configuration specifies GPT-6-Sol/medium for the coordinator and GPT-6-Luna/high -for all four custom roles. Start with M10.1, one writer at a time. The -coordinator owns this plan; record detailed evidence in -`docs/evidence/milestone-10.md` only after actual checks exist. No new -dependencies are planned. +M10.2 added synthetic TestBackend coverage for 120×30, 80×24, 40×8, 34×8, +below-minimum fallback, all applicable saved-client list hints, QR Esc/footer +rendering, and key/action styles. README now describes contextual shortcuts. +`cargo test --locked tui::render` passed 13/13 and +`cargo test --locked --test tui_workflow` passed 6/6 including PTY smoke; +`cargo fmt --all -- --check` and `git diff --check` passed. Integrated review +subsequently closed its findings. No VM or production operations were run. + +Fresh integrated review found **M10.3-R1 (P2, closed)**: Clippy failed on a +collapsible `if`; repair preserved the action prerequisite and the gate passed. +It also found **M10.3-R2 (P3, closed)**: README overstated narrow-screen Help +availability; repair qualified the claim to list/details. Independent re-review +closed both with no remaining actionable finding. A 34×8 QR assertion confirms +Esc guidance and no fixture payload exposure; compact QR may be too large to +display, but can be cleared. [M10 evidence](docs/evidence/milestone-10.md) +records the integrated results and limitations. + +The user removed M9 as a prerequisite without accepting its unchecked M9-A1–A9 +criteria. The project configuration specifies GPT-6-Sol/medium for the +coordinator and GPT-6-Luna/high for all four custom roles. No new dependencies +were added. #### Outcome -Milestone in progress; no M10 acceptance is claimed. Next action: implement -M10.1, then independent review/repair and M10.2–M10.4 checks. M9 stays retired. +Milestone 10 accepted for scoped TUI presentation on the integrated source +identified in [M10 evidence](docs/evidence/milestone-10.md). M9 stays retired +and unaccepted. The user's subsequent AllowedIPs classification request is +handled in M11 below. + +### Milestone 11 — Valid subnet routes shown as a capability limitation + +Status: accepted (2026-10-26T21:20:07Z). Prerequisite M10 accepted; M9 was +retired by user direction and remains unaccepted. This is the subsequent +user-requested behavior change. No VM work is in scope. + +#### Outcome and scope + +Keep a peer with valid extra subnet routes in the client list as `inspection +only`, without treating those routes alone as a configuration anomaly or +counting them on the issues screen. Details explain neutrally: “This peer +contains subnet routes. Managing this configuration is not supported yet.” +Malformed addresses, invalid keys, duplicate identities, document issues and +other genuine errors remain visible. Preserve all source bytes and routes; +do not expand the mutable client model, address allocation or restart +verification, or remove safety checks. + +#### Context and implementation steps + +Read the parser, projection and TUI before editing. `ConfigDocument::parse` +already distinguishes parse errors from valid `IpNet` values outside the +client model. `Application::display_projection` retains all peer issues and +marks projection failures inspection-only. The TUI currently treats every +peer issue as an anomaly in list/details/`!` at the start of M11. Classify valid unsupported +address shape separately at the presentation boundary, while retaining raw +issues for edit/restart safety. A subnet-specific capability notice must be +derived from parsed routes, not from parsing display strings. M2's historical +“inspection issues” wording described visibility; this user-directed change +refines their UI severity without rewriting M2's acceptance evidence. + +| Task | Owner | Scope and expected result | State | +| --- | --- | --- | --- | +| M11.1 | implementer | Add explicit error-versus-capability classification and focused parser/projection/TUI regressions for valid subnet routes, normal client and genuine errors; preserve mutation guards. | complete: parser/projection/TUI regressions and safety preservation inspected; integrated review closed | +| M11.2 | reviewer / repairer | Independently review behavior, safety boundaries and tests; repair and re-review actionable findings. | complete: R1 withdrawn after source-based re-evaluation; R2 repaired and independently re-reviewed with no remaining finding | +| M11.3 | coordinator | Run focused and full Rust gates, record source/evidence and make acceptance decision. | complete: integrated gates passed; source fingerprints/results in [M11 evidence](docs/evidence/milestone-11.md) | + +#### Acceptance and validation + +- [x] M11-A1: a peer with `10.9.0.10/32, 192.168.88.0/24, + 192.168.8.0/24` is visible as `inspection only`, with original bytes/routes + intact and the exact neutral details explanation. +- [x] M11-A2: those valid subnet routes alone cause no `! anomaly`, error + count or `UnsupportedAllowedIpShape` text in the list or issues screen. +- [x] M11-A3: a normal supported peer remains mutable and unchanged; malformed + addresses, invalid keys, duplicate identities and document issues remain + visible as errors/ambiguities alongside any valid capability limitation. +- [x] M11-A4: unsupported peers remain protected from TUI/application unsafe + mutations; allocation and restart verification behavior are unchanged. +- [x] M11-A5: focused regressions, independent review and all applicable Rust + completion gates pass on the integrated result. + +Planned checks: focused document, application, TUI and config-preservation +tests; `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- +-D warnings`, `cargo test --locked`, `cargo build --locked --release`, and +`git diff --check`. Use synthetic fixtures only. No Alpine, VM or production +checks are needed for this classification/presentation change. + +#### Outcome + +Accepted for the requested classification/presentation behavior. **M11-R1 +(withdrawn)**: review initially called a second +distinct `/32` route a configuration error; reviewer re-evaluated against +the WireGuard `wg(8)` format and confirmed it is another valid shape beyond +the client model, with no actionable finding. **M11-R2 (coordinator, closed)**: +without any subnet, valid additional host routes or IPv6-only shapes still +appeared as anomalies. The repair classifies parsed-valid shape variants as +capability limitations for display, retains raw issues for safety, and adds a +generic detail notice for non-subnet cases. Independent re-review found no +actionable issue. [M11 evidence](docs/evidence/milestone-11.md) records the +source, focused checks and all Rust gates. An external workspace actor advanced +HEAD to `18f28ee` while M11.1 was underway; the review covered committed and +uncommitted M11 inputs. This team created no commit. No Alpine, VM or +production behavior is claimed. Stop after this milestone. ## Current progress and resume point - Bounded TUI maintenance (2026-10-26): repaired list arrows opening client details. Up/Down now only move the highlight; Enter opens details and Esc returns. Initial navigation follows the renderer's first-row highlight. Independent review closed NAV-001 with no remaining scoped findings; validation is recorded in [navigation repair evidence](docs/evidence/tui-navigation.md). This does not advance M9 or supply VM/Alpine acceptance evidence; prior artifact identities remain historical after this Rust change. - Milestones 1–8 are accepted; milestone 9 is retired by user direction and unaccepted. Its real systemd/D-Bus checks remain unverified and are not a prerequisite to M10. -- Milestone 10 is in progress: implement the styled keyboard quick reference in the bottom bordered panel, then review and validate it. +- Milestone 10 is accepted: the styled keyboard quick reference passed independent review, focused synthetic/PTY checks and Rust completion gates. +- Milestone 11 is accepted for valid but unsupported AllowedIPs route presentation; focused and full Rust gates and independent re-review passed. Real systemd/D-Bus and IPv6 behavior remain unverified. - The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md). - Milestone 1 is expanded and the Rust environment is pinned. - Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests. @@ -3389,7 +3483,7 @@ M10.1, then independent review/repair and M10.2–M10.4 checks. M9 stays retired - 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. -- Next action: execute M10.1. Do not restart the VM, access production, commit, or deploy. +- Next action: stop after M11 and await user direction. Do not restart the VM, access production, commit, or deploy. ## Plan maintenance diff --git a/README.md b/README.md index 7fdc1bf..834c8c1 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,16 @@ claim a completed systemd VM run or real IPv6 qualification. ## Terminal workflows -The client list supports arrow-key selection, Enter for details, `?` for help, -and `q` to exit. Exit warns when a draft would be discarded; it never commits -implicitly. From the list or details screen, the current shortcuts are: +The bordered **Keys** panel at the bottom of the screen shows shortcuts for the +current screen and form. On narrow list and details screens it prioritizes +navigation and Help, and indicates when additional actions are omitted; resize +the terminal to see more. Forms, QR disclosure and progress show only their +applicable controls. Press `?` from the list or details screen to open Help, +which contains the complete shortcut reference. The client list supports +arrow-key selection, Enter for details, and `q` to exit. Exit warns when a draft +would be discarded; it never commits implicitly. Uppercase shortcuts are +distinct from lowercase shortcuts (`R` previews restart, while `r` renames; +`B` creates a backup, while `b` lists backups): | Key | Action | | --- | --- | diff --git a/docs/evidence/milestone-10.md b/docs/evidence/milestone-10.md new file mode 100644 index 0000000..814ac9d --- /dev/null +++ b/docs/evidence/milestone-10.md @@ -0,0 +1,49 @@ +# Milestone 10 acceptance evidence + +Run: 2026-10-26T21:07:24Z, local development workspace. This report covers the +styled TUI keyboard footer only. Milestone 9 remains retired and unaccepted. + +## Source and environment + +- HEAD: `e141c03c2c401b09ad0fa78557cb9403640ea80a`; working tree was dirty + with `PLANS.md`, `README.md`, `src/delivery/qr.rs`, and `src/tui/render.rs`. + The following SHA-256 values identify the relevant integrated inputs at the + gates: `render.rs` `40e2abc891cc562a628eb661dcdcd3e052818c4332c25f3ae555e184d9adb65d`; + `forms.rs` `8ecbd614c1ecc7f1ab4cbae9223f9820c5d8315c62a54cffbc6d5c2e640f6c31`; + `qr.rs` `b8d30e6b24370fd1e3d666586481af74b12f279043cb1e0a7a1e61923588c4b8`; + `README.md` `fa4ef797565895dbd78f920e92263df718720bdb84dba48da88e611020638741`; + `Cargo.lock` `b8549df27f971dc550218cfc28e788f6630a1e13e7df538aded38e969ab713b6`. +- `rustc 1.97.1 (8bab26f4f 2026-07-14)` and `cargo 1.97.1 + (c980f4866 2026-06-30)`. The release build used the locked local target; + no container, VM, production service or VPN was accessed. + +## Checks and scope + +| Exact command | Result | +| --- | --- | +| `cargo test --locked tui::` | Exit 0; 38 TUI unit tests passed. | +| `cargo test --locked --test tui_workflow` | Exit 0; 6 integration tests passed, including isolated PTY smoke. | +| `cargo fmt --all -- --check` | Exit 0. | +| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0 after M10.3-R1 repair. | +| `cargo test --locked` | Exit 0; 271 library units passed, 2 opt-in container drivers ignored; all integration suites and 2 compile-fail doctests passed. The Testcontainers target reported 25 passed and 1 ignored; no opt-in Alpine run occurred. | +| `cargo build --locked --release` | Exit 0. | +| `git diff --check` | Exit 0. | + +The TestBackend checks cover 120×30, 80×24, 40×8, 34×8 and below-minimum +rendering. They assert visible footer interior, styled key labels, applicable +80×24 list actions, compact navigation/Help and overflow, context-specific +forms/progress, and QR Esc guidance without fixture payload disclosure. These +are synthetic buffer checks; no physical terminal QR scan or systemd behavior +is claimed. + +## Review and decision + +Independent M10.1 review found R1/R2 context/form hint issues; repair and +re-review closed both on the integrated implementation. A separate fresh +integrated M10.3 review found R1 (Clippy failure) and R2 (README context +overstatement). The repairer fixed both; the same independent reviewer +re-reviewed the repaired state and reported no remaining actionable finding. +The reviewer supported M10-A1–A5 within synthetic/PTY scope; the coordinator's +full gates above satisfy M10-A6. Compact QR may show a too-large condition at +34×8 while retaining Esc clearing guidance. M10 acceptance is scoped to TUI +presentation and does not upgrade M9 systemd/IPv6 evidence. diff --git a/docs/evidence/milestone-11.md b/docs/evidence/milestone-11.md new file mode 100644 index 0000000..6477afd --- /dev/null +++ b/docs/evidence/milestone-11.md @@ -0,0 +1,62 @@ +# Milestone 11 acceptance evidence + +Run: 2026-10-26T21:20:07Z, local development workspace. This report covers +presentation of valid but unsupported peer `AllowedIPs` shapes. It does not +establish runtime WireGuard, Alpine or systemd behavior. + +## Source and environment + +- HEAD `18f28eef02d6a9f94a1d34ad94e25079ebf125a2`; the working tree was + dirty with `PLANS.md`, `src/app.rs`, `src/tui/render.rs`, + `src/tui/terminal.rs`, and `tests/config_preservation.rs`. An external + workspace actor created HEAD during implementation; this team created no + commit. The integrated inputs at the gates have SHA-256 values: + `app.rs` `75935d694d85cad11511ffbcff093cc7ebded08f02eb26589cb552fdbe1c7720`; + `document.rs` `af4fed6c25fe4b801112d19fe4867ed809c6a05e68492c3a53cc4829ac3b766b`; + `render.rs` `2f673febaba9e6f0ada1c274f33c0db02fe78fd2bca50310b1215d5a22dd8c9f`; + `terminal.rs` `ed9a99932dddbaf508667c5f17886704bda749264c53cc60c7e29b889a56e074`; + `config_preservation.rs` `0cdac39ac6ac370aaa85ea13610cb00f778a7efc559a39896212d96c6147f0b0`; + `Cargo.lock` `b8549df27f971dc550218cfc28e788f6630a1e13e7df538aded38e969ab713b6`. +- `rustc 1.97.1 (8bab26f4f 2026-07-14)`; `cargo 1.97.1 + (c980f4866 2026-06-30)`. Synthetic fixtures and Ratatui TestBackend only; + no production, Docker, VM or live VPN action. + +## Checks and scope + +| Exact command | Result | +| --- | --- | +| `cargo test --locked valid_subnet_routes_are_classified_without_changing_source_or_mutation_safety` | Exit 0; focused config preservation test passed. | +| `cargo test --locked tui::render::tests::client_rows_show_draft_state_and_escape_anomalous_unsupported_peers` | Exit 0; focused list/details test passed. | +| `cargo test --locked --lib issues_screen_hides_valid_address_shape_limits_but_keeps_genuine_errors` | Exit 0; focused issue-screen test passed after repair. | +| `cargo test --locked --test config_preservation` | Exit 0; 10 tests passed. | +| `cargo fmt --all -- --check` | Exit 0. | +| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. | +| `cargo test --locked` | Exit 0; 272 library tests passed, 2 opt-in drivers ignored; integration and 2 compile-fail doctests passed. Testcontainers target reported 25 passed and 1 ignored without starting Alpine. | +| `cargo build --locked --release` | Exit 0. | +| `git diff --check` | Exit 0. | + +The parser preserves the valid example's original source and all three routes. +The application leaves its raw peer issues and unsupported projection intact; +the TUI presents parsed-valid address-shape limitations separately from +malformed input. The list and issue screen omit capability-only diagnostics; +details show the exact subnet explanation. Tests also cover a normal supported +client, extra host routes, IPv6-only shape, mixed malformed route, and existing +broad-route deletion safety. Alias-only rename of an unprojectable peer retains +its existing behavior and preserves routes. The TUI still blocks normal client +mutations for unprojectable peers. + +## Review and decision + +Fresh independent review initially raised M11-R1, treating a second distinct +`/32` host route as a configuration error. After checking the WireGuard +[`wg(8)` AllowedIPs format](https://man7.org/linux/man-pages/man8/wg.8.html), +the reviewer withdrew R1: a valid additional host route is another model +capability limitation. The coordinator raised M11-R2 because non-subnet valid +address shapes were still displayed as anomalies. A repairer classified the +two parsed-valid shape variants consistently for presentation, kept all raw +issues for safety, and added regressions. Independent re-review reported no +remaining actionable finding. A1–A4 passed within source/fixture scope; the +integrated gates above satisfy A5. + +No real WireGuard or systemd behavior was exercised. Milestone 9 remains +retired and unaccepted; real systemd/D-Bus and IPv6 capability are unverified. diff --git a/src/app.rs b/src/app.rs index da6d013..488a1e8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -46,10 +46,29 @@ pub struct ClientDisplay { pub identity: Option, pub label: String, pub allowed_ips: Vec, + pub has_subnet_routes: bool, pub issues: Vec, pub mutable_identity: Option, } +impl ClientDisplay { + /// Whether a retained parser issue describes a valid address shape the + /// client model cannot manage, rather than a malformed configuration. + pub fn is_capability_limitation_issue(&self, issue: &crate::wireguard::PeerIssue) -> bool { + matches!( + issue, + crate::wireguard::PeerIssue::UnsupportedAllowedIpShape { .. } + | crate::wireguard::PeerIssue::MissingIpv4HostRoute + ) + } + + pub fn presented_issues(&self) -> impl Iterator { + self.issues + .iter() + .filter(|issue| !self.is_capability_limitation_issue(issue)) + } +} + /// Secret-free display projection of the current document and client state. #[derive(Clone, Debug, Eq, PartialEq)] pub struct SessionDisplay { @@ -553,6 +572,7 @@ impl Application { 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.states.draft().get(&client.identity).is_some()) diff --git a/src/delivery/qr.rs b/src/delivery/qr.rs index ae3c641..c1115b7 100644 --- a/src/delivery/qr.rs +++ b/src/delivery/qr.rs @@ -84,6 +84,12 @@ fn encode_bytes(bytes: &[u8]) -> Result { Ok(QrCodeOutput { side, modules }) } +/// Small, non-secret QR used only to exercise the terminal disclosure renderer. +#[cfg(test)] +pub(crate) fn synthetic_test_output() -> QrCodeOutput { + encode_bytes(b"synthetic QR disclosure fixture").expect("synthetic QR payload encodes") +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/tui/render.rs b/src/tui/render.rs index bfc16a8..f0db189 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -230,17 +230,16 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec { add("c", "Commit", HintKind::Action); add("x", "discard", HintKind::Destructive); add("R", "restart", HintKind::Destructive); - if let Some(id) = state.selected_identity { - if state + if let Some(id) = state.selected_identity + && state .view .as_ref() .and_then(|view| view.client_states.get(&id)) .is_some_and(|row| row.saved.is_some()) - && selected - { - add("o", "first file", HintKind::Action); - add("O", "password reissue", HintKind::Action); - } + && selected + { + add("o", "first file", HintKind::Action); + add("O", "password reissue", HintKind::Action); } add("b", "backups / restore", HintKind::Action); add("B", "backup", HintKind::Action); @@ -497,6 +496,7 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta .clients .iter() .map(|client| { + let visible_issues: Vec<_> = client.presented_issues().collect(); let identity = client .identity .map(|key| short_key(&key)) @@ -520,15 +520,15 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta (None, None) => "State unavailable".to_owned(), }, ); - let anomaly_marker = if client.issues.is_empty() { + let anomaly_marker = if visible_issues.is_empty() { String::new() } else { " ! anomaly".to_owned() }; - let anomaly_details = if client.issues.is_empty() { + let anomaly_details = if visible_issues.is_empty() { String::new() } else { - format!(" {}", safe_text(&format!("{:?}", client.issues))) + format!(" {}", safe_text(&format!("{:?}", visible_issues))) }; ListItem::new(Line::from(vec![ Span::raw(format!("{row_state}{anomaly_marker} ")), @@ -652,6 +652,19 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec } )), ]; + let has_capability_limitation = client + .issues + .iter() + .any(|issue| client.is_capability_limitation_issue(issue)); + if client.has_subnet_routes { + lines.push(Line::from( + "This peer contains subnet routes. Managing this configuration is not supported yet.", + )); + } else if has_capability_limitation { + lines.push(Line::from( + "This peer uses valid address routes that this client model cannot manage yet.", + )); + } if let Some(identity) = client.identity { if let Some(row) = view.client_states.get(&identity) { lines.push(Line::from(format!( @@ -697,7 +710,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec } } lines.push(Line::from(format!("Applied state: {:?}", view.applied))); - for issue in &client.issues { + for issue in client.presented_issues() { lines.push(Line::from(format!( "Peer issue: {}", safe_text(&format!("{issue:?}")) @@ -833,6 +846,44 @@ mod tests { } } + fn saved_client_view(identity: PublicKey) -> SessionView { + let client = crate::domain::Client::new( + identity, + "synthetic client", + crate::domain::ClientAddresses { + ipv4: Ipv4Addr::new(10, 0, 0, 2), + ipv6: None, + }, + ) + .unwrap(); + SessionView { + generation: 0, + clients: vec![ClientDisplay { + peer_index: 0, + line: 1, + identity: Some(identity), + label: "synthetic client".to_owned(), + allowed_ips: vec!["10.0.0.2/32".to_owned()], + has_subnet_routes: false, + issues: vec![], + mutable_identity: Some(identity), + }], + client_states: BTreeMap::from([( + identity, + super::super::state::ClientStateRow { + saved: Some(client.clone()), + draft: Some(client), + }, + )]), + document_issues: Vec::new(), + applied: super::super::state::AppliedState::Unknown, + reserved_addresses: Default::default(), + statistics: RuntimeObservation::unknown(), + has_draft: false, + vault_initialized: true, + } + } + #[test] fn bottom_keyboard_panel_has_visible_interior_and_styled_key_labels() { let state = UiState { @@ -869,6 +920,9 @@ mod tests { let style = buffer[key].style(); assert!(style.add_modifier.contains(Modifier::BOLD)); assert_eq!(style.fg, Some(Color::Green)); + let action = buffer[(key.0 + 1, key.1)].style(); + assert!(matches!(action.fg, None | Some(Color::Reset))); + assert!(!action.add_modifier.contains(Modifier::BOLD)); } #[test] @@ -889,6 +943,111 @@ mod tests { assert!(text.lines().last().unwrap().contains('└')); } + #[test] + fn requested_terminal_sizes_keep_borders_nonoverlapping_and_footer_inside_panel() { + let state = UiState { + view: Some(empty_view()), + ..UiState::default() + }; + for (width, height) in [(120, 30), (80, 24), (40, 8), (34, 8)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| draw(frame, &state, &FormController::default())) + .unwrap(); + let buffer = terminal.backend().buffer(); + let text = rendered_text(&terminal); + assert!(text.contains("Keys"), "missing footer at {width}x{height}"); + let bottom = height - 1; + assert!(buffer[(0, bottom)].symbol().contains('└')); + assert!(buffer[(width - 1, bottom)].symbol().contains('┘')); + assert!(buffer[(1, bottom)].symbol().contains('─')); + assert!(buffer[(0, bottom - 1)].symbol().contains('│')); + assert!( + text.lines() + .any(|line| line.contains("select") || line.contains("new")), + "footer has no visible interior hint at {width}x{height}: {text}" + ); + } + } + + #[test] + fn list_at_80x24_shows_each_applicable_saved_client_action() { + let identity = PublicKey::from_bytes([4; 32]); + let state = UiState { + selected_identity: Some(identity), + view: Some(saved_client_view(identity)), + ..UiState::default() + }; + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal + .draw(|frame| draw(frame, &state, &FormController::default())) + .unwrap(); + let text = rendered_text(&terminal); + for expected in [ + "↑/↓ select", + "Enter details", + "? Help", + "q exit", + "n new", + "r rename", + "d delete", + "p preview", + "c Commit", + "x discard", + "R restart", + "o first file", + "O password reissue", + "b backups / restore", + "B backup", + "i import", + "! issues", + ] { + assert!( + text.contains(expected), + "missing {expected:?} in 80x24 render:\n{text}" + ); + } + assert!(text.contains("R restart") && text.contains("r rename")); + } + + #[test] + fn qr_disclosure_keeps_esc_clear_hint_and_never_renders_fixture_payload() { + let mut forms = FormController::default(); + forms.open_disclosure( + crate::delivery::qr::synthetic_test_output(), + "Activation pending".to_owned(), + ); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal + .draw(|frame| draw(frame, &UiState::default(), &forms)) + .unwrap(); + let text = rendered_text(&terminal); + assert!(text.contains("Secret disclosure")); + assert!(text.contains("Esc clears QR") || text.contains("Esc clear QR")); + assert!(text.contains("clear QR")); + assert!(!text.contains("synthetic QR disclosure fixture")); + + let mut compact = Terminal::new(TestBackend::new(34, 8)).unwrap(); + compact + .draw(|frame| draw(frame, &UiState::default(), &forms)) + .unwrap(); + let compact_text = rendered_text(&compact); + assert!(compact_text.contains("clear QR")); + assert!(!compact_text.contains("synthetic QR disclosure fixture")); + } + + #[test] + fn below_minimum_terminal_shows_resize_message_without_drawing_footer() { + let mut terminal = Terminal::new(TestBackend::new(33, 7)).unwrap(); + terminal + .draw(|frame| draw(frame, &UiState::default(), &FormController::default())) + .unwrap(); + let text = rendered_text(&terminal); + assert!(text.contains("Terminal too small")); + assert!(text.contains("34 × 8")); + assert!(!text.contains("Keys")); + } + #[test] fn compact_form_hints_follow_the_active_form_without_leaking_input() { let state = UiState::default(); @@ -945,6 +1104,7 @@ mod tests { identity: Some(identity), label: "client".to_owned(), allowed_ips: vec![], + has_subnet_routes: false, issues: vec![], mutable_identity: Some(identity), }], @@ -1040,6 +1200,7 @@ mod tests { identity: Some(identity), label: "new alias".to_owned(), allowed_ips: vec!["10.20.0.2/32".to_owned()], + has_subnet_routes: false, issues: vec![], mutable_identity: Some(identity), }, @@ -1049,10 +1210,14 @@ mod tests { identity: Some(unsupported_key), label: "odd\u{1b}[31m peer".to_owned(), allowed_ips: vec!["bad\nroute".to_owned()], - issues: vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape { - field: 0, - item: 1, - }], + has_subnet_routes: true, + issues: vec![ + crate::wireguard::PeerIssue::UnsupportedAllowedIpShape { + field: 0, + item: 1, + }, + crate::wireguard::PeerIssue::MalformedAllowedIp { field: 0, item: 2 }, + ], mutable_identity: None, }, ], @@ -1079,7 +1244,7 @@ mod tests { has_draft: true, vault_initialized: true, }; - let state = UiState { + let mut state = UiState { view: Some(view), ..UiState::default() }; @@ -1100,9 +1265,54 @@ mod tests { assert!(rendered.contains("inspection only")); assert!(rendered.contains("removed alias")); assert!(rendered.contains("Draft removed")); - assert!(rendered.contains("UnsupportedAllowedIpShape")); + assert!(rendered.contains("MalformedAllowedIp")); + assert!(!rendered.contains("UnsupportedAllowedIpShape")); + assert!( + rendered.contains("! anomaly"), + "mixed genuine error stays visible" + ); + let subnet_only = ClientDisplay { + has_subnet_routes: true, + issues: vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape { + field: 0, + item: 1, + }], + ..state.view.as_ref().unwrap().clients[1].clone() + }; + assert!( + subnet_only.issues.len() == 1, + "raw safety issue is retained" + ); + assert!(subnet_only.presented_issues().next().is_none()); + assert!(detail_lines(&state.view.as_ref().unwrap().clients[1], state.view.as_ref().unwrap()) + .iter() + .any(|line| line.to_string().contains( + "This peer contains subnet routes. Managing this configuration is not supported yet." + ))); + let additional_host = ClientDisplay { + has_subnet_routes: false, + issues: vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape { + field: 0, + item: 1, + }], + ..state.view.as_ref().unwrap().clients[1].clone() + }; + let additional_host_details = detail_lines(&additional_host, state.view.as_ref().unwrap()); + assert!( + additional_host_details + .iter() + .any(|line| line.to_string().contains( + "This peer uses valid address routes that this client model cannot manage yet." + )) + ); + assert!(!additional_host_details.iter().any(|line| { + line.to_string() + .contains("Peer issue: UnsupportedAllowedIpShape") + })); assert!(rendered.contains("odd\\u{1b}[31m peer")); assert!(rendered.contains("bad\\nroute")); + state.view.as_mut().unwrap().clients[1].issues = + vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 }]; let mut narrow = Terminal::new(TestBackend::new(34, 12)).unwrap(); narrow @@ -1118,7 +1328,8 @@ mod tests { .collect::>() .join("\n"); assert!(narrow_rendered.contains("Draft changed")); - assert!(narrow_rendered.contains("! anomaly")); + assert!(!narrow_rendered.contains("! anomaly")); + assert!(!narrow_rendered.contains("UnsupportedAllowedIpShape")); assert!(narrow_rendered.contains("Draft removed")); } } diff --git a/src/tui/state.rs b/src/tui/state.rs index 7ca5e35..481fbab 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -315,6 +315,7 @@ mod tests { identity: Some(identity), label: label.to_owned(), allowed_ips: vec!["10.0.0.2/32".to_owned()], + has_subnet_routes: false, issues: if mutable { Vec::new() } else { diff --git a/src/tui/terminal.rs b/src/tui/terminal.rs index 11e9afe..bcff639 100644 --- a/src/tui/terminal.rs +++ b/src/tui/terminal.rs @@ -19,7 +19,7 @@ use super::events::{ }; use super::forms::{FormAction, FormController}; use super::render; -use super::state::{Screen, UiState}; +use super::state::{Screen, SessionView, UiState}; use crate::app::Application; const MIN_WIDTH: u16 = 34; @@ -765,22 +765,7 @@ fn handle_key( state.screen = Screen::Message; return; }; - let mut issues = Vec::new(); - issues.extend( - view.document_issues - .iter() - .map(|issue| format!("Document issue: {issue:?}")), - ); - for client in &view.clients { - for issue in &client.issues { - issues.push(format!("Peer at line {}: {issue:?}", client.line)); - } - } - state.status_message = Some(if issues.is_empty() { - "No document or peer issues were reported.".to_owned() - } else { - issues.join("\n") - }); + state.status_message = Some(issue_summary(view)); state.screen = Screen::Message; state.scroll_offset = 0; } @@ -802,6 +787,25 @@ fn handle_key( } } +fn issue_summary(view: &SessionView) -> String { + let mut issues = Vec::new(); + issues.extend( + view.document_issues + .iter() + .map(|issue| format!("Document issue: {issue:?}")), + ); + for client in &view.clients { + for issue in client.presented_issues() { + issues.push(format!("Peer at line {}: {issue:?}", client.line)); + } + } + if issues.is_empty() { + "No document or peer issues were reported.".to_owned() + } else { + issues.join("\n") + } +} + /// Handle list/detail navigation and return whether the key was consumed. /// Keeping this path independent of worker actions makes its state transitions /// directly testable without starting an application worker. @@ -921,6 +925,7 @@ mod tests { identity: None, label: "unsupported peer".to_owned(), allowed_ips: Vec::new(), + has_subnet_routes: false, issues: vec![PeerIssue::MissingIpv4HostRoute], mutable_identity: None, }; @@ -933,6 +938,7 @@ mod tests { identity: Some(first), label: "first".to_owned(), allowed_ips: vec!["10.0.0.2/32".to_owned()], + has_subnet_routes: false, issues: Vec::new(), mutable_identity: Some(first), }, @@ -1007,6 +1013,38 @@ mod tests { assert!(!state.selected_client_mutable()); } + #[test] + fn issues_screen_hides_valid_address_shape_limits_but_keeps_genuine_errors() { + let mut view = navigation_view(); + view.clients.truncate(1); + view.clients[0].issues = vec![PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 }]; + view.clients[0].has_subnet_routes = false; + let additional_host_only = issue_summary(&view); + assert_eq!( + additional_host_only, + "No document or peer issues were reported." + ); + assert_eq!( + view.clients[0].issues, + vec![PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 }], + "presentation must retain the parser issue for safety checks" + ); + + view.clients[0].issues = vec![PeerIssue::MissingIpv4HostRoute]; + assert_eq!( + issue_summary(&view), + "No document or peer issues were reported.", + "valid IPv6-only address shape is a capability limit" + ); + + view.clients[0] + .issues + .push(PeerIssue::MalformedAllowedIp { field: 0, item: 2 }); + let mixed = issue_summary(&view); + assert!(mixed.contains("MalformedAllowedIp")); + assert!(!mixed.contains("UnsupportedAllowedIpShape")); + } + #[test] fn restart_results_render_cancel_success_and_uncertain_failure_honestly() { use crate::app::{RestartFailureKind, RestartReport, RestartStage}; diff --git a/src/wireguard/document.rs b/src/wireguard/document.rs index 2e005bf..7cdf1e2 100644 --- a/src/wireguard/document.rs +++ b/src/wireguard/document.rs @@ -113,6 +113,10 @@ pub struct PeerInspection { pub public_key_fields: Vec, /// Parsed route values. Unparseable items are represented by `PeerIssue` entries. pub allowed_ips: Vec, + /// True when a valid parsed route is a subnet rather than a host route. + /// This is a capability limitation for the current client model, not a + /// malformed configuration issue. + pub has_subnet_routes: bool, pub allowed_ips_fields: Vec, /// Parsed alias and safe owned block span, or an explicit metadata issue. pub metadata: MetadataAttachment, @@ -346,11 +350,16 @@ impl ConfigDocument { let mut allowed_ips_fields = Vec::new(); let mut has_ipv4_host_route = false; let mut has_ipv6_host_route = false; + let mut has_subnet_routes = false; for (field, (directive, items)) in allowed_fields.into_iter().enumerate() { allowed_ips_fields.push(directive); for (item, result) in items { match result { Ok(net) => { + has_subnet_routes |= match net { + IpNet::V4(network) => network.prefix_len() < 32, + IpNet::V6(network) => network.prefix_len() < 128, + }; let supported = match net { IpNet::V4(network) if network.prefix_len() == 32 => { let first = !has_ipv4_host_route; @@ -385,6 +394,7 @@ impl ConfigDocument { public_key_candidates: parsed_keys.into_iter().flatten().collect(), public_key_fields, allowed_ips, + has_subnet_routes, allowed_ips_fields, metadata: MetadataAttachment::Absent, issues: peer_issues, diff --git a/tests/config_preservation.rs b/tests/config_preservation.rs index 3e6a9c3..814d14f 100644 --- a/tests/config_preservation.rs +++ b/tests/config_preservation.rs @@ -372,6 +372,45 @@ fn unsupported_routes_multiple_addresses_and_ipv6_only_round_trip_but_projection } } +#[test] +fn valid_subnet_routes_are_classified_without_changing_source_or_mutation_safety() { + let source = format!( + "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=Y2xpZW50\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.9.0.10/32, 192.168.88.0/24, 192.168.8.0/24\n" + ); + let document = ConfigDocument::parse(&source).unwrap(); + let peer = &document.peers()[0]; + assert!(peer.has_subnet_routes); + assert_eq!(peer.allowed_ips.len(), 3); + assert!(peer.issues.iter().any(|issue| matches!( + issue, + PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 } + ))); + assert_eq!(document.render(), source.as_bytes()); + assert_eq!( + document.project_client(&key(KEY_A)), + Err(EditError::UnsupportedAddresses) + ); + let renamed = document + .rename_client(&key(KEY_A), "alias-only edit remains byte-preserving") + .expect("safe alias-only edit remains supported"); + assert!( + String::from_utf8_lossy(renamed.render()) + .contains("AllowedIPs = 10.9.0.10/32, 192.168.88.0/24, 192.168.8.0/24") + ); + + let malformed = format!( + "[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.9.0.10/32, 192.168.88.0/24, not-an-address\n" + ); + let mixed = ConfigDocument::parse(&malformed).unwrap(); + assert!(mixed.peers()[0].has_subnet_routes); + assert!( + mixed.peers()[0] + .issues + .iter() + .any(|issue| matches!(issue, PeerIssue::MalformedAllowedIp { .. })) + ); +} + #[test] fn parse_errors_debug_and_failed_edits_never_expose_document_secrets() { let document = ConfigDocument::parse(FIXTURE).unwrap();