Fix subnets validation
This commit is contained in:
@@ -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
|
### 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
|
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
|
#### Outcome and scope
|
||||||
|
|
||||||
@@ -3301,10 +3301,10 @@ paragraph cannot meet the requirement.
|
|||||||
|
|
||||||
| Task | Owner | Scope and expected result | State |
|
| 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.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. | 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. | 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. | pending |
|
| 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. | pending |
|
| 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`
|
List/details quick reference must include navigation (`Up/Down`, `Enter`, `Esc`
|
||||||
as applicable), `?` Help and `q` Exit, plus the implemented management keys:
|
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
|
#### 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.
|
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.
|
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.
|
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
|
34x8 causes no panic, overlapping borders or invisible footer text; compact
|
||||||
layouts retain navigation/Help and indicate omitted commands. Below-minimum
|
layouts retain navigation/Help and indicate omitted commands. Below-minimum
|
||||||
sizes retain a readable resize message.
|
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.
|
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.
|
pass; independent review closes actionable findings with recorded evidence.
|
||||||
|
|
||||||
Planned validation: `cargo test --locked tui::`,
|
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 clippy --locked --all-targets -- -D warnings`, `cargo test --locked`,
|
||||||
`cargo build --locked --release` and `git diff --check`. Use synthetic fixtures
|
`cargo build --locked --release` and `git diff --check`. Use synthetic fixtures
|
||||||
and Ratatui test buffers; inspect styles as well as rendered text and geometry.
|
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
|
No production or VM operations are needed for this presentation change. Actual
|
||||||
checks have not been run for M10; no implementation or acceptance is claimed.
|
integrated results and source identity are in [M10 evidence](docs/evidence/milestone-10.md).
|
||||||
|
|
||||||
#### Review, evidence and handoff
|
#### Review, evidence and handoff
|
||||||
|
|
||||||
M10.1 initial implementation added visible styled footer rows and passed
|
M10.1 initial implementation added visible styled footer rows and passed
|
||||||
`cargo test --locked tui::render` (7/7), `cargo fmt --all -- --check`, and
|
`cargo test --locked tui::render` (7/7), `cargo fmt --all -- --check`, and
|
||||||
`git diff --check`. Independent review found **M10.1-R1 (P2, open)**: compact
|
`git diff --check`. Independent review found **M10.1-R1 (P2, closed)**: compact
|
||||||
rows advertise list navigation/Help during forms, QR, and pending work;
|
rows advertised list navigation/Help during forms, QR, and pending work; and
|
||||||
**M10.1-R2 (P2, open)**: generic form Enter is false for draft review and Tab
|
**M10.1-R2 (P2, closed)**: generic form Enter was false for draft review and
|
||||||
is missing from tabbed forms. Targeted repair/re-review is next; no acceptance
|
Tab was missing from tabbed forms. Targeted repair added secret-free form key
|
||||||
item is closed yet.
|
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
|
M10.2 added synthetic TestBackend coverage for 120×30, 80×24, 40×8, 34×8,
|
||||||
a prerequisite without accepting its unchecked M9-A1–A9 criteria. The project
|
below-minimum fallback, all applicable saved-client list hints, QR Esc/footer
|
||||||
configuration specifies GPT-6-Sol/medium for the coordinator and GPT-6-Luna/high
|
rendering, and key/action styles. README now describes contextual shortcuts.
|
||||||
for all four custom roles. Start with M10.1, one writer at a time. The
|
`cargo test --locked tui::render` passed 13/13 and
|
||||||
coordinator owns this plan; record detailed evidence in
|
`cargo test --locked --test tui_workflow` passed 6/6 including PTY smoke;
|
||||||
`docs/evidence/milestone-10.md` only after actual checks exist. No new
|
`cargo fmt --all -- --check` and `git diff --check` passed. Integrated review
|
||||||
dependencies are planned.
|
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
|
#### Outcome
|
||||||
|
|
||||||
Milestone in progress; no M10 acceptance is claimed. Next action: implement
|
Milestone 10 accepted for scoped TUI presentation on the integrated source
|
||||||
M10.1, then independent review/repair and M10.2–M10.4 checks. M9 stays retired.
|
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
|
## 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.
|
- 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.
|
- 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).
|
- 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 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.
|
- 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 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.
|
||||||
- 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
|
## Plan maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -56,9 +56,16 @@ claim a completed systemd VM run or real IPv6 qualification.
|
|||||||
|
|
||||||
## Terminal workflows
|
## Terminal workflows
|
||||||
|
|
||||||
The client list supports arrow-key selection, Enter for details, `?` for help,
|
The bordered **Keys** panel at the bottom of the screen shows shortcuts for the
|
||||||
and `q` to exit. Exit warns when a draft would be discarded; it never commits
|
current screen and form. On narrow list and details screens it prioritizes
|
||||||
implicitly. From the list or details screen, the current shortcuts are:
|
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 |
|
| Key | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
+20
@@ -46,10 +46,29 @@ pub struct ClientDisplay {
|
|||||||
pub identity: Option<PublicKey>,
|
pub identity: Option<PublicKey>,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub allowed_ips: Vec<String>,
|
pub allowed_ips: Vec<String>,
|
||||||
|
pub has_subnet_routes: bool,
|
||||||
pub issues: Vec<crate::wireguard::PeerIssue>,
|
pub issues: Vec<crate::wireguard::PeerIssue>,
|
||||||
pub mutable_identity: Option<PublicKey>,
|
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.
|
/// Secret-free display projection of the current document and client state.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct SessionDisplay {
|
pub struct SessionDisplay {
|
||||||
@@ -553,6 +572,7 @@ impl Application {
|
|||||||
identity,
|
identity,
|
||||||
label,
|
label,
|
||||||
allowed_ips: peer.allowed_ips.iter().map(ToString::to_string).collect(),
|
allowed_ips: peer.allowed_ips.iter().map(ToString::to_string).collect(),
|
||||||
|
has_subnet_routes: peer.has_subnet_routes,
|
||||||
issues: peer.issues.clone(),
|
issues: peer.issues.clone(),
|
||||||
mutable_identity: projected
|
mutable_identity: projected
|
||||||
.filter(|client| self.states.draft().get(&client.identity).is_some())
|
.filter(|client| self.states.draft().get(&client.identity).is_some())
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ fn encode_bytes(bytes: &[u8]) -> Result<QrCodeOutput, QrError> {
|
|||||||
Ok(QrCodeOutput { side, modules })
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+223
-12
@@ -230,8 +230,8 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
|
|||||||
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);
|
add("R", "restart", HintKind::Destructive);
|
||||||
if let Some(id) = state.selected_identity {
|
if let Some(id) = state.selected_identity
|
||||||
if state
|
&& state
|
||||||
.view
|
.view
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|view| view.client_states.get(&id))
|
.and_then(|view| view.client_states.get(&id))
|
||||||
@@ -241,7 +241,6 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
|
|||||||
add("o", "first file", HintKind::Action);
|
add("o", "first file", HintKind::Action);
|
||||||
add("O", "password reissue", HintKind::Action);
|
add("O", "password reissue", HintKind::Action);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
add("b", "backups / restore", HintKind::Action);
|
add("b", "backups / restore", HintKind::Action);
|
||||||
add("B", "backup", HintKind::Action);
|
add("B", "backup", HintKind::Action);
|
||||||
if state
|
if state
|
||||||
@@ -497,6 +496,7 @@ 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 identity = client
|
let identity = client
|
||||||
.identity
|
.identity
|
||||||
.map(|key| short_key(&key))
|
.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(),
|
(None, None) => "State unavailable".to_owned(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let anomaly_marker = if client.issues.is_empty() {
|
let anomaly_marker = if visible_issues.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
" ! anomaly".to_owned()
|
" ! anomaly".to_owned()
|
||||||
};
|
};
|
||||||
let anomaly_details = if client.issues.is_empty() {
|
let anomaly_details = if visible_issues.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!(" {}", safe_text(&format!("{:?}", client.issues)))
|
format!(" {}", safe_text(&format!("{:?}", visible_issues)))
|
||||||
};
|
};
|
||||||
ListItem::new(Line::from(vec![
|
ListItem::new(Line::from(vec![
|
||||||
Span::raw(format!("{row_state}{anomaly_marker} ")),
|
Span::raw(format!("{row_state}{anomaly_marker} ")),
|
||||||
@@ -652,6 +652,19 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
|
|||||||
}
|
}
|
||||||
)),
|
)),
|
||||||
];
|
];
|
||||||
|
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(identity) = client.identity {
|
||||||
if let Some(row) = view.client_states.get(&identity) {
|
if let Some(row) = view.client_states.get(&identity) {
|
||||||
lines.push(Line::from(format!(
|
lines.push(Line::from(format!(
|
||||||
@@ -697,7 +710,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines.push(Line::from(format!("Applied state: {:?}", view.applied)));
|
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!(
|
lines.push(Line::from(format!(
|
||||||
"Peer issue: {}",
|
"Peer issue: {}",
|
||||||
safe_text(&format!("{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]
|
#[test]
|
||||||
fn bottom_keyboard_panel_has_visible_interior_and_styled_key_labels() {
|
fn bottom_keyboard_panel_has_visible_interior_and_styled_key_labels() {
|
||||||
let state = UiState {
|
let state = UiState {
|
||||||
@@ -869,6 +920,9 @@ mod tests {
|
|||||||
let style = buffer[key].style();
|
let style = buffer[key].style();
|
||||||
assert!(style.add_modifier.contains(Modifier::BOLD));
|
assert!(style.add_modifier.contains(Modifier::BOLD));
|
||||||
assert_eq!(style.fg, Some(Color::Green));
|
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]
|
#[test]
|
||||||
@@ -889,6 +943,111 @@ mod tests {
|
|||||||
assert!(text.lines().last().unwrap().contains('└'));
|
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]
|
#[test]
|
||||||
fn compact_form_hints_follow_the_active_form_without_leaking_input() {
|
fn compact_form_hints_follow_the_active_form_without_leaking_input() {
|
||||||
let state = UiState::default();
|
let state = UiState::default();
|
||||||
@@ -945,6 +1104,7 @@ mod tests {
|
|||||||
identity: Some(identity),
|
identity: Some(identity),
|
||||||
label: "client".to_owned(),
|
label: "client".to_owned(),
|
||||||
allowed_ips: vec![],
|
allowed_ips: vec![],
|
||||||
|
has_subnet_routes: false,
|
||||||
issues: vec![],
|
issues: vec![],
|
||||||
mutable_identity: Some(identity),
|
mutable_identity: Some(identity),
|
||||||
}],
|
}],
|
||||||
@@ -1040,6 +1200,7 @@ mod tests {
|
|||||||
identity: Some(identity),
|
identity: Some(identity),
|
||||||
label: "new alias".to_owned(),
|
label: "new alias".to_owned(),
|
||||||
allowed_ips: vec!["10.20.0.2/32".to_owned()],
|
allowed_ips: vec!["10.20.0.2/32".to_owned()],
|
||||||
|
has_subnet_routes: false,
|
||||||
issues: vec![],
|
issues: vec![],
|
||||||
mutable_identity: Some(identity),
|
mutable_identity: Some(identity),
|
||||||
},
|
},
|
||||||
@@ -1049,10 +1210,14 @@ mod tests {
|
|||||||
identity: Some(unsupported_key),
|
identity: Some(unsupported_key),
|
||||||
label: "odd\u{1b}[31m peer".to_owned(),
|
label: "odd\u{1b}[31m peer".to_owned(),
|
||||||
allowed_ips: vec!["bad\nroute".to_owned()],
|
allowed_ips: vec!["bad\nroute".to_owned()],
|
||||||
issues: vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape {
|
has_subnet_routes: true,
|
||||||
|
issues: vec![
|
||||||
|
crate::wireguard::PeerIssue::UnsupportedAllowedIpShape {
|
||||||
field: 0,
|
field: 0,
|
||||||
item: 1,
|
item: 1,
|
||||||
}],
|
},
|
||||||
|
crate::wireguard::PeerIssue::MalformedAllowedIp { field: 0, item: 2 },
|
||||||
|
],
|
||||||
mutable_identity: None,
|
mutable_identity: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1079,7 +1244,7 @@ mod tests {
|
|||||||
has_draft: true,
|
has_draft: true,
|
||||||
vault_initialized: true,
|
vault_initialized: true,
|
||||||
};
|
};
|
||||||
let state = UiState {
|
let mut state = UiState {
|
||||||
view: Some(view),
|
view: Some(view),
|
||||||
..UiState::default()
|
..UiState::default()
|
||||||
};
|
};
|
||||||
@@ -1100,9 +1265,54 @@ mod tests {
|
|||||||
assert!(rendered.contains("inspection only"));
|
assert!(rendered.contains("inspection only"));
|
||||||
assert!(rendered.contains("removed alias"));
|
assert!(rendered.contains("removed alias"));
|
||||||
assert!(rendered.contains("Draft removed"));
|
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("odd\\u{1b}[31m peer"));
|
||||||
assert!(rendered.contains("bad\\nroute"));
|
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();
|
let mut narrow = Terminal::new(TestBackend::new(34, 12)).unwrap();
|
||||||
narrow
|
narrow
|
||||||
@@ -1118,7 +1328,8 @@ mod tests {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
assert!(narrow_rendered.contains("Draft changed"));
|
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"));
|
assert!(narrow_rendered.contains("Draft removed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ mod tests {
|
|||||||
identity: Some(identity),
|
identity: Some(identity),
|
||||||
label: label.to_owned(),
|
label: label.to_owned(),
|
||||||
allowed_ips: vec!["10.0.0.2/32".to_owned()],
|
allowed_ips: vec!["10.0.0.2/32".to_owned()],
|
||||||
|
has_subnet_routes: false,
|
||||||
issues: if mutable {
|
issues: if mutable {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+55
-17
@@ -19,7 +19,7 @@ use super::events::{
|
|||||||
};
|
};
|
||||||
use super::forms::{FormAction, FormController};
|
use super::forms::{FormAction, FormController};
|
||||||
use super::render;
|
use super::render;
|
||||||
use super::state::{Screen, UiState};
|
use super::state::{Screen, SessionView, UiState};
|
||||||
use crate::app::Application;
|
use crate::app::Application;
|
||||||
|
|
||||||
const MIN_WIDTH: u16 = 34;
|
const MIN_WIDTH: u16 = 34;
|
||||||
@@ -765,22 +765,7 @@ fn handle_key(
|
|||||||
state.screen = Screen::Message;
|
state.screen = Screen::Message;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut issues = Vec::new();
|
state.status_message = Some(issue_summary(view));
|
||||||
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.screen = Screen::Message;
|
state.screen = Screen::Message;
|
||||||
state.scroll_offset = 0;
|
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.
|
/// Handle list/detail navigation and return whether the key was consumed.
|
||||||
/// Keeping this path independent of worker actions makes its state transitions
|
/// Keeping this path independent of worker actions makes its state transitions
|
||||||
/// directly testable without starting an application worker.
|
/// directly testable without starting an application worker.
|
||||||
@@ -921,6 +925,7 @@ mod tests {
|
|||||||
identity: None,
|
identity: None,
|
||||||
label: "unsupported peer".to_owned(),
|
label: "unsupported peer".to_owned(),
|
||||||
allowed_ips: Vec::new(),
|
allowed_ips: Vec::new(),
|
||||||
|
has_subnet_routes: false,
|
||||||
issues: vec![PeerIssue::MissingIpv4HostRoute],
|
issues: vec![PeerIssue::MissingIpv4HostRoute],
|
||||||
mutable_identity: None,
|
mutable_identity: None,
|
||||||
};
|
};
|
||||||
@@ -933,6 +938,7 @@ mod tests {
|
|||||||
identity: Some(first),
|
identity: Some(first),
|
||||||
label: "first".to_owned(),
|
label: "first".to_owned(),
|
||||||
allowed_ips: vec!["10.0.0.2/32".to_owned()],
|
allowed_ips: vec!["10.0.0.2/32".to_owned()],
|
||||||
|
has_subnet_routes: false,
|
||||||
issues: Vec::new(),
|
issues: Vec::new(),
|
||||||
mutable_identity: Some(first),
|
mutable_identity: Some(first),
|
||||||
},
|
},
|
||||||
@@ -1007,6 +1013,38 @@ mod tests {
|
|||||||
assert!(!state.selected_client_mutable());
|
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]
|
#[test]
|
||||||
fn restart_results_render_cancel_success_and_uncertain_failure_honestly() {
|
fn restart_results_render_cancel_success_and_uncertain_failure_honestly() {
|
||||||
use crate::app::{RestartFailureKind, RestartReport, RestartStage};
|
use crate::app::{RestartFailureKind, RestartReport, RestartStage};
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ pub struct PeerInspection {
|
|||||||
pub public_key_fields: Vec<DirectiveSpan>,
|
pub public_key_fields: Vec<DirectiveSpan>,
|
||||||
/// Parsed route values. Unparseable items are represented by `PeerIssue` entries.
|
/// Parsed route values. Unparseable items are represented by `PeerIssue` entries.
|
||||||
pub allowed_ips: Vec<IpNet>,
|
pub allowed_ips: Vec<IpNet>,
|
||||||
|
/// 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<DirectiveSpan>,
|
pub allowed_ips_fields: Vec<DirectiveSpan>,
|
||||||
/// Parsed alias and safe owned block span, or an explicit metadata issue.
|
/// Parsed alias and safe owned block span, or an explicit metadata issue.
|
||||||
pub metadata: MetadataAttachment,
|
pub metadata: MetadataAttachment,
|
||||||
@@ -346,11 +350,16 @@ impl ConfigDocument {
|
|||||||
let mut allowed_ips_fields = Vec::new();
|
let mut allowed_ips_fields = Vec::new();
|
||||||
let mut has_ipv4_host_route = false;
|
let mut has_ipv4_host_route = false;
|
||||||
let mut has_ipv6_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() {
|
for (field, (directive, items)) in allowed_fields.into_iter().enumerate() {
|
||||||
allowed_ips_fields.push(directive);
|
allowed_ips_fields.push(directive);
|
||||||
for (item, result) in items {
|
for (item, result) in items {
|
||||||
match result {
|
match result {
|
||||||
Ok(net) => {
|
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 {
|
let supported = match net {
|
||||||
IpNet::V4(network) if network.prefix_len() == 32 => {
|
IpNet::V4(network) if network.prefix_len() == 32 => {
|
||||||
let first = !has_ipv4_host_route;
|
let first = !has_ipv4_host_route;
|
||||||
@@ -385,6 +394,7 @@ impl ConfigDocument {
|
|||||||
public_key_candidates: parsed_keys.into_iter().flatten().collect(),
|
public_key_candidates: parsed_keys.into_iter().flatten().collect(),
|
||||||
public_key_fields,
|
public_key_fields,
|
||||||
allowed_ips,
|
allowed_ips,
|
||||||
|
has_subnet_routes,
|
||||||
allowed_ips_fields,
|
allowed_ips_fields,
|
||||||
metadata: MetadataAttachment::Absent,
|
metadata: MetadataAttachment::Absent,
|
||||||
issues: peer_issues,
|
issues: peer_issues,
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn parse_errors_debug_and_failed_edits_never_expose_document_secrets() {
|
fn parse_errors_debug_and_failed_edits_never_expose_document_secrets() {
|
||||||
let document = ConfigDocument::parse(FIXTURE).unwrap();
|
let document = ConfigDocument::parse(FIXTURE).unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user