Fix subnets validation

This commit is contained in:
2026-10-26 22:59:11 +02:00
parent e141c03c2c
commit ecc5c83d78
11 changed files with 605 additions and 68 deletions
+124 -30
View File
@@ -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
+10 -3
View File
@@ -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 |
| --- | --- | | --- | --- |
+49
View File
@@ -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.
+62
View File
@@ -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
View File
@@ -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())
+6
View File
@@ -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::*;
+229 -18
View File
@@ -230,17 +230,16 @@ 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))
.is_some_and(|row| row.saved.is_some()) .is_some_and(|row| row.saved.is_some())
&& selected && selected
{ {
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);
@@ -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,
field: 0, issues: vec![
item: 1, crate::wireguard::PeerIssue::UnsupportedAllowedIpShape {
}], field: 0,
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"));
} }
} }
+1
View File
@@ -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
View File
@@ -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};
+10
View File
@@ -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,
+39
View File
@@ -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();