Compare commits

...

11 Commits

38 changed files with 6648 additions and 2607 deletions
+42
View File
@@ -7,6 +7,45 @@ Terminology for managing device access to a WireGuard VPN.
**Draft**: **Draft**:
Changes prepared by the administrator but not yet saved through Commit. Changes prepared by the administrator but not yet saved through Commit.
**Saved state**:
The installed settings, server configuration, internal metadata, and encrypted
vault after Commit or restoration. Saved state may differ from the running VPN.
**Applied state**:
Current knowledge that the running interface has been verified against the
saved peer configuration. A saved file, a successful historical restart, or a
handshake alone does not establish current applied state. After reopening, the
current state is unknown until checked again.
**Server peer record**:
A `[Peer]` section in the authoritative server configuration, including its
routes, comments, and any detected issues. A malformed record may have no usable
public key and must remain available for inspection.
**Manageable client**:
A server peer that can be represented by the supported client fields and
identified unambiguously by its public key for an allowed management action.
Some valid peer records, such as peers with subnet routes, remain verifiable
without being manageable.
**Runtime observation**:
A read-only measurement of the running interface and its peers, such as routes,
last handshake, or current traffic counters. Missing observations are unknown or
unavailable; they do not imply zero traffic or successful application.
**Reserved address**:
An address retained so that it cannot be assigned to another client while a
deleted peer may still exist on the running interface. It is released only
after a confirmed restart has verified the complete saved peer set and the
verification state has been durably saved.
**First delivery**:
The one-time, password-free delivery of a newly created client's connection
file after Commit in the same creation session. A failed file write can be
retried in that session; later delivery requires the vault password. Until the
saved configuration is verified as applied, delivery does not establish VPN
access.
**Commit**: **Commit**:
Saving prepared changes without applying them to the running VPN. Saving prepared changes without applying them to the running VPN.
@@ -23,5 +62,8 @@ _Avoid_: User, account
**Client alias**: **Client alias**:
An administrator-friendly name for a client. A client is identified by its public key rather than its alias. An administrator-friendly name for a client. A client is identified by its public key rather than its alias.
An existing client's creation date remains unknown unless it is present in its
managed metadata; an unknown date is not filled in with an estimated value.
**Anomalous client record**: **Anomalous client record**:
A record with only a server configuration entry or only a client connection file, without its corresponding counterpart. Records are matched by public key and are not automatically repaired when discovered. A record with only a server configuration entry or only a client connection file, without its corresponding counterpart. Records are matched by public key and are not automatically repaired when discovered.
+298 -4
View File
@@ -126,7 +126,17 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo
| 14 | Restart verification for valid legacy peers | 13 | accepted | | 14 | Restart verification for valid legacy peers | 13 | accepted |
| 15 | Diagnosable systemd restart and unloaded-unit resolution | 14 | accepted | | 15 | Diagnosable systemd restart and unloaded-unit resolution | 14 | accepted |
| 16 | Root systemd private peer restart transport | 15 | accepted | | 16 | Root systemd private peer restart transport | 15 | accepted |
| 17 | Adaptive client list, persistent details and clear states | 16 accepted; existing M7/M12 TUI | accepted || 18 | Readable, complete in-app help for implemented workflows | 17 | accepted | | 17 | Adaptive client list, persistent details and clear states | 16 accepted; existing M7/M12 TUI | accepted |
| 18 | Readable, complete in-app help for implemented workflows | 17 | accepted |
| 19 | Refactoring contract, terminology and behavior map | 18 | accepted |
| 20 | Explicit vocabulary and named operation contexts | 19 | accepted |
| 21 | Domain ownership of reservations and apply knowledge | 20 | accepted |
| 22 | Transactional draft editing and client creation vertical slice | 21 | accepted |
| 23 | Readable saved-state Commit and restore scenarios | 22 | accepted |
| 24 | Session-owned first delivery and password reissue scenarios | 23 | accepted |
| 25 | Explicit restart verification scenario | 24 | accepted |
| 26 | Application read models and thin TUI command adapter | 25 | accepted |
| 27 | Integrated refactoring acceptance and architecture handoff | 26 | accepted |
### Milestone 1 — Rust foundation, settings, and domain states ### Milestone 1 — Rust foundation, settings, and domain states
@@ -3865,11 +3875,295 @@ A more detailed guide requires functional scrolling as well as rewritten text. K
Accepted for presentation and synthetic fixture scope after independent review, R1/R2 closure and pinned Rust gates. No manual terminal-emulator, Alpine, disposable-VM, live WireGuard or production result is claimed. Stop after M18; do not commit, publish, deploy, access production or resume VM work. Accepted for presentation and synthetic fixture scope after independent review, R1/R2 closure and pinned Rust gates. No manual terminal-emulator, Alpine, disposable-VM, live WireGuard or production result is claimed. Stop after M18; do not commit, publish, deploy, access production or resume VM work.
## Approved domain refactoring programme — milestones 19–27
Status: M19–M20 accepted; M21 in progress; M22–M27 pending. The user approved the proposed direction and requested detailed milestones on 2026-10-29. This paragraph records that plan's origin; current task progress is in the milestone records and resume point below. M18 remains accepted. These milestones preserve the approved product contract and all existing acceptance limitations, including retired/unaccepted M9 and unverified real-system paths. They do not resume VM work.
### Shared execution contract for M19–M27
Read before each dispatch: AGENTS.md, docs/SPEC.md, CONTEXT.md, the three docs/adr/0001–0003 decisions, docs/PROJECT_LAYOUT.md, docs/DEVELOPMENT.md, docs/TESTING.md, docs/ENVIRONMENT.md, docs/DEPENDENCIES.md, docs/CODEX.md, docs/IMPLEMENTATION.md, docs/EXECUTION_RECORDS.md, this section, the active milestone and its predecessor's evidence. Read only relevant historical evidence/diagnostics when a concrete question requires it. The coordinator must use the configured model/effort prerequisites; no silent substitution. Implementer/reviewer/repairer are separate GPT-6-Luna/high agents. One writer at a time; coordinator owns PLANS.md, task boundaries, findings and acceptance. Each table row is a bounded dispatch, not a request to implement the whole programme. Supply exact owned files, relevant interfaces, prerequisites and expected checks to every agent. Inspect the current tree before dispatch because paths below describe planned destinations, not modules already present.
**Fixed constraints.** No product/UI workflow, shortcut, persisted schema/version, archive member, default path, cryptographic primitive, authentication policy, dependency, Cargo feature or transport behavior change. No new crates/workspace, generic repository framework, event sourcing, CQRS infrastructure, plugin system, speculative traits or arbitrary file-size targets. Concrete internal owners and narrow functions are preferred. Keep byte-preserving editing in wireguard, secure I/O and recovery in their adapters, and temporary secrets out of public projections/Debug/errors. Do not change Cargo.toml/Cargo.lock for this programme. If a correctness defect or requirement conflict appears, record it and isolate affected work; do not hide a behavior change in a move. Existing external API/test consumers must be inventoried before removing a public escape hatch. Retain a narrow forwarding API where needed during migration; remove it only after all actual callers have migrated.
**Target responsibilities.** Names are working names to finalize in M19. `ServerPeerRecord` means an authoritative document record, potentially malformed/ambiguous/inspection-only; it is not a second independently mutable registry. The existing `Client` remains a restricted management projection, not a universal representation of every WireGuard peer. `RuntimePeer` remains an observation. `DraftChanges` is a boundary for coordinated document/client/vault edits, not a pure-domain holder of filesystem or crypto operations. `AddressReservations` owns reservation rules. `ApplyStatus` describes present knowledge; historical verification and raw observations remain distinct. `FirstDeliverySession` owns creation-session eligibility and short-lived secrets. Pure rules belong in domain; use cases coordinate adapters. The server document remains authoritative.
**Preservation matrix (required throughout).** Check creation/rename/delete/discard/Commit; IPv4-only and dual-stack clients; duplicate aliases; metadata-free, subnet-route, missing-IPv4, malformed and duplicate-identity peers; import anomalies and mismatches; original file preservation; mandatory backup failure; partial install/recovery; restore cancellation/stale preview/settings path restrictions; first-delivery retry/overwrite/cleanup/publication uncertainty; password-per-client reissue; shared restart deadline, service submission uncertainty, complete-peer verification and durable publication failure; reservation release only after verified restart; reopening does not infer current application from historical success; responsive worker cancellation/shutdown, generation checks and explicit QR disclosure. Preserve successful behavior and honest failures, not just function signatures.
**Validation profiles.** `DOC` means inspect Markdown local links/anchors, register/status/resume consistency, task numbering and factual claims, then `git diff --check`. `RUST` means environment checks in docs/ENVIRONMENT.md, focused existing behavioral tests for owned responsibilities, followed by `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked`, `cargo build --locked --release`, and `git diff --check` on final integrated inputs. Commands assume the verified pinned-toolchain environment; do not use a different system Cargo silently. Expected outcome is exit 0; record actual counts/ignored tests rather than copying M18 results. Do not run full gates for every diagnostic edit. Use existing test adapters and synthetic fixtures; add tests only for uncovered behavioral boundaries, not private field names or forwarding methods. Existing tests may move with their owner, but do not weaken/delete assertions to make the refactor pass. Review final production paths independently of test results.
**Evidence and acceptance.** At each milestone, coordinator creates/updates docs/evidence/milestone-N.md with source identity including dirty inputs, commands and results, acceptance mapping, independent review and limitations. Create docs/diagnostics/milestone-N.md only when needed. Review findings use stable M<N>-R<N> IDs with location, impact, expected behavior and reproduction; repair and re-review before acceptance. No claims from commands not run. Root, production keys/configs/services and live VPN testing are forbidden. Alpine is separate opt-in evidence and never systemd evidence; unavailable prerequisites remain incomplete validation. A public item is not made private merely to satisfy a structural goal without checking binary, integration tests and container-driver callers. Update docs/PROJECT_LAYOUT.md when actual responsibilities move; keep CONTEXT.md a glossary, without module paths or implementation notes.
### Milestone 19 — Refactoring contract, terminology and behavior map
Status: accepted (2026-10-29T22:24:30+02:00). Prerequisite: M18 accepted; no code refactoring was performed in this milestone.
#### Outcome and scope
Provide a precise migration contract so subsequent workers can perform bounded changes without inventing domain semantics. Inspect current source and test callers; documentation only. Planned owned files: CONTEXT.md, docs/architecture/domain-refactoring.md (new, created for substantive content), docs/evidence/milestone-19.md. Coordinator alone updates PLANS.md. Do not rewrite SPEC/ADRs or historical evidence.
#### Tasks and implementation steps
| Task | Owner | State | Bounded deliverable |
| --- | --- | --- | --- |
| M19.1 | implementer `/root/m19_inventory` | complete | Inventoried authority, application/domain/TUI ownership, test callers and public escape hatches; no edits or tests |
| M19.2 | implementer `/root/m19_inventory` | complete | Added glossary and source-referenced architecture contract with transitions, ownership, caller audit and M20–M27 test map; no code changes |
| M19.3 | fresh reviewer `/root/m19_reviewer`; coordinator | complete | R1–R6 closed by independent re-review; integrated DOC passed; M19 accepted |
1. Trace src/domain.rs, src/app.rs, src/runtime.rs, src/wireguard/{document,edit}.rs, src/persistence.rs, src/backup.rs, src/vault{,/import,/source}.rs, src/delivery.rs and src/tui/{state,events,forms,terminal,render}.rs. Include startup.rs, lib.rs, container_driver.rs and tests as API consumers. Distinguish production lines from colocated tests; file length alone is not a reason to split.
2. In the architecture document, make a table: current type/function/field -> business meaning -> authoritative source -> proposed owner -> readers/writers -> existing tests -> migration milestone. Cover Application fields, ClientStates fields/mutators, display/projection logic, authorization contexts and worker generations. Use verified source references. Record public escape-hatch callers (`draft_mut`, `vault_draft_mut`, `add_client`, `open`) and whether they can be replaced safely.
3. Add glossary entries for saved state, applied state/verification, server peer record, manageable client, runtime observation, reserved address and first delivery. Preserve existing meanings and avoid introducing user/account identities. Explain that a malformed peer can lack a usable public key and must still be inspectable; a valid subnet-route peer can be restart-verifiable without being manageable. Unknown creation dates remain unknown. Implementation names belong in the architecture document only.
4. Write transition tables with trigger, preconditions, next state, side effects, failure/cancel outcome and existing proof. Cover draft lifecycle, reservation creation/release, current vs historical verification, first delivery stages (including publication uncertainty/manual cleanup), restore preview/confirmation and restart attempt identity. Distinguish cancellation before execution from a submitted service job that may still finish.
5. Agree concrete destination modules and dependency direction; prefer domain child modules, app child modules and an application-owned read model. Record incremental API compatibility/removal strategy, persisted-format compatibility fixtures, gaps needing focused tests, and responsibility-specific test commands discovered from actual test names. No empty modules or architecture-only traits.
#### Acceptance and validation
- [x] M19-A1: every proposed owner/transition maps to actual source and tests; unsupported peers and historical verification are explicitly covered, with the unprojectable-peer restore gap recorded for M23.
- [x] M19-A2: glossary is domain-only; architecture map specifies authority and prevents an independent client registry.
- [x] M19-A3: each later milestone has an unambiguous migration surface and existing behavioral baseline; no product changes or new dependency are implied.
- [x] M19-A4: separate reviewer checked actual source, R1–R6 closed; integrated DOC passed.
Review and repair: M19-R1 (P2, restore state) closed by corrected rewrite/history contract and re-review. M19-R2 (P2, historical M18 handoff) closed by plan clarification/re-review. M19-R3 (P2, unprojectable-peer restore reservations) closed by documenting current behavior, focused test gap and M23 repair under the existing requirement; re-review confirmed. M19-R4 (P3, programme/M17 handoff) closed by plan clarification/re-review. M19-R5 (P3, stale M19.3 task row) closed by correction and re-review. M19-R6 (P2, future task/test-map fidelity) closed after tuple arity and actual test targets were aligned and independently rechecked.
Evidence/handoff: [M19 evidence](docs/evidence/milestone-19.md) identifies the dirty source inputs, exact DOC checks, reviewer dispositions and limitations. The user confirmed the coordinator uses GPT-6-Sol/medium; workers were dispatched with GPT-6-Luna/high. M18 is accepted; M9 is retired/unaccepted and is not an M19 prerequisite. No Rust tests/build, Alpine or VM checks were required or run for this documentation-only milestone. Outcome: accepted for the migration contract; next action, only on a separate request, is M20.1.
### Milestone 20 — Explicit vocabulary and named operation contexts
Status: accepted (2026-10-29T22:40:28+02:00). Prerequisite: M19 accepted (2026-10-29T22:24:30+02:00). Coordinator model GPT-6-Sol/medium confirmed earlier in this session by the user; worker dispatches selected GPT-6-Luna/high explicitly. Initial tree clean at `ede45d224a9ad255a919900e556a03d7e575430f`; pinned Rust/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 used.
#### Outcome and scope
Remove name ambiguity and opaque tuples without moving business rules. Planned owners: src/domain.rs and optional domain child modules, src/runtime.rs, src/app.rs, src/tui/{events,state}.rs, actual callers/tests, docs/PROJECT_LAYOUT.md. Keep public forwarding names only where the M19 caller map justifies them.
| Task | Owner | State | Bounded deliverable |
| --- | --- | --- | --- |
| M20.1 | implementer `/root/m20_vocab` | complete | Renamed domain knowledge to ApplyStatus, removed duplicate TUI enum and unnecessary observation aliases, migrated callers; focused tests passed |
| M20.2 | implementer `/root/m20_vocab` | complete | Replaced prepared-operation tuples with private named contexts/mode; focused behavior and workflow tests passed |
| M20.3 | fresh reviewer `/root/m20_review`; repairer `/root/m20_repair`; coordinator | complete | M20-R1 repaired and closed on independent re-review; final integrated RUST gates passed |
1. Rename the domain `RuntimeObservation` (currently Unknown/Unavailable/Verified) to the M19 canonical knowledge name, such as `ApplyStatus`; retain runtime::RuntimeObservation for measured interface data. Decide whether tui::AppliedState is a necessary presentation mapping or an exact redundant copy. Remove only redundant representation; do not merge raw statistics and verification status. Inventory every `as Statistics`/`as TechnicalObservation` alias and change only aliases made unnecessary by clarified names.
2. Replace ApplicationExecutor.prepared_destination's six-element tuple with named fields for identity, QR choice, delivery mode, retry attempt, generation and PreparedDestination. Replace restore_preview's tuple similarly. Preserve secret ownership, clear-on-unrelated-command behavior, stale-generation refusal, overwrite confirmation and cancellation. Avoid adding Clone/Debug to secret-bearing types.
3. Introduce only justified value types from M19 (for example a typed delivery mode replacing `reissue: bool` inside the prepared context). Migrate constructors/pattern matches/callers atomically for each type; do not redesign public commands or retry flags in this milestone.
4. If splitting domain.rs, move coherent definitions/tests mechanically, preserve exports needed by callers and keep implementation separate from semantic edits. Inspect diff for changed branches/order/errors; those belong to later milestones or separately recorded defects.
#### Acceptance and validation
- [x] M20-A1: present apply knowledge and measured observation have distinct canonical names; UI labels retain their meaning.
- [x] M20-A2: prepared contexts use named fields; generation/cancel/confirmation behavior and secret redaction remain equivalent.
- [x] M20-A3: actual consumers compile, dependencies/formats untouched; focused worker/state tests and final RUST gates passed after independent review.
Review and repair: Fresh independent source review found no behavior findings. M20-R1 (P2, `src/app.rs` test and `src/container_driver.rs` test-only path) is closed: repairer replaced two `clone_on_copy` calls with dereferences; independent re-review and affected tests/Clippy passed.
Evidence/handoff: [M20 evidence](docs/evidence/milestone-20.md) records the clean base, dirty source patch/artifact fingerprints, focused tests, R1 closure, exact final checks and limits. Final coordinator commands `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` (391 passed, 3 ignored plus 2 doctests) and `cargo build --locked --release` all exited 0; `git diff --check` passed. Outcome: accepted for host/synthetic scope; stop after M20. Next action only on a separate request is M21.1; no fresh Alpine/VM result is claimed.
### Milestone 21 — Domain ownership of reservations and apply knowledge
Status: accepted (2026-10-29T23:00:09+02:00; independent M21-R1 re-review and integrated Rust gates passed). Prerequisite: M20 accepted (2026-10-29T22:40:28+02:00). The user confirmed this session's coordinator model GPT-6-Sol/medium earlier; dispatches selected GPT-6-Luna/high explicitly. Initial tree clean at `8413b382b178991d2e1e597f1422d147f3454581`; pinned Rust/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 available. M9 is retired/unaccepted by user direction and is not an M21 prerequisite.
#### Outcome and scope
Encapsulate address retention and verification transitions without changing persistence or technical matching. Planned production owners: src/domain.rs, src/domain/{reservations,apply}.rs as needed, app.rs and persistence.rs caller migration. Runtime matching stays in runtime; serialization stays in persistence. Creation-session secret state is out of scope.
| Task | Owner | State | Bounded deliverable |
| --- | --- | --- | --- |
| M21.1 | implementer `/root/m21_reservations` | complete | Domain-owned committed/pending reservation sets, exact-host policy and allocator/Commit/discard integration; focused tests passed |
| M21.2 | implementer `/root/m21_reservations` | complete | Explicit current/historical verification transitions and caller migration; focused tests passed |
| M21.3 | reviewer `/root/m21_reviewer`; repairer `/root/m21_repair`; coordinator | complete | M21-R1 closed on independent re-review; integrated RUST and diff check passed |
1. Extract `AddressReservations` with private storage and intention-revealing operations to retain removed host addresses, restore conservative persisted reservations and expose read-only iteration/availability. Preserve conflict rejection, draft unsupported-peer reservations, Commit timing, discard behavior, IPv4/IPv6 allocation and broad-route deletion restrictions. Do not enumerate networks or infer free addresses from unavailable runtime data.
2. Keep draft-only pending reservations distinct from committed reservations until publication. Follow M19's transition table; preserve reservations from unprojectable exact host routes, and reject unsafe broad-route deletion as before. Move policy, not filesystem/parsing/crypto, into domain.
3. Separate current apply knowledge, optional last applied management snapshot and historical verified timestamp/projection. Preserve the valid state in which complete technical matching is verified but no complete management Snapshot can represent all peers. A single enum must not force a fake/partial snapshot.
4. Replace scattered field assignments with narrow transitions: saved state changed; runtime knowledge lost/unavailable; verified result durably published. Technical comparison success alone must not clear reservations or publish applied state before required internal-state persistence succeeds. Keep attempt authorization/checks private to the restart scenario until M25; do not expose an unrestricted public `mark_applied` method.
5. Migrate PersistedState conversion without changing TOML fields/version/order semantics or archive compatibility. Round-trip pre-refactor synthetic state, including reservations and historical timestamp. Reopen must restore conservative reservations and Unknown current knowledge. Keep existing failure paths intact.
#### Acceptance and validation
- [x] M21-A1: only named domain transitions can release committed reservations; backup/service/read/mismatch/persistence failures cannot release them.
- [x] M21-A2: legacy technical verification without a management snapshot, reopening and current vs historical status retain prior semantics.
- [x] M21-A3: allocator, reservation, persisted round-trip and app failure tests pass; independent invariant review and RUST pass.
M21.1 and M21.2 focused checks passed. Independent reviewer found M21-R1: `commit_with_adapters` reset current apply status on unchanged Commit, changing prior Verified/Unavailable semantics. Repair removed that call and added three behavioral tests. Independent re-review closed M21-R1 with no remaining scoped finding. Final coordinator commands `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` (399 passed, 3 ignored) and `cargo build --locked --release` all exited 0; `git diff --check` passed. [M21 evidence](docs/evidence/milestone-21.md) records final dirty source and artifact fingerprints, review and validation limits. Outcome: accepted for host/synthetic scope; stop after M21. Next action only on a separate request is M22.1; no fresh Alpine/VM result is claimed.
### Milestone 22 — Transactional draft editing and client creation vertical slice
Status: accepted (2026-10-29T23:14:00+02:00; independent review and integrated Rust gates passed). Prerequisite: M21 accepted (2026-10-29T23:00:09+02:00). The user previously confirmed the coordinator GPT-6-Sol/medium session; project configuration and explicit dispatch selected GPT-6-Luna/high for workers. Initial tree clean at `2766c62`; pinned Rust/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 verified. M9 is retired/unaccepted by user direction and is not an M22 prerequisite.
#### Outcome and scope
Give document/client/vault drafts one coordinated publication boundary and make create -> preview -> Commit readable end to end. Planned owners: src/app.rs, src/app/{draft,clients,import}.rs as warranted, necessary domain APIs, startup/test callers and focused tests. Preserve app::Application public entry points during extraction. Commit internals are refactored in M23; delivery internals in M24.
| Task | Owner | State | Bounded deliverable |
| --- | --- | --- | --- |
| M22.1 | implementer `/root/m22_draft_owner` | complete | DraftChanges owns document/states/vault; staged replacement API; app (62), fmt and diff checks passed |
| M22.2 | implementer `/root/m22_draft_owner` | complete | Create/rename/delete/discard and setup/import use staged publication; focused checks passed |
| M22.3 | reviewer `/root/m22_review`; coordinator | complete | Independent review found no actionable issue; integrated RUST and evidence passed |
1. Build an internal `DraftChanges` owner containing the lossless document, restricted client projection, encrypted vault draft and draft-only reservation changes, or the concrete composition agreed in M19. It belongs to the application boundary because it coordinates crypto/document components. Avoid deep-cloning plaintext keys and avoid generic transactions/closures that obscure what is staged.
2. For each edit, prepare all fallible replacements locally, validate the edited document and corresponding public projection, then replace live draft components together. No file/runtime mutation is allowed. Document is authoritative: never rebuild it from Snapshot, and do not drop malformed/unsupported records merely because projection fails.
3. Migrate prepare_new_client first: resolve settings as currently required, calculate occupancy from full document plus reservations/server addresses, validate manual dual-stack addresses or allocate defaults, generate key, encrypt record, stage document/client/vault updates, and register session-first-delivery material only on successful publication. Preserve timestamp validation and key/error redaction. Keep first-delivery ownership temporarily behind its existing interface for M24.
4. Migrate add_client/rename_client/delete_client/discard_draft one at a time. Preserve alias validation/duplicates, metadata preservation, fallback labels, deletion restrictions, retained-address policy and precise revocation of session issue rights. Cancel/discard must restore saved data without writes or recreating consumed first-delivery eligibility.
5. Migrate initial vault setup and selected-file import through the same draft publication boundary; preserve all-or-nothing staging, key matching, anomaly reports and source-file preservation. Keep source scanning/reconciliation and encryption in existing adapters. Restrict `draft_mut`/`vault_draft_mut` only after replacing all real callers with intentional operations; tests must not be granted a production bypass.
6. Keep Application a facade delegating to cohesive owners; avoid moving the same large impl into a differently named catch-all file. Add focused failure tests only where no existing test proves that an error leaves every draft component and first-delivery eligibility unchanged. Update actual layout documentation.
#### Acceptance and validation
- [x] M22-A1: create/rename/delete/import/setup/discard use coordinated draft publication; intermediate errors leave no partial draft or newly authorized issue.
- [x] M22-A2: source bytes outside allowed edits, existing client parameters, unsupported records and original import files remain preserved.
- [x] M22-A3: synthetic create -> preview -> Commit -> first delivery -> explicit fixture restart and deletion/discard flows still pass; no live runtime operation occurs during draft/Commit.
- [x] M22-A4: independent review checks authority/secret lifetimes; RUST passes with unchanged formats/dependencies.
M22.1 added the internal owner and staged replacement API; M22.2 migrated assigned flows and narrowed draft mutation. Fresh independent review reran the five required focused suites and diff check and found no actionable issue. Final coordinator commands `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` (401 passed, 3 ignored), and `cargo build --locked --release` exited 0; `git diff --check` passed. [M22 evidence](docs/evidence/milestone-22.md) records focused results, final dirty source and artifact fingerprints, review and limits. Outcome: accepted for host/synthetic scope; stop after M22. Next action only on a separate request is M23.1; no fresh Alpine/VM result is claimed.
### Milestone 23 — Readable saved-state Commit and restore scenarios
Status: accepted (2026-10-29T23:33:11+02:00; independent M23-R1 re-review, M23-R2 evidence verification and integrated Rust gates passed). Prerequisite: M22 accepted (2026-10-29T23:14:00+02:00). The user previously confirmed this session's coordinator GPT-6-Sol/medium setting; project configuration and explicit worker dispatch selected GPT-6-Luna/high. Initial tree clean at `c1d4f93`; pinned Rust/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 verified. M9 is retired/unaccepted by user direction and is not an M23 prerequisite.
#### Outcome and scope
Extract explicit saved-state scenarios with visible ordering and honest failure publication. Planned owners: src/app.rs, src/app/{commit,restore,backups}.rs, facade/private session interfaces, affected tests. Persistence journal, archive format, path security and low-level I/O algorithms stay in persistence.rs/backup.rs; only necessary call-site adjustments there.
| Task | Owner | State | Bounded deliverable |
| --- | --- | --- | --- |
| M23.1 | implementer `/root/m23_commit` | complete | Commit prepare/validate -> backup -> install -> publish; focused checks passed |
| M23.2 | implementer `/root/m23_commit` | complete | Backup listing/creation and preview-confirm restore extraction; exact-host reservation gap test/repair; focused checks passed |
| M23.3 | reviewer `/root/m23_review`; repairer `/root/m23_repair`; coordinator | complete | M23-R1 closed on independent re-review; M23-R2 evidence verified; integrated RUST passed |
1. Extract Commit as readable preparation -> validate proposed saved bytes -> completed pre-Commit backup of installed state -> journaled install -> publish new session state. Keep a concrete prepared result if it clarifies which fallible work precedes mutation. Do not turn backup confirmation into a public forgeable permission or allow callers to skip it.
2. Preserve failure behavior: draft remains retryable, readback after failed install conservatively retains/revokes first-issue rights, saved/application history is not advanced on failure, and recovery on reopen follows the existing journal. Avoid an apparently infallible final publication that hides parsing or state-install errors.
3. Extract preview_restore and confirm_restore. Preview remains read-only and secret-safe; confirmation validates the bound saved generation/installation paths, creates precautionary backup and fully replaces the saved components (including absent optional components). Preserve draft warning/cancel and settings re-resolution. Restore never requests a vault password or touches the running interface.
4. Move manual/pre-restart backup entry points only as needed to establish a clear backup responsibility. Historical verification labels must refer to the archived generation, not the newest archive or current statistics. Keep cloned SavedBytes secret-bearing and out of ordinary read models/Debug.
5. Keep service/runtime dependencies unavailable to Commit and restore modules except shared public status types when necessary; reviewer must inspect actual dependency/call paths. Preserve existing callback failure seams with concrete narrow interfaces; do not add a general storage/service framework.
#### Acceptance and validation
- [x] M23-A1: completed backup precedes protected mutation for Commit/restore; failures and cancel retain documented draft/saved/runtime behavior.
- [x] M23-A2: restore compatibility, path restrictions, absent components, recovery and historical status tests retain their assertions.
- [x] M23-A3: fixture old-state backups and state/vault/archive compatibility checks pass; no runtime/service call in saved-only paths; independent review and RUST pass.
M23.1 and M23.2 focused checks passed. Fresh reviewer found M23-R1: over-reservation of exact routes retained by an unprojectable restored peer. Repair separated archived from newly carried reservations; independent re-review closed M23-R1. M23-R2, the missing evidence artifact, was closed after the reviewer verified the report and source/artifact hashes. Final coordinator commands `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` (404 passed, 3 ignored) and `cargo build --locked --release` exited 0; `git diff --check` passed. [M23 evidence](docs/evidence/milestone-23.md) records focused runs, final source, review and limits. Outcome: accepted for host/synthetic scope; stop after M23. Next action only on a separate request is M24.1; no fresh Alpine/VM result is claimed.
### Milestone 24 — Session-owned first delivery and password reissue scenarios
Status: accepted (2026-10-30T21:24:34+02:00; independent security-path review, M24-R1 re-review and integrated Rust gates passed). Prerequisite: M23 accepted. M1–8 and M10–23 are accepted; M9 is retired by user direction and is not a prerequisite. The user confirmed this session's GPT-6-Sol/medium coordinator; `.codex/config.toml` specifies GPT-6-Luna/high workers, dispatched explicitly. Initial tree clean at `ed602f0`; pinned Rust/Cargo 1.97.1 environment verified.
#### Outcome and scope
Isolate the session-first-delivery state machine and expose readable authenticated delivery scenarios. Planned owners: src/app.rs, src/app/{first_delivery,reissue}.rs or cohesive delivery children; affected facade/TUI adapter callers and tests. Cryptography, secure publication and QR encoding remain in vault/delivery adapters. No workflow or password policy change.
| Task | Owner | State | Bounded deliverable |
| --- | --- | --- | --- |
| M24.1 | implementer `/root/m24_state` | complete | Private `FirstDeliverySession` owns issue keys, contexts, stages, tokens and transitions; app/client-delivery tests, fmt and Clippy passed |
| M24.2 | implementer `/root/m24_scenarios` | complete | First-file/QR and password reissue moved to cohesive modules; all five focused commands, fmt, Clippy and diff check passed |
| M24.3 | reviewer `/root/m24_review`; coordinator | complete | No source findings; M24-R1 evidence gap closed on independent re-review; integrated RUST passed |
1. Move InitialIssue/IssueStage/IssueContext/token bookkeeping and matching/promote/retain helpers under one private owner. Keep ephemeral private keys in zeroizing/secret wrappers with no ordinary Clone/Debug/serialization. Use M19's transition table rather than simplifying PendingDraft/AwaitingFirstFile/RetryAvailable/Authorizing/overwrite/manual-cleanup states into one bool.
2. Expose narrow internal transitions for draft publication, successful Commit, discard/delete, changed peer/encrypted record, begin attempt and completed/failed publication. Tokens remain single-use, session-local, bound to client and saved context; no caller can manufacture a first-issue authorization. Preserve unrelated clients' rights when rename/restart does not invalidate their context.
3. Keep final saved-state authorization check adjacent to secure file publication with exclusive session ownership across the boundary. Extracting a component must not allow mutations between checking authorization and publishing a secret. Preserve retry to another directory and explicit overwrite/cleanup acknowledgements after uncertain failures.
4. Extract password reissue separately: selected client + password for this action -> decrypt/validate -> render -> mandatory file publication -> optional QR from the same payload. Never cache an unlocked vault or fall back to original plaintext files. Preserve imported parameters, discrepancy blockers and authoritative server values.
5. Preserve independent outcomes: file publication may succeed while QR fails; file may exist while bookkeeping is uncertain; warnings about not-yet-verified activation must survive failed/uncertain publication. Only successful mandatory publication consumes eligibility according to existing conservative uncertainty rules. Do not remove existing detailed failure variants or replace them with success/failure bools.
#### Acceptance and validation
- [x] M24-A1: all first-delivery transitions/invalidation rules have one owner; pre-Commit, stale, consumed and reopened sessions cannot deliver without authorization.
- [x] M24-A2: retry/uncertain publication/manual cleanup/bookkeeping failure and same-payload QR behavior remain covered; passwords and secrets absent from errors/Debug/projections.
- [x] M24-A3: reissue asks per client/action and respects import mismatch policy; independent security-relevant path review and RUST pass.
Focused commands `cargo test --locked app::` (67), `delivery::` (22), `vault::` (24), `--test client_delivery` (5) and `--test tui_workflow` (8) exited 0 on the integrated tree. Final coordinator `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` (library 337 passed, 2 ignored; integration/doc tests passed), `cargo build --locked --release`, and `git diff --check` exited 0. Fresh reviewer found no source issue. M24-R1: specified evidence file absent; coordinator created it and the reviewer independently verified all source/artifact hashes, check records and limits, closing the finding. [M24 evidence](docs/evidence/milestone-24.md) identifies inputs and host/synthetic scope. No fresh Alpine or VM/systemd run is claimed. Outcome: accepted for M24 host/synthetic scope. Stop after M24; M25 starts only on a separate request.
### Milestone 25 — Explicit restart verification scenario
Status: accepted (2026-10-30T21:35:56+02:00; independent source review, M25-R1 evidence re-review and integrated Rust gates passed). Prerequisite: M24 accepted. M1–8 and M10–24 are accepted; M9 is retired and not a prerequisite. This session's coordinator GPT-6-Sol/medium was user-confirmed; `.codex/config.toml` and explicit worker dispatch use GPT-6-Luna/high. Initial tree clean at `7cef400`; Rust/Cargo 1.97.1 verified.
#### Outcome and scope
Make restart orchestration independently readable while preserving complete technical verification and every uncertainty boundary. Planned owners: src/app.rs, src/app/restart.rs and optional private restart children, facade/state transition interfaces, affected tests/container-driver imports. Netlink and systemd transports/decoders remain unchanged.
| Task | Owner | State | Bounded deliverable |
| --- | --- | --- | --- |
| M25.1 | implementer `/root/m25_prepare` | complete | Preview/types and confirmed preparation through backup, attempt binding, optional discard and shared deadline moved to private restart owner; app/runtime-restart tests, fmt and Clippy passed |
| M25.2 | implementer `/root/m25_sequence` | complete | Service/job, bounded observation, complete comparison and durable publication moved into restart owner; all five focused commands, fmt, Clippy and diff check passed |
| M25.3 | reviewer `/root/m25_review`; coordinator | complete | No source findings; M25-R1 evidence gap closed on independent re-review; integrated RUST passed |
1. Move preview/report/choice/diagnostic types and restart implementation coherently. Preserve current public imports with re-exports until callers migrate. Preview validates unsafe SaveConfig, structural ambiguity and complete SavedPeerProjection; it must not require every saved peer to fit Client/Snapshot.
2. Read the scenario in this order: validate preview against saved state -> honor cancel/draft choice -> complete backup -> discard draft at existing safe point -> submit configured service restart -> successful job completion -> bounded interface observation -> compare complete saved peer set -> durable verification bookkeeping -> publish applied knowledge/release reservations. Preserve actual existing ordering where this outline omits preparation; document it instead of introducing behavior changes.
3. Keep one shared monotonic deadline, existing clock/failure injection, restart attempt identity, generation checks and stale/duplicate-result rejection. An accepted job request is not a completed job. Timeout/submitted/may-still-complete/manual-recovery reporting must not collapse into one generic error.
4. Keep complete-peer technical projection/comparison in its existing responsibility; management metadata/aliases must not become technical match requirements. Preserve metadata-free, IPv4-only, subnet-route and missing-IPv4 valid peers, malformed/duplicate identities, incomplete dumps and peer mismatch behavior. Current statistics must not assert verification.
5. Persistence failure after successful matching must retain conservative apply knowledge/reservations and honest report. Do not introduce rollback or a second restart. Fixture apply callback must remain cfg(test)-only and unreachable from the production executable. Review both real transport call paths for unchanged use, without executing host services.
#### Acceptance and validation
- [x] M25-A1: backup/job/read/verify/persist/publication sequence and cancellation semantics unchanged; current/saved generations and attempt identity remain bound.
- [x] M25-A2: full-peer legacy cases, shared deadlines, service/transport distinctions, mismatch and post-verification persistence failure tests pass; reservations release only after durable verified success.
- [x] M25-A3: no production fixture bypass, rollback or transport change; independent review and RUST pass. Real-system/IPv6 limits remain explicit.
Focused commands `cargo test --locked app::` (67), `runtime::` (25), `service::` (26), `--test commit_restore` (9) and `--test runtime_restart` (3) exited 0 on the integrated tree. Final coordinator `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` (library 337 passed, 2 ignored; integration/doc tests passed), `cargo build --locked --release`, and `git diff --check` exited 0. Fresh reviewer found no source defect. M25-R1: specified evidence file absent; coordinator created it and reviewer independently verified source/artifact hashes, check records and limits, closing the finding. [M25 evidence](docs/evidence/milestone-25.md) identifies inputs and host/fixture scope. No fresh Alpine or VM/systemd run is claimed. Outcome: accepted for M25 host/fixture scope. Stop after M25; M26 starts only on a separate request.
### Milestone 26 — Application read models and thin TUI command adapter
Status: accepted (2026-10-30T23:01:09+02:00; independent R1–R4 re-review, evidence verification and integrated Rust gates passed). Prerequisite: M25 accepted. M1–8 and M10–25 are accepted; M9 is retired and not a prerequisite. This session's coordinator GPT-6-Sol/medium was user-confirmed; `.codex/config.toml` and explicit worker dispatch use GPT-6-Luna/high. Initial tree clean at `9a143e9`; pinned Rust/Cargo 1.97.1 verified. TUI previously reparsed saved config for peer count; app deletion already supports some exact-host-route peers outside manageable `ClientStates`.
#### Outcome and scope
TUI consumes public application facts and action eligibility without parsing saved configuration or owning authorization rules. Planned owners: src/app/read_model.rs, app facade, src/tui/{state,events,forms,terminal,render}.rs and new cohesive TUI children, focused tests. Preserve output wording/geometry/keys/help, fixed footer and worker lifecycle.
| Task | Owner | State | Bounded deliverable |
| --- | --- | --- | --- |
| M26.1 | implementer `/root/m26_read_model` | complete | `ApplicationView` and per-action eligibility built from authoritative peer and session facts; app/TUI/workflow focused checks, fmt and Clippy passed |
| M26.2 | implementer `/root/m26_tui_projection` | complete | TUI view consumes app read model; selection-bound guards/hints use per-action eligibility; TUI/workflow/client-delivery focused checks, fmt and Clippy passed |
| M26.3 | implementer `/root/m26_command_adapter` | complete | Application command/prepared-context adapter extracted under events; TUI/workflow/client-delivery focused checks, fmt and Clippy passed; broad terminal/render split deferred as no cohesive gain |
| M26.4 | reviewer `/root/m26_review`; repairer `/root/m26_repair`; coordinator | complete | M26-R1 removed unprojectable saved peer, R2 unrelated issue overblock, R3 false Removed row and R4 capability-issue reissue mismatch closed on re-review; evidence and integrated RUST verified |
1. Move ClientDisplay/SessionDisplay projection responsibilities to an application-owned read model. Include saved peer count, draft/saved public client differences, issue classification, reservation/apply facts and vault-initialized state. Keep UI-only selection, scroll, dialog focus, rendered strings/styles and worker generation in TUI. Raw ConfigDocument/SavedBytes/vault/password/payload must not be fields of the public view.
2. Represent action eligibility explicitly per action with typed reasons where useful. Audit current guards first: rename, delete, first delivery and restart do not necessarily have identical eligibility. In particular inspection-only management projection must not accidentally prohibit an already-supported deletion by public key. Never silently enable actions for malformed/ambiguous peers. Eligibility is guidance for presentation; application execution revalidates its actual preconditions.
3. Replace SessionView::from_application reparsing of saved bytes and recovery of business facts with the application view. Preserve unsupported peers without public keys, duplicate-identity records, removed-draft rows, selected public identity and safe peer-index inspection. Application view does not infer applied from handshake/counters or historical timestamp.
4. Extract ApplicationExecutor/prepared contexts/result adaptation from worker transport into a focused command adapter if it improves ownership. Keep the bounded single-owner worker, statistics generation filtering, prepared-preview/destination lifetime, cancellation, duplicate-submit protection, shutdown and terminal cleanup unchanged. No independent executor may share mutable Application or plaintext secrets.
5. Separate terminal lifecycle from key navigation/result messages only after the application boundary is clear. Split render.rs into cohesive list/details/help/footer modules if their responsibilities warrant it; preserve existing metrics, wrapping, QR disclosure and footer packing. Avoid generic dispatch registries or purely mechanical many-file splits. Each subtask must stay within one bounded responsibility and compile before the next begins.
6. Update internal help/user docs only for factual architecture references; no product wording/shortcut redesign. Keep masks and secret forms short-lived. Compare existing synthetic buffers and PTY workflows across established terminal dimensions, including 34x8, 40x9, 60x16, 80x24, 100x30 and 180x50.
#### Acceptance and validation
- [x] M26-A1: TUI no longer parses saved server bytes to obtain application facts; read model is secret-free and includes all authoritative records.
- [x] M26-A2: per-action availability matches existing handlers, with authoritative revalidation; selection/cancel/draft/QR behavior preserved.
- [x] M26-A3: worker generation/continuation/shutdown tests, responsive help/list/footer buffer tests and synthetic PTY workflows pass without weakened assertions.
- [x] M26-A4: reviewer checks UI parity and boundary direction; RUST passes; actual module layout documented.
Focused commands `cargo test --locked tui::` (88), `--test tui_workflow` (9), `--test client_delivery` (5), and affected `app::` (74) exited 0 on the integrated tree. Final coordinator `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` (library 352 passed, 2 ignored; integration/doc tests passed), `cargo build --locked --release`, and `git diff --check` exited 0. Independent reviewer closed M26-R1 (removed unprojectable peer omission), R2 (unrelated issue reissue overblock), R3 (false Removed row from repair) and R4 (capability-issue reissue mismatch); all 11 evidence hashes and final gate results were rechecked. [M26 evidence](docs/evidence/milestone-26.md) identifies inputs, fixes and host/synthetic limits. No fresh Alpine or VM/systemd run is claimed. Outcome: accepted for M26 host/synthetic scope. Stop after M26; M27 starts only on a separate request.
### Milestone 27 — Integrated refactoring acceptance and architecture handoff
Status: accepted (2026-10-30T23:21:10+02:00; independent R1–R4 re-review, A4 evidence check and final integrated Rust gates passed). Prerequisite: M26 accepted. Initial tree clean at `7123197`; pinned Rust/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 verified. This session's coordinator GPT-6-Sol/medium was user-confirmed; project configuration and explicit M27 worker dispatch selected GPT-6-Luna/high. M1–8 and M10–26 are accepted; M9 remains retired/unaccepted by user direction and is not a prerequisite.
#### Outcome and scope
Verify the integrated refactoring against the original behavior map and demonstrate that common operations can be understood from their owning scenario. Owned scope: actual migrated APIs/tests, docs/architecture/domain-refactoring.md, CONTEXT.md, docs/PROJECT_LAYOUT.md and evidence. No new features or broad cleanup. Repairs must have named findings and bounded ownership.
| Task | Owner | State | Bounded deliverable |
| --- | --- | --- | --- |
| M27.1 | implementer `/root/m27_implement` | complete | Reconciled architecture map, reading guide, glossary and layout; caller audit retained active fixture APIs; DOC checks passed; no Rust edits |
| M27.2 | fresh reviewer `/root/m27_review` | complete | A2 passed on source inspection; M27-R1/R3 actionable documentation/comment findings and R2 plan consistency finding recorded |
| M27.3 | repairer `/root/m27_repair`; reviewer; coordinator | complete | R1–R4 closed; six focused suites, final RUST gates and independent A4 evidence/plan check passed |
1. Compare every M19 source/test mapping with actual final owners. Remove obsolete forwarding APIs only after checking all binary, library, integration and container-driver callers. Remove stale milestone comments that incorrectly describe implemented paths as future work. Do not delete historical evidence or turn old verification limitations into new success claims.
2. Record a concise reading guide for create -> Commit -> first delivery -> explicit restart, delete -> Commit -> verified address release, and preview -> confirm restore. For each, name the entry point, rule owner, side-effect boundary and failure tests. A reviewer must be able to follow the scenario without discovering hidden UI authorization or independent metadata authority. Record justified exceptions instead of hiding them behind a size metric.
3. Independent reviewer audits the full preservation matrix and baseline-to-final diff, not only the latest milestone. Confirm persisted schemas/versions, archive members, cryptography/dependencies/defaults, public-key identity, byte-preserving authority, imported originals and short-lived secrets. Check for redundant mutable state and accidental widening of pub visibility. Existing fixtures from before the refactor must still load with unchanged meaning.
4. Run focused integrated tests and final RUST on identified final inputs. Establish which M8 Alpine evidence was invalidated by changed application inputs. If an explicitly authorized disposable Alpine environment is available, rebuild the current driver and follow docs/TESTING.md; otherwise record Alpine revalidation as incomplete, not a host-gate pass. No VM work is included, no systemd/IPv6 verification is inferred, and no release-readiness claim is authorized by this programme.
5. Accept the refactoring only for its documented host/synthetic scope after all required RUST checks and independent findings close. Report Alpine state separately; if any task explicitly made an Alpine check mandatory, keep that task incomplete until run. Final plan/evidence must state real-system limitations and identify the tested source. Stop after M27; do not commit/publish/deploy or launch another programme.
#### Acceptance and validation
- [x] M27-A1: all M19 mappings resolved to actual owners/tests; glossary/layout/reading guide agree with code; obsolete bypasses removed or explicitly justified.
- [x] M27-A2: integrated independent audit finds no unresolved behavior, authority, authorization or secret-handling regression.
- [x] M27-A3: preserved-format synthetic fixtures and all required host RUST gates pass on final source; exact outcomes and separate Alpine/VM limitations recorded.
- [x] M27-A4: milestone register, outcomes and current resume point agree; evidence contains reproducible source identity and final review disposition.
Review and repair: independent M27-R1 (missing `display_projection` caller audit), R2 (stale resume point), R3 (implemented paths described as future) and R4 (fixture-only public backup API) were repaired and re-reviewed. R4's first repair exposed a Clippy dead-code child helper; the bounded follow-up made both levels test-only, and final Clippy/full gates passed. Independent reviewer confirmed A4 after verifying evidence hashes, artifact, gates and plan consistency. [M27 evidence](docs/evidence/milestone-27.md) records source identity, exact host results and Alpine/VM limits. Outcome: accepted for host/synthetic refactoring scope. Handoff: stop after M27; no subsequent milestone is authorized by this plan.
## Current progress and resume point ## Current progress and resume point
- M18 is accepted (2026-10-29): structured, accurate in-app Help, responsive wrapping and bounded scrolling passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M18 evidence](docs/evidence/milestone-18.md) records final inputs and limits. Stop here; no next milestone is started. - M27 is accepted for host/synthetic refactoring scope after independent R1–R4 closure, six focused suites, final Rust gates and A4 evidence/plan verification. [M27 evidence](docs/evidence/milestone-27.md) identifies tested source and limitations. Stop here; no next milestone or deployment is authorized. Current Alpine revalidation and disposable-VM systemd checks remain incomplete; M9 remains retired/unaccepted.
- M17 is accepted (2026-10-28): adaptive columns, persistent selected-client details and explicit state/count presentation passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M17 evidence](docs/evidence/milestone-17.md) records final inputs and limits. Stop here; no next milestone is started. - M18 was accepted on 2026-10-29: structured, accurate in-app Help, responsive wrapping and bounded scrolling passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M18 evidence](docs/evidence/milestone-18.md) records final inputs and limits. At that handoff, M19 had not started.
- M17 was accepted on 2026-10-28: adaptive columns, persistent selected-client details and explicit state/count presentation passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M17 evidence](docs/evidence/milestone-17.md) records final inputs and limits. At that handoff, M18 had not started.
- TUI-FL-001 is complete (2026-10-27): fixed-height two-row keyboard footer, narrow Help/overflow behavior and compact nine-row handling passed synthetic rendering tests, independent R1–R3 closure and pinned Rust gates. [Footer evidence](docs/evidence/tui-footer-layout.md) identifies source/artifact inputs. - TUI-FL-001 is complete (2026-10-27): fixed-height two-row keyboard footer, narrow Help/overflow behavior and compact nine-row handling passed synthetic rendering tests, independent R1–R3 closure and pinned Rust gates. [Footer evidence](docs/evidence/tui-footer-layout.md) identifies source/artifact inputs.
- Milestone 16 is accepted by explicit user direction following a reported successful manual check. Earlier independent review/repair and pinned Rust gates passed; private-peer and both real transport paths still lack recorded disposable-VM evidence. [M16 evidence](docs/evidence/milestone-16.md) records the source and artifact. No production result is claimed by this execution. - Milestone 16 is accepted by explicit user direction following a reported successful manual check. Earlier independent review/repair and pinned Rust gates passed; private-peer and both real transport paths still lack recorded disposable-VM evidence. [M16 evidence](docs/evidence/milestone-16.md) records the source and artifact. No production result is claimed by this execution.
@@ -3894,7 +4188,7 @@ Accepted for presentation and synthetic fixture scope after independent review,
- 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: none for M18. Stop after acceptance. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work. - M18–M27 handoffs are complete. Stop after M27 acceptance; no next milestone is authorized. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work.
## Plan maintenance ## Plan maintenance
+55 -155
View File
@@ -1,71 +1,63 @@
# wireguard-admin # wireguard-admin
This repository contains a root-only native terminal interface for one existing This repository contains a root-only native terminal interface for one existing WireGuard interface and configurable systemd service. It connects the validated settings loader, byte-preserving server configuration editor, encrypted client vault, recoverable saved-file persistence, backups, Commit, previewed restore, explicit restart, read-only runtime statistics, and client file/QR delivery. The interface keeps draft, saved, and applied states distinct. Commit saves files; restore replaces saved state; only an explicit, verified restart applies saved changes to the running interface.
WireGuard interface and configurable systemd service. It connects the validated
settings loader, byte-preserving server configuration editor, encrypted client
vault, recoverable saved-file persistence, backups, Commit, previewed restore,
explicit restart, read-only runtime statistics, and client file/QR delivery.
The interface keeps draft, saved, and applied states distinct. Commit saves
files; restore replaces saved state; only an explicit, verified restart applies
saved changes to the running interface.
The package requires Rust 1.97.1 or newer and uses the Rust 2024 edition. From The package requires Rust 1.97.1 or newer and uses the Rust 2024 edition. From the repository root, run:
the repository root, run:
```sh ```sh
cargo run -- --help cargo run -- --help
cargo run -- --version cargo run -- --version
``` ```
`--help` and `--version` work without root, settings, or a terminal. Starting `--help` and `--version` work without root, settings, or a terminal. Starting with no arguments requires root and a suitable interactive terminal. Settings are read from `wireguard-admin.toml` beside the executable. If terminal checks or saved-state recovery fail, startup exits before entering raw mode or performing setup mutations.
with no arguments requires root and a suitable interactive terminal. Settings
are read from `wireguard-admin.toml` beside the executable. If terminal checks
or saved-state recovery fail, startup exits before entering raw mode or
performing setup mutations.
## Settings ## Settings
Settings use TOML in the fixed file `wireguard-admin.toml` beside the Copy this example to `wireguard-admin.toml` beside the executable. Adapt every address, range, path, interface name, and service name to the existing server; `192.0.2.1` is a documentation address, not a usable public endpoint. This configuration manages an existing WireGuard interface and systemd service; it does not create either one.
executable. Start from [`config/wireguard-admin.example.toml`](config/wireguard-admin.example.toml).
The required fields are:
- `interface_name`: existing WireGuard interface name (1–15 ASCII characters). ```toml
- `server_config_path`: path to the existing server configuration. # Copy this file to wireguard-admin.toml beside the wireguard-admin executable.
- `client_source_directory`: directory containing existing client files. # Relative paths are resolved from that executable directory, never from the
- `server_endpoint`: hostname or IPv4 address followed by `:port`, or IPv6 in # process working directory. This example uses documentation-only addresses.
brackets followed by `:port` (for example, `[2001:db8::2]:51820`).
- `ipv4_allocation_range` and `ipv6_allocation_range`: CIDR ranges of the
matching address family.
- `dns_addresses`: non-empty list of IPv4 and/or IPv6 literal addresses.
- `systemd_unit`: service unit name ending in `.service`.
- `restart_timeout_seconds`: positive integer.
`data_root` is optional. It defaults to `Backup` beside the executable; interface_name = "wg0"
current internal data and the encrypted vault are under `data`, and archives server_config_path = "/etc/wireguard/wg0.conf"
are under `archives`. Relative configured paths, including an explicit client_source_directory = "clients"
`data_root`, resolve from the executable directory. Absolute paths remain server_endpoint = "37.26.136.232:51820"
absolute. Settings validation checks syntax and values only; it does not ipv4_allocation_range = "10.9.0.0/24"
require the referenced files, directories, or service to exist and does not ipv6_allocation_range = "2001:db8:1::/64"
perform DNS lookups. dns_addresses = ["10.9.0.1", "192.168.0.200"]
systemd_unit = "wg-quick@wg0.service"
restart_timeout_seconds = 30
For installation, backup custody, restart failure handling and manual recovery, # Optional. Defaults to Backup beside the executable. The application stores
see [`docs/OPERATIONS.md`](docs/OPERATIONS.md). The qualified evidence and the # current data in <data_root>/data and archives in <data_root>/archives.
current host release-candidate assessment are in # data_root = "Backup"
[`docs/RELEASE_READINESS.md`](docs/RELEASE_READINESS.md); those records do not ```
claim a completed systemd VM run or real IPv6 qualification.
The parser requires every uncommented field above and rejects unknown fields.
The example values are **not defaults**. Only `data_root` has a default.
| Parameter | Required? | Purpose and validated constraints |
| ------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `interface_name` | Yes | Name of the existing WireGuard interface. Must be 1–15 ASCII letters, digits, `.`, `_`, or `-`; `.` and `..` are rejected. |
| `server_config_path` | Yes | Path to the existing, authoritative server configuration. Must be non-empty. The settings parser does not check that the file exists; startup validates the saved installation. |
| `client_source_directory` | Yes | Directory of original client configuration files used for initial vault import and explicit selected-file import. Must be non-empty. Scans consider immediate regular `.conf` files without following symlinks; originals are neither modified nor deleted. |
| `server_endpoint` | Yes | Address and port written to generated client configurations: a hostname or IPv4 address followed by `:port`, or an IPv6 address in brackets followed by `:port`. Port must be 1–65535. Hostnames are syntax-checked but not resolved during settings validation. |
| `ipv4_allocation_range` | Yes | IPv4 CIDR range from which new-client IPv4 addresses are allocated. Must parse as an IPv4 CIDR network. |
| `ipv6_allocation_range` | Yes | IPv6 CIDR range from which new-client IPv6 addresses are allocated. Must parse as an IPv6 CIDR network. |
| `dns_addresses` | Yes | Non-empty list of IPv4 and/or IPv6 literal DNS server addresses included in generated client configurations. This does not configure the host's DNS settings. Hostnames are rejected. |
| `systemd_unit` | Yes | Existing systemd service targeted by an explicitly requested restart. Must end in `.service`, be at most 255 ASCII bytes, and use only letters, digits, `.`, `_`, `-`, `@`, or `:`; paths and whitespace are rejected. |
| `restart_timeout_seconds` | Yes | Positive integer number of seconds for the shared restart deadline. After the mandatory backup, it covers systemd connection, subscription, unit resolution, request and job completion, then the runtime interface read and verification deadline check. It does not time the precautionary backup or durable verification-state write. |
| `data_root` | No | Non-empty root directory for application data. Defaults to `Backup` beside the executable. Current internal data and the encrypted vault are stored in `<data_root>/data`; archives are stored in `<data_root>/archives`. |
The settings file name and location are fixed: `wireguard-admin.toml` must sit beside the executable, regardless of the process working directory. Relative values for `server_config_path`, `client_source_directory`, and `data_root` resolve from that executable directory; absolute paths stay absolute. Settings validation checks syntax and values without checking referenced paths or the service for existence. Commit and Restore change saved state only. Restart is separate and explicit; only successful service completion followed by matching interface/client verification and durable bookkeeping marks the saved state applied. The separate [`config/wireguard-admin.example.toml`](config/wireguard-admin.example.toml) is another example, with different illustrative values.
For installation, backup custody, restart failure handling and manual recovery, see [`docs/OPERATIONS.md`](docs/OPERATIONS.md). The qualified evidence and the current host release-candidate assessment are in [`docs/RELEASE_READINESS.md`](docs/RELEASE_READINESS.md); those records do not claim a completed systemd VM run or real IPv6 qualification.
## Terminal workflows ## Terminal workflows
The bordered **Keys** panel at the bottom of the screen shows shortcuts for the The bordered **Keys** panel at the bottom of the screen shows shortcuts for the current screen and form. On narrow list and details screens it prioritizes navigation and Help, and indicates when additional actions are omitted; resize the terminal to see more. Forms, QR disclosure and progress show only their applicable controls. Press `?` from the list or details screen to open Help, which contains the complete shortcut reference. The client list supports arrow-key selection, Enter for details, and `q` to exit. Exit warns when a draft would be discarded; it never commits implicitly. Uppercase shortcuts are distinct from lowercase shortcuts (`R` previews restart, while `r` renames; `B` creates a backup, while `b` lists backups):
current screen and form. On narrow list and details screens it prioritizes
navigation and Help, and indicates when additional actions are omitted; resize
the terminal to see more. Forms, QR disclosure and progress show only their
applicable controls. Press `?` from the list or details screen to open Help,
which contains the complete shortcut reference. The client list supports
arrow-key selection, Enter for details, and `q` to exit. Exit warns when a draft
would be discarded; it never commits implicitly. Uppercase shortcuts are
distinct from lowercase shortcuts (`R` previews restart, while `r` renames;
`B` creates a backup, while `b` lists backups):
| Key | Action | | Key | Action |
| --- | --- | | --- | --- |
@@ -79,43 +71,15 @@ distinct from lowercase shortcuts (`R` previews restart, while `r` renames;
| `s` / `i` | Initial vault setup / import one selected client file | | `s` / `i` | Initial vault setup / import one selected client file |
| `!` | Inspect document and peer anomalies | | `!` | Inspect document and peer anomalies |
The list header labels **Draft**, **Saved**, and **Applied** separately. Draft The list header labels **Draft**, **Saved**, and **Applied** separately. Draft reports pending changes even when only vault or managed metadata changed. Saved counts peer sections in the saved server file, including peers this manager cannot edit, and separately counts managed clients. Applied reports verification of the saved configuration; a draft is excluded. “Not verified” does not claim that a restart is pending when that divergence is unknown. A saved peer row does not prove active access, and handshake or traffic counters are observations, not proof that the saved configuration was applied. Details distinguish document or peer problems from valid configuration shapes that are currently outside the management model.
reports pending changes even when only vault or managed metadata changed. Saved
counts peer sections in the saved server file, including peers this manager
cannot edit, and separately counts managed clients. Applied reports verification
of the saved configuration; a draft is excluded. “Not verified” does not claim
that a restart is pending when that divergence is unknown. A saved peer row does
not prove active access, and handshake or traffic counters are observations,
not proof that the saved configuration was applied. Details distinguish
document or peer problems from valid configuration shapes that are currently
outside the management model.
In Help, Up/Down scroll by one displayed row and PageUp/PageDown move by the In Help, Up/Down scroll by one displayed row and PageUp/PageDown move by the visible page. The Help panel shows the current row range and clamps navigation when the terminal is resized. Press `?` to return to the beginning or Esc to return to the client list without changing the selected peer or application state.
visible page. The Help panel shows the current row range and clamps navigation
when the terminal is resized. Press `?` to return to the beginning or Esc to
return to the client list without changing the selected peer or application
state.
Forms use Enter to advance or submit, Tab to change fields/options, and Esc to Forms use Enter to advance or submit, Tab to change fields/options, and Esc to cancel. Backup restore previews identify the selected archive and require an explicit confirmation. Restore does not prompt for the vault password and does not restart WireGuard. Client configuration output is a disclosure: first delivery requires a successful Commit in the same session; QR appears only after file publication. Esc closes and clears a QR disclosure. Help in the interface contains the full current shortcut list.
cancel. Backup restore previews identify the selected archive and require an
explicit confirmation. Restore does not prompt for the vault password and does
not restart WireGuard. Client configuration output is a disclosure: first
delivery requires a successful Commit in the same session; QR appears only
after file publication. Esc closes and clears a QR disclosure. Help in the
interface contains the full current shortcut list.
## Library configuration API ## Library configuration API
The library exposes `wireguard_admin::wireguard::ConfigDocument` for UTF-8 The library exposes `wireguard_admin::wireguard::ConfigDocument` for UTF-8 configuration bytes supplied by the caller. It retains the source and renders unmodified input byte-for-byte, including line endings and a missing final newline. Peer inspection exposes public identities, parsed `AllowedIPs`, spans, and redacted issues; arbitrary directive values are not included in the view. The edit methods `add_client`, `delete_client`, `rename_client`, and `insert_legacy_metadata` return new in-memory documents. They do not read or write files, save changes, or alter the running VPN. See [`docs/CONFIG_FORMAT.md`](docs/CONFIG_FORMAT.md) for the managed metadata block format.
configuration bytes supplied by the caller. It retains the source and renders
unmodified input byte-for-byte, including line endings and a missing final
newline. Peer inspection exposes public identities, parsed `AllowedIPs`, spans,
and redacted issues; arbitrary directive values are not included in the view.
The edit methods `add_client`, `delete_client`, `rename_client`, and
`insert_legacy_metadata` return new in-memory documents. They do not read or
write files, save changes, or alter the running VPN. See
[`docs/CONFIG_FORMAT.md`](docs/CONFIG_FORMAT.md) for the managed metadata block
format.
Example: Example:
@@ -127,76 +91,25 @@ let updated = document.rename_client(&public_key, "office laptop")?;
let bytes_to_review = updated.render(); // no persistence or VPN operation let bytes_to_review = updated.render(); // no persistence or VPN operation
``` ```
Runtime and restart behavior has synthetic fixture and fake-adapter coverage; Runtime and restart behavior has synthetic fixture and fake-adapter coverage; the accepted Alpine milestone also exercised real IPv4 WireGuard traffic and the Rustix reader. The Alpine fixture did not use systemd/D-Bus, and the retired systemd VM milestone did not establish real-system acceptance. Real IPv6 capability and traffic remain unverified.
the accepted Alpine milestone also exercised real IPv4 WireGuard traffic and
the Rustix reader. The Alpine fixture did not use systemd/D-Bus, and the
disposable systemd VM milestone has not been run. Real IPv6 capability and
traffic remain unverified.
## Saved state, Commit, and backups ## Saved state, Commit, and backups
wireguard_admin::app::Application opens saved files through wireguard_admin::app::Application opens saved files through wireguard_admin::persistence::SavedStore, stages client and vault changes, creates manual and mandatory operation backups, and previews/confirms a full restore. Commit writes saved files only. Restore also changes saved files only; neither operation reloads or restarts WireGuard. The server file is kept at its trusted configured path, while internal state and the encrypted vault are kept under Backup/data by default. Archives are stored under Backup/archives and contain fixed logical members, not source client files or prior archives.
wireguard_admin::persistence::SavedStore, stages client and vault changes,
creates manual and mandatory operation backups, and previews/confirms a full
restore. Commit writes saved files only. Restore also changes saved files only;
neither operation reloads or restarts WireGuard. The server file is kept at its
trusted configured path, while internal state and the encrypted vault are kept
under Backup/data by default. Archives are stored under Backup/archives and
contain fixed logical members, not source client files or prior archives.
The persistence journal recovers interrupted file replacement on reopen and The persistence journal recovers interrupted file replacement on reopen and returns a recovery error while earlier work remains unresolved. A confirmed restart marks the saved generation applied only after a successful service job, a matching complete runtime observation, and journaled verification-state write. Restoring a backup does not restore runtime state: the running interface is unknown until a later explicit restart and verification. Pending deleted-client addresses remain reserved through Commit, reopen, and restore. See [docs/BACKUP_FORMAT.md](docs/BACKUP_FORMAT.md) and [docs/PERSISTENCE.md](docs/PERSISTENCE.md) for archive and recovery details, and [docs/RUNTIME.md](docs/RUNTIME.md) for observation and restart boundaries.
returns a recovery error while earlier work remains unresolved. A confirmed
restart marks the saved generation applied only after a successful service job,
a matching complete runtime observation, and journaled verification-state write.
Restoring a backup does not restore runtime state: the running interface is
unknown until a later explicit restart and verification. Pending deleted-client
addresses remain reserved through Commit, reopen, and restore. See
[docs/BACKUP_FORMAT.md](docs/BACKUP_FORMAT.md) and
[docs/PERSISTENCE.md](docs/PERSISTENCE.md) for archive and recovery details,
and [docs/RUNTIME.md](docs/RUNTIME.md) for observation and restart boundaries.
## Vault and existing-client import library ## Vault and existing-client import library
`wireguard_admin::vault` provides WireGuard key handling, an encrypted versioned `wireguard_admin::vault` provides WireGuard key handling, an encrypted versioned vault bundle, caller-buffer client configuration parsing, and reconciliation against a supplied server document. The source adapter can prepare an initial encrypted bundle or explicitly import candidates into an existing prepared bundle. These operations return in-memory data only: they do not install or save a vault, edit source files or server configuration, Commit, or touch the VPN.
vault bundle, caller-buffer client configuration parsing, and reconciliation
against a supplied server document. The source adapter can prepare an initial
encrypted bundle or explicitly import candidates into an existing prepared
bundle. These operations return in-memory data only: they do not install or save
a vault, edit source files or server configuration, Commit, or touch the VPN.
Directory scans use the resolved `client_source_directory` only. A candidate is Directory scans use the resolved `client_source_directory` only. A candidate is an immediate regular file whose name ends in `.conf` (case-insensitive); the adapter does not recurse, follow symlinks, or read files larger than 1 MiB. A scan is limited to 4,096 directory entries, 1,024 candidates, and 32 MiB total input. Per-file failures are redacted and do not prevent other files from being examined. Enumeration, read, or limit failures mark the scan incomplete. The original files remain untouched. Adapter examples and behavior are covered with synthetic temporary directories in `tests/vault_import.rs`.
an immediate regular file whose name ends in `.conf` (case-insensitive); the
adapter does not recurse, follow symlinks, or read files larger than 1 MiB. A
scan is limited to 4,096 directory entries, 1,024 candidates, and 32 MiB total
input. Per-file failures are redacted and do not prevent other files from being
examined. Enumeration, read, or limit failures mark the scan incomplete. The
original files remain untouched. Adapter examples and behavior are covered with
synthetic temporary directories in `tests/vault_import.rs`.
## Client delivery and reissue ## Client delivery and reissue
`Application::prepare_new_client` stages a dual-stack peer and encrypted key. `Application::prepare_new_client` stages a dual-stack peer and encrypted key. After a successful `commit`, `deliver_first_issue_file` can publish its first configuration without a vault password during that same session; a failed file publication can be retried to a newly selected destination. The first file is mandatory. `deliver_first_issue_file_and_qr` optionally returns a QR matrix from the same authorized bytes after file publication. Both success results carry an activation warning unless this session has verified the exact installed saved generation and its matching applied peer snapshot; historical verification and unknown runtime state do not suppress it.
After a successful `commit`, `deliver_first_issue_file` can publish its first
configuration without a vault password during that same session; a failed file
publication can be retried to a newly selected destination. The first file is
mandatory. `deliver_first_issue_file_and_qr` optionally returns a QR matrix from
the same authorized bytes after file publication. Both success results carry an
activation warning unless this session has verified the exact installed saved
generation and its matching applied peer snapshot; historical verification and
unknown runtime state do not suppress it.
Later delivery uses `Application::reissue_client_file`, which takes the client Later delivery uses `Application::reissue_client_file`, which takes the client public key, a password for that action, a prepared destination, and an optional QR choice. It freshly unlocks the installed saved vault each time, reads saved server/settings bytes rather than draft state, preserves validated imported parameters, and rejects blocked or stale records. It never reads original plaintext client files as a fallback. The result reports a secret-free failure stage and warns when the current saved peer set has not been verified as applied in this live session. Historical verification timestamps do not suppress that warning after reopen. File output uses mode `0600`, explicit overwrite confirmation, and separate QR failure reporting. These APIs and the TUI password prompt/terminal QR display have synthetic fixture coverage. No production configuration or key is included in the repository.
public key, a password for that action, a prepared destination, and an optional
QR choice. It freshly unlocks the installed saved vault each time, reads saved
server/settings bytes rather than draft state, preserves validated imported
parameters, and rejects blocked or stale records. It never reads original
plaintext client files as a fallback. The result reports a secret-free failure
stage and warns when the current saved peer set has not been verified as applied
in this live session. Historical verification timestamps do not suppress that
warning after reopen. File output uses mode `0600`, explicit overwrite
confirmation, and separate QR failure reporting. These APIs and the TUI
password prompt/terminal QR display have synthetic fixture coverage. No
production configuration or key is included in the repository.
## Test commands and integration limits ## Test commands and integration limits
@@ -207,19 +120,6 @@ cargo test --locked --test tui_workflow
cargo test --locked tui:: cargo test --locked tui::
``` ```
The first exercises setup, client staging, Commit, delivery, backup listing, The first exercises setup, client staging, Commit, delivery, backup listing, and restore through the serialized worker with temporary files and a fake statistics reader. The second covers navigation/forms/rendering, delayed worker behavior, terminal lifecycle seams, and QR sizing. Neither command starts the management UI or uses host service/runtime mutation. See [`docs/TESTING.md`](docs/TESTING.md) for full gates and the isolated Alpine (M8) and disposable systemd VM (M9) boundaries. An actual PTY smoke run using synthetic application/runtime adapters is included in `tui_workflow`; it uses `script` and a fake unavailable-statistics reader, without root or host settings. It checks an 80×24 to 40×8 to 80×24 resize sequence and terminal cleanup after `q`. The workflow also checks that Esc clears a returned QR disclosure; the
and restore through the serialized worker with temporary files and a fake focused renderer test checks complete quiet-zone fit. Do not use the development host's settings or VPN as a substitute. M1–M8 qualification evidence and the remaining M9 limitations are summarized in [`docs/RELEASE_READINESS.md`](docs/RELEASE_READINESS.md).
statistics reader. The second covers navigation/forms/rendering, delayed worker
behavior, terminal lifecycle seams, and QR sizing. Neither command starts the
management UI or uses host service/runtime mutation. See
[`docs/TESTING.md`](docs/TESTING.md) for full gates and the isolated Alpine
(M8) and disposable systemd VM (M9) boundaries. An actual PTY smoke run using
synthetic application/runtime adapters is included in `tui_workflow`; it uses
`script` and a fake unavailable-statistics reader, without root or host settings.
It checks an 80×24 to 40×8 to 80×24 resize sequence and terminal cleanup after
`q`. The workflow also checks that Esc clears a returned QR disclosure; the
focused renderer test checks complete quiet-zone fit. Do not use the development
host's settings or VPN as a substitute.
M1–M8 qualification evidence and the remaining M9 limitations are summarized
in [`docs/RELEASE_READINESS.md`](docs/RELEASE_READINESS.md).
+90 -49
View File
@@ -1,19 +1,13 @@
# Project Layout # Project Layout
Read this document when creating or reorganizing modules. The layout below This is the integrated package layout after M26. Paths below exist in the
reflects the current package; sections marked planned describe later test and repository; scenario descriptions name current owners. Add modules when a
integration work only. cohesive responsibility needs them, not as placeholders.
## Target project layout The repository has one Cargo package with a binary (`src/main.rs`) and a library
(`src/lib.rs`). Startup and process exit remain in `main.rs`; testable
The repository has one Cargo package with a binary (`src/main.rs`) and a application logic and the TUI are library modules. The library target is not a
library (`src/lib.rs`). Startup and process exit remain in `main.rs`; testable separately published package.
application logic and the management TUI are library modules. A library target
does not require publishing a separate library package. Introduce a workspace
only when independently reusable or separately built components justify it.
Implemented modules include the following. Add modules as implementation
requires them; do not generate empty scaffolding for future integration work.
```text ```text
Cargo.toml Cargo.toml
@@ -23,51 +17,98 @@ AGENTS.md
CONTEXT.md CONTEXT.md
PLANS.md PLANS.md
README.md README.md
.gitignore
src/ src/
main.rs # Startup, arguments, process exit main.rs # Startup, arguments, process exit
lib.rs # Module declarations and application API lib.rs # Library modules; test-only container driver gate
config.rs # Application settings and path resolution startup.rs # Root boundary, settings/store loading, session entry
domain.rs # Clients, identities, address allocation, change states config.rs # Settings validation and executable-relative paths
app.rs # Application operations and orchestration domain.rs # Client projection, address allocation/reservations, apply knowledge
app.rs # Application facade, session, and public operation types
app/
draft.rs # Coordinated document/client/vault draft replacements
first_delivery.rs # Session-local one-use capability and retry state
first_file.rs # First-delivery file and optional QR scenario
reissue.rs # Password-per-action saved-client reissue
read_model.rs # Secret-free application facts and per-action eligibility
commit.rs # Backup, saved install, and Commit publication
restore.rs # Preview, reservation rebuild, and confirmed saved replacement
backups.rs # Manual/pre-operation backup and archive listing
restart.rs # Restart preview, backup/job/verify/publication sequence
wireguard.rs # Server configuration module boundary wireguard.rs # Server configuration module boundary
wireguard/ wireguard/
document.rs # Parsing and edits preserving original text document.rs # Lossless parse, peer inspection and document authority
metadata.rs # Managed client comment blocks edit.rs # Byte-preserving peer and metadata edits
vault.rs # Encryption, import, secret retrieval metadata.rs # Managed comment grammar and metadata validation
backup.rs # Backup archives and restoration vault.rs # Vault format, encryption, key matching and retrieval
runtime.rs # Running WireGuard interface and statistics vault/
service.rs # Systemd operations and restart verification source.rs # Read-only configured client-source access
delivery.rs # Client configuration files and QR generation import.rs # Client-file reconciliation and encrypted import
tui.rs # Terminal UI entry point persistence.rs # Saved-file installation, recovery and state codec
backup.rs # Versioned archive encoding, parsing and preview
runtime.rs # Runtime observations, peer projections and comparison
runtime/
netlink.rs # Bounded Generic Netlink WireGuard reader
transport.rs # Read-only runtime transport
service.rs # Systemd request and job completion adapter
service/
dbus.rs # D-Bus systemd implementation
delivery.rs # Client configuration rendering and adapter boundary
delivery/
files.rs # Secure client file publication
qr.rs # Terminal QR generation
tui.rs # TUI entry and session lifecycle
tui/ tui/
state.rs # Screen state and navigation state.rs # Screens, selection, UI generations and navigation
events.rs # Keyboard and background events events.rs # Typed commands, worker queue and lifecycle
forms.rs # Short-lived forms and secret owners events/
render.rs # Rendering executor.rs # Application command adaptation and prepared contexts
terminal.rs # Terminal setup and cleanup forms.rs # Short-lived form inputs and sensitive commands
render.rs # Visual strings, styles, wrapping and layout
terminal.rs # Terminal setup, event loop and cleanup
container_driver.rs # Test-only Alpine scenario driver (`cfg(test)`)
tests/ tests/
config_preservation.rs # Untouched server configuration remains identical config_preservation.rs # Synthetic byte-preservation behavior
commit_restore.rs # Persistence, backups, restore, runtime isolation commit_restore.rs # Persistence, backups, restore and runtime isolation
client_lifecycle.rs # Import, creation, delivery, deletion, state transitions client_delivery.rs # First delivery, retry, reissue and lifecycle behavior
tui_workflow.rs # Synthetic worker-level TUI workflows runtime_restart.rs # Restart scenario behavior using controlled adapters
fixtures/ # Synthetic configuration and archive inputs vault_import.rs # Import and reconciliation behavior
support/ tui_workflow.rs # Worker-level TUI workflows and synthetic PTY checks
mod.rs # Shared test adapters and helpers testcontainers_wireguard.rs # Explicitly selected, ignored Alpine integration suite
support/ # Shared fixtures, adapters and container harness
fixtures/wireguard/ # Synthetic server configuration
containers/alpine/ # Pinned Alpine runtime image and readiness support
vm/ # Guarded disposable-VM preparation/test scripts
config/ config/
wireguard-admin.example.toml # Safe example; settings format chosen at implementation wireguard-admin.example.toml # Safe distributable example settings
docs/ docs/
SPEC.md SPEC.md
IMPLEMENTATION.md PROJECT_LAYOUT.md
architecture/domain-refactoring.md
adr/ adr/
target/ # Cargo output; never commit target/ # Cargo output; never commit
``` ```
- Use modules named for their responsibility. Avoid catch-all `utils`, `helpers`, or `common` modules; place supporting code with its owner. ## Ownership rules
- Begin with a single module file and split into child modules when needed. Prefer `name.rs` with `name/child.rs` for production modules; `tests/support/mod.rs` keeps test helpers from becoming a separate integration test target.
- Declare application modules once in the library, rather than duplicating them in both `main.rs` and `lib.rs`. Keep internals private or `pub(crate)`; expose only the library API needed by the binary and behavioral integration tests. - `Application` is the public facade. Scenario ordering belongs in `src/app/`
- Keep focused unit tests beside their modules under `#[cfg(test)]`; put cross-module behavioral tests in `tests/`. Use `tests/support/` for test-only adapters rather than exporting test infrastructure from the production library. owners; the facade delegates without transferring authority to the TUI.
- The `config/` directory contains distributable examples, not live settings. The example filename above is illustrative and does not settle the settings format. Actual settings remain alongside the deployed executable as specified. - The server `ConfigDocument` is authoritative for peers and metadata.
- Keep live server configurations, private keys, delivered client files, and runtime `Backup/` data out of the source tree and version control. Ignore generated build output and local deployment data without hiding synthetic fixtures. `ClientStates` is only the manageable-client projection. Editing preserves
- Add `examples/` only for runnable Rust examples and `benches/` only when benchmarks are needed. Do not add empty directories or scripts that duplicate Cargo commands. unowned bytes and comments.
- Keep the layout aligned with actual module responsibilities as the code grows. Update this section when files move; do not preserve obsolete paths merely to match the initial tree. - Domain logic does not perform file, crypto, runtime, service, or rendering
work. Persistence owns installed state; backup owns archive encoding;
runtime owns observations/comparison; service owns systemd calls.
- `app/read_model.rs` supplies secret-free facts and action eligibility.
TUI state owns selection/navigation/generation; the executor binds prepared
continuations and calls application operations; renderers own presentation.
- Keep module internals private or `pub(crate)` where possible. Public facade
APIs retained for integration fixture setup are documented with their caller
audit in [the architecture map](architecture/domain-refactoring.md).
- Keep focused unit tests beside their modules and cross-module behavior in
`tests/`. `src/container_driver.rs` is included only in the library test
harness. Its opt-in Alpine application fixture is not systemd acceptance.
- `config/` holds safe examples, not live settings. Runtime data and archives
remain outside the source tree. Do not commit live configuration, keys,
delivered client files, or generated `Backup/` data.
- Keep this map aligned with actual files and responsibility. Do not add empty
directories or speculative traits/modules.
+285
View File
@@ -0,0 +1,285 @@
# Domain and application refactoring contract
Status: integrated architecture map and handoff after M26. M20–M26 moved the
responsibilities below to their current owners. This document records verified
source ownership and behavioral proof; it does not authorize behavior,
file-format, dependency, or product changes. M27 review and acceptance remain
pending.
## Authority and boundaries
The server configuration is authoritative for server peers and their managed
descriptive metadata. `ConfigDocument` preserves its bytes and exposes every
peer section, even when a section has no usable public key or cannot be
represented by the supported client model. `ClientStates` is a restricted
projection used for client operations; it is not a second peer registry. The
vault is authoritative for encrypted client secrets, and the saved files are
authoritative for persisted state. Runtime reads are observations, not desired
configuration. Historical successful verification is not current verification.
The dependency direction remains:
```text
TUI -> application facade / application read model -> domain rules
| -> config document/editor
| -> vault/import/delivery
| -> backup/persistence
| -> runtime/service adapters
```
Domain rules do not read files, parse server configuration, perform cryptography,
call the runtime/service, or render terminal text. The application coordinates
these owners. The TUI submits typed requests and displays secret-free facts; it
does not authorize business operations or parse saved configuration. The
document editor never rebuilds the server file from a client snapshot.
## Reading guide: common workflows
These paths identify the application entry, the rule owner, the side-effect
boundary, and tests that exercise failures as well as success.
### Create, Commit, first delivery, explicit restart
- **Entry:** TUI `UiCommand::CreateClient`, `Commit`, `DeliverFirstIssue`, and
`PreviewRestart` followed by the explicit restart choice; the adapter is
`src/tui/events/executor.rs`. The facade methods are
`Application::prepare_new_client`, `commit`, `deliver_first_issue_file`,
`preview_restart`, and `restart`.
- **Rule owners:** `app/draft.rs` coordinates the draft; `app/commit.rs` owns
Commit; `app/first_delivery.rs` owns the one-use session capability and
retries; `app/first_file.rs` owns first-file output; `app/restart.rs` owns
backup, job completion, comparison and durable apply publication. The app
read model reports eligibility; the TUI does not grant it.
- **Effect boundary:** creation changes memory only. Commit backs up and installs
saved files/vault, without a runtime call. First delivery writes the selected
client file and may display its QR. Only the later explicit restart may call
the service/runtime adapters; addresses and current applied knowledge change
only after complete verification and durable bookkeeping.
- **Failure proof:** `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue`,
`app::commit_failure_keeps_pending_issue_and_successful_retry_authorizes_it`,
`app::failed_file_publication_can_retry_in_a_different_directory`,
`app::restart_backup_failure_preserves_draft_and_submits_no_job`,
`app::restart_mismatch_and_post_restart_write_failure_never_release_addresses`.
### Delete, Commit, verified address release
- **Entry:** `UiCommand::DeleteClient`, then `Commit`, then `PreviewRestart`
and a confirmed restart choice through the executor; facade methods are
`Application::delete_client`, `commit`, and `restart`.
- **Rule owners:** `app/draft.rs` stages peer removal; `ConfigDocument` in
`wireguard/document.rs` and `wireguard/edit.rs` owns peer identity and
byte-preserving edits; `domain.rs` owns pending/committed reservations and
allocation; `app/commit.rs` persists the deletion; `app/restart.rs` publishes
release only after `runtime.rs` verifies the full saved peer set and durable
state install succeeds.
- **Effect boundary:** delete is draft-only. Commit backs up and changes saved
files/vault but leaves the running interface unchanged. The address remains
reserved through any restart failure or mismatch and is released only after
verified publication.
- **Failure proof:** `app::deletion_of_unprojectable_peer_persists_its_exact_host_route_reservation`,
`app::deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change`,
`commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes`,
`app::restart_mismatch_and_post_restart_write_failure_never_release_addresses`.
### Preview and confirm restore
- **Entry:** TUI `UiCommand::PreviewRestore`, then `ConfirmRestore` or cancel;
`src/tui/events/executor.rs` binds the preview to its worker generation.
Facade methods are `Application::preview_restore` and `confirm_restore`.
- **Rule owners:** `app/restore.rs` validates the archive, rebuilds internal
reservations and coordinates confirmed replacement; `backup.rs` owns archive
validation/encoding; `persistence.rs` owns safe installation and recovery.
The selected archive's settings, configuration and vault remain authoritative
for restored saved state.
- **Effect boundary:** preview and cancel are read-only. Confirmation requires a
fresh precautionary backup, then replaces saved files/vault as a whole. It
resets current apply knowledge to Unknown and does not call the running VPN or
request a vault password.
- **Failure proof:** `app::preview_is_read_only_and_confirm_restores_without_unlocking_vault`,
`app::restore_accepts_changed_non_path_settings_and_rejects_changed_paths`,
`app::restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document`,
`commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes`,
`events::prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation`.
## Current concepts and migration map
References use current repository-relative module paths. The M19.1 map is
resolved below against the integrated M20–M26 implementation. Test names point
to existing behavioral coverage; this documentation update does not claim those
tests were rerun.
### Application session fields and workflows
| Current type / field / function | Business meaning | Authority | Current owner | Readers / writers | Existing proof | Resolution |
| --- | --- | --- | --- | --- | --- | --- |
| `Application::store` (`src/app.rs`) | Saved-file boundary, configured paths, validation and install capability | `SavedStore` paths and installed files | Application facade; scenario owners in `app/commit.rs`, `app/restore.rs`, `app/backups.rs`, `app/restart.rs` | Read by application scenarios; saved files change only through saved-operation owners | `commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes`; persistence `complete_install_publishes_components_with_private_modes` | Resolved M23/M25 |
| `Application::saved` (`src/app.rs`) | Exact installed generation held by this session | `SavedBytes` read from `SavedStore` | Application session; bound contexts in `app/restore.rs` and `app/restart.rs` | Open/read, Commit, restore, verified bookkeeping, restart preview | `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes`; `app::successful_restore_clears_verified_current_history_but_backup_retains_it` | Resolved M23/M25 |
| `Application::draft` (`src/app.rs`, `src/app/draft.rs`) and its document | Coordinated in-memory document, client projection, vault, and draft-only reservations | Authoritative server document plus encrypted vault; `ClientStates` is a restricted projection | `app/draft.rs` stages and publishes coordinated replacements; `wireguard/edit.rs` owns byte-preserving edits | Draft operations prepare replacements; Commit serializes the staged values | `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue`; `config_preservation::edits_keep_interface_and_unowned_bytes_exact_and_can_be_reparsed` | Resolved M22 |
| `Application::states` (`src/domain.rs`) | Narrow draft/saved management snapshots, current apply knowledge, and address reservations | Document projection for manageable clients plus persisted internal state | Domain `ClientStates` and `AddressReservations`; persistence only encodes/decodes durable fields | App draft/Commit/restart/reopen; read model exposes secret-free facts | `domain::discard_restores_draft_and_commit_keeps_applied_snapshot_separate`; `domain::successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not`; `persistence::persisted_history_never_marks_the_runtime_verified` | Resolved M20–M22 |
| `Application::draft.vault` (`src/app/draft.rs`) | Encrypted vault draft used for secret encryption, import, deletion, and reissue | Installed vault bytes at open, then current app draft | `app/draft.rs` coordinates; cryptographic/value operations stay in `vault.rs` and `vault/import.rs` | App setup/import/create/delete/Commit/reissue | `app::selected_file_import_uses_the_existing_vault_draft_without_saving`; `app::deletion_only_removes_selected_current_record_and_retains_other_records`; `vault::encrypted_bundle_round_trips_without_unlocking_and_retrieves_with_password` | Resolved M22/M24 |
| Pending exact-host reservations (`ClientStates::address_reservations`, `src/domain.rs`) | Draft-only exact host addresses retained when a saved peer cannot project to `Client` | Saved document peer routes and pending deletion | Domain `AddressReservations`, coordinated by `app/draft.rs` | Delete adds; Commit persists/promotes; discard clears | `app::deletion_of_unprojectable_peer_persists_its_exact_host_route_reservation`; `app::deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change` | Resolved M21/M22 |
| `historical_apply` | Last successfully persisted verification timestamp/status, for archive/history display | `PersistedState.last_verified_at_utc` and archive manifest | Persistence codec owns representation; application read model presents it | Open/backup/restore/restart bookkeeping | `persistence::persisted_history_never_marks_the_runtime_verified`; `app::verified_history_persists_across_reopen_without_marking_runtime_applied`; `app::historical_verified_timestamp_does_not_suppress_reissue_warning_after_reopen` | Resolved M21/M23/M26 |
| `verified_saved_projection` | Saved generation verified by the current session, used for delivery activation warning | Successful full runtime comparison bound to saved bytes | Application session/restart scenario; read-only view exposes only safe status | Restart publication writes; delivery warning reads; edits/restore invalidate as appropriate | `app::first_issue_warning_tracks_verified_saved_generation_for_file_and_qr`; `app::historical_verified_timestamp_does_not_suppress_reissue_warning_after_reopen` | Resolved M21/M24–M26 |
| `active_restart_attempt`, `next_restart_attempt` | One-use authorization that binds verification publication to the confirmed attempt and saved generation | Application-created attempt ID plus saved-byte equality | Private `app/restart.rs` scenario context | Preview/confirm/restart lifecycle; verified persistence consumes it | `app::restart_confirmation_rejects_a_generation_changed_after_preview`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses` | Resolved M20/M25 |
| `FirstDeliverySession` (`src/app/first_delivery.rs`) | Creation-session-only first-delivery eligibility, one-use authorization and retry state | Generated key held in memory, committed peer directives and encrypted record binding | `app/first_delivery.rs` owns session capability state; `app/first_file.rs` runs first-file output; vault owns ciphertext/crypto and delivery adapters own publication | Coordinated creation registers; Commit promotes; file delivery consumes or updates; delete/discard/invalidation removes | `app::first_issue_authorization_is_single_flight_and_success_consumes_it`; `app::deletion_and_discard_revoke_only_session_bound_initial_material`; `app::uncertain_first_file_publication_retains_warning_and_skips_qr` | Resolved M20/M22/M24 |
| `open_from_saved_store`, `open` (`src/app.rs`) | Construct a session from installed saved bytes; injected-state `open` exists for fixture/state-seeding callers. Reopen is Unknown regardless of history. | Installed files/document; persisted reservations/history | Application session constructors | Production startup uses `open_from_saved_store`; `open` is still called by library unit and integration fixtures | `startup::management_session_loads_supported_state_and_keeps_unsupported_peers_visible`; `app::verified_history_persists_across_reopen_without_marking_runtime_applied` | Resolved M22; retained fixture API, see caller audit |
| `prepare_new_client`, `add_client`, `rename_client`, `delete_client`, `discard_draft` (`src/app.rs`) | Stage client/document/vault changes without saved-file or runtime effects; `add_client` is a legacy fixture helper and does not issue a key/vault secret | Document plus restricted client projection and vault | `app/draft.rs`, application facade and domain/editor/vault owners; no separate `app/clients.rs` was needed | TUI uses prepared create/rename/delete/discard; test fixtures still call `add_client` | `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue`; `client_delivery::committed_creation_retries_after_overwrite_decline_and_duplicate_aliases_are_independent`; `client_delivery::deletion_and_restore_remove_password_free_authority_and_reopen_requires_vault_password` | Resolved M22; retained fixture helper, see caller audit |
| `commit`, `preview_restore`, `confirm_restore` (`src/app.rs`) | Commit atomically installs staged saved bytes after backup; restore is a preview-bound full saved-state replacement after backup | App draft for Commit; validated archive payload for restore | `app/commit.rs`, `app/restore.rs`; low-level formats/install remain in `backup.rs`/`persistence.rs` | TUI executor calls facade; store installs only with `BackupComplete` | `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes`; `app::preview_is_read_only_and_confirm_restores_without_unlocking_vault`; `commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes` | Resolved M23 |
| `preview_restart`, `restart`, verified-result publication (`src/app.rs`, `src/app/restart.rs`) | Confirmed restart scenario: preconditions, backup, service job completion, runtime comparison, durable status then publish applied knowledge | Saved generation + complete `SavedPeerProjection` + runtime observation | `app/restart.rs`; service submission in `service.rs`; comparison in `runtime.rs`; durable fields in persistence | TUI previews/confirms; fixture apply callback is `#[cfg(test)]`; success updates state only after durable write | `app::confirmed_restart_orders_backup_job_read_and_durable_publication`; `app::service_timeout_reports_submission_and_may_still_complete`; `app::failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified` | Resolved M20/M21/M25 |
### Domain state and transitions
| Current type / field / operation | Business meaning | Authority | Current owner | Readers / writers | Existing proof | Resolution |
| --- | --- | --- | --- | --- | --- | --- |
| `Client` / `ClientAddresses` / `Snapshot` (`src/domain.rs`) | Public, manageable projection keyed by public key; aliases may repeat, IPv6 may be absent for legacy clients | Public key and supported host-address projection from document | Domain value types in `domain.rs`; no child module split was needed | App operations and read model; draft/saved snapshot transitions | `domain::aliases_may_repeat_but_control_characters_are_rejected`; `domain::existing_ipv4_only_clients_remain_ipv4_only`; `wireguard::wireguard_projection_supports_ipv4_only_and_rejects_ipv6_only_or_default_routes` | Resolved M20/M21 |
| `ClientStates::draft` / `saved` (`src/domain.rs`) | Manageable-client snapshots for staged and committed state, not all server peers | Document is source; snapshots are derived | Domain client states, mutated only through narrow transitions | Application edits draft and Commit advances saved; TUI consumes read-model facts | `domain::discard_restores_draft_and_commit_keeps_applied_snapshot_separate`; `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes` | Resolved M21/M22 |
| `applied_snapshot` / `ApplyStatus` | Optional management snapshot last verified plus current knowledge; may be `None` while full technical verification is Verified for valid unmanageable peers | Current verified runtime comparison; historical timestamp is separate | Domain apply knowledge; technical evidence remains runtime-owned | Restart publication changes; reopen resets current knowledge; read model maps current knowledge | `app::restart_verifies_metadata_free_legacy_routes_without_partial_applied_snapshot`; `app::verified_history_persists_across_reopen_without_marking_runtime_applied` | Resolved M20/M21 |
| `reserved_addresses` and `restore_persisted_reservations` (`src/domain.rs`) | Persisted conservative addresses that cannot be allocated | Prior saved peer routes and internal state | `domain.rs` owns reservation value/rules; `persistence.rs` only encodes/decodes | Allocator reads; Commit/reopen/restart update; restart success may release | `domain::a_draft_only_deletion_does_not_create_a_committed_reservation`; `domain::successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not`; `persistence::state_codec_is_canonical_and_rejects_bad_or_unsupported_state` | Resolved M21 |
| Narrow `stage_client`, `remove_draft_client`, `commit_draft`, `discard_draft` (`src/domain.rs`) | Mutate the manageable draft projection through scoped operations and preserve saved/applied separation | `ClientStates` snapshots, ultimately document-derived | Application draft coordinator calls crate-private stage/remove and public commit/discard transitions | No unrestricted `draft_mut` accessor remains; direct mutation in domain tests is limited to transitions | `domain::discard_restores_draft_and_commit_keeps_applied_snapshot_separate`; `app::discard_restores_draft_without_writing_installed_bytes`; `app::commit_failure_keeps_pending_issue_and_successful_retry_authorizes_it` | Resolved M21/M22; escape hatch removed |
| Verified apply, technically verified apply, runtime unknown/unavailable transitions (`src/domain.rs`) | Change present apply knowledge only after verified result publication; preserve current uncertainty and optional management projection | Complete runtime comparison plus successful persisted bookkeeping | Domain transitions are crate-private and called by the application restart scenario | App restart success/failure/reopen; statistics reads cannot call transitions | `domain::successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses`; `tui::statistics_refresh_is_generation_bound_and_never_changes_applied_state` | Resolved M20/M21 |
| `allocate_ipv4`, `allocate_ipv6`, `AddressAvailability` (`src/domain.rs`) | Pure address allocation against server, occupied and reserved sets | Validated settings and document-derived address sets | Domain address allocator and reservations | App create reads | `domain::ipv4_allocation_skips_server_occupied_reserved_network_and_broadcast`; `domain::ipv6_allocates_first_free_address_from_a_very_large_range`; `tui_workflow::create_form_submits_automatic_dual_stack_client_without_changing_saved_state` | Resolved M21 |
### Peer and runtime projections
| Current projection | Business meaning / authority | Current owner and consumers | Existing proof | Resolution |
| --- | --- | --- | --- | --- |
| `PeerInspection`, `PeerIssue`, `DocumentIssue`, `ConfigDocument` (`src/wireguard/document.rs`) | Every authoritative server peer section, byte spans, parsed routes and anomalies. Identity can be absent or duplicated. Malformed/no-key peers remain inspectable by document position. | `wireguard/document.rs`; app editing/deletion/restart and application read model consume it | `wireguard::wireguard_peer_issues_report_missing_malformed_and_duplicate_identity_fields`; `tui::malformed_peer_can_be_inspected_by_document_location_without_mutation_identity`; `tui::duplicate_public_keys_remain_inspectable_by_peer_position_but_not_mutable` | Resolved M22/M25/M26 |
| `ClientDisplay`, `SessionDisplay`, `ApplicationView` (`src/app/read_model.rs`) | Secret-free current draft-document rows and authoritative per-action eligibility; mutable identity only for an unambiguous manageable peer | App-owned read model; `Application` facade exposes it | `tui::enter_opens_an_unsupported_peer_for_inspection_without_mutation_identity`; `tui::issues_screen_hides_valid_address_shape_limits_but_keeps_genuine_errors` | Resolved M26 |
| `SessionView`, `ClientStateRow`, `VisibleRecord` (`src/tui/state.rs`) | Maps `ApplicationView` into UI navigation state; keeps generation, selection, screens and rows, without reparsing saved bytes or constructing authority | TUI state consumes app facts and owns UI selection/navigation | `tui_workflow::saved_peer_projection_counts_legacy_peers_without_inventing_managed_state`; `tui::keyboard_navigation_reaches_draft_removed_rows_without_authorizing_them`; `tui::refresh_preserves_selection_by_public_key_not_alias_or_row_position` | Resolved M26 |
| Renderer-derived labels and issue text (`src/tui/render.rs`) | Styles and renders app facts; no operation eligibility or peer authority is inferred from labels | Renderer owns visual strings, styling, wrapping, geometry and layout | `tui::persistent_details_follow_the_highlight_and_keep_geometry_fixed`; `tui::client_rows_show_draft_state_and_escape_anomalous_unsupported_peers`; `tui::issues_screen_hides_valid_address_shape_limits_but_keeps_genuine_errors` | Resolved M26 |
| `SavedPeerProjection`, `RuntimeObservation`, `TechnicalMatch` (`src/runtime.rs`) | Complete technical route set independent of manageable `Client`; valid subnet-route peers can be restart-verified without UI mutability. Handshakes/counters are not apply evidence. | Runtime owns projection/comparison and read-only transport; app restart scenario coordinates; read model exposes only safe status/findings | `runtime::projection_keeps_valid_unsupported_routes_and_ipv4_only_peers`; `runtime::counters_and_handshake_changes_do_not_change_technical_match`; `app::restart_verifies_metadata_free_legacy_routes_without_partial_applied_snapshot` | Resolved M20/M25/M26 |
### Authorization, prepared contexts and worker generations
| Current context | Meaning and authority | Current owner | Readers / writers | Existing proof | Resolution |
| --- | --- | --- | --- | --- | --- |
| UI selection guard `UiState::selected_client_mutable` (`src/tui/state.rs`) | Presentation-level guard requires one matching row, matching document position if selected, and a mutable identity; cannot grant operation authority | TUI selection state only; application owns typed per-action eligibility and execution revalidation | Terminal key handlers and footer hints read it; app methods/editors independently reject invalid actions | `tui::selection_uses_peer_position_for_duplicate_keys_and_disables_mutation`; `tui::removed_saved_client_is_visible_and_never_mutable` | Resolved M26 |
| Per-action UI prerequisites (`src/tui/terminal.rs`, `src/app/read_model.rs`) | Selection is presentation-only; rename/delete/delivery/reissue availability comes from typed app eligibility, and each operation revalidates on execution | App read model owns availability facts; terminal maps them to actions and hints | `tui::state` binds selected row; app methods and config editor validate mutation | `tui::detail_hints_only_offer_saved_delivery_for_mutable_saved_selection`; `app::deletion_of_unprojectable_peer_persists_its_exact_host_route_reservation`; `app::deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change` | Resolved M26 |
| First-issue session context (`src/app/first_delivery.rs`, `src/app/first_file.rs`) | Session-local secret and one-use authorization bound to identity, peer directives and encrypted record; retry state reflects publication/cleanup uncertainty | `app/first_delivery.rs` owns session lifecycle; `app/first_file.rs` owns first-file scenario; adapters own crypto and publication | Create registers; Commit enables; delivery validates/consumes; edits/delete/discard invalidate | `app::committed_first_issue_publishes_selected_file_and_consumes_eligibility`; `app::published_file_is_reported_when_saved_store_bookkeeping_fails`; `app::failed_first_delivery_requires_consuming_capability_before_retry`; `app::temporary_cleanup_failure_requires_manual_cleanup_before_first_issue_retry` | Resolved M20/M24 |
| `ApplicationExecutor` prepared destination and restore preview (`src/tui/events/executor.rs`) | Worker-owned continuation state bound to target, delivery mode/QR choice, attempt, preview and request generation | Private named contexts in the executor child | Executor command handling; clears on unrelated actions, consumes on confirmation/cancel | `events::prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation`; `client_delivery::overwrite_confirmation_is_bound_to_the_selected_destination`; TUI workflow restore cancellation/confirmation | Resolved M20; both contexts use named structs |
| `PendingOperation`, `SessionView.generation`, worker generation (`src/tui/events.rs`, `src/tui/state.rs`) | Monotonic identity for submitted work and returned view; stale view/statistics cannot overwrite current UI state | TUI worker/execution adapter only; not domain state | Mutating submit increments; worker tags result; UI accepts matching generations; periodic statistics rechecks generation across read | `events::delayed_statistics_are_bounded_and_do_not_advance_generation`; `events::statistics_started_before_a_mutation_are_discarded_as_stale`; `tui::stale_view_is_rejected_and_unsupported_selected_peer_is_not_mutable` | Resolved M20/M26 |
| `FormController`, `SecretInput`, `SecretText`, `SensitiveCommand` (`src/tui/forms.rs`, `src/tui/events.rs`) | Short-lived form state and zeroizing password command; secrets excluded from view/debug/clone | Forms own input; worker carries secret command; application consumes only during vault operation | Form submits sensitive command; worker invokes setup/reissue; form clears on cancel/consumption | `tui::compact_form_hints_follow_the_active_form_without_leaking_input`; `vault::every_retrieval_requires_the_correct_password`; `client_delivery::reissue_unlocks_installed_saved_record_each_time_and_warns_after_reopen` | Resolved M24/M26; lifetimes preserved |
### Public API escape-hatch callers and removal conditions
| API | Actual callers | Safe path and timing |
| --- | --- | --- |
| `ClientStates::draft_mut` | No current declaration or callers. Former unrestricted accessor was replaced by crate-private `stage_client`/`remove_draft_client` and narrow transitions in `src/domain.rs`; full `rg` audit finds no production, test, startup, binary, integration or container-driver reference. | Removed in M22 after caller audit; no replacement is needed by callers. [M22 evidence](../evidence/milestone-22.md) records the review. |
| `Application::vault_draft_mut` | No current declaration or callers. The public mutation bypass was removed and no test-only API replaced it. | Removed in M22; vault state is mutated through coordinated application operations. [M22 evidence](../evidence/milestone-22.md) records the review. |
| `Application::add_client` (`src/app.rs`) | Unit-test setup in `src/app.rs`; integration-fixture setup in `tests/commit_restore.rs` and `tests/runtime_restart.rs`. No binary, startup, production module, `tests/support`, or `src/container_driver.rs` caller. `ConfigDocument::add_client` remains the actual byte-preserving editor used by `prepare_new_client` and preservation tests. | Retained as a public compatibility/test-seeding operation because current integration fixtures still use it. It does not generate/encrypt a private key and must not be mistaken for the normal create workflow. Removal requires deliberate fixture migration. |
| `Application::open(store, states)` (`src/app.rs`) | Unit tests and fixture helpers in `tests/client_delivery.rs`, `tests/commit_restore.rs`, `tests/runtime_restart.rs`, and `tests/tui_workflow.rs`. No binary/startup or production caller; no container-driver caller. | Retained for tests that seed `ClientStates` and apply knowledge. Production startup uses `open_from_saved_store`; fixture construction is the only current consumer class. |
| `Application::open_from_saved_store` (`src/app.rs`) | Binary startup through `startup::open_management_session`; application/TUI/integration tests; `container_driver::open_fixture_application` in `src/container_driver.rs`, which is included only under `#[cfg(test)]` in `src/lib.rs`. | Required production and fixture entry point; retain. Container driver normal tests are synthetic; its ignored Alpine scenario is opt-in and is not a systemd acceptance test. |
| `Application::display_projection` (`src/app.rs`) | No production-module, binary, or actual startup-path call. `startup::tests::management_session_loads_supported_state_and_keeps_unsupported_peers_visible` calls it after exercising session startup. There are no `tests/` integration-test calls. The only other direct callers are in `src/container_driver.rs`'s `#[cfg(test)]` synthetic lifecycle test `phase_protocol_keeps_creation_commit_and_first_delivery_in_one_app_session`; the ignored Alpine scenario does not call it. Current TUI presentation uses `Application::read_model`. | Retain for now as a public compatibility projection: the startup unit and synthetic fixture tests still exercise its inclusion of unsupported peers. Its compatibility value and test coverage do not make it a production authorization/read path. Removal requires deliberate migration of those callers and an API compatibility decision; do not infer it is obsolete solely because M26 moved TUI reads to `read_model`. |
| Former public `Application::create_pre_restart_backup` (`src/app.rs`) | Direct callers are confined to colocated app unit tests: helper `test_mark_verified`, `manual_and_pre_restart_backups_are_listed_without_runtime_calls`, and `failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified`. No binary, integration-test, or container-driver caller. | Restricted to a private `#[cfg(test)]` method because only fixtures use it. Production restart performs its mandatory backup within `app/restart.rs`; no production API exception remains. |
## Transition tables
These tables preserve observed behavior. “No mutation” below means no protected
saved-file or runtime mutation; drafts may remain in memory where specified.
### Draft lifecycle
| Trigger | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
| --- | --- | --- | --- | --- | --- |
| Open session | Saved settings/config validate; optional state/vault parse | Draft begins from saved document and client snapshot; current apply knowledge is Unknown | Read installed bytes, restore conservative reservations, load history separately | Invalid saved state rejects open; history never promotes current apply knowledge | `startup::management_session_loads_supported_state_and_keeps_unsupported_peers_visible`; `app::verified_history_persists_across_reopen_without_marking_runtime_applied` |
| Create client | Vault exists; alias/address inputs valid; configured ranges available | Document/client/vault drafts all contain new peer/record; first-issue material registers only after staging succeeds | Generate key, allocate/check dual stack, encrypt record, write no files | Any preparation failure leaves all live draft components and issue rights unchanged | `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue`; `app::manual_addresses_reject_occupied_and_out_of_range_without_partial_staging` |
| Rename or delete | Target identity resolves to an authoritative unambiguous peer; deletion of unprojectable peer can reserve only exact host routes | Corresponding document and manageable snapshot edit; delete removes its vault record if present | Mutate in-memory drafts; retain exact unsupported routes pending Commit; invalidate relevant issue eligibility | Malformed/ambiguous/unsafe broad-route edit fails without partial draft change | `app::deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change`; `app::deletion_only_removes_selected_current_record_and_retains_other_records` |
| Discard draft | Session can reread saved bytes | Draft returns to installed saved state | Reparse installed document/vault, restore persisted reservations; retain only still-valid awaiting-first-file issue material | Read/parse failure returns error; no installed writes; already-consumed delivery is not recreated | `app::discard_restores_draft_without_writing_installed_bytes`; `app::deletion_and_discard_revoke_only_session_bound_initial_material` |
| Commit | Draft validates; mandatory pre-Commit archive succeeds | Saved state advances to installed draft only after successful recoverable install | Persist document, encrypted vault and internal reservations; no runtime call; apply knowledge becomes unknown when saved differs | Backup failure stops before protected writes; partial install reports failure/recovery; draft remains retryable | `app::backup_failure_preserves_installed_files_and_draft`; `app::injected_archive_and_install_failures_preserve_bytes_and_session_saved_state`; `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes` |
| Restart with draft | Valid saved-state restart preview; explicit choice | Cancel keeps draft; discard choice resets it before restart sequence | Separate restart flow; Commit never applies | Cancel makes no backup/job/runtime call. Once service request is submitted, cancellation/timeout cannot assert that the job did not finish | `app::restart_cancel_preserves_draft_and_never_calls_adapters`; `app::service_timeout_reports_submission_and_may_still_complete` |
### Address reservations
| Trigger | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
| --- | --- | --- | --- | --- | --- |
| Delete saved manageable client in draft | Target's addresses known | No committed reservation yet; addresses remain occupied by saved snapshot | Draft deletion only | Discard restores client and no pending reservation is published | `domain::a_draft_only_deletion_does_not_create_a_committed_reservation` |
| Delete saved unprojectable peer in draft | Every route to be retained is an exact IPv4 `/32` or IPv6 `/128` host route | Exact routes enter draft-only reservation set | Prevent allocation while deletion is staged | Broad/subnet route or missing address evidence rejects deletion without draft mutation | `app::deletion_of_unprojectable_peer_persists_its_exact_host_route_reservation`; `app::deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change` |
| Commit deletion | Mandatory backup and saved install succeed | Deleted saved addresses enter persisted reservation set; committed client snapshot advances | Allocator excludes current draft/saved and reserved addresses | Backup/install failure does not publish release or new saved state | `domain::discard_restores_draft_and_commit_keeps_applied_snapshot_separate`; `app::state_file_round_trip_keeps_pending_addresses` |
| Restore archive removing current peers | Preview is valid and confirmation accepted; pre-restore backup succeeds | Rebuilt internal state carries archived/current persisted reservations, addresses from current manageable clients, and exact host routes from unsupported current peers; reservations are excluded for addresses still assigned by restored peers | Full saved replacement only; runtime is untouched | Cancel leaves saved state unchanged; backup/install failure leaves recoverable prior state and drafts | `commit_restore::restore_reserves_addresses_of_current_saved_clients_that_the_archive_removes`; `app::restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document`; `app::partial_restore_failure_preserves_session_and_pre_restore_backup_then_reopens_to_old_set` |
| Verified restart publication | Confirmed attempt still matches installed generation; completed service job; complete interface/peer match; internal-state install succeeds | Reservations for removed clients clear | Persist verified timestamp/state, then publish current applied knowledge | Mismatch/read/service/timeout/persistence failure retains reservations and conservative apply knowledge | `domain::successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses` |
### Current and historical verification
| Trigger | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
| --- | --- | --- | --- | --- | --- |
| Open/reopen | Saved configuration and internal state parse | Current state Unknown; optional management applied snapshot absent; historical timestamp remains separately available | Restore reservations from persisted metadata only | Invalid internal state rejects session; historical success is not live proof | `persistence::persisted_history_never_marks_the_runtime_verified`; `app::verified_history_persists_across_reopen_without_marking_runtime_applied` |
| Periodic statistics read | Worker has an interface name; bounded read returns observation | UI statistics update only | No domain apply transition, no saved write, no reservation release | Unavailable/unknown data remains so; stale-generation result is discarded | `tui::events::statistics_started_before_a_mutation_are_discarded_as_stale`; `tui::state::statistics_refresh_is_generation_bound_and_never_changes_applied_state` |
| Saved state changes | Commit/restore changes installed generation | Current apply knowledge becomes Unknown; old historical timestamp may remain or be cleared according to existing successful restore bookkeeping | No runtime operation | Failed write leaves in-memory published state conservative | `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes`; `app::successful_restore_clears_verified_current_history_but_backup_retains_it` |
| Restart comparison matches complete saved technical peer set | Interface identity and complete peer dump match; malformed/duplicate/unverifiable records absent | Still not published until durable verification bookkeeping succeeds | Prepare persisted reservations/timestamp update | Persistence failure leaves in-memory apply knowledge/reservations unchanged | `app::confirmed_restart_orders_backup_job_read_and_durable_publication`; `app::failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified` |
| Full technical match with peer outside management projection | Peer record is valid/verifiable even if not a `Client`, e.g. subnet routes | Knowledge may be Verified with no complete `applied_snapshot` | Persist verified result; do not invent partial management snapshot | Any incomplete runtime dump still cannot pass | `app::restart_verifies_metadata_free_legacy_routes_without_partial_applied_snapshot`; `runtime::projection_keeps_valid_unsupported_routes_and_ipv4_only_peers` |
### First delivery and password reissue
| Trigger / stage | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
| --- | --- | --- | --- | --- | --- |
| Create before Commit | Successful coordinated create in this session | Pending draft issue | Hold generated private key in session and bind peer directives/ciphertext | Any failed create publishes no new issue authorization | `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue` |
| Commit succeeds | Created peer and encrypted record appear in installed state and match session context | Awaiting first file | Promote one-use capability for current session | Commit failure keeps retryable draft capability but does not permit output | `app::commit_failure_keeps_pending_issue_and_successful_retry_authorizes_it` |
| Prepare destination | Eligible committed issue or saved reissue selected; output path validated | Prepared destination is bound to identity, mode, QR request, attempt and generation | No key disclosure or file write yet | Unrelated command/cancel invalidates the prepared context; stale generation requires reselection | `events::prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation`; `client_delivery::overwrite_confirmation_is_bound_to_the_selected_destination` |
| Confirm first file | Same session, context still matches saved record, overwrite confirmation if required | File is published; first issue is consumed only after saved bookkeeping | Secure file publication; optional QR derives from the same payload and happens after mandatory file | Before-publication failure can retry; uncertain publication requires overwrite confirmation; cleanup failure requires manual cleanup; QR failure does not undo file | `app::failed_file_publication_can_retry_in_a_different_directory`; `app::uncertain_first_file_publication_retains_warning_and_skips_qr`; `app::temporary_cleanup_failure_requires_manual_cleanup_before_first_issue_retry`; `app::file_and_qr_share_payload_and_qr_failure_still_consumes_first_issue` |
| Saved bookkeeping after file | File publication succeeded | Consumed if bookkeeping succeeds; otherwise explicit published-but-uncertain result, no automatic retry capability | Report file exists; preserve activation warning | Do not misreport as failed/unpublished or auto-delete file | `app::published_file_is_reported_when_saved_store_bookkeeping_fails` |
| Reissue after session/reopen | Selected client is in saved state; password supplied for this action | No first-issue session eligibility is involved | Unlock matching saved vault record, render/write selected client file; optional QR | Wrong password/record mismatch/file failure is reported without secret leakage; each client requires fresh password | `client_delivery::reissue_unlocks_installed_saved_record_each_time_and_warns_after_reopen`; `vault::every_retrieval_requires_the_correct_password`; `client_delivery::imported_ipv4_only_parameters_are_preserved_and_authenticated_conflict_blocks_reissue` |
### Restore preview and confirmation
| Trigger | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
| --- | --- | --- | --- | --- | --- |
| Select archive for preview | Safe archive name and valid archive format | Opaque preview holds validated bytes plus metadata; UI receives redacted summary/change list and generation | Read-only archive validation; no password and no runtime call | Invalid/traversal archive rejected; cancel drops preview | `app::preview_is_read_only_and_confirm_restores_without_unlocking_vault`; `commit_restore::archive_parser_rejects_traversal_links_duplicates_and_oversized_members` |
| Confirm matching preview | Preview token still belongs to the operation generation; installation paths remain compatible | Selected archive replaces saved settings, server configuration and vault as a whole after mandatory pre-restore backup; running VPN remains unchanged | Rebuilt internal state carries archived and current persisted reservations, addresses of current manageable clients and unsupported peers' exact host routes that the restore removes; addresses still assigned by the restored configuration are excluded. Draft-only exact routes are included. The rebuilt state has no historical verification timestamp; current apply knowledge becomes Unknown. No restart occurs. The pre-restore backup retains the prior saved bytes and history. | Stale/cancelled preview cannot write; backup/install failure preserves prior saved state or requires journal recovery | `events::prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation`; `app::restore_accepts_changed_non_path_settings_and_rejects_changed_paths`; `commit_restore::restore_reserves_addresses_of_current_saved_clients_that_the_archive_removes`; app `restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document`; `app::successful_restore_clears_verified_current_history_but_backup_retains_it`; `commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes` |
| Cancel preview/confirmation | No restore install has begun | Existing saved state and draft remain | Clear app-executor preview context | No backup/install/runtime side effect | `tui_workflow::worker_backup_restore_preview_cancel_and_confirm_are_separate_saved_operations`; app preview read-only test above |
### Restart attempt identity and uncertainty
| Trigger | Preconditions | Next state | Side effects | Failure or cancellation | Existing proof |
| --- | --- | --- | --- | --- | --- |
| Preview restart | Installed bytes equal session saved generation; interface/service settings and complete saved peer projection are safe | Read-only preview captures saved bytes/settings/projection and draft presence | No backup/job/runtime operation | Unsafe or ambiguous peer state blocks before backup; stale saved bytes reject confirmation | `app::unsafe_saved_peer_diagnostics_block_preview_before_backup_with_safe_location`; `app::restart_confirmation_rejects_a_generation_changed_after_preview` |
| Cancel choice | Preview still valid | Cancelled report; draft remains | No backup, service request, runtime read, or persistence | No operation was submitted | `app::restart_cancel_preserves_draft_and_never_calls_adapters` |
| Confirm restart | Preview still current; explicit choice; backup succeeds | Attempt identity bound to saved generation, then service job submitted | Optional draft discard at specified safe point; complete backup; one shared deadline | Backup failure submits no job; before submission cancellation is definite | `app::restart_backup_failure_preserves_draft_and_submits_no_job`; `app::confirmed_restart_orders_backup_job_read_and_durable_publication` |
| Service job submitted | Request accepted | Await job completion under same deadline | Job may still complete even if caller times out/disconnects | Timeout report retains submitted/may-still-complete distinction; never claim cancellation or rollback | `app::service_timeout_reports_submission_and_may_still_complete`; `app::submitted_terminal_job_failures_cannot_still_complete` |
| Job complete, observe and compare | Job successful; remaining shared deadline; expected configured interface and complete runtime peer set | Matching result becomes candidate for durable verification | Bounded observation and pure comparison; no direct state publish yet | Read error/timeout/missing interface/incomplete dump/mismatch stops publication and retains reservations | `app::shared_deadline_expired_after_successful_job_stops_before_runtime_read`; `app::shared_deadline_expired_after_runtime_read_reports_timeout_before_comparison`; `runtime::partial_duplicate_and_unavailable_records_never_pass` |
| Durable verification succeeds | Attempt token and both current/attempt saved bytes still equal; full technical match; internal state install succeeds | Publish current verified knowledge and clear retained addresses | Persist timestamp/reservations first; update in-memory session after successful install | Failed persistence leaves conservative state; no second restart or automatic rollback | `app::failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses` |
## M20–M26 migration disposition
This register resolves the destinations proposed by M19 against the integrated
source. The milestone labels are historical; the owner column names the current
code. Public facade calls remain where the caller audit below documents an
active integration-fixture dependency. Existing low-level adapters and
persisted formats remain in place.
| Milestone | Production surface and destination | Compatibility / preserved boundary | Existing baseline and exact focused commands |
| --- | --- | --- | --- |
| M20 — vocabulary and named contexts | Domain/apply vocabulary is in `src/domain.rs`; observations/comparison in `src/runtime.rs`; presentation mapping in `src/tui/state.rs`; `PreparedDestinationContext` and `RestorePreviewContext` are private named structs in `src/tui/events/executor.rs`. | Semantics remain unchanged; no domain child split was justified. | Historical focused checks recorded in M20 evidence; current generation-bound proof is `events::prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation`. |
| M21 — reservations and apply knowledge | `src/domain.rs` owns `AddressReservations`, allocation and narrow apply transitions; `src/persistence.rs` encodes durable state; `src/runtime.rs` compares technical peer state; `src/app/restart.rs` coordinates verified publication. | No `domain/reservations.rs` or `domain/apply.rs` split was warranted; unrestricted draft mutation was removed. Persisted schema and semantics remain in persistence. | `domain::exact_host_routes_stay_pending_until_commit_and_discard_drops_them`; `persistence::persisted_history_never_marks_the_runtime_verified`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses`. |
| M22 — coordinated draft and create/edit/import | `src/app/draft.rs` coordinates document/client/vault/reservation drafts; `src/app.rs` remains the facade; `src/vault/source.rs` reads sources and `src/vault/import.rs` reconciles/encrypts. | No separate `app/clients.rs` or `app/import.rs` was justified. `Application::open`/`add_client` remain for injected-state and fixture setup, documented in the caller audit. | `app::new_client_preparation_is_transactional_and_uncommitted_keys_cannot_issue`; config preservation, delivery, import and workflow integration suites. |
| M23 — saved Commit, backups and restore | `src/app/commit.rs`, `src/app/restore.rs`, and `src/app/backups.rs` own scenarios; `src/persistence.rs` owns journaling/path safety and `src/backup.rs` owns archive encoding/parsing. | Preview is byte-bound and saved-only. M23 added restoration of exact routes from unsupported current peers; this closed the earlier noted reservation gap. | `app::commit_advances_saved_state_without_applying_and_backup_contains_old_bytes`; `app::preview_is_read_only_and_confirm_restores_without_unlocking_vault`; `restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document`; `commit_restore::backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes`. |
| M24 — delivery scenarios | `src/app/first_delivery.rs` owns first-issue capability/retry state; `src/app/first_file.rs` runs first-file delivery; `src/app/reissue.rs` runs password reissue. `src/delivery.rs`, `src/delivery/files.rs`, `src/delivery/qr.rs`, and `src/vault.rs` are focused adapters/value owners. | No `app/delivery.rs` was created because session capability, file delivery and saved-record reissue have separate cohesive ownership. | `app::failed_file_publication_can_retry_in_a_different_directory`; `app::temporary_cleanup_failure_requires_manual_cleanup_before_first_issue_retry`; `client_delivery::reissue_unlocks_installed_saved_record_each_time_and_warns_after_reopen`. |
| M25 — restart scenario | `src/app/restart.rs` owns preview, attempt token, backup/job/read/compare/durable-publication order; `src/runtime.rs` owns complete peer projection/comparison; `src/service.rs` owns systemd adapter; persistence owns durable state. | Fixture apply is compiled only under `#[cfg(test)]`. No host service calls occur in ordinary fixture tests. | `app::confirmed_restart_orders_backup_job_read_and_durable_publication`; `app::service_timeout_reports_submission_and_may_still_complete`; `app::restart_mismatch_and_post_restart_write_failure_never_release_addresses`. |
| M26 — read model and TUI command adapter | `src/app/read_model.rs` owns secret-free peer/state/issue/reservation/apply facts and per-action eligibility. `src/tui/state.rs` owns UI navigation/generation; `src/tui/events.rs` and `src/tui/events/executor.rs` own worker lifecycle and command adaptation; forms own transient inputs; renderers own visual output. | TUI does not parse saved bytes or duplicate business authorization. Split of broader terminal/render modules was deferred; their shared lifecycle/layout helpers did not justify a larger move. | `tui::`; `tui_workflow`; `client_delivery`; M26 evidence records focused/full host results and limits. |
### Test inventory and verification notes
- There is no `tests/client_lifecycle.rs` target. Lifecycle coverage is
distributed across `app::`, `client_delivery`, `commit_restore`,
`vault_import`, and `tui_workflow`; the M22–M26 work used existing
targets, and a new target is needed only if acceptance finds a real behavioral
gap.
- Application projection and eligibility are covered by colocated `app::`
tests plus `tui::` and `tui_workflow::` tests; no separate integration target
was needed.
- Restore reservation coverage includes manageable saved clients, exact routes
from unprojectable current peers, persisted reservations, and exact routes
retained by the restored document. `app/restore.rs` excludes routes still
owned by restored peers. The previous M19/M23 gap note is superseded by
`restore_does_not_reserve_unprojectable_exact_hosts_owned_by_restored_document`.
- `ClientStates::draft_mut` and `Application::vault_draft_mut` no longer exist.
`Application::add_client` and injected-state `Application::open` remain
public because integration fixtures use them; the caller audit above records
those exceptions. They are not the production create/startup path.
- Existing test-only container driver is `src/container_driver.rs` included only
by `src/lib.rs` under `#[cfg(test)]`. Its normal fixture tests are synthetic;
ignored `container_driver::alpine_scenario_driver` is opt-in and is not an
ordinary host test or a systemd acceptance test. Do not run it as part of
this documentation task.
- M19.2 and the subsequent migrations use only approved dependencies already
present. No dependency, manifest, lockfile, configuration, or product behavior
change is implied.
+132
View File
@@ -0,0 +1,132 @@
# Milestone 19 evidence: refactoring contract
Recorded 2026-10-29T22:23:28+02:00. Base commit:
`23d6180f2478f636c185d67b8bb3788d21656ce3`. The working tree is dirty:
`PLANS.md` already held the user-approved M19–M27 plan before execution, and
this milestone changed `PLANS.md` and `CONTEXT.md` and added
`docs/architecture/domain-refactoring.md` and this report. No Rust source,
manifest, lockfile, production configuration, or generated secret was changed.
The reviewed substantive document inputs have these SHA256 fingerprints:
| Input | SHA256 |
| --- | --- |
| `PLANS.md` (accepted snapshot) | `724bb710c2f8db7ceca7a059425be6a868e23ab9dd41cb1ff5ae5c32986a4b8e` |
| `CONTEXT.md` | `06efa3f8bb91dbd3b7fb6a00f0035dde7c3be1c01d0a6fe9c096460d6d28deb8` |
| `docs/architecture/domain-refactoring.md` | `7dbb01cfa85dd89a3d122ecf8a564dd204c805bea8a33cb09c4268d0308329c8` |
## Scope and acceptance mapping
M19.1 read-only inventory traced the domain/application, WireGuard document,
runtime, persistence/backup, vault/import/source, delivery, startup, TUI,
library, test-only container driver, and integration tests. M19.2 added a
domain-only glossary and a source-referenced migration contract. The contract
maps field/function authority, readers/writers, actual test names and M20–M27
owners, and describes draft, reservation, verification, delivery, restore and
restart transitions. It keeps the server document authoritative for malformed,
metadata-free and unsupported peers, including valid subnet-route peers that
can be verified technically without becoming manageable clients. Historical
verification is distinct from current apply knowledge. This supports M19-A1–A3.
The caller audit found `ClientStates::draft_mut` used by app production code and
colocated tests; `Application::vault_draft_mut` by one colocated test;
`Application::add_client` and `Application::open` by integration/test fixtures;
and `open_from_saved_store` by production startup and the test-only container
driver. The contract retains compatibility until actual callers migrate.
M20 tuple arity is six; M22–M26 planned focused commands name present targets.
No dependency, schema, archive, UI or behavior change is implied.
The remaining behavior gap is explicit in the contract: current restore
reservation coverage proves manageable saved clients, while
`preview_restore` does not collect exact host routes from unprojectable current
peers or `draft_unsupported_reservations`. M23 must add focused coverage and
repair any uncovered preservation under the existing reserved-address policy.
M19 does not claim that this behavior is already correct. M9 remains retired
and unaccepted; M19 depends on accepted M18, not M9.
## Independent review and repair
A GPT-6-Luna/high implementer completed M19.1 and M19.2. A separate, fresh
GPT-6-Luna/high reviewer inspected the actual source, callers, tests and
documents. A GPT-6-Luna/high repairer corrected architecture wording; the
coordinator alone corrected plan wording and later task commands. The reviewer
rechecked each repair and reported no remaining actionable findings:
| Finding | Final disposition |
| --- | --- |
| M19-R1 | Closed: restore rewrites internal state, retains conservative reservations and clears historical verification. |
| M19-R2 | Closed: historical M18 handoff no longer contradicts active M19. |
| M19-R3 | Closed for this documentation milestone: current unprojectable-peer restore reservation gap and M23 focused test/repair are recorded. |
| M19-R4 | Closed: programme and historical M17 handoff wording reflect the current plan. |
| M19-R5 | Closed: M19.3 task row reflects the current finding disposition. |
| M19-R6 | Closed: tuple arity and later focused test targets match the actual repository. |
This independent review supports M19-A4 subject to the final DOC checks below.
## Validation and limits
Final integrated DOC checks ran in the repository root on the accepted plan
snapshot. The exact link/whitespace command was:
```sh
python3 - <<'PY'
from pathlib import Path
import re, sys
files = [Path(p) for p in ('PLANS.md', 'CONTEXT.md', 'docs/architecture/domain-refactoring.md', 'docs/evidence/milestone-19.md')]
errors = []
links = anchors = 0
for file in files:
body = file.read_text()
if not body.endswith('\n'): errors.append(f'{file}: final newline')
for number, line in enumerate(body.splitlines(), 1):
if line.rstrip() != line: errors.append(f'{file}:{number}: trailing whitespace')
for target in re.findall(r'(?<!!)\[[^]]+\]\(([^)]+)\)', line):
if '://' in target or target.startswith(('mailto:', 'plugin:')): continue
path, _, anchor = target.partition('#')
dest = (file.parent / path).resolve() if path else file.resolve()
links += 1
if not dest.exists(): errors.append(f'{file}:{number}: missing {target}'); continue
if anchor:
anchors += 1
headings = [re.sub(r'[^\w\- ]', '', h.lower()).strip().replace(' ', '-') for h in re.findall(r'^#{1,6}\s+(.+)$', dest.read_text(), re.M)]
if anchor not in headings: errors.append(f'{file}:{number}: missing anchor {target}')
print(f'{len(files)} files, {links} local links, {anchors} anchors, {len(errors)} errors')
for error in errors: print(error)
sys.exit(bool(errors))
PY
```
It exited 0: four files, 72 local links, zero anchors and zero errors. The
new untracked documents were included in the final-newline and trailing-space
check. The exact plan-consistency command was:
```sh
python3 - <<'PY'
from pathlib import Path
import re, sys
plan = Path('PLANS.md').read_text()
rows = [(int(i), state) for i, state in re.findall(r'^\|\s*(\d+)\s*\|[^\n]*?\|\s*(accepted|retired|pending|in_progress|in_review|needs_changes|blocked)\s*\|$', plan, re.M)]
errors = []
if [i for i, _ in rows] != list(range(1, 28)): errors.append('register numbering')
states = dict(rows)
if states.get(18) != 'accepted' or states.get(19) != 'accepted' or any(states.get(i) != 'pending' for i in range(20, 28)): errors.append('register states')
record = plan.split('### Milestone 19 —', 1)[1].split('### Milestone 20 —', 1)[0]
if 'Status: accepted' not in record or any(f'M19.{i} |' not in record for i in (1, 2, 3)): errors.append('M19 record/tasks')
resume = plan.split('## Current progress and resume point', 1)[1].split('## Plan maintenance', 1)[0]
if 'M19 is accepted' not in resume or 'Stop after M19' not in resume: errors.append('resume point')
for target in set(re.findall(r'cargo test --locked --test ([a-z_]+)', plan.split('### Milestone 20 —', 1)[1].split('## Current progress and resume point', 1)[0])):
if not Path(f'tests/{target}.rs').exists(): errors.append(f'missing test target {target}')
if "prepared_destination's six-element tuple" not in plan: errors.append('tuple arity')
print(f'{len(rows)} register IDs, M19 tasks/status/resume, future test targets and tuple: {len(errors)} errors')
for error in errors: print(error)
sys.exit(bool(errors))
PY
```
It exited 0: 27 ordered register IDs and no status, task, target or tuple
errors. `git diff --check` exited 0. The coordinator also inspected factual
claims and the final changed-file set; the independent reviewer compared
the migration contract with production and test source. Rust formatting,
Clippy, tests, release build, Alpine Testcontainers and disposable-VM checks
were not required for documentation-only M19 and were not run. No production
or live WireGuard access occurred.
+80
View File
@@ -0,0 +1,80 @@
# Milestone 20 evidence: vocabulary and prepared contexts
Recorded 2026-10-29T22:38:53+02:00. Base commit
`ede45d224a9ad255a919900e556a03d7e575430f` had a clean working tree.
The final tree is dirty only for this milestone's `PLANS.md`, Rust source and
test changes, and this evidence report. No `Cargo.toml` or `Cargo.lock` diff,
new dependency, persisted-format change, production configuration, or secret
fixture was introduced. The final Rust source/test patch fingerprint is
SHA256 `cba97207f926f1927c70643754db2743603e0f72892115ef8d959696d769992a`,
produced by:
```sh
git diff -- src/app.rs src/domain.rs src/persistence.rs src/container_driver.rs src/tui/events.rs src/tui/render.rs src/tui/state.rs src/tui/terminal.rs tests/client_delivery.rs tests/commit_restore.rs tests/runtime_restart.rs tests/tui_workflow.rs | sha256sum
```
The release artifact `target/release/wireguard-admin` built from those inputs
has SHA256 `7a6ee46b1d9c39bac9a7b3627b0b8ba30e70321126ebd16b1948b42b8658ac98`.
`file target/release/wireguard-admin` identified an x86-64 dynamically linked
GNU/Linux PIE with interpreter `/lib64/ld-linux-x86-64.so.2`.
## Implementation and acceptance mapping
- M20.1 renamed the domain's current knowledge enum to `ApplyStatus`, retained
`runtime::RuntimeObservation` for measured interface data, and migrated actual
application, persistence, TUI and integration callers. The exact duplicate
TUI `AppliedState` enum/mapping and now unnecessary `Statistics`,
`TechnicalObservation` and `ObservedRuntime` aliases were removed. UI labels
and current-versus-historical state transitions were preserved (M20-A1).
- M20.2 replaced the six-value prepared delivery tuple and three-value restore
tuple with private named contexts. A private `DeliveryMode` distinguishes
first issue and reissue without changing public `reissue` flags. Neither
context derives `Clone` or `Debug`; identity, attempt, generation, overwrite,
cancellation, unrelated-command clearing, password ownership and QR/file
order remain unchanged. A behavioral test covers cancel/unrelated-command
clearing (M20-A2).
- All affected production and test callers compile; no manifests, formats or
transport behavior changed. Focused tests, final full RUST gates and
independent review passed (M20-A3).
## Independent review and repair
A GPT-6-Luna/high implementer completed M20.1 and M20.2 sequentially. A
separate fresh GPT-6-Luna/high read-only reviewer inspected the integrated
diff, production paths, callers, tests and secret-bearing contexts and found
no behavior issue. The coordinator's first Clippy gate found M20-R1 (P2): two
test/test-only `.clone()` calls on the now-`Copy` status. A separate
GPT-6-Luna/high repairer changed only those two calls to dereferences in
`src/app.rs` and `src/container_driver.rs`. The reviewer independently
rechecked the repair and affected tests, closed R1 and found no new issue.
No actionable finding remains.
## Checks and limits
Environment at execution: `rustc`/Cargo 1.97.1 on
`x86_64-unknown-linux-gnu`, rustfmt 1.9.0 and Clippy 0.1.97, matching the
pinned compiler. Focused implementer/reviewer runs covered `domain::` (13),
`runtime::` (25), `tui::state::` (7), `tui::events::` (7),
`--test commit_restore` (9), `--test runtime_restart` (3),
`--test tui_workflow` (8), and `--test client_delivery` (5). After R1, the
repairer ran `cargo test --locked app::` (58 passed) and
`cargo test --locked --lib container_driver::` (23 passed, 2 ignored). The
reviewer reran affected app/event tests. These are host synthetic/PTY checks;
ignored Alpine selectors were not run.
| Final coordinator command | Actual result |
| --- | --- |
| `cargo fmt --all -- --check` | exit 0 |
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 after M20-R1 repair |
| `cargo test --locked` | exit 0: 391 unit/integration tests passed, 3 ignored; 2 doctests passed; 0 failed |
| `cargo build --locked --release` | exit 0; artifact SHA256 above |
| `git diff --check` | exit 0 |
The initial Clippy run exited 101 on M20-R1 before repair and is not the final
gate result. Default tests did not execute the three explicitly ignored
Alpine/selected-daemon tests. No Alpine Testcontainers or disposable systemd VM
run was required for this naming/context refactor or performed. Prior Alpine
results are historical for earlier source and do not validate this artifact;
real systemd/D-Bus and IPv6 limitations remain as previously recorded. No
production, live VPN, host service or host network operation was performed.
+25
View File
@@ -0,0 +1,25 @@
# Milestone 21 evidence — domain reservations and apply knowledge
Run: 2026-10-29T23:00:09+02:00. Base commit: `8413b382b178991d2e1e597f1422d147f3454581` (clean at start). Final dirty source inputs: `src/domain.rs`, `src/app.rs`, and `docs/PROJECT_LAYOUT.md`; `PLANS.md` and this evidence record are coordinator documentation. `git diff -- src/domain.rs src/app.rs docs/PROJECT_LAYOUT.md | sha256sum` returned `d9056e1fd05db2425a127ac57f906333ae311bdb0f3cce13b9153f39c041c982`. `target/release/wireguard-admin` SHA-256: `60b3ad8b9c1bf2750239d3500b319cf42e5581fe027e5b6e39c8ff4115feb024`. `Cargo.toml` and `Cargo.lock` have no diff. Rust and Cargo: 1.97.1, pinned toolchain.
## Implementation and invariant evidence
- M21.1: `AddressReservations` owns private committed and draft-only pending host-address sets. Staging validates the entire exact `/32` or `/128` route list before mutation. The allocator sees both sets. Draft discard drops pending addresses; successful Commit promotes pending and retains addresses removed from the prior manageable saved snapshot. Persisted restoration is crate-scoped and rejects saved-client conflicts. No serialization field or version changed.
- M21.2: private `ApplyKnowledge` owns current runtime status and optional complete last-verified management snapshot. Historical verification remains separate in persisted application history. Session open resets current knowledge to `Unknown`; technical verification may publish `Verified` with no complete management snapshot. The restart scenario prepares a cloned verified candidate, then publishes it only after internal-state installation succeeds. Only that verified transition releases committed reservations. Backup, service, read, mismatch and persistence failure paths retain reservations.
- M21-R1: independent review found unconditional Commit demotion of `Verified` and `Unavailable` on unchanged saved client snapshots. The repair removed the extra application call; the domain Commit transition still demotes `Verified` when the saved management snapshot changes. Three behavioral app tests cover unchanged `Verified`, unchanged `Unavailable`, and changed `Verified`. The same independent reviewer re-reviewed the repair and full affected reservation/apply paths, closed M21-R1, and reported no remaining actionable finding.
## Validation on integrated source
| Check | Result |
| --- | --- |
| `cargo fmt --all -- --check` | exit 0 |
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 |
| `cargo test --locked` | exit 0; 399 passed, 3 ignored (opt-in Alpine drivers), 0 failed, including 2 doctests |
| `cargo build --locked --release` | exit 0 |
| `git diff --check` | exit 0 |
Focused implementation, repair and review runs covered `domain::`, `persistence::`, `app::`, `--test commit_restore`, and `--test runtime_restart`; the final full test command supersedes those focused source results. The independent reviewer specifically inspected reservation mutation/release callers, pending/committed timing, exact-host policy, unchanged/changed Commit behavior, reopen/current versus historical state, legacy technical matching without a management snapshot, and failed verified publication.
## Scope and acceptance
M21-A1: passed by reviewed transition callers and synthetic failure-path tests. M21-A2: passed by legacy/reopen/history tests and the M21-R1 behavioral repair. M21-A3: passed by allocator, reservation, persistence and application tests, independent re-review and all required Rust gates. Evidence is host/synthetic. The opt-in Alpine suites and disposable systemd VM were not run for M21, and no real systemd, live VPN, production or IPv6 integration result is claimed. M9 remains retired/unaccepted. The previously recorded unprojectable-peer restore reservation gap belongs to M23 and is not represented as resolved here.
+37
View File
@@ -0,0 +1,37 @@
# Milestone 22 evidence — coordinated draft editing
Run: 2026-10-29T23:14:00+02:00. Base commit: `2766c62` (clean at start). The integrated dirty source inputs are `src/app.rs`, new `src/app/draft.rs`, `src/domain.rs`, and `docs/PROJECT_LAYOUT.md`. `PLANS.md` and this report are coordinator records. Reproduce source identity with `sha256sum`:
| Input | SHA-256 |
| --- | --- |
| `src/app.rs` | `a2ae18af4a5755397d86f36da3938baba01bc434da1b024aad64720267cebb7d` |
| `src/app/draft.rs` | `4b4c7b4ef9f8d673dad44d017badad8edbe1c73c19a74c1454008229e8fb1cbc` |
| `src/domain.rs` | `47740c902eee54053c1a4e46c7dacaf18204dc6366cb32d1189d6a04b293a516` |
| `docs/PROJECT_LAYOUT.md` | `500fd91848e5896153fa39a330131103a50a2f442e58ecfd6ad2bafc6ba9ba07` |
| `target/release/wireguard-admin` | `1b955bc2858924811043f48a66bfc7e2180ff0ecbc6d93b8b0339b791a9646e5` |
The native host toolchain was rustc/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97. `Cargo.toml`, `Cargo.lock`, persistence and archive code have no diff.
## Implementation and independent review
M22.1 added private `DraftChanges` to own the lossless document, narrower client states with pending address reservations, and encrypted vault draft. Its explicit staged replacement publishes selected prepared components without saved-file or runtime effects. M22.2 moved create/add/rename/delete, discard, initial vault setup and selected-file import through that boundary. Fallible document parsing/projection, state insertion, vault serialization/encryption/import and saved-state reading finish before publication. Vault copies are made through encrypted TOML only when a vault-changing operation needs one; document-only edits keep the current vault. A new client's session-first-delivery material is registered after successful draft publication.
The unrestricted `ClientStates::draft_mut` and test-only public `Application::vault_draft_mut` methods were removed after caller audit. `Application` facade entry points remain. Three behavioral app tests cover a late missing-vault creation failure, selected import failure, and discard parse failure without partial draft or issue-state mutation. Layout documentation now names the actual draft module and existing integration targets.
Fresh GPT-6-Luna/high reviewer `/root/m22_review`, separate from the implementer, inspected the base-to-current diff and relevant startup, TUI, integration and container-driver callers. The reviewer found no actionable issue. Review covered authority, secret lifetime, all publication callers, unsupported peers, source-file preservation, public API, fixture restart scope and unchanged formats/dependencies. No repair cycle was needed.
## Validation on integrated source
| Exact command | Result |
| --- | --- |
| `cargo fmt --all -- --check` | exit 0 |
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 |
| `cargo test --locked` | exit 0; 401 passed, 3 ignored opt-in Alpine tests, 0 failed, including 2 doctests |
| `cargo build --locked --release` | exit 0 |
| `git diff --check` | exit 0 |
The implementer and reviewer also ran the required focused suites: `cargo test --locked app::` (64), `cargo test --locked --test config_preservation` (10), `cargo test --locked --test client_delivery` (5), `cargo test --locked --test vault_import` (5), and `cargo test --locked --test tui_workflow` (8), all passed. The implementer additionally ran `cargo test --locked domain::` (15), passed. The final full test command supersedes the focused runs as integrated host evidence.
## Acceptance scope
M22-A1 is supported by staged publication and failure tests. M22-A2 is supported by byte-preserving document and import tests plus source review. M22-A3 is supported by synthetic app/TUI creation, Commit, first delivery, explicit fixture restart, deletion and discard coverage; draft and Commit flows make no live runtime call. M22-A4 is supported by the independent review and completion gates. This is host/synthetic evidence. The opt-in Alpine driver and disposable systemd VM were not run for M22; no real systemd, live VPN, production or IPv6 integration result is claimed. The M23 unprojectable-peer restore reservation gap remains assigned to M23 and is not claimed resolved here.
+37
View File
@@ -0,0 +1,37 @@
# Milestone 23 evidence — saved-state Commit, backup, and restore scenarios
Run: 2026-10-29T23:32:25+02:00. Base commit: `c1d4f93` (clean at start). Final dirty source inputs are `src/app.rs`, new `src/app/commit.rs`, `src/app/restore.rs`, `src/app/backups.rs`, and `docs/PROJECT_LAYOUT.md`. `PLANS.md` and this report are coordinator records. Reproduce identity with `sha256sum`:
| Input | SHA-256 |
| --- | --- |
| `src/app.rs` | `9fe74372a68182dc24d7061b4409d3dd6970bc77e3528560da2cacd9d70876b2` |
| `src/app/commit.rs` | `e8af86c36613d91605c81c3dca82fb3d1b3b0dd9b41fdbfeaba9e84984fc091b` |
| `src/app/restore.rs` | `5dd96ee9650ce66e17378eebea2adf18da746264814f0f0d6258a6ebbda651e4` |
| `src/app/backups.rs` | `d837fb0dd29cd3affd9ae12233ecb0337e9a308d730b24a4c892c7bc2605e6ef` |
| `docs/PROJECT_LAYOUT.md` | `c9d873caf3c46d48392e6352b5aed036ce3fdaa471c66b4df8d92994c1a2f5ef` |
| `target/release/wireguard-admin` | `44444967d552bbb7bac2d36423cc943cde2ced783190946aae67f12e0283541e` |
Native host toolchain: rustc/Cargo 1.97.1, rustfmt 1.9.0, Clippy 0.1.97. `Cargo.toml`, `Cargo.lock`, `src/persistence.rs`, and `src/backup.rs` have no diff.
## Implementation and review
- M23.1 extracted Commit into a private scenario: prepare and validate proposed bytes, parsed document, and next client state; complete a pre-Commit archive of the installed generation; perform journaled install; then publish the prepared in-memory state. A failed install reads back conservatively to retain or revoke session-first-delivery rights. The public `Application` facade and narrow test callback remain. No service or runtime call is made by Commit.
- M23.2 extracted manual/pre-restart backup listing/creation and previewed restore. Preview is read-only and holds the exact installed generation and selected archive bytes. Confirmation rejects a stale generation before backup, prepares path-compatible settings/document/vault/state, completes a pre-restore archive, performs journaled full replacement, then publishes. An install failure clears session-only delivery rights and reports recovery uncertainty. Absent optional components and archive/historical labels retain their documented semantics. No vault password or runtime call is used.
- The M19 exact-host restore gap is repaired: removed unprojectable peers' exact IPv4 `/32` and IPv6 `/128` routes are retained as reservations through reopen and allocation. Broad routes are not expanded into guessed addresses.
- Fresh GPT-6-Luna/high reviewer `/root/m23_review`, separate from implementer `/root/m23_commit`, found M23-R1: newly carried routes were reserved even when still present in the restored document. GPT-6-Luna/high repairer `/root/m23_repair` separated archived reservations from carried current/pending addresses and excluded carried exact routes still owned by the restored document. Focused tests cover retained, removed, pending-deletion and archived-reservation cases. Independent re-review closed M23-R1 with no remaining code finding. M23-R2 identified that this evidence file was pending during the first review; it is now present for final verification.
## Integrated validation
| Exact command | Result |
| --- | --- |
| `cargo fmt --all -- --check` | exit 0 |
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 |
| `cargo test --locked` | exit 0; 404 passed, 3 ignored opt-in Alpine tests, 0 failed, including 2 doctests |
| `cargo build --locked --release` | exit 0 |
| `git diff --check` | exit 0 |
Focused implementer/repairer/reviewer runs covered `cargo test --locked app::` (final 67), `cargo test --locked persistence::` (15), `cargo test --locked backup::` (8), `cargo test --locked --test commit_restore` (9), and `cargo test --locked --test client_delivery` (5), all passed on the final code. The full test command supersedes those focused runs as integrated host evidence. The independent review inspected backup-before-install order, saved-only dependencies, path/generation binding, optional components, secret handling, recovery and preserved formats.
## Acceptance scope and limits
M23-A1 is supported by reviewed operation ordering and backup/install/cancel failure tests. M23-A2 is supported by restored settings/path, optional-component, recovery, historical-status and old-archive tests. M23-A3 is supported by fixture old-state backups, compatibility tests, the saved-only dependency audit, independent re-review and required Rust gates. This is host/synthetic evidence. No opt-in Alpine or disposable systemd VM run was made for M23; no real systemd, live VPN, production or IPv6 integration behavior is claimed. M9 remains retired/unaccepted.
+43
View File
@@ -0,0 +1,43 @@
# Milestone 24 evidence — session-owned delivery scenarios
Run date: 2026-10-30T21:23:54+02:00. Baseline commit: `ed602f0`; the working tree contains the uncommitted M24 source and documentation changes listed below, plus coordinator-owned `PLANS.md`. Rust/Cargo: 1.97.1, host native target. Checks used synthetic fixtures. No production configuration, keys, VPN, or host service was accessed.
## Integrated inputs
| Input | SHA-256 |
| --- | --- |
| `src/app.rs` | `a337a6c25db64f0a1422e0efa1bf67ecae06f7c59e74f6144d2964345d06117d` |
| `src/app/first_delivery.rs` | `0814de038423a0b8886d216aa3cf2b0913337595fbde4624da93bdb610f1df62` |
| `src/app/first_file.rs` | `770f833c99c42ac2b138a071bf4df26bbcaffcc431733380b846d600d8be8b6b` |
| `src/app/reissue.rs` | `2bf5b2f967c55f08d53b6d1e5d3d727d2774d5ad97454b2c4eb2425fdacdd563` |
| `src/app/commit.rs` | `eac859338dd11b8cb2cac8db2dc621d6862215998ad89701f6eadd6a6c2609d8` |
| `src/app/restore.rs` | `1ac76556d31d3f1502c644195c3f479fa092c321ebb53e671911a632ecc91f35` |
| `docs/PROJECT_LAYOUT.md` | `302f2a7eb16def7e2c0810780a3e9805caea416915737ce4ab4033900a379306` |
| `target/release/wireguard-admin` | `3aa6c56f32ed3da82c25c6da1f209c6dc55293998e497f57de0a2bd2c6c7a39a` |
No dependency, manifest, or lockfile changes. The release artifact hash identifies the binary built from the current integrated source; it is not a production deployment.
## Checks on integrated tree
| Command | Result |
| --- | --- |
| `cargo test --locked app::` | Exit 0; 67 app tests passed. |
| `cargo test --locked delivery::` | Exit 0; 22 delivery tests passed. |
| `cargo test --locked vault::` | Exit 0; 24 vault tests passed. |
| `cargo test --locked --test client_delivery` | Exit 0; 5 tests passed. |
| `cargo test --locked --test tui_workflow` | Exit 0; 8 tests passed. |
| `cargo fmt --all -- --check` | Exit 0. |
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. |
| `cargo test --locked` | Exit 0; library 337 passed, 2 ignored; integration and doc suites passed. Ignored Alpine suites were not selected. |
| `cargo build --locked --release` | Exit 0. |
| `git diff --check` | Exit 0. |
## Acceptance mapping and review
- **M24-A1:** `FirstDeliverySession` privately owns keys, bound context, stages and tokens. Existing app and client-delivery tests pass for pre-Commit, stale, consumed and reopened rights, Commit promotion, discard/delete, and independent rights across rename/restart.
- **M24-A2:** App tests pass for retry to a different directory, uncertain publication and explicit overwrite/manual cleanup acknowledgement, post-publication bookkeeping uncertainty, activation warnings, same-payload file/QR, and independent QR failure. The new owner and authorization have no ordinary `Debug`, `Clone`, or serialization; public errors and projections remain redacted.
- **M24-A3:** Reissue reads installed saved state, unlocks the selected record with the password supplied for that action, renders authoritative server values, writes the mandatory file before optional QR, and retains imported-record mismatch checks. `client_delivery` and vault tests pass.
Fresh independent reviewer `/root/m24_review` examined the combined implementation and ran the focused suites, formatting, Clippy, full tests, release build and diff check. No source correctness or security finding remained. **M24-R1** was a missing evidence record named by `PLANS.md`; the coordinator created this file after independently rerunning the required checks. Independent re-review of this record and its source hashes remains the next action before acceptance.
This is host/synthetic evidence only. No fresh Alpine Testcontainers scenario or disposable systemd VM test was run; Alpine evidence would not establish systemd behavior. The retired M9 real-system acceptance limitation remains outside M24.
+39
View File
@@ -0,0 +1,39 @@
# Milestone 25 evidence — explicit restart verification scenario
Run date: 2026-10-30T21:35:10+02:00. Baseline commit: `7cef400`; the working tree has uncommitted M25 changes in `src/app.rs`, new `src/app/restart.rs`, and `docs/PROJECT_LAYOUT.md`, plus coordinator-owned `PLANS.md`. Rust/Cargo: 1.97.1, native host target. Tests used synthetic fixtures/adapters only. No production configuration, keys, VPN, host service, Docker environment, or VM was accessed.
## Integrated inputs
| Input | SHA-256 |
| --- | --- |
| `src/app.rs` | `784001013f90d9bbdb1579cf3c6672a27dd34dc0e295d3e6ea3f70d53d05c586` |
| `src/app/restart.rs` | `fb724054faec6adf1b58a31800a2a636307b2620ac3d980309638ad508a00fa3` |
| `docs/PROJECT_LAYOUT.md` | `8f156088800e469e6bbf7ac0f012afdb4bf1f190ab4c9161fb3c41d871e04df2` |
| `target/release/wireguard-admin` | `b383a1f30e776dcce983bb123af6f4e474e71d6b3d28866f3a06ba3c423228b7` |
No dependency, manifest, lockfile, service-adapter or runtime-transport files changed. The release artifact hash identifies the binary built from this integrated source; no deployment occurred.
## Checks on integrated tree
| Command | Result |
| --- | --- |
| `cargo test --locked app::` | Exit 0; 67 app tests passed. |
| `cargo test --locked runtime::` | Exit 0; 25 runtime tests passed. |
| `cargo test --locked service::` | Exit 0; 26 service tests passed. |
| `cargo test --locked --test commit_restore` | Exit 0; 9 tests passed. |
| `cargo test --locked --test runtime_restart` | Exit 0; 3 tests passed. |
| `cargo fmt --all -- --check` | Exit 0. |
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. |
| `cargo test --locked` | Exit 0; library 337 passed, 2 ignored; integration and doc tests passed. The selected Alpine tests stayed ignored. |
| `cargo build --locked --release` | Exit 0. |
| `git diff --check` | Exit 0. |
## Acceptance mapping and review
- **M25-A1:** `restart.rs` contains the preview, validated preparation, completed pre-restart backup, one-use attempt, optional draft discard, service job, bounded runtime read, full comparison and durable publication in the original order. Cancel preserves draft and performs no backup/job/read. The service deadline is created after backup and shared with observation; both saved generation and attempt ID are checked before persistence.
- **M25-A2:** Existing app/runtime/service tests cover legacy metadata-free, IPv4-only, subnet-route and missing-IPv4 valid peers; malformed or duplicate identities, incomplete observations, mismatches, service and transport distinctions, shared deadline exhaustion, and post-match persistence failure. `SavedPeerProjection` remains the complete technical target while a management `Snapshot` is optional. Reservations release only after durable verified success.
- **M25-A3:** `Application::restart` still calls the existing `service::restart_systemd_unit_report` and `runtime::transport::observe_interface` adapters. The fixture apply method and container driver remain `#[cfg(test)]`; no rollback or second restart was introduced. `app` public restart types remain re-exported.
Fresh independent reviewer `/root/m25_review` examined the integrated source and original behavior, verified all five focused suites and Rust gates, and found no actionable source defect. **M25-R1** was the missing specified evidence artifact; the coordinator created this report after independently running all required commands. The reviewer independently rechecked the record and all four hashes and closed M25-R1; no finding remains.
This is host/fixture evidence only. No fresh Alpine Testcontainers scenario or disposable systemd VM test was run, so real systemd/D-Bus and live IPv6 behavior remain unverified. Alpine would not establish systemd behavior. The retired M9 real-system acceptance limitation remains outside M25.
+46
View File
@@ -0,0 +1,46 @@
# Milestone 26 evidence — application read model and TUI command adapter
Run date: 2026-10-30T22:59:07+02:00. Baseline commit: `9a143e9`; the working tree contains uncommitted M26 source, test and layout changes below, plus coordinator-owned `PLANS.md`. Rust/Cargo: 1.97.1, native host target. Tests used synthetic fixtures, buffer rendering and PTY workflows only. No production configuration, keys, VPN, host service, Docker environment or VM was accessed.
## Integrated inputs
| Input | SHA-256 |
| --- | --- |
| `src/app.rs` | `5aeab061f45a75f3b4f4ec41b078cbecce3cbdd5a49ec2c47c0a98f062747023` |
| `src/app/read_model.rs` | `92a1945ca0ad0d4f6dc7aad255643d3d515712a1750c466738d29655f69d282d` |
| `src/app/first_delivery.rs` | `b9e80c7802b083f0ffa6e1e99898e9be9459e9eb9f3ea415b00e5592020a6ef3` |
| `src/tui/state.rs` | `da429e61bf918bf8524b7d131bc553460eb260f76f39d3763824e399764dcfac` |
| `src/tui/terminal.rs` | `9131ef9ae1e4d6f8a84715cdcdfcdd528262930dc95834991916484ff5ce4e21` |
| `src/tui/render.rs` | `3fa3b6496b792f3d7d3db16405a847c91736f2e975e5dba892d6921ca367df19` |
| `src/tui/events.rs` | `114c9505e69a1e9ec0782b149daf2ff9dcdc1a29af99abdb28df91fee70b758c` |
| `src/tui/events/executor.rs` | `4a46a8b03b1f708ab89d2e50ceceb60674c30e3bc1bcbb4c87af93b57815cd57` |
| `tests/tui_workflow.rs` | `8050c2e7887366ca6145c62fa8ded25b9dcd36f01062afa85ce85518ce64733f` |
| `docs/PROJECT_LAYOUT.md` | `06b83f3c0a26de4a7c3f8ccbaf1d37a697afb17cf67c297b69326018d144262c` |
| `target/release/wireguard-admin` | `70b45b51e2e345e1d914b1196355fde9f5688f123bed47332690d56752ca5cbe` |
No dependency, manifest or lockfile changed. The release artifact hash identifies the binary built from this integrated source; no deployment occurred.
## Checks on integrated tree
| Command | Result |
| --- | --- |
| `cargo test --locked app::` | Exit 0; 74 app tests passed. |
| `cargo test --locked tui::` | Exit 0; 88 TUI tests passed. |
| `cargo test --locked --test tui_workflow` | Exit 0; 9 tests passed. |
| `cargo test --locked --test client_delivery` | Exit 0; 5 tests passed. |
| `cargo fmt --all -- --check` | Exit 0. |
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. |
| `cargo test --locked` | Exit 0; library 352 passed, 2 ignored; integration and doc tests passed. Opt-in Alpine scenarios stayed ignored. |
| `cargo build --locked --release` | Exit 0. |
| `git diff --check` | Exit 0. |
## Acceptance mapping and review
- **M26-A1:** `Application::read_model` owns the secret-free peer rows, saved peer count, draft/saved public client changes, issue classes, reservations, applied knowledge, vault/draft flags and per-action eligibility. `SessionView::from_application` consumes those facts and maps them into compatibility UI fields without accessing saved server bytes or `ClientStates`. Malformed, duplicate and no-key draft peer rows remain available for inspection, and removed saved peers outside manageable `ClientStates` remain visible.
- **M26-A2:** Selection binds action presentation to public identity and peer index. App eligibility separates rename, deletion, first delivery, reissue and restart; execution still checks its own preconditions. Exact-host-route deletion remains available even for a peer outside the manageable client projection, and the removed row is inspection-only. First-delivery session availability is queried without secret disclosure. Unrelated saved-document issues do not suppress a valid selected reissue; selected-peer issues and vault conflicts do.
- **M26-A3:** TUI, workflow and delivery suites cover worker generations, stale continuations and statistics, shutdown, QR, selection, list/help/footer buffers and synthetic PTY behavior. Existing dimensioned tests include 34x8, 40x9, 60x16, 80x24, 100x30 and 180x50 cases. No assertions were weakened to pass the refactor.
- **M26-A4:** `src/tui/events/executor.rs` owns command adaptation and prepared destination/restore contexts; `events.rs` keeps the single-owner worker queue and generation lifecycle. `docs/PROJECT_LAYOUT.md` matches the integrated modules. Broader terminal/render splitting was deferred because it would broaden visibility across shared lifecycle and layout helpers without a cohesive ownership gain in M26.
Fresh independent reviewer `/root/m26_review` reviewed the integrated diff. M26-R1 (removed unprojectable peer omitted) and M26-R2 (unrelated issue overblocked reissue) were fixed by repairer `/root/m26_repair`. Coordinator found M26-R3 (false Removed row for unchanged saved client) in the repair; it was fixed with a one-row regression. Reviewer found M26-R4 (reissue shown for selected capability-issue peer); the repair aligned eligibility with delivery validation and added a failing-before/passing-after regression. The reviewer independently re-reviewed and closed R1–R4 with no remaining source finding, then verified all 11 evidence hashes, reran the full test suite, app tests and release build, and found no evidence issue.
This is host/synthetic evidence only. No fresh Alpine Testcontainers scenario or disposable systemd VM test was run; prior Alpine results do not identify this M26 source, and Alpine does not prove systemd behavior. Real systemd/D-Bus and live IPv6 behavior remain unverified. The retired M9 real-system limitation remains outside M26.
+54
View File
@@ -0,0 +1,54 @@
# Milestone 27 evidence — integrated refactoring handoff
Recorded 2026-10-30T23:18:12+02:00. Baseline HEAD: `7123197ec4a3fad74b71f5c7b598f1d6fd74d3a1`, initially clean. Final working tree is uncommitted and changes `CONTEXT.md`, `PLANS.md`, `docs/PROJECT_LAYOUT.md`, `docs/architecture/domain-refactoring.md`, `src/app.rs`, `src/app/backups.rs`, `src/app/first_file.rs`, `src/app/restart.rs`, `src/runtime/netlink.rs`, and `src/service.rs`. No Cargo manifest, lockfile, dependency, feature, persisted codec, archive, vault, settings-default, WireGuard editor, or fixture file changed in M27. Rust/Cargo 1.97.1, rustfmt 1.9.0, Clippy 0.1.97, native host target. All tests below used synthetic fixtures/PTY only; no production configuration, keys, VPN, host service, Docker daemon, or VM was accessed.
## Final inputs
| Input | SHA-256 |
| --- | --- |
| `CONTEXT.md` | `7dcfa4d86f70fb850f3416d6659275ee28237b5075c5beed82d2a33ad44150a4` |
| `docs/PROJECT_LAYOUT.md` | `f973207e71f20c6c912105c16546a51150acf63551a7f196ecd38d2101e9a44b` |
| `docs/architecture/domain-refactoring.md` | `45ad18a43b224f318b0a7e55548d47f7ccea8e2e719e3378ef35f419e3ec8391` |
| `src/app.rs` | `e235ff788c1b5f1ac0ac9951a9c4bb578205dfc65903d3ae80ed9f3142fb38da` |
| `src/app/backups.rs` | `b3b01289b20e262c24cfce79c507fe4d3761102df4863a11b3a90b2398b0692f` |
| `src/app/first_file.rs` | `d41fa3e2d2150c401ceee9caaeb7268b741a970105e5266123334cd78d977073` |
| `src/app/restart.rs` | `5d26cb877fd8a8211549e15868ccd450729ce3ecb54ea954efd15b379f5a2ce0` |
| `src/runtime/netlink.rs` | `396f4726880c7cf54764d6ff0c67aa1ab2d6c66cd36af7b1be4ca0e04862bab3` |
| `src/service.rs` | `e7c7a4367e2a0dc8a87113e3083d8998e7aebb56f6d4c32d5c763b9f239fa8eb` |
| `target/release/wireguard-admin` | `cb6e995d64a7cb7537c5fb17e86d609ca62fff150f519a970febd0800b0d4177` |
Source-input fingerprint from `rg --files src tests/support tests/containers tests/testcontainers_wireguard.rs Cargo.toml Cargo.lock | sort | xargs sha256sum | sha256sum`: `1b746e31e2e8c96f511c34b51e417b486d65fa0cc17983fa15e12882dd9c6a02`. This includes the final test-only backup visibility repair and identifies the host-tested source independently of the dirty plan/evidence files.
## Architecture and preservation audit
M19's baseline source was recorded against `23d6180f2478f636c185d67b8bb3788d21656ce3`. The coordinator inspected `git diff --stat` from that commit through M26 HEAD and the changed production/format-sensitive file list; that diff spans the M20–26 application/domain/TUI moves and changes only persistence test references, not its codec. `Cargo.toml`, `Cargo.lock`, settings defaults, backup/vault/byte-preserving editor implementation, and synthetic fixture files did not change in that baseline-to-M26 diff. The independent reviewer inspected the integrated refactor against the M19 map, current source and shared preservation matrix, including authority, public identity, unsupported/ambiguous peers, original imports, retained plaintext discipline, short-lived delivery secrets, saved/runtime separation, restart uncertainty, address release, TUI eligibility/generation and public visibility. No unresolved behavior, authorization, or secret-handling regression was found. Historical M19 restore-reservation gap was closed by M23 and is now mapped to actual tests and owner.
The architecture document now gives the three requested end-to-end reading paths with entry point, rule owner, side-effect boundary and failure tests, and resolves each M19 owner/transition to actual M20–26 modules/tests. The glossary remains domain-only and the layout lists present files. `ClientStates::draft_mut` and `Application::vault_draft_mut` were already removed. `Application::open`, `add_client`, and `display_projection` have active unit/integration or test-only container callers and remain with explicit caller/retention rationale; production startup/create uses the intended methods. A fixture-only pre-restart backup facade and its helper were restricted to private `#[cfg(test)]` code. Stale comments describing implemented confirmation, D-Bus and netlink flows as future work were corrected. No test assertion was weakened.
Existing preserved-format and behavior coverage includes the focused `config_preservation`, `commit_restore`, `client_delivery`, `runtime_restart`, `vault_import`, and `tui_workflow` suites below, plus persistence, archive, vault, domain, service and application tests in the full suite. Old fixture inputs still load with unchanged meaning under these host checks; this is synthetic compatibility evidence, not a new real-system run.
## Integrated checks
All commands ran in the repository root after the final source repair. Exit 0 unless specified.
| Command | Actual result |
| --- | --- |
| `cargo test --locked --test config_preservation` | 10 passed, 0 failed |
| `cargo test --locked --test commit_restore` | 9 passed, 0 failed |
| `cargo test --locked --test client_delivery` | 5 passed, 0 failed |
| `cargo test --locked --test runtime_restart` | 3 passed, 0 failed |
| `cargo test --locked --test vault_import` | 5 passed, 0 failed |
| `cargo test --locked --test tui_workflow` | 9 passed, 0 failed |
| `cargo fmt --all -- --check` | Exit 0 |
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0 |
| `cargo test --locked` | Exit 0; library 352 passed, 2 ignored; integration and 2 doc tests passed; opt-in Alpine tests stayed ignored |
| `cargo build --locked --release` | Exit 0; artifact hash above |
| `git diff --check` | Exit 0 |
The first final-gate attempt after making the fixture facade test-only found Clippy dead code in its child helper. The repair made the child helper test-only too; the entire table above was rerun on that final source. The initial failed attempt is diagnostic history, not a passing gate.
## Independent review and limits
Fresh reviewer `/root/m27_review` was separate from implementer `/root/m27_implement`; repairer `/root/m27_repair` made bounded fixes. R1 (missing `display_projection` caller audit), R2 (stale plan resume point), R3 (future-work source comments), and R4 (fixture-only public backup API) were repaired and re-reviewed. R4's first repair exposed the Clippy dead-code helper; the second repair and final gates closed it. The reviewer independently verified the final source, fingerprint and binary hashes, reran formatting, Clippy, full tests, release build and diff check, and confirmed M27-A4 plan/evidence consistency after the register correction. No scoped review finding remains.
M8's last Alpine result used fingerprint `b28dc6e4cb92aa6edf384d966ba5952a8d6d0efe4c22d6a804b67fb2c0297da2`; today's final source fingerprint differs. The M8 run is historical and **does not validate this source**. No explicitly selected current disposable daemon/driver was provided for M27, so Alpine revalidation is incomplete and no Alpine pass is claimed. The retired M9 disposable-VM/systemd checks remain unverified; Alpine would not prove actual systemd/D-Bus behavior. Real IPv6 capability/traffic and release readiness are also unverified. M27 acceptance is limited to host/synthetic scope.
+1112 -1632
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
//! Application-owned backup listing and creation scenarios.
use crate::backup::{self, ArchiveSummary, BackupReason};
use super::{AppError, Application, historical_apply_from_saved};
pub(super) fn create_backup(
application: &Application,
reason: BackupReason,
) -> Result<std::path::PathBuf, AppError> {
let installed = application.store.read_saved()?;
let historical_apply = historical_apply_from_saved(&installed)?;
Ok(backup::create_archive(
application.store.archives_directory(),
reason,
historical_apply,
&installed,
)?)
}
pub(super) fn list_backups(application: &Application) -> Result<Vec<ArchiveSummary>, AppError> {
match backup::list_archives(application.store.archives_directory()) {
Ok(archives) => Ok(archives),
Err(crate::backup::BackupError::Io(error))
if error.kind() == std::io::ErrorKind::NotFound =>
{
Ok(Vec::new())
}
Err(error) => Err(error.into()),
}
}
#[cfg(test)]
pub(super) fn create_pre_restart_backup(
application: &mut Application,
) -> Result<std::path::PathBuf, AppError> {
application.active_restart_attempt = None;
create_backup(application, BackupReason::PreRestart)
}
+141
View File
@@ -0,0 +1,141 @@
//! Saved-state Commit scenario.
//!
//! The ordering is deliberately explicit: prepare and validate the complete
//! candidate, archive the installed generation, install it through the
//! journaled store, then publish only already-prepared in-memory values.
use std::net::IpAddr;
use crate::backup::{BackupError, BackupReason, HistoricalApplyStatus, SavedBytes};
use crate::domain::ClientStates;
use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore};
use crate::vault::VaultBundle;
use crate::wireguard::ConfigDocument;
use super::{AppError, Application, historical_apply_from_saved};
struct PreparedCommit {
proposed: SavedBytes,
document: ConfigDocument,
states: ClientStates,
}
impl Application {
/// Prepare a candidate without changing the live session or installed
/// files. All parsing, serialization and store validation finish here.
fn prepare_commit(
&self,
installed: &SavedBytes,
) -> Result<(SavedBytes, ConfigDocument, ClientStates), AppError> {
if let Some(existing_bytes) = &installed.vault {
let existing_text =
std::str::from_utf8(existing_bytes).map_err(|_| AppError::InvalidSavedState)?;
let existing = VaultBundle::from_toml(existing_text)?;
if self
.draft
.vault
.as_ref()
.is_none_or(|vault| existing.recipient() != vault.recipient())
{
return Err(AppError::VaultIdentityChange);
}
}
let mut states = self.draft.states.clone();
states.commit_draft();
let reservations = states
.reserved_addresses()
.iter()
.map(IpAddr::to_string)
.collect::<Vec<_>>();
let internal_state = Some(PersistedState::new(reservations, None)?.to_toml()?);
let vault = self
.draft
.vault
.as_ref()
.map(VaultBundle::to_toml)
.transpose()?
.map(String::into_bytes);
let proposed = SavedBytes {
settings: installed.settings.clone(),
server_config: self.draft.document.render().to_vec(),
internal_state,
vault,
};
self.store.validate_saved(&proposed)?;
// Reuse this parsed document after install; publication must not have a
// fallible parse step after the filesystem has already advanced.
let document = ConfigDocument::parse(&proposed.server_config)?;
Ok((proposed, document, states))
}
}
pub(super) fn commit(
application: &mut Application,
create_backup: impl FnOnce(
&std::path::Path,
BackupReason,
HistoricalApplyStatus,
&SavedBytes,
) -> Result<std::path::PathBuf, BackupError>,
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
) -> Result<std::path::PathBuf, AppError> {
let installed = application.store.read_saved()?;
let historical_apply = historical_apply_from_saved(&installed)?;
let (proposed, document, states) = application.prepare_commit(&installed)?;
let prepared = PreparedCommit {
proposed,
document,
states,
};
// Passing BackupComplete is the only path to installation, and this point
// is reached only after the full archive has been published successfully.
let archive = create_backup(
application.store.archives_directory(),
BackupReason::PreCommit,
historical_apply,
&installed,
)?;
if let Err(error) = install(&application.store, &prepared.proposed, BackupComplete(())) {
retain_first_issue_rights_after_failed_install(application);
return Err(error.into());
}
application.saved = prepared.proposed;
application.draft.states = prepared.states;
application.draft.document = prepared.document;
application
.first_delivery
.invalidate_active_authorizations();
application.first_delivery.promote_matching(
&application.draft.document,
application.draft.vault.as_ref(),
);
application.historical_apply = HistoricalApplyStatus::Unknown;
application.verified_saved_projection = None;
application.active_restart_attempt = None;
Ok(archive)
}
fn retain_first_issue_rights_after_failed_install(application: &mut Application) {
match application.store.read_saved() {
Ok(current) => {
let document = ConfigDocument::parse(&current.server_config).ok();
let vault = current
.vault
.as_deref()
.and_then(|bytes| std::str::from_utf8(bytes).ok())
.and_then(|text| VaultBundle::from_toml(text).ok());
if let Some(document) = document {
application
.first_delivery
.retain_matching(&document, vault.as_ref());
} else {
application.first_delivery.clear();
}
}
Err(_) => application.first_delivery.clear(),
}
}
+89
View File
@@ -0,0 +1,89 @@
//! Coordinated in-memory draft state for one application session.
//!
//! This owner groups the authoritative lossless document with its restricted
//! public-client projection, encrypted vault draft, and draft-only address
//! reservations (held inside `ClientStates`). It performs no I/O or
//! cryptographic operations itself.
use crate::domain::ClientStates;
use crate::vault::VaultBundle;
use crate::wireguard::ConfigDocument;
pub(super) struct DraftChanges {
pub(super) document: ConfigDocument,
pub(super) states: ClientStates,
pub(super) vault: Option<VaultBundle>,
}
/// Fully prepared replacement for selected coordinated in-memory draft parts.
///
/// Callers build this only after all fallible editing, projection, and vault
/// work has succeeded. Publishing it is a narrow, infallible operation and
/// never writes saved files or changes runtime state.
pub(super) struct StagedDraftReplacement {
document: Option<ConfigDocument>,
states: Option<ClientStates>,
vault: VaultChange,
}
enum VaultChange {
Keep,
Replace(Option<VaultBundle>),
}
impl DraftChanges {
pub(super) fn new(
document: ConfigDocument,
states: ClientStates,
vault: Option<VaultBundle>,
) -> Self {
Self {
document,
states,
vault,
}
}
pub(super) fn stage_clients(
document: ConfigDocument,
states: ClientStates,
) -> StagedDraftReplacement {
StagedDraftReplacement {
document: Some(document),
states: Some(states),
vault: VaultChange::Keep,
}
}
pub(super) fn stage_clients_and_vault(
document: ConfigDocument,
states: ClientStates,
vault: Option<VaultBundle>,
) -> StagedDraftReplacement {
StagedDraftReplacement {
document: Some(document),
states: Some(states),
vault: VaultChange::Replace(vault),
}
}
pub(super) fn stage_vault(vault: Option<VaultBundle>) -> StagedDraftReplacement {
StagedDraftReplacement {
document: None,
states: None,
vault: VaultChange::Replace(vault),
}
}
pub(super) fn publish_replacement(&mut self, replacement: StagedDraftReplacement) {
if let Some(document) = replacement.document {
self.document = document;
}
if let Some(states) = replacement.states {
self.states = states;
}
if let VaultChange::Replace(vault) = replacement.vault {
self.vault = vault;
}
}
}
+359
View File
@@ -0,0 +1,359 @@
//! Session-local ownership of password-free first-delivery eligibility.
//!
//! The session keeps each generated private key beside the saved peer and
//! encrypted-record context that authorizes its single first-file publication.
use std::collections::BTreeMap;
use crate::delivery::ClientConfigPayload;
use crate::domain::PublicKey;
use crate::vault::{VaultBundle, WireGuardPrivateKey};
use crate::wireguard::ConfigDocument;
use super::{AppError, FirstIssueAttempt, FirstIssueDeliveryFailure, FirstIssueRetry};
#[derive(Clone, Eq, PartialEq)]
pub(super) struct IssueContext {
peer_directives: String,
encrypted_record: String,
}
pub(super) struct FirstDeliverySession {
next_authorization: u64,
issues: BTreeMap<PublicKey, InitialIssue>,
}
struct InitialIssue {
private_key: WireGuardPrivateKey,
context: IssueContext,
stage: IssueStage,
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum IssueStage {
PendingDraft,
AwaitingFirstFile,
RetryAvailable,
Authorizing(u64),
RetryNeedsOverwriteConfirmation,
RetryNeedsManualCleanup,
}
/// Read-only first-file availability with no access to the held private key,
/// issue context, or payload.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum SessionFirstDeliveryAvailability {
InitialFile,
Retry,
RetryNeedsOverwriteConfirmation,
RetryNeedsManualCleanup,
Unavailable,
}
pub(super) struct FirstIssueAuthorization {
identity: PublicKey,
context: IssueContext,
token: u64,
payload: ClientConfigPayload,
}
impl FirstIssueAuthorization {
pub(super) fn payload(&self) -> &ClientConfigPayload {
&self.payload
}
}
impl FirstDeliverySession {
pub(super) fn new() -> Self {
Self {
next_authorization: 1,
issues: BTreeMap::new(),
}
}
pub(super) fn len(&self) -> usize {
self.issues.len()
}
pub(super) fn read_model_availability(
&self,
identity: PublicKey,
document: &ConfigDocument,
vault: Option<&VaultBundle>,
) -> SessionFirstDeliveryAvailability {
let Some(issue) = self.issues.get(&identity) else {
return SessionFirstDeliveryAvailability::Unavailable;
};
let matching = vault
.and_then(|vault| context_for(document, vault, identity).ok())
.is_some_and(|actual| actual == issue.context);
if !matching {
return SessionFirstDeliveryAvailability::Unavailable;
}
match issue.stage {
IssueStage::AwaitingFirstFile => SessionFirstDeliveryAvailability::InitialFile,
IssueStage::RetryAvailable => SessionFirstDeliveryAvailability::Retry,
IssueStage::RetryNeedsOverwriteConfirmation => {
SessionFirstDeliveryAvailability::RetryNeedsOverwriteConfirmation
}
IssueStage::RetryNeedsManualCleanup => {
SessionFirstDeliveryAvailability::RetryNeedsManualCleanup
}
IssueStage::PendingDraft | IssueStage::Authorizing(_) => {
SessionFirstDeliveryAvailability::Unavailable
}
}
}
#[cfg(test)]
pub(super) fn is_empty(&self) -> bool {
self.issues.is_empty()
}
#[cfg(test)]
pub(super) fn contains(&self, identity: &PublicKey) -> bool {
self.issues.contains_key(identity)
}
pub(super) fn remember_pending(
&mut self,
identity: PublicKey,
private_key: WireGuardPrivateKey,
context: IssueContext,
) {
self.issues.insert(
identity,
InitialIssue {
private_key,
context,
stage: IssueStage::PendingDraft,
},
);
}
pub(super) fn remove(&mut self, identity: &PublicKey) {
self.issues.remove(identity);
}
pub(super) fn clear(&mut self) {
self.issues.clear();
}
/// In-flight output can no longer be retried silently after a draft or
/// runtime transition. Keep unrelated clients' rights intact.
pub(super) fn invalidate_active_authorizations(&mut self) {
for issue in self.issues.values_mut() {
if matches!(issue.stage, IssueStage::Authorizing(_)) {
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
}
}
}
/// Keep only already committed first-file rights when a draft is dropped.
pub(super) fn discard_draft(&mut self, document: &ConfigDocument, vault: Option<&VaultBundle>) {
self.issues
.retain(|_, issue| issue.stage == IssueStage::AwaitingFirstFile);
self.retain_matching(document, vault);
}
pub(super) fn retain_matching(
&mut self,
document: &ConfigDocument,
vault: Option<&VaultBundle>,
) {
self.issues.retain(|identity, issue| {
if issue.stage == IssueStage::PendingDraft {
return true;
}
vault
.and_then(|vault| context_for(document, vault, *identity).ok())
.is_some_and(|actual| actual == issue.context)
});
}
pub(super) fn promote_matching(
&mut self,
document: &ConfigDocument,
vault: Option<&VaultBundle>,
) {
self.issues.retain(|identity, issue| {
let matches = vault
.and_then(|vault| context_for(document, vault, *identity).ok())
.is_some_and(|actual| actual == issue.context);
if matches && issue.stage == IssueStage::PendingDraft {
issue.stage = IssueStage::AwaitingFirstFile;
}
matches
});
}
pub(super) fn begin_attempt(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
document: &ConfigDocument,
vault: Option<&VaultBundle>,
render: impl FnOnce(&WireGuardPrivateKey) -> Result<ClientConfigPayload, AppError>,
) -> Result<FirstIssueAuthorization, AppError> {
self.retain_matching(document, vault);
let (is_retry, confirm_possible_overwrite, confirm_manual_cleanup) = match attempt {
FirstIssueAttempt::Initial => (false, false, false),
FirstIssueAttempt::Retry {
confirm_possible_overwrite,
confirm_manual_cleanup,
} => (true, confirm_possible_overwrite, confirm_manual_cleanup),
};
let issue = self
.issues
.get(&identity)
.ok_or(AppError::FirstIssueUnavailable)?;
let allowed = match issue.stage {
IssueStage::AwaitingFirstFile => !is_retry,
IssueStage::RetryAvailable => is_retry,
IssueStage::RetryNeedsOverwriteConfirmation => is_retry && confirm_possible_overwrite,
IssueStage::RetryNeedsManualCleanup => is_retry && confirm_manual_cleanup,
IssueStage::PendingDraft | IssueStage::Authorizing(_) => false,
};
if !allowed {
return Err(AppError::FirstIssueUnavailable);
}
let current_context = context_for(
document,
vault.ok_or(AppError::FirstIssueUnavailable)?,
identity,
)?;
if current_context != issue.context {
self.issues.remove(&identity);
return Err(AppError::FirstIssueUnavailable);
}
let payload = render(&issue.private_key)?;
let context = issue.context.clone();
let token = self.next_authorization;
let next_token = token
.checked_add(1)
.ok_or(AppError::IssueAuthorizationExhausted)?;
let issue = self
.issues
.get_mut(&identity)
.ok_or(AppError::FirstIssueUnavailable)?;
self.next_authorization = next_token;
issue.stage = IssueStage::Authorizing(token);
Ok(FirstIssueAuthorization {
identity,
context,
token,
payload,
})
}
pub(super) fn validate_authorization(
&mut self,
authorization: &FirstIssueAuthorization,
document: &ConfigDocument,
vault: Option<&VaultBundle>,
) -> Result<(), AppError> {
self.retain_matching(document, vault);
let current = context_for(
document,
vault.ok_or(AppError::FirstIssueUnavailable)?,
authorization.identity,
)?;
if current != authorization.context
|| !matches!(
self.issues.get(&authorization.identity),
Some(issue)
if issue.stage == IssueStage::Authorizing(authorization.token)
&& issue.context == authorization.context
)
{
return Err(AppError::FirstIssueUnavailable);
}
Ok(())
}
pub(super) fn require_overwrite_confirmation(&mut self, identity: PublicKey) {
if let Some(issue) = self.issues.get_mut(&identity)
&& matches!(issue.stage, IssueStage::Authorizing(_))
{
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
}
}
pub(super) fn complete_publication(
&mut self,
authorization: FirstIssueAuthorization,
document: &ConfigDocument,
vault: Option<&VaultBundle>,
) -> Result<(), AppError> {
self.validate_authorization(&authorization, document, vault)?;
self.issues.remove(&authorization.identity);
Ok(())
}
pub(super) fn fail_publication(
&mut self,
authorization: FirstIssueAuthorization,
failure: FirstIssueDeliveryFailure,
document: &ConfigDocument,
vault: Option<&VaultBundle>,
) -> Result<FirstIssueRetry, AppError> {
self.validate_authorization(&authorization, document, vault)?;
let issue = self
.issues
.get_mut(&authorization.identity)
.ok_or(AppError::FirstIssueUnavailable)?;
let retry = match failure {
FirstIssueDeliveryFailure::BeforePublication => {
issue.stage = IssueStage::RetryAvailable;
FirstIssueRetry::Available
}
FirstIssueDeliveryFailure::PublicationUncertain => {
issue.stage = IssueStage::RetryNeedsOverwriteConfirmation;
FirstIssueRetry::OverwriteConfirmationRequired
}
FirstIssueDeliveryFailure::CleanupRequired => {
issue.stage = IssueStage::RetryNeedsManualCleanup;
FirstIssueRetry::ManualCleanupRequired
}
};
Ok(retry)
}
}
pub(super) fn context_for(
document: &ConfigDocument,
vault: &VaultBundle,
identity: PublicKey,
) -> Result<IssueContext, AppError> {
let matching = document
.peers()
.iter()
.filter(|peer| {
peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity)
})
.collect::<Vec<_>>();
let [peer] = matching.as_slice() else {
return Err(AppError::FirstIssueUnavailable);
};
if peer.identity != Some(identity) || !peer.issues.is_empty() {
return Err(AppError::FirstIssueUnavailable);
}
// Alias/admin comments are descriptive metadata; only WireGuard directives
// bind first-issue authority, so renames do not invalidate the creation.
let peer_directives = document
.source_slice(peer.section)
.ok_or(AppError::FirstIssueUnavailable)?
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with(['#', ';']))
.collect::<Vec<_>>()
.join("\n");
let encrypted_record = vault
.record_context(identity)
.ok_or(AppError::FirstIssueUnavailable)?
.to_owned();
Ok(IssueContext {
peer_directives,
encrypted_record,
})
}
+183
View File
@@ -0,0 +1,183 @@
//! Password-free first-file delivery after Commit, with optional QR disclosure.
use super::{
AppError, Application, FirstIssueAttempt, FirstIssueDeliveryFailure, FirstIssueDeliveryOutcome,
FirstIssueFileAndQr, FirstIssueFileDelivery,
};
use crate::delivery::files::{self, FileOutputError, PreparedDestination};
use crate::delivery::qr;
use crate::delivery::{ClientConfigPayload, QrCodeOutput, QrError};
use crate::domain::PublicKey;
impl Application {
/// Publish a password-free first issue while the committed peer, encrypted
/// record, and one-use token are valid. The mutable app borrow remains held
/// across `publish`, so no application mutation can interleave after the
/// final saved-state validation and before the file adapter runs.
#[allow(dead_code)] // Retained as a unit-test seam for generic publication closures.
pub(crate) fn deliver_first_issue<T, E>(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
publish: impl FnOnce(&ClientConfigPayload) -> Result<T, (E, FirstIssueDeliveryFailure)>,
) -> Result<FirstIssueDeliveryOutcome<T, E>, AppError> {
self.deliver_first_issue_with_warning(identity, attempt, |payload, _| publish(payload))
}
pub(super) fn deliver_first_issue_with_warning<T, E>(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
publish: impl FnOnce(&ClientConfigPayload, bool) -> Result<T, (E, FirstIssueDeliveryFailure)>,
) -> Result<FirstIssueDeliveryOutcome<T, E>, AppError> {
let authorization = self.authorize_first_issue_inner(identity, attempt)?;
let activation_warning = match self.validate_first_issue_authorization(&authorization) {
Ok(warning) => warning,
Err(error) => {
self.require_overwrite_confirmation(identity);
return Err(error);
}
};
match publish(authorization.payload(), activation_warning) {
Ok(value) => {
if let Err(error) = self.mark_first_issue_delivered(authorization) {
// Publication is definite. Drop the session-held key even
// when saved-state bookkeeping cannot be completed.
self.first_delivery.remove(&identity);
return Ok(
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
value,
error,
},
);
}
Ok(FirstIssueDeliveryOutcome::Delivered(value))
}
Err((error, failure)) => {
let retry = match self.mark_first_issue_delivery_failed(authorization, failure) {
Ok(retry) => retry,
Err(error) => {
self.require_overwrite_confirmation(identity);
return Err(error);
}
};
Ok(FirstIssueDeliveryOutcome::Failed {
error,
retry,
activation_warning,
})
}
}
}
/// Prepare a caller-selected plaintext destination without writing it. The
/// returned destination records any existing regular file so a later
/// overwrite confirmation is bound to this exact selection.
pub fn prepare_first_issue_destination(
&self,
path: impl AsRef<std::path::Path>,
) -> Result<PreparedDestination, FileOutputError> {
let protection = self.store.delivery_output_protection()?;
files::prepare_destination(path, &protection)
}
/// Deliver the committed new-client configuration through the app-owned
/// one-use authorization closure. A failed write retains retry eligibility;
/// a post-publication sync failure requires an explicit overwrite retry.
pub fn deliver_first_issue_file(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
destination: PreparedDestination,
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileDelivery, FileOutputError>, AppError> {
self.deliver_first_issue_file_with(identity, attempt, destination, files::publish)
}
pub(super) fn deliver_first_issue_file_with(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
destination: PreparedDestination,
publish_file: impl FnOnce(
PreparedDestination,
&ClientConfigPayload,
) -> Result<(), FileOutputError>,
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileDelivery, FileOutputError>, AppError> {
self.deliver_first_issue_with_warning(identity, attempt, |payload, activation_warning| {
publish_file(destination, payload).map_err(|error| {
let failure = match error {
FileOutputError::PublicationUncertain => {
FirstIssueDeliveryFailure::PublicationUncertain
}
FileOutputError::ManualCleanupRequired => {
FirstIssueDeliveryFailure::CleanupRequired
}
_ => FirstIssueDeliveryFailure::BeforePublication,
};
(error, failure)
})?;
Ok(FirstIssueFileDelivery { activation_warning })
})
}
/// Publish the mandatory plaintext file, then optionally encode a QR from
/// that same authorized payload. QR failure is returned separately and the
/// successful file publication still consumes first-issue eligibility.
pub fn deliver_first_issue_file_and_qr(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
destination: PreparedDestination,
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
self.deliver_first_issue_file_and_qr_with(identity, attempt, destination, |payload| {
qr::encode_payload(payload)
})
}
pub(super) fn deliver_first_issue_file_and_qr_with(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
destination: PreparedDestination,
encode: impl FnOnce(&ClientConfigPayload) -> Result<QrCodeOutput, QrError>,
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
self.deliver_first_issue_file_and_qr_with_publisher(
identity,
attempt,
destination,
files::publish,
encode,
)
}
pub(super) fn deliver_first_issue_file_and_qr_with_publisher(
&mut self,
identity: PublicKey,
attempt: FirstIssueAttempt,
destination: PreparedDestination,
publish_file: impl FnOnce(
PreparedDestination,
&ClientConfigPayload,
) -> Result<(), FileOutputError>,
encode: impl FnOnce(&ClientConfigPayload) -> Result<QrCodeOutput, QrError>,
) -> Result<FirstIssueDeliveryOutcome<FirstIssueFileAndQr, FileOutputError>, AppError> {
self.deliver_first_issue_with_warning(identity, attempt, |payload, activation_warning| {
publish_file(destination, payload).map_err(|error| {
let failure = match error {
FileOutputError::PublicationUncertain => {
FirstIssueDeliveryFailure::PublicationUncertain
}
FileOutputError::ManualCleanupRequired => {
FirstIssueDeliveryFailure::CleanupRequired
}
_ => FirstIssueDeliveryFailure::BeforePublication,
};
(error, failure)
})?;
Ok(FirstIssueFileAndQr {
activation_warning,
qr: encode(payload),
})
})
}
}
+585
View File
@@ -0,0 +1,585 @@
//! Secret-free application facts prepared for the terminal and other readers.
use std::net::IpAddr;
use crate::domain::{ApplyStatus, Client, PublicKey};
use crate::wireguard::{ConfigDocument, PeerInspection, PeerIssue};
use super::Application;
use super::first_delivery::SessionFirstDeliveryAvailability;
/// A presentation action is available only as far as the current application
/// facts can establish. The operation itself remains the final authority.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ActionEligibility {
pub available: bool,
pub reason: Option<ActionUnavailableReason>,
}
impl ActionEligibility {
fn available() -> Self {
Self {
available: true,
reason: None,
}
}
fn unavailable(reason: ActionUnavailableReason) -> Self {
Self {
available: false,
reason: Some(reason),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ActionUnavailableReason {
NoUniquePeer,
PeerNotInDraft,
PeerNotInSavedState,
UnsafePeerOrDocument,
ExactHostRoutesRequired,
VaultNotInitialized,
VaultRecordMissing,
VaultRecordConflict,
FirstDeliveryNotAvailable,
RestartPreviewRejected,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FirstDeliveryEligibility {
InitialFile,
Retry,
RetryNeedsOverwriteConfirmation,
RetryNeedsManualCleanup,
Unavailable,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientActions {
pub rename: ActionEligibility,
pub delete: ActionEligibility,
pub first_delivery: FirstDeliveryEligibility,
pub reissue: ActionEligibility,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PeerIssueClass {
MalformedOrAmbiguous,
UnsupportedCapability,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClassifiedPeerIssue {
pub issue: PeerIssue,
pub class: PeerIssueClass,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClientStateChange {
Added,
Removed,
Changed,
Unchanged,
UnsupportedOrUnmanaged,
}
/// Compatibility row for the original `display_projection` API.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientDisplay {
pub peer_index: usize,
pub line: usize,
pub identity: Option<PublicKey>,
pub label: String,
pub allowed_ips: Vec<String>,
pub has_subnet_routes: bool,
pub issues: Vec<PeerIssue>,
pub mutable_identity: Option<PublicKey>,
}
impl ClientDisplay {
/// Whether an issue is a valid configuration shape outside the current
/// client model's capabilities.
pub fn is_capability_limitation_issue(&self, issue: &PeerIssue) -> bool {
classify_issue(issue) == PeerIssueClass::UnsupportedCapability
}
pub fn presented_issues(&self) -> impl Iterator<Item = &PeerIssue> {
self.issues
.iter()
.filter(|issue| !self.is_capability_limitation_issue(issue))
}
}
/// Secret-free authoritative peer record used by the application read model.
/// Saved-only rows have no draft peer index and retain the former saved index.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerRecord {
pub peer_index: Option<usize>,
pub draft_peer_index: Option<usize>,
pub saved_peer_index: Option<usize>,
pub line: usize,
pub identity: Option<PublicKey>,
pub label: String,
pub allowed_ips: Vec<String>,
pub has_subnet_routes: bool,
pub issues: Vec<PeerIssue>,
pub classified_issues: Vec<ClassifiedPeerIssue>,
pub mutable_identity: Option<PublicKey>,
pub draft_client: Option<Client>,
pub saved_client: Option<Client>,
pub state_change: ClientStateChange,
pub actions: ClientActions,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ReservationFacts {
pub committed: Vec<IpAddr>,
pub pending_host_routes: Vec<IpAddr>,
}
/// Compatibility projection for existing TUI callers. New consumers should
/// use [`ApplicationView`] for saved-state and action facts.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SessionDisplay {
pub clients: Vec<ClientDisplay>,
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
}
/// Public projection of application facts. It intentionally has no document,
/// saved-byte, vault, password, or client-payload owner.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ApplicationView {
pub clients: Vec<PeerRecord>,
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
pub saved_peer_count: usize,
pub draft_client_count: usize,
pub saved_client_count: usize,
pub reservations: ReservationFacts,
pub applied: ApplyStatus,
pub vault_initialized: bool,
pub has_draft: bool,
pub restart: ActionEligibility,
}
pub(super) fn legacy_session_display(application: &Application) -> SessionDisplay {
SessionDisplay {
clients: application
.draft
.document
.peers()
.iter()
.map(|peer| {
let projected = peer
.identity
.and_then(|identity| application.draft.document.project_client(&identity).ok());
let label = match &peer.metadata {
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
Some(metadata.alias.clone())
}
_ => None,
}
.unwrap_or_else(|| {
peer.identity.map_or_else(
|| format!("Peer at line {}", peer.section_line),
|key| format!("{}…", key.to_string().chars().take(8).collect::<String>()),
)
});
ClientDisplay {
peer_index: peer.index,
line: peer.section_line,
identity: peer.identity,
label,
allowed_ips: peer.allowed_ips.iter().map(ToString::to_string).collect(),
has_subnet_routes: peer.has_subnet_routes,
issues: peer.issues.clone(),
mutable_identity: projected
.filter(|client| {
application
.draft
.states
.draft()
.get(&client.identity)
.is_some()
})
.map(|client| client.identity),
}
})
.collect(),
document_issues: application.draft.document.issues().to_vec(),
}
}
pub(super) fn session_display(application: &Application) -> ApplicationView {
let draft_document = &application.draft.document;
// The saved document was parsed when the session opened and every saved
// replacement is parsed before publication. Keep this projection total if
// that internal invariant is ever violated: no saved records are inferred.
let saved_document = ConfigDocument::parse(&application.saved.server_config).ok();
let saved_peer_count = saved_document.as_ref().map_or(0, |doc| doc.peers().len());
let mut clients = draft_document
.peers()
.iter()
.map(|peer| display_peer(application, peer, saved_document.as_ref()))
.collect::<Vec<_>>();
let draft_peer_identities = draft_document
.peers()
.iter()
.flat_map(|peer| peer.public_key_candidates.iter().copied())
.collect::<std::collections::BTreeSet<_>>();
let mut saved_only_identities = std::collections::BTreeSet::new();
if let Some(document) = saved_document.as_ref() {
for saved_peer in document.peers() {
let Some(identity) = saved_peer.identity else {
continue;
};
if draft_peer_identities.contains(&identity)
|| !saved_only_identities.insert(identity)
|| document
.peers()
.iter()
.filter(|peer| peer.identity == Some(identity))
.count()
!= 1
{
continue;
}
let saved_client = application.draft.states.saved().get(&identity).cloned();
let label = saved_client.as_ref().map_or_else(
|| match &saved_peer.metadata {
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
metadata.alias.clone()
}
_ => format!(
"{}…",
identity.to_string().chars().take(8).collect::<String>()
),
},
|client| client.alias.clone(),
);
clients.push(PeerRecord {
peer_index: None,
draft_peer_index: None,
saved_peer_index: Some(saved_peer.index),
line: saved_peer.section_line,
identity: Some(identity),
label,
allowed_ips: saved_peer
.allowed_ips
.iter()
.map(ToString::to_string)
.collect(),
has_subnet_routes: saved_peer.has_subnet_routes,
issues: saved_peer.issues.clone(),
classified_issues: classify_issues(&saved_peer.issues),
mutable_identity: None,
draft_client: None,
saved_client,
state_change: ClientStateChange::Removed,
actions: actions_for(application, Some(identity), false),
});
}
}
// A saved client can be absent from a malformed or unsupported saved
// document projection. Retain the state-backed row only when no
// authoritative peer row represented that identity above.
for client in application.draft.states.saved().clients() {
if draft_peer_identities.contains(&client.identity)
|| clients.iter().any(|peer| {
peer.state_change == ClientStateChange::Removed
&& peer.identity == Some(client.identity)
})
{
continue;
}
clients.push(PeerRecord {
peer_index: None,
draft_peer_index: None,
saved_peer_index: None,
line: 0,
identity: Some(client.identity),
label: client.alias.clone(),
allowed_ips: Vec::new(),
has_subnet_routes: false,
issues: Vec::new(),
classified_issues: Vec::new(),
mutable_identity: None,
draft_client: None,
saved_client: Some(client.clone()),
state_change: ClientStateChange::Removed,
actions: actions_for(application, Some(client.identity), false),
});
}
let reservations = application.draft.states.address_reservations();
let restart = if restart_is_eligible(application) {
ActionEligibility::available()
} else {
ActionEligibility::unavailable(ActionUnavailableReason::RestartPreviewRejected)
};
ApplicationView {
clients,
document_issues: draft_document.issues().to_vec(),
saved_peer_count,
draft_client_count: application.draft.states.draft().clients().count(),
saved_client_count: application.draft.states.saved().clients().count(),
reservations: ReservationFacts {
committed: reservations.committed().iter().copied().collect(),
pending_host_routes: reservations.pending_host_routes().collect(),
},
applied: *application.draft.states.applied(),
vault_initialized: application.draft.vault.is_some(),
has_draft: application.has_draft(),
restart,
}
}
fn display_peer(
application: &Application,
peer: &PeerInspection,
saved_document: Option<&ConfigDocument>,
) -> PeerRecord {
let identity = peer.identity;
let draft_client = identity.and_then(|key| application.draft.states.draft().get(&key).cloned());
let saved_client = identity.and_then(|key| application.draft.states.saved().get(&key).cloned());
let projected = identity.and_then(|key| application.draft.document.project_client(&key).ok());
let metadata_label = match &peer.metadata {
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
Some(metadata.alias.clone())
}
_ => None,
};
let label = metadata_label.unwrap_or_else(|| {
identity.map_or_else(
|| format!("Peer at line {}", peer.section_line),
|key| format!("{}…", key.to_string().chars().take(8).collect::<String>()),
)
});
let saved_peer_index = identity.and_then(|key| {
saved_document.and_then(|document| {
document
.peers()
.iter()
.find(|saved_peer| saved_peer.identity == Some(key))
.map(|saved_peer| saved_peer.index)
})
});
let mutable_identity = projected
.filter(|client| {
draft_client
.as_ref()
.is_some_and(|draft| draft.identity == client.identity)
})
.map(|client| client.identity);
let state_change = match (&draft_client, &saved_client) {
(Some(_), None) => ClientStateChange::Added,
(None, Some(_)) => ClientStateChange::UnsupportedOrUnmanaged,
(Some(draft), Some(saved)) if draft == saved => ClientStateChange::Unchanged,
(Some(_), Some(_)) => ClientStateChange::Changed,
(None, None) => ClientStateChange::UnsupportedOrUnmanaged,
};
PeerRecord {
peer_index: Some(peer.index),
draft_peer_index: Some(peer.index),
saved_peer_index,
line: peer.section_line,
identity,
label,
allowed_ips: peer.allowed_ips.iter().map(ToString::to_string).collect(),
has_subnet_routes: peer.has_subnet_routes,
issues: peer.issues.clone(),
classified_issues: classify_issues(&peer.issues),
mutable_identity,
draft_client,
saved_client,
state_change,
actions: actions_for(application, identity, true),
}
}
fn actions_for(
application: &Application,
identity: Option<PublicKey>,
peer_is_in_draft: bool,
) -> ClientActions {
let (rename, delete) = match identity {
None => {
let reason = ActionUnavailableReason::NoUniquePeer;
(
ActionEligibility::unavailable(reason),
ActionEligibility::unavailable(reason),
)
}
Some(_identity) if !peer_is_in_draft => {
let reason = ActionUnavailableReason::PeerNotInDraft;
(
ActionEligibility::unavailable(reason),
ActionEligibility::unavailable(reason),
)
}
Some(identity) => (
if application
.draft
.document
.rename_client(&identity, "eligibility probe")
.is_ok()
{
ActionEligibility::available()
} else {
ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument)
},
delete_eligibility(application, identity),
),
};
let first_delivery =
identity.map_or(
FirstDeliveryEligibility::Unavailable,
|identity| match application.first_delivery.read_model_availability(
identity,
&application.draft.document,
application.draft.vault.as_ref(),
) {
SessionFirstDeliveryAvailability::InitialFile => {
FirstDeliveryEligibility::InitialFile
}
SessionFirstDeliveryAvailability::Retry => FirstDeliveryEligibility::Retry,
SessionFirstDeliveryAvailability::RetryNeedsOverwriteConfirmation => {
FirstDeliveryEligibility::RetryNeedsOverwriteConfirmation
}
SessionFirstDeliveryAvailability::RetryNeedsManualCleanup => {
FirstDeliveryEligibility::RetryNeedsManualCleanup
}
SessionFirstDeliveryAvailability::Unavailable => {
FirstDeliveryEligibility::Unavailable
}
},
);
let reissue = match identity {
None => ActionEligibility::unavailable(ActionUnavailableReason::NoUniquePeer),
Some(identity) => reissue_eligibility(application, identity),
};
ClientActions {
rename,
delete,
first_delivery,
reissue,
}
}
fn delete_eligibility(application: &Application, identity: PublicKey) -> ActionEligibility {
if application.draft.document.delete_client(&identity).is_err() {
return ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument);
}
let saved_peer = ConfigDocument::parse(&application.saved.server_config)
.ok()
.and_then(|document| {
document
.peers()
.iter()
.find(|peer| peer.identity == Some(identity))
.cloned()
});
if application.draft.states.saved().get(&identity).is_some() || saved_peer.is_none() {
return ActionEligibility::available();
}
let Some(peer) = saved_peer else {
return ActionEligibility::available();
};
let exact_host_routes = !peer.allowed_ips.is_empty()
&& peer.allowed_ips.iter().all(|route| match route {
ipnet::IpNet::V4(net) => net.prefix_len() == 32,
ipnet::IpNet::V6(net) => net.prefix_len() == 128,
});
if exact_host_routes {
ActionEligibility::available()
} else {
ActionEligibility::unavailable(ActionUnavailableReason::ExactHostRoutesRequired)
}
}
fn reissue_eligibility(application: &Application, identity: PublicKey) -> ActionEligibility {
let saved_document = match ConfigDocument::parse(&application.saved.server_config) {
Ok(document) => document,
Err(_) => {
return ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument);
}
};
let matching_peers = saved_document
.peers()
.iter()
.filter(|peer| peer.identity == Some(identity))
.collect::<Vec<_>>();
let Some(peer) = matching_peers.first() else {
return ActionEligibility::unavailable(ActionUnavailableReason::PeerNotInSavedState);
};
if matching_peers.len() != 1
|| peer.identity != peer.public_key_candidates.first().copied()
|| peer.public_key_candidates.len() != 1
|| !peer.issues.is_empty()
|| peer.allowed_ips.is_empty()
{
return ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument);
}
let Some(bytes) = application.saved.vault.as_deref() else {
return ActionEligibility::unavailable(ActionUnavailableReason::VaultNotInitialized);
};
let vault_text = match std::str::from_utf8(bytes) {
Ok(text) => text,
Err(_) => {
return ActionEligibility::unavailable(ActionUnavailableReason::VaultNotInitialized);
}
};
let vault = match crate::vault::VaultBundle::from_toml(vault_text) {
Ok(vault) => vault,
Err(_) => {
return ActionEligibility::unavailable(ActionUnavailableReason::VaultNotInitialized);
}
};
if vault.contains_record(identity) {
if vault.reissue_blocked(identity).unwrap_or(true) {
ActionEligibility::unavailable(ActionUnavailableReason::VaultRecordConflict)
} else {
ActionEligibility::available()
}
} else {
ActionEligibility::unavailable(ActionUnavailableReason::VaultRecordMissing)
}
}
fn restart_is_eligible(application: &Application) -> bool {
let Ok(document) = ConfigDocument::parse(&application.saved.server_config) else {
return false;
};
document
.sections()
.iter()
.filter(|section| section.kind == crate::wireguard::SectionKind::Interface)
.count()
== 1
&& !document.has_unsafe_save_config()
&& super::restart::restart_projection_from_document(&document).is_ok()
}
fn classify_issues(issues: &[PeerIssue]) -> Vec<ClassifiedPeerIssue> {
issues
.iter()
.cloned()
.map(|issue| ClassifiedPeerIssue {
class: classify_issue(&issue),
issue,
})
.collect()
}
fn classify_issue(issue: &PeerIssue) -> PeerIssueClass {
match issue {
PeerIssue::UnsupportedAllowedIpShape { .. } | PeerIssue::MissingIpv4HostRoute => {
PeerIssueClass::UnsupportedCapability
}
_ => PeerIssueClass::MalformedOrAmbiguous,
}
}
+97
View File
@@ -0,0 +1,97 @@
//! Freshly authenticated publication of a saved client's configuration.
use super::{Application, ReissueDelivery, ReissueError, ReissueFailureKind, ReissueStage};
use crate::delivery::files::{self, PreparedDestination};
use crate::delivery::qr;
use crate::delivery::{self, DeliverySettings};
use crate::domain::PublicKey;
use crate::vault::VaultBundle;
use crate::wireguard::ConfigDocument;
impl Application {
/// Reissue a saved client's configuration after freshly unlocking its
/// installed vault record with this action's password. This operation reads
/// only installed saved bytes, never uses a draft or retained source file,
/// and publishes the required file before attempting optional QR encoding.
pub fn reissue_client_file(
&self,
identity: PublicKey,
password: &str,
destination: PreparedDestination,
include_qr: bool,
) -> Result<ReissueDelivery, ReissueError> {
let installed = self.store.read_saved().map_err(|_| ReissueError {
stage: ReissueStage::SavedState,
kind: ReissueFailureKind::SavedStateUnavailable,
})?;
let document =
ConfigDocument::parse(&installed.server_config).map_err(|_| ReissueError {
stage: ReissueStage::SavedState,
kind: ReissueFailureKind::SavedStateUnavailable,
})?;
let encoded_vault = installed.vault.as_deref().ok_or(ReissueError {
stage: ReissueStage::Authorization,
kind: ReissueFailureKind::VaultUnavailable,
})?;
let vault_text = std::str::from_utf8(encoded_vault).map_err(|_| ReissueError {
stage: ReissueStage::Authorization,
kind: ReissueFailureKind::VaultUnavailable,
})?;
let vault = VaultBundle::from_toml(vault_text).map_err(|_| ReissueError {
stage: ReissueStage::Authorization,
kind: ReissueFailureKind::VaultUnavailable,
})?;
// Retrieval performs the password unlock and authenticated conflict
// check on every invocation. The short-lived owner is dropped when
// this method returns and is never kept in Application state.
let record = vault
.retrieve_client_record(identity, password)
.map_err(|_| ReissueError {
stage: ReissueStage::Authorization,
kind: ReissueFailureKind::PasswordOrRecordRejected,
})?;
let resolved = self
.store
.resolved_settings_for(&installed)
.map_err(|_| ReissueError {
stage: ReissueStage::SavedState,
kind: ReissueFailureKind::SavedStateUnavailable,
})?;
let delivery_settings = DeliverySettings {
server_endpoint: &resolved.server_endpoint,
dns_addresses: &resolved.dns_addresses,
};
let payload = if record.is_new_template() {
delivery::render_new_client(
identity,
record.private_key().ok_or(ReissueError {
stage: ReissueStage::Authorization,
kind: ReissueFailureKind::PasswordOrRecordRejected,
})?,
&document,
&delivery_settings,
)
} else {
delivery::render_imported_client(identity, &record, &document, &delivery_settings)
}
.map_err(|_| ReissueError {
stage: ReissueStage::Rendering,
kind: ReissueFailureKind::ConfigurationRejected,
})?;
files::publish(destination, &payload).map_err(|error| ReissueError {
stage: ReissueStage::FilePublication,
kind: ReissueFailureKind::FileOutput(error),
})?;
// A historical timestamp is deliberately insufficient: after reopen,
// the runtime observation is unknown. Warn unless this live session
// has verified the exact saved peer snapshot currently on disk.
let activation_warning = !self.saved_generation_is_verified_applied(&installed, &document);
let qr = include_qr.then(|| qr::encode_payload(&payload));
Ok(ReissueDelivery {
activation_warning,
qr,
})
}
}
+670
View File
@@ -0,0 +1,670 @@
//! Saved-generation-bound restart preparation, execution, and publication.
//!
//! This module coordinates the service job, runtime observation and comparison,
//! durable verification bookkeeping, and applied state publication. Low-level
//! service and runtime adapters remain separate.
use std::time::{Duration, Instant};
use crate::backup::{self, BackupReason, HistoricalApplyStatus, SavedBytes};
use crate::config::ResolvedSettings;
use crate::domain::{PublicKey, Snapshot};
use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore};
use crate::runtime::transport::TransportError;
use crate::runtime::{self, RuntimeObservation, SavedPeerProjection, TechnicalMatch};
use crate::service::{self, RestartDeadline, RestartExecution, RestartOutcome};
use crate::vault::VaultBundle;
use crate::wireguard::{ConfigDocument, SectionKind};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use super::{AppError, Application, clone_saved, historical_apply_from_saved, same_saved_bytes};
/// Read-only, saved-generation-bound preview used by the explicit restart confirmation flow.
pub struct RestartPreview {
saved: SavedBytes,
interface: String,
unit: String,
timeout: Duration,
projection: SavedPeerProjection,
expected_clients: Option<Snapshot>,
has_draft: bool,
}
impl RestartPreview {
pub fn interface(&self) -> &str {
&self.interface
}
pub fn systemd_unit(&self) -> &str {
&self.unit
}
pub const fn has_draft(&self) -> bool {
self.has_draft
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RestartChoice {
Cancel,
RestartSavedState,
DiscardDraftAndRestart,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RestartStage {
Cancelled,
Preparation,
Backup,
Service,
RuntimeRead,
Verification,
Persistence,
Complete,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RestartFailureKind {
InvalidOrStaleSavedGeneration,
UnsafeSaveConfig,
UnsupportedSavedShape,
BackupFailed,
DraftDiscardFailed,
ServiceFailed,
RuntimeReadFailed,
RuntimeNoDevice,
RuntimePermissionDenied,
RuntimeMalformed,
RuntimeTransport,
RuntimeTimeout,
RuntimeLimitExceeded,
RuntimeMismatch,
VerificationPersistenceFailed,
}
/// Secret-free explanation for saved configuration that cannot be used as a
/// restart verification target. The identity is a public WireGuard key.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RestartDiagnostic {
pub identity: Option<PublicKey>,
pub section_line: Option<usize>,
pub reason: &'static str,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RestartReport {
pub stage: RestartStage,
pub failure: Option<RestartFailureKind>,
pub service_failure: Option<service::RestartFailure>,
pub service_phase: Option<service::RestartPhase>,
pub systemd_unit: Option<String>,
pub backup_path: Option<std::path::PathBuf>,
pub restart_submitted: bool,
pub restart_may_still_complete: bool,
pub manual_recovery_required: bool,
}
pub(super) struct RestartAttempt {
pub(super) id: u64,
pub(super) saved: SavedBytes,
}
pub(super) struct PreparedRestart {
pub(super) attempt_id: u64,
pub(super) installed: SavedBytes,
pub(super) settings: ResolvedSettings,
pub(super) projection: SavedPeerProjection,
pub(super) expected_clients: Option<Snapshot>,
pub(super) backup_path: std::path::PathBuf,
pub(super) deadline: service::RestartDeadline,
}
pub(super) fn preview_restart(application: &Application) -> Result<RestartPreview, AppError> {
let installed = application.store.read_saved()?;
if !same_saved_bytes(&installed, &application.saved) {
return Err(AppError::SavedGenerationChanged);
}
let settings = application.store.resolved_settings_for(&installed)?;
let document = ConfigDocument::parse(&installed.server_config)?;
if document
.sections()
.iter()
.filter(|section| section.kind == SectionKind::Interface)
.count()
!= 1
{
return Err(AppError::InvalidSavedState);
}
if document.has_unsafe_save_config() {
return Err(AppError::UnsafeSaveConfig);
}
let (projection, expected_clients) =
restart_projection_from_document(&document).map_err(AppError::UnsafeSavedPeers)?;
let expected_clients =
expected_clients.filter(|snapshot| snapshot == application.draft.states.saved());
Ok(RestartPreview {
saved: clone_saved(&installed),
interface: settings.interface_name,
unit: settings.systemd_unit,
timeout: settings.restart_timeout,
projection,
expected_clients,
has_draft: application.has_draft(),
})
}
/// Validate the reviewed preview and preserve the established preparation
/// order: finalize the pre-restart backup before discarding any draft, then bind
/// the one-use attempt and initialize the single deadline before service work.
pub(super) fn prepare_confirmed_restart(
application: &mut Application,
preview: RestartPreview,
choice: RestartChoice,
now: &impl Fn() -> Instant,
) -> Result<PreparedRestart, RestartReport> {
if choice == RestartChoice::Cancel {
return Err(report(
RestartStage::Cancelled,
None,
None,
false,
false,
false,
));
}
application.active_restart_attempt = None;
if application.has_draft() != preview.has_draft
|| (preview.has_draft && choice != RestartChoice::DiscardDraftAndRestart)
|| (!preview.has_draft && choice == RestartChoice::DiscardDraftAndRestart)
{
return Err(report(
RestartStage::Preparation,
Some(RestartFailureKind::InvalidOrStaleSavedGeneration),
None,
false,
false,
false,
));
}
let installed = match application.store.read_saved() {
Ok(saved)
if same_saved_bytes(&saved, &preview.saved)
&& same_saved_bytes(&saved, &application.saved) =>
{
saved
}
_ => {
application.active_restart_attempt = None;
return Err(report(
RestartStage::Preparation,
Some(RestartFailureKind::InvalidOrStaleSavedGeneration),
None,
false,
false,
false,
));
}
};
let settings = match application.store.resolved_settings_for(&installed) {
Ok(settings) => settings,
Err(_) => return Err(preparation_failure()),
};
let document = match ConfigDocument::parse(&installed.server_config) {
Ok(document) => document,
Err(_) => return Err(preparation_failure()),
};
if document
.sections()
.iter()
.filter(|section| section.kind == SectionKind::Interface)
.count()
!= 1
{
return Err(preparation_failure());
}
if document.has_unsafe_save_config() {
return Err(report(
RestartStage::Preparation,
Some(RestartFailureKind::UnsafeSaveConfig),
None,
false,
false,
false,
));
}
let (projection, expected_clients) = match restart_projection_from_document(&document) {
Ok(projection) => projection,
Err(_) => {
return Err(report(
RestartStage::Preparation,
Some(RestartFailureKind::UnsupportedSavedShape),
None,
false,
false,
false,
));
}
};
let expected_clients =
expected_clients.filter(|snapshot| snapshot == application.draft.states.saved());
if settings.interface_name != preview.interface
|| settings.systemd_unit != preview.unit
|| settings.restart_timeout != preview.timeout
|| projection != preview.projection
|| expected_clients != preview.expected_clients
{
return Err(preparation_failure());
}
let historical_apply = match historical_apply_from_saved(&installed) {
Ok(status) => status,
Err(_) => return Err(preparation_failure()),
};
let backup_path = match backup::create_archive(
application.store.archives_directory(),
BackupReason::PreRestart,
historical_apply,
&installed,
) {
Ok(path) => path,
Err(_) => {
application.active_restart_attempt = None;
return Err(report(
RestartStage::Backup,
Some(RestartFailureKind::BackupFailed),
None,
false,
false,
false,
));
}
};
let id = application.next_restart_attempt;
let Some(next_id) = id.checked_add(1) else {
return Err(report(
RestartStage::Preparation,
Some(RestartFailureKind::InvalidOrStaleSavedGeneration),
Some(backup_path),
false,
false,
false,
));
};
application.next_restart_attempt = next_id;
application.active_restart_attempt = Some(id);
if choice == RestartChoice::DiscardDraftAndRestart && application.discard_draft().is_err() {
application.active_restart_attempt = None;
return Err(report(
RestartStage::Preparation,
Some(RestartFailureKind::DraftDiscardFailed),
Some(backup_path),
false,
false,
false,
));
}
application.draft.states.runtime_knowledge_unknown();
let deadline = service::RestartDeadline::new(now(), settings.restart_timeout);
Ok(PreparedRestart {
attempt_id: id,
installed,
settings,
projection,
expected_clients,
backup_path,
deadline,
})
}
/// Run the confirmed restart scenario after preparation has completed. The
/// backup is already durable before this function is entered; exactly one
/// shared deadline bounds the service job and the subsequent runtime read.
pub(super) fn execute_confirmed_restart(
application: &mut Application,
prepared: PreparedRestart,
service: impl FnOnce(&str, RestartDeadline) -> RestartExecution,
runtime_read: impl FnOnce(&str, Duration) -> Result<RuntimeObservation, TransportError>,
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
now: impl Fn() -> Instant,
) -> RestartReport {
let PreparedRestart {
attempt_id,
installed,
settings,
projection,
expected_clients,
backup_path,
deadline,
} = prepared;
let execution = service(&settings.systemd_unit, deadline);
let job = execution.outcome;
let submitted = execution.submitted;
let service_phase = execution.phase;
if job != RestartOutcome::Succeeded {
application.active_restart_attempt = None;
let may_complete = submitted
&& !matches!(
job,
RestartOutcome::Failed(
service::RestartFailure::JobFailed
| service::RestartFailure::JobDependency
| service::RestartFailure::JobTimedOut
| service::RestartFailure::JobCanceled
| service::RestartFailure::JobSkipped
| service::RestartFailure::JobResultUnknown
)
);
let mut report = report(
RestartStage::Service,
Some(RestartFailureKind::ServiceFailed),
Some(backup_path),
submitted,
may_complete,
submitted,
);
report.service_failure = match job {
RestartOutcome::Failed(failure) => Some(failure),
RestartOutcome::Waiting | RestartOutcome::Succeeded => None,
};
report.service_phase = Some(service_phase);
report.systemd_unit = Some(settings.systemd_unit);
return report;
}
let remaining = deadline.remaining_at(now());
if remaining.is_zero() {
application.active_restart_attempt = None;
return report(
RestartStage::RuntimeRead,
Some(RestartFailureKind::RuntimeTimeout),
Some(backup_path),
true,
false,
true,
);
}
let observation = match runtime_read(&settings.interface_name, remaining) {
Ok(observation) => observation,
Err(error) => {
application.active_restart_attempt = None;
return report(
RestartStage::RuntimeRead,
Some(match error {
TransportError::NoDevice => RestartFailureKind::RuntimeNoDevice,
TransportError::PermissionDenied => RestartFailureKind::RuntimePermissionDenied,
TransportError::Malformed => RestartFailureKind::RuntimeMalformed,
TransportError::Transport => RestartFailureKind::RuntimeTransport,
TransportError::Timeout => RestartFailureKind::RuntimeTimeout,
TransportError::LimitExceeded => RestartFailureKind::RuntimeLimitExceeded,
}),
Some(backup_path),
true,
false,
true,
);
}
};
if deadline.is_expired_at(now()) {
application.active_restart_attempt = None;
return report(
RestartStage::Verification,
Some(RestartFailureKind::RuntimeTimeout),
Some(backup_path),
true,
false,
true,
);
}
if runtime::compare_peers(&projection, &settings.interface_name, &observation)
!= TechnicalMatch::Match
{
application.active_restart_attempt = None;
return report(
RestartStage::Verification,
Some(RestartFailureKind::RuntimeMismatch),
Some(backup_path),
true,
false,
true,
);
}
let Some(verified_at) = OffsetDateTime::now_utc()
.replace_nanosecond(0)
.ok()
.and_then(|timestamp| timestamp.format(&Rfc3339).ok())
else {
application.active_restart_attempt = None;
return report(
RestartStage::Persistence,
Some(RestartFailureKind::VerificationPersistenceFailed),
Some(backup_path),
true,
false,
true,
);
};
let attempt = RestartAttempt {
id: attempt_id,
saved: clone_saved(&installed),
};
if mark_verified_with_attempt(application, attempt, expected_clients, verified_at, install)
.is_err()
{
application.active_restart_attempt = None;
return report(
RestartStage::Persistence,
Some(RestartFailureKind::VerificationPersistenceFailed),
Some(backup_path),
true,
false,
true,
);
}
report(
RestartStage::Complete,
None,
Some(backup_path),
true,
false,
false,
)
}
/// Persist verified apply knowledge using the active restart's one-use attempt
/// and exact saved generation. Session publication follows durable success.
pub(super) fn mark_verified_with_attempt(
application: &mut Application,
attempt: RestartAttempt,
observed_management_snapshot: Option<Snapshot>,
verified_at_utc: String,
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
) -> Result<(), AppError> {
if application.active_restart_attempt != Some(attempt.id) {
return Err(AppError::PreRestartBackupRequired);
}
let installed = application.store.read_saved()?;
if !same_saved_bytes(&installed, &application.saved)
|| !same_saved_bytes(&installed, &attempt.saved)
{
return Err(AppError::SavedGenerationChanged);
}
let mut next_states = application.draft.states.clone();
next_states.prepare_technically_verified_result(observed_management_snapshot)?;
let mut persisted = match &installed.internal_state {
Some(bytes) => PersistedState::from_toml(bytes)?,
None => PersistedState::new(
next_states
.reserved_addresses()
.iter()
.map(ToString::to_string),
None,
)?,
};
persisted.reserved_addresses = next_states
.reserved_addresses()
.iter()
.map(ToString::to_string)
.collect();
let historical_apply = HistoricalApplyStatus::Verified {
verified_at_utc: verified_at_utc.clone(),
};
persisted.last_verified_at_utc = Some(verified_at_utc);
let mut proposed = installed;
proposed.internal_state = Some(persisted.to_toml()?);
application.store.validate_saved(&proposed)?;
if let Err(error) = install(&application.store, &proposed, BackupComplete(())) {
application
.first_delivery
.invalidate_active_authorizations();
match application.store.read_saved() {
Ok(current) => {
let document = ConfigDocument::parse(&current.server_config).ok();
let vault = current
.vault
.as_deref()
.and_then(|bytes| std::str::from_utf8(bytes).ok())
.and_then(|text| VaultBundle::from_toml(text).ok());
if let Some(document) = document {
application
.first_delivery
.retain_matching(&document, vault.as_ref());
} else {
application.first_delivery.clear();
}
}
Err(_) => application.first_delivery.clear(),
}
return Err(error.into());
}
application.saved = proposed;
application.draft.states = next_states;
application.verified_saved_projection = Some(SavedPeerProjection::from_document(
&application.draft.document,
));
application.historical_apply = historical_apply;
application.active_restart_attempt = None;
Ok(())
}
fn preparation_failure() -> RestartReport {
report(
RestartStage::Preparation,
Some(RestartFailureKind::InvalidOrStaleSavedGeneration),
None,
false,
false,
false,
)
}
pub(super) fn report(
stage: RestartStage,
failure: Option<RestartFailureKind>,
backup_path: Option<std::path::PathBuf>,
restart_submitted: bool,
restart_may_still_complete: bool,
manual_recovery_required: bool,
) -> RestartReport {
RestartReport {
stage,
failure,
service_failure: None,
service_phase: None,
systemd_unit: None,
backup_path,
restart_submitted,
restart_may_still_complete,
manual_recovery_required,
}
}
pub(super) fn restart_projection_from_document(
document: &ConfigDocument,
) -> Result<(SavedPeerProjection, Option<Snapshot>), Vec<RestartDiagnostic>> {
use crate::wireguard::{DocumentIssue, PeerIssue};
let mut diagnostics = Vec::new();
for issue in document.issues() {
let (line, reason) = match issue {
DocumentIssue::MalformedSectionHeader { line } => (*line, "malformed section header"),
DocumentIssue::DirectiveOutsideSection { line } => {
(*line, "directive appears outside a section")
}
DocumentIssue::MalformedDirective { line } => (*line, "malformed directive"),
DocumentIssue::ManagedMetadata(_) => continue,
};
diagnostics.push(RestartDiagnostic {
identity: None,
section_line: Some(line),
reason,
});
}
for peer in document.peers() {
let identity = peer
.identity
.or_else(|| peer.public_key_candidates.first().copied());
for issue in &peer.issues {
let reason = match issue {
PeerIssue::MissingPublicKey => Some("peer is missing a public key"),
PeerIssue::MalformedPublicKey { .. } => Some("peer has a malformed public key"),
PeerIssue::DuplicatePublicKeyField { .. } => {
Some("peer has duplicate public key fields")
}
PeerIssue::DuplicatePeerIdentity { .. } => {
Some("public key is used by multiple peer sections")
}
PeerIssue::MalformedAllowedIp { .. } => {
Some("peer has a malformed AllowedIPs item")
}
PeerIssue::UnsupportedAllowedIpShape { .. } | PeerIssue::MissingIpv4HostRoute => {
None
}
};
if let Some(reason) = reason {
diagnostics.push(RestartDiagnostic {
identity,
section_line: Some(peer.section_line),
reason,
});
}
}
}
let projection = SavedPeerProjection::from_document(document);
if (projection.document_unverifiable
|| !projection.unverifiable_peer_indices.is_empty()
|| !projection.duplicate_public_keys.is_empty())
&& diagnostics.is_empty()
{
diagnostics.push(RestartDiagnostic {
identity: None,
section_line: None,
reason: "saved peer set is not uniquely verifiable",
});
}
if !diagnostics.is_empty() {
return Err(diagnostics);
}
let mut clients = Vec::with_capacity(document.peers().len());
for peer in document.peers() {
let Some(identity) = peer.identity else {
return Err(vec![RestartDiagnostic {
identity: None,
section_line: Some(peer.section_line),
reason: "peer identity is unavailable",
}]);
};
let Ok(client) = document.project_client(&identity) else {
clients.clear();
break;
};
clients.push(client);
}
let management_snapshot = (clients.len() == document.peers().len())
.then(|| Snapshot::new(clients).ok())
.flatten();
Ok((projection, management_snapshot))
}
+221
View File
@@ -0,0 +1,221 @@
//! Previewed, saved-state-only restore scenario.
use std::collections::BTreeSet;
use std::net::IpAddr;
use crate::backup::{self, BackupReason, SavedBytes};
use crate::domain::{ApplyStatus, ClientStates};
use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore};
use crate::vault::VaultBundle;
use crate::wireguard::{ConfigDocument, PeerInspection};
use super::{
AppError, Application, RestorePreview, historical_apply_from_saved, safe_archive_name,
same_saved_bytes, snapshot_from_document,
};
struct PreparedRestore {
proposed: SavedBytes,
resolved_settings: crate::config::ResolvedSettings,
document: ConfigDocument,
vault: Option<VaultBundle>,
states: ClientStates,
}
pub(super) fn preview_restore(
application: &Application,
file_name: &str,
) -> Result<RestorePreview, AppError> {
if !safe_archive_name(file_name) {
return Err(AppError::InvalidArchiveSelection);
}
let installed_generation = application.store.read_saved()?;
let current_document = ConfigDocument::parse(&installed_generation.server_config)?;
let selected_path = application.store.archives_directory().join(file_name);
let archive = backup::preview_archive(&selected_path)?;
application.store.validate_saved(&archive.saved_bytes)?;
let mut proposed = archive.saved_bytes;
let document = ConfigDocument::parse(&proposed.server_config)?;
let mut states = ClientStates::new(
snapshot_from_document(&document),
None,
ApplyStatus::Unknown,
)?;
let mut carried_reservations = application
.draft
.states
.address_reservations()
.all()
.collect::<BTreeSet<_>>();
carried_reservations.extend(
snapshot_from_document(&current_document)
.clients()
.flat_map(|client| client.addresses.iter()),
);
// Saved peers outside the manageable client projection can still own
// exact host routes. Keep those addresses if this restore removes them;
// never enumerate broad routes or infer other addresses from a subnet.
carried_reservations.extend(exact_host_route_reservations(&current_document));
let archived_had_state = proposed.internal_state.is_some();
let mut archived_reservations = BTreeSet::new();
if let Some(state_bytes) = &proposed.internal_state {
let archived = PersistedState::from_toml(state_bytes)?;
archived_reservations = archived
.reserved_addresses
.iter()
.map(|address| {
address
.parse::<IpAddr>()
.map_err(|_| AppError::InvalidSavedState)
})
.collect::<Result<BTreeSet<_>, _>>()?;
}
let restored_exact_hosts = exact_host_routes(&document);
let mut reservations = carried_reservations.clone();
reservations.extend(archived_reservations.iter().copied());
reservations.retain(|address| {
let assigned_to_restored_client = states
.saved()
.clients()
.any(|client| client.addresses.iter().any(|saved| saved == *address));
!assigned_to_restored_client
&& (archived_reservations.contains(address)
|| !carried_reservations.contains(address)
|| !restored_exact_hosts.contains(address))
});
if !reservations.is_empty() || archived_had_state {
let state = PersistedState::new(reservations.iter().map(ToString::to_string), None)?;
state.apply_to(&mut states)?;
proposed.internal_state = Some(state.to_toml()?);
}
application.store.validate_saved(&proposed)?;
Ok(RestorePreview {
created_at_utc: archive.created_at_utc,
reason: archive.reason,
historical_apply: archive.historical_apply,
installed_generation,
saved_bytes: proposed,
})
}
pub(super) fn confirm_restore(
application: &mut Application,
preview: RestorePreview,
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
) -> Result<std::path::PathBuf, AppError> {
let current = application.store.read_saved()?;
if !same_saved_bytes(&current, &preview.installed_generation) {
return Err(AppError::SavedGenerationChanged);
}
let prepared = prepare_restore(application, preview.saved_bytes)?;
let historical_apply = historical_apply_from_saved(&current)?;
let archive = backup::create_archive(
application.store.archives_directory(),
BackupReason::PreRestore,
historical_apply,
&current,
)?;
if let Err(error) = install(&application.store, &prepared.proposed, BackupComplete(())) {
// A partial journaled install may leave disk in a recovery-required
// state. Revoke session-only delivery rights until the store is read
// again and the actual installed generation is known.
application.first_delivery.clear();
return Err(error.into());
}
application.saved = prepared.proposed;
application
.store
.set_resolved_settings(prepared.resolved_settings);
application.draft.document = prepared.document;
application.draft.vault = prepared.vault;
application.draft.states = prepared.states;
application.first_delivery.clear();
application.historical_apply = crate::backup::HistoricalApplyStatus::Unknown;
application.verified_saved_projection = None;
application.active_restart_attempt = None;
Ok(archive)
}
fn prepare_restore(
application: &Application,
proposed: SavedBytes,
) -> Result<PreparedRestore, AppError> {
application.store.validate_saved(&proposed)?;
// Check installation path compatibility and parse every in-memory value
// before the precautionary archive or file replacement begins.
let resolved_settings = application.store.resolved_settings_for(&proposed)?;
let document = ConfigDocument::parse(&proposed.server_config)?;
let vault = proposed
.vault
.as_deref()
.map(std::str::from_utf8)
.transpose()
.map_err(|_| AppError::InvalidSavedState)?
.map(VaultBundle::from_toml)
.transpose()?;
let mut states = ClientStates::new(
snapshot_from_document(&document),
None,
ApplyStatus::Unknown,
)?;
if let Some(bytes) = &proposed.internal_state {
PersistedState::from_toml(bytes)?.apply_to(&mut states)?;
}
Ok(PreparedRestore {
proposed,
resolved_settings,
document,
vault,
states,
})
}
fn exact_host_route_reservations(document: &ConfigDocument) -> BTreeSet<IpAddr> {
let mut reservations = BTreeSet::new();
for peer in document.peers() {
if manageable_peer(document, peer) {
continue;
}
for route in &peer.allowed_ips {
match route {
ipnet::IpNet::V4(network) if network.prefix_len() == 32 => {
reservations.insert(IpAddr::V4(network.addr()));
}
ipnet::IpNet::V6(network) if network.prefix_len() == 128 => {
reservations.insert(IpAddr::V6(network.addr()));
}
_ => {}
}
}
}
reservations
}
fn exact_host_routes(document: &ConfigDocument) -> BTreeSet<IpAddr> {
document
.peers()
.iter()
.flat_map(|peer| peer.allowed_ips.iter())
.filter_map(|route| match route {
ipnet::IpNet::V4(network) if network.prefix_len() == 32 => {
Some(IpAddr::V4(network.addr()))
}
ipnet::IpNet::V6(network) if network.prefix_len() == 128 => {
Some(IpAddr::V6(network.addr()))
}
_ => None,
})
.collect()
}
fn manageable_peer(document: &ConfigDocument, peer: &PeerInspection) -> bool {
peer.identity
.is_some_and(|identity| document.project_client(&identity).is_ok())
}
+1 -1
View File
@@ -1047,7 +1047,7 @@ fn run_failure_prevents_application_scenario(
let files_before_restart = protected_saved_file_snapshot(fixture_root)?; let files_before_restart = protected_saved_file_snapshot(fixture_root)?;
let reservations_before_restart = app.states().reserved_addresses().clone(); let reservations_before_restart = app.states().reserved_addresses().clone();
let history_before_restart = saved_last_verified_timestamp(app.saved_bytes())?; let history_before_restart = saved_last_verified_timestamp(app.saved_bytes())?;
let applied_before_restart = app.states().applied().clone(); let applied_before_restart = *app.states().applied();
let applied_snapshot_before_restart = app.states().applied_snapshot().cloned(); let applied_snapshot_before_restart = app.states().applied_snapshot().cloned();
let runtime_before_restart = observe_fixture_peer_set()?; let runtime_before_restart = observe_fixture_peer_set()?;
let apply_called = std::cell::Cell::new(false); let apply_called = std::cell::Cell::new(false);
+259 -72
View File
@@ -8,6 +8,70 @@ use std::str::FromStr;
use base64::Engine as _; use base64::Engine as _;
use base64::engine::general_purpose::STANDARD; use base64::engine::general_purpose::STANDARD;
use ipnet::{Ipv4Net, Ipv6Net}; use ipnet::{Ipv4Net, Ipv6Net};
/// Committed addresses retained until verified application and exact host
/// routes staged by a draft deletion.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct AddressReservations {
committed: BTreeSet<IpAddr>,
pending_host_routes: BTreeSet<IpAddr>,
}
impl AddressReservations {
pub fn committed(&self) -> &BTreeSet<IpAddr> {
&self.committed
}
pub fn pending_host_routes(&self) -> impl Iterator<Item = IpAddr> + '_ {
self.pending_host_routes.iter().copied()
}
pub fn all(&self) -> impl Iterator<Item = IpAddr> + '_ {
self.committed
.iter()
.chain(&self.pending_host_routes)
.copied()
}
/// Validate the complete route list before staging any pending address.
pub(crate) fn stage_exact_host_routes(
&mut self,
routes: impl IntoIterator<Item = ipnet::IpNet>,
) -> Result<(), DomainError> {
let mut addresses = BTreeSet::new();
let mut route_count = 0;
for route in routes {
route_count += 1;
match route {
ipnet::IpNet::V4(network) if network.prefix_len() == 32 => {
addresses.insert(IpAddr::V4(network.addr()));
}
ipnet::IpNet::V6(network) if network.prefix_len() == 128 => {
addresses.insert(IpAddr::V6(network.addr()));
}
_ => return Err(DomainError::UnsafeReservationRoute),
}
}
if route_count == 0 || addresses.is_empty() {
return Err(DomainError::UnsafeReservationRoute);
}
self.pending_host_routes.extend(addresses);
Ok(())
}
fn restore_committed(&mut self, reservations: impl IntoIterator<Item = IpAddr>) {
self.committed = reservations.into_iter().collect();
self.pending_host_routes.clear();
}
fn promote_pending(&mut self) {
self.committed.append(&mut self.pending_host_routes);
}
fn release_committed(&mut self) {
self.committed.clear();
}
}
use thiserror::Error; use thiserror::Error;
/// A WireGuard public key, decoded and validated as exactly 32 bytes. /// A WireGuard public key, decoded and validated as exactly 32 bytes.
@@ -146,32 +210,77 @@ impl Snapshot {
} }
} }
/// Runtime knowledge is not inferred from saved state or missing data. /// Current apply knowledge is not inferred from saved state or missing data.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RuntimeObservation { pub enum ApplyStatus {
Unknown, Unknown,
Unavailable, Unavailable,
Verified, Verified,
} }
/// Current runtime knowledge and the optional complete management snapshot
/// most recently verified. Historical verification time is persisted
/// separately and never initializes this value on reopen.
#[derive(Clone, Debug, Eq, PartialEq)]
struct ApplyKnowledge {
current: ApplyStatus,
last_verified_snapshot: Option<Snapshot>,
}
impl ApplyKnowledge {
fn new(current: ApplyStatus, last_verified_snapshot: Option<Snapshot>) -> Self {
Self {
current,
last_verified_snapshot,
}
}
fn runtime_unknown(&mut self) {
self.current = ApplyStatus::Unknown;
}
fn runtime_unavailable(&mut self) {
self.current = ApplyStatus::Unavailable;
}
fn saved_state_changed(&mut self) {
self.current = ApplyStatus::Unknown;
}
fn new_session(&mut self) {
self.current = ApplyStatus::Unknown;
}
fn persisted_state_restored(&mut self) {
self.current = ApplyStatus::Unknown;
self.last_verified_snapshot = None;
}
fn prepare_verified_result(&mut self, complete_snapshot: Option<Snapshot>) {
self.last_verified_snapshot = complete_snapshot;
self.current = ApplyStatus::Verified;
}
}
/// Draft, saved, and last verified applied data remain independent snapshots. /// Draft, saved, and last verified applied data remain independent snapshots.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientStates { pub struct ClientStates {
draft: Snapshot, draft: Snapshot,
saved: Snapshot, saved: Snapshot,
applied_snapshot: Option<Snapshot>, apply_knowledge: ApplyKnowledge,
applied: RuntimeObservation, reservations: AddressReservations,
reserved_addresses: BTreeSet<IpAddr>,
} }
impl ClientStates { impl ClientStates {
/// Start with a saved snapshot and an explicit initial runtime observation. /// Start with a saved snapshot and an explicit initial runtime observation.
/// `Application::open` treats caller-provided state as untrusted runtime
/// evidence and resets current knowledge before exposing a session.
pub fn new( pub fn new(
saved: Snapshot, saved: Snapshot,
applied_snapshot: Option<Snapshot>, applied_snapshot: Option<Snapshot>,
applied: RuntimeObservation, applied: ApplyStatus,
) -> Result<Self, DomainError> { ) -> Result<Self, DomainError> {
if applied == RuntimeObservation::Verified && applied_snapshot.as_ref() != Some(&saved) { if applied == ApplyStatus::Verified && applied_snapshot.as_ref() != Some(&saved) {
return Err(DomainError::AppliedSnapshotMismatch); return Err(DomainError::AppliedSnapshotMismatch);
} }
let saved_addresses = saved.addresses().collect::<BTreeSet<_>>(); let saved_addresses = saved.addresses().collect::<BTreeSet<_>>();
@@ -185,9 +294,11 @@ impl ClientStates {
Ok(Self { Ok(Self {
draft: saved.clone(), draft: saved.clone(),
saved, saved,
applied_snapshot, apply_knowledge: ApplyKnowledge::new(applied, applied_snapshot),
applied, reservations: AddressReservations {
reserved_addresses, committed: reserved_addresses,
pending_host_routes: BTreeSet::new(),
},
}) })
} }
@@ -199,21 +310,32 @@ impl ClientStates {
&self.saved &self.saved
} }
pub fn applied(&self) -> &RuntimeObservation { pub fn applied(&self) -> &ApplyStatus {
&self.applied &self.apply_knowledge.current
} }
pub fn applied_snapshot(&self) -> Option<&Snapshot> { pub fn applied_snapshot(&self) -> Option<&Snapshot> {
self.applied_snapshot.as_ref() self.apply_knowledge.last_verified_snapshot.as_ref()
} }
pub fn reserved_addresses(&self) -> &BTreeSet<IpAddr> { pub fn reserved_addresses(&self) -> &BTreeSet<IpAddr> {
&self.reserved_addresses self.reservations.committed()
}
pub fn address_reservations(&self) -> &AddressReservations {
&self.reservations
}
pub(crate) fn stage_exact_host_routes(
&mut self,
routes: impl IntoIterator<Item = ipnet::IpNet>,
) -> Result<(), DomainError> {
self.reservations.stage_exact_host_routes(routes)
} }
/// Restore conservative reservations from saved metadata without asserting /// Restore conservative reservations from saved metadata without asserting
/// that the running interface matches the saved snapshot. /// that the running interface matches the saved snapshot.
pub fn restore_persisted_reservations( pub(crate) fn restore_persisted_reservations(
&mut self, &mut self,
reservations: impl IntoIterator<Item = IpAddr>, reservations: impl IntoIterator<Item = IpAddr>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
@@ -224,49 +346,54 @@ impl ClientStates {
{ {
return Err(DomainError::ReservationConflictsWithSavedClient); return Err(DomainError::ReservationConflictsWithSavedClient);
} }
self.reserved_addresses = reservations; self.reservations.restore_committed(reservations);
self.applied_snapshot = None; self.apply_knowledge.persisted_state_restored();
self.applied = RuntimeObservation::Unknown;
Ok(()) Ok(())
} }
pub fn draft_mut(&mut self) -> &mut Snapshot { pub(crate) fn stage_client(&mut self, client: Client) -> Result<(), DomainError> {
&mut self.draft self.draft.insert(client).map(|_| ())
}
pub(crate) fn remove_draft_client(&mut self, identity: &PublicKey) -> Option<Client> {
self.draft.remove(identity)
} }
/// Save the draft in the model only. This performs no persistence or runtime operation. /// Save the draft in the model only. This performs no persistence or runtime operation.
pub fn commit_draft(&mut self) { pub fn commit_draft(&mut self) {
let next_addresses = self.draft.addresses().collect::<BTreeSet<_>>(); let next_addresses = self.draft.addresses().collect::<BTreeSet<_>>();
self.reserved_addresses.extend( self.reservations.committed.extend(
self.saved self.saved
.addresses() .addresses()
.filter(|address| !next_addresses.contains(address)), .filter(|address| !next_addresses.contains(address)),
); );
self.reservations.promote_pending();
self.saved = self.draft.clone(); self.saved = self.draft.clone();
if self.applied == RuntimeObservation::Verified if self.apply_knowledge.current == ApplyStatus::Verified
&& self.applied_snapshot.as_ref() != Some(&self.saved) && self.apply_knowledge.last_verified_snapshot.as_ref() != Some(&self.saved)
{ {
self.applied = RuntimeObservation::Unknown; self.apply_knowledge.saved_state_changed();
} }
} }
/// Discard draft edits by returning the draft to the saved snapshot. /// Discard draft edits by returning the draft to the saved snapshot.
pub fn discard_draft(&mut self) { pub fn discard_draft(&mut self) {
self.draft = self.saved.clone(); self.draft = self.saved.clone();
self.reservations.pending_host_routes.clear();
} }
/// Record an applied state only when the verified observation matches saved clients. /// Prepare a verified candidate only when the observation matches saved
pub(crate) fn mark_applied_after_verification( /// clients. Callers publish the candidate after durable state installation.
pub(crate) fn prepare_verified_result(
&mut self, &mut self,
observed: Snapshot, observed: Snapshot,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
if observed != self.saved { if observed != self.saved {
self.applied = RuntimeObservation::Unknown; self.apply_knowledge.runtime_unknown();
return Err(DomainError::AppliedSnapshotMismatch); return Err(DomainError::AppliedSnapshotMismatch);
} }
self.applied_snapshot = Some(observed); self.apply_knowledge.prepare_verified_result(Some(observed));
self.applied = RuntimeObservation::Verified; self.reservations.release_committed();
self.reserved_addresses.clear();
Ok(()) Ok(())
} }
@@ -274,25 +401,28 @@ impl ClientStates {
/// be represented by the narrower management snapshot. `None` deliberately /// be represented by the narrower management snapshot. `None` deliberately
/// keeps `applied_snapshot` absent instead of treating a partial snapshot as /// keeps `applied_snapshot` absent instead of treating a partial snapshot as
/// evidence that every saved peer was applied. /// evidence that every saved peer was applied.
pub(crate) fn mark_technically_applied_after_verification( pub(crate) fn prepare_technically_verified_result(
&mut self, &mut self,
observed_management_snapshot: Option<Snapshot>, observed_management_snapshot: Option<Snapshot>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
if let Some(observed) = observed_management_snapshot { if let Some(observed) = observed_management_snapshot {
return self.mark_applied_after_verification(observed); return self.prepare_verified_result(observed);
} }
self.applied_snapshot = None; self.apply_knowledge.prepare_verified_result(None);
self.applied = RuntimeObservation::Verified; self.reservations.release_committed();
self.reserved_addresses.clear();
Ok(()) Ok(())
} }
pub fn mark_runtime_unavailable(&mut self) { pub fn runtime_knowledge_unavailable(&mut self) {
self.applied = RuntimeObservation::Unavailable; self.apply_knowledge.runtime_unavailable();
} }
pub fn mark_runtime_unknown(&mut self) { pub(crate) fn runtime_knowledge_unknown(&mut self) {
self.applied = RuntimeObservation::Unknown; self.apply_knowledge.runtime_unknown();
}
pub(crate) fn begin_runtime_session(&mut self) {
self.apply_knowledge.new_session();
} }
/// Addresses in draft, saved, or last verified applied data cannot be reused. /// Addresses in draft, saved, or last verified applied data cannot be reused.
@@ -301,8 +431,13 @@ impl ClientStates {
self.draft self.draft
.addresses() .addresses()
.chain(self.saved.addresses()) .chain(self.saved.addresses())
.chain(self.reserved_addresses.iter().copied()) .chain(self.reservations.all())
.chain(self.applied_snapshot.iter().flat_map(Snapshot::addresses)) .chain(
self.apply_knowledge
.last_verified_snapshot
.iter()
.flat_map(Snapshot::addresses),
)
.collect() .collect()
} }
} }
@@ -333,6 +468,8 @@ pub enum DomainError {
AddressUnavailable, AddressUnavailable,
#[error("verified applied clients do not match the saved snapshot")] #[error("verified applied clients do not match the saved snapshot")]
AppliedSnapshotMismatch, AppliedSnapshotMismatch,
#[error("only exact IPv4/32 or IPv6/128 host routes can be reserved")]
UnsafeReservationRoute,
#[error("persisted reservation conflicts with a saved client address")] #[error("persisted reservation conflicts with a saved client address")]
ReservationConflictsWithSavedClient, ReservationConflictsWithSavedClient,
} }
@@ -558,12 +695,12 @@ mod tests {
} }
#[test] #[test]
fn unknown_and_unavailable_runtime_observations_are_explicit() { fn unknown_and_unavailable_apply_statuses_are_explicit() {
let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap(); let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
let unknown = ClientStates::new(saved.clone(), None, RuntimeObservation::Unknown).unwrap(); let unknown = ClientStates::new(saved.clone(), None, ApplyStatus::Unknown).unwrap();
assert_eq!(unknown.applied(), &RuntimeObservation::Unknown); assert_eq!(unknown.applied(), &ApplyStatus::Unknown);
let unavailable = ClientStates::new(saved, None, RuntimeObservation::Unavailable).unwrap(); let unavailable = ClientStates::new(saved, None, ApplyStatus::Unavailable).unwrap();
assert_eq!(unavailable.applied(), &RuntimeObservation::Unavailable); assert_eq!(unavailable.applied(), &ApplyStatus::Unavailable);
} }
#[test] #[test]
@@ -572,16 +709,16 @@ mod tests {
let other = Snapshot::new([client(2, "two", "10.0.0.3", None)]).unwrap(); let other = Snapshot::new([client(2, "two", "10.0.0.3", None)]).unwrap();
assert_eq!( assert_eq!(
ClientStates::new(saved.clone(), None, RuntimeObservation::Verified).err(), ClientStates::new(saved.clone(), None, ApplyStatus::Verified).err(),
Some(DomainError::AppliedSnapshotMismatch) Some(DomainError::AppliedSnapshotMismatch)
); );
assert_eq!( assert_eq!(
ClientStates::new(saved.clone(), Some(other), RuntimeObservation::Verified).err(), ClientStates::new(saved.clone(), Some(other), ApplyStatus::Verified).err(),
Some(DomainError::AppliedSnapshotMismatch) Some(DomainError::AppliedSnapshotMismatch)
); );
let verified = let verified =
ClientStates::new(saved.clone(), Some(saved), RuntimeObservation::Verified).unwrap(); ClientStates::new(saved.clone(), Some(saved), ApplyStatus::Verified).unwrap();
assert_eq!(verified.applied(), &RuntimeObservation::Verified); assert_eq!(verified.applied(), &ApplyStatus::Verified);
} }
#[test] #[test]
@@ -591,23 +728,19 @@ mod tests {
client(2, "two", "10.0.0.3", None), client(2, "two", "10.0.0.3", None),
]) ])
.unwrap(); .unwrap();
let mut states = ClientStates::new( let mut states =
saved.clone(), ClientStates::new(saved.clone(), Some(saved.clone()), ApplyStatus::Verified).unwrap();
Some(saved.clone()), states.remove_draft_client(&key(2));
RuntimeObservation::Verified,
)
.unwrap();
states.draft_mut().remove(&key(2));
states.discard_draft(); states.discard_draft();
assert_eq!(states.draft(), &saved); assert_eq!(states.draft(), &saved);
states.draft_mut().remove(&key(2)); states.remove_draft_client(&key(2));
states.commit_draft(); states.commit_draft();
assert_eq!(states.saved().clients().count(), 1); assert_eq!(states.saved().clients().count(), 1);
assert_eq!(states.applied(), &RuntimeObservation::Unknown); assert_eq!(states.applied(), &ApplyStatus::Unknown);
assert_eq!(states.applied_snapshot(), Some(&saved)); assert_eq!(states.applied_snapshot(), Some(&saved));
states.mark_runtime_unavailable(); states.runtime_knowledge_unavailable();
assert_eq!(states.applied(), &RuntimeObservation::Unavailable); assert_eq!(states.applied(), &ApplyStatus::Unavailable);
assert!(states.applied_snapshot().is_some()); assert!(states.applied_snapshot().is_some());
assert!( assert!(
states states
@@ -628,8 +761,8 @@ mod tests {
#[test] #[test]
fn a_draft_only_deletion_does_not_create_a_committed_reservation() { fn a_draft_only_deletion_does_not_create_a_committed_reservation() {
let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap(); let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
let mut states = ClientStates::new(saved, None, RuntimeObservation::Unknown).unwrap(); let mut states = ClientStates::new(saved, None, ApplyStatus::Unknown).unwrap();
states.draft_mut().remove(&key(1)); states.remove_draft_client(&key(1));
assert!(states.reserved_addresses().is_empty()); assert!(states.reserved_addresses().is_empty());
assert!( assert!(
states states
@@ -638,31 +771,85 @@ mod tests {
); );
} }
#[test]
fn exact_host_routes_stay_pending_until_commit_and_discard_drops_them() {
let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
let address = IpAddr::V4("10.0.0.9".parse().unwrap());
let route = "10.0.0.9/32".parse::<ipnet::IpNet>().unwrap();
let mut states = ClientStates::new(saved.clone(), None, ApplyStatus::Unknown).unwrap();
states.stage_exact_host_routes([route]).unwrap();
assert!(states.reserved_addresses().is_empty());
assert_eq!(
states
.address_reservations()
.pending_host_routes()
.collect::<Vec<_>>(),
[address]
);
assert!(states.addresses_in_use().contains(&address));
states.discard_draft();
assert!(
states
.address_reservations()
.pending_host_routes()
.next()
.is_none()
);
assert!(!states.addresses_in_use().contains(&address));
states.stage_exact_host_routes([route]).unwrap();
states.commit_draft();
assert!(
states
.address_reservations()
.pending_host_routes()
.next()
.is_none()
);
assert!(states.reserved_addresses().contains(&address));
}
#[test]
fn unsafe_route_cannot_partially_change_pending_reservations() {
let mut reservations = AddressReservations::default();
let exact = "10.0.0.9/32".parse::<ipnet::IpNet>().unwrap();
let broad = "10.0.0.0/24".parse::<ipnet::IpNet>().unwrap();
reservations.stage_exact_host_routes([exact]).unwrap();
assert_eq!(
reservations.stage_exact_host_routes(["10.0.0.10/32".parse().unwrap(), broad]),
Err(DomainError::UnsafeReservationRoute)
);
assert_eq!(
reservations.pending_host_routes().collect::<Vec<_>>(),
[IpAddr::V4("10.0.0.9".parse().unwrap())]
);
}
#[test] #[test]
fn successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not() { fn successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not() {
let initial = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap(); let initial = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap();
let mut states = ClientStates::new( let mut states = ClientStates::new(
initial.clone(), initial.clone(),
Some(initial.clone()), Some(initial.clone()),
RuntimeObservation::Verified, ApplyStatus::Verified,
) )
.unwrap(); .unwrap();
let wrong = Snapshot::new([client(2, "other", "10.0.0.3", None)]).unwrap(); let wrong = Snapshot::new([client(2, "other", "10.0.0.3", None)]).unwrap();
assert_eq!( assert_eq!(
states.mark_applied_after_verification(wrong), states.prepare_verified_result(wrong),
Err(DomainError::AppliedSnapshotMismatch) Err(DomainError::AppliedSnapshotMismatch)
); );
assert_eq!(states.applied(), &RuntimeObservation::Unknown); assert_eq!(states.applied(), &ApplyStatus::Unknown);
assert_eq!(states.applied_snapshot(), Some(&initial)); assert_eq!(states.applied_snapshot(), Some(&initial));
states.draft_mut().remove(&key(1)); states.remove_draft_client(&key(1));
states.commit_draft(); states.commit_draft();
assert!(!states.reserved_addresses().is_empty()); assert!(!states.reserved_addresses().is_empty());
let saved = states.saved().clone(); let saved = states.saved().clone();
states states.prepare_verified_result(saved.clone()).unwrap();
.mark_applied_after_verification(saved.clone()) assert_eq!(states.applied(), &ApplyStatus::Verified);
.unwrap();
assert_eq!(states.applied(), &RuntimeObservation::Verified);
assert!(states.reserved_addresses().is_empty()); assert!(states.reserved_addresses().is_empty());
assert!( assert!(
!states !states
+4 -10
View File
@@ -953,22 +953,16 @@ restart_timeout_seconds = 30
) )
.unwrap(); .unwrap();
let saved = crate::domain::Snapshot::new([client]).unwrap(); let saved = crate::domain::Snapshot::new([client]).unwrap();
let mut states = crate::domain::ClientStates::new( let mut states =
saved, crate::domain::ClientStates::new(saved, None, crate::domain::ApplyStatus::Unknown)
None, .unwrap();
crate::domain::RuntimeObservation::Unknown,
)
.unwrap();
let state = PersistedState::new( let state = PersistedState::new(
["10.0.0.8".to_owned()], ["10.0.0.8".to_owned()],
Some("2026-10-19T12:30:00Z".to_owned()), Some("2026-10-19T12:30:00Z".to_owned()),
) )
.unwrap(); .unwrap();
state.apply_to(&mut states).unwrap(); state.apply_to(&mut states).unwrap();
assert_eq!( assert_eq!(states.applied(), &crate::domain::ApplyStatus::Unknown);
states.applied(),
&crate::domain::RuntimeObservation::Unknown
);
assert!( assert!(
states states
.reserved_addresses() .reserved_addresses()
+2 -2
View File
@@ -40,8 +40,8 @@ pub(crate) struct DecodeContext {
pub family_id: u16, pub family_id: u16,
} }
/// Hard aggregate limits for one dump. These are deliberately supplied by the /// Hard aggregate limits for one dump, supplied by the current netlink
/// future adapter so its policy is visible at construction time. /// transport so its policy is visible at construction time.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct DecodeLimits { pub(crate) struct DecodeLimits {
pub max_datagrams: usize, pub max_datagrams: usize,
+1 -1
View File
@@ -66,7 +66,7 @@ pub enum JobResult {
Other, Other,
} }
/// An event delivered by the future D-Bus adapter. /// An event emitted by the D-Bus adapter and consumed by the job tracker.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum ServiceEvent { pub enum ServiceEvent {
JobRemoved { JobRemoved {
+99 -384
View File
@@ -10,13 +10,10 @@ use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::app::Application; use crate::app::Application;
use crate::app::{ use crate::app::{FirstIssueAttempt, FirstIssueRetry, RestartChoice, RestartReport};
FirstIssueAttempt, FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartReport,
RestorePreview,
};
use crate::backup::{ArchiveSummary, BackupReason, HistoricalApplyStatus}; use crate::backup::{ArchiveSummary, BackupReason, HistoricalApplyStatus};
use crate::config::SettingsFile; use crate::config::SettingsFile;
use crate::delivery::files::{FileOutputError, PreparedDestination}; use crate::delivery::files::FileOutputError;
use crate::delivery::{QrCodeOutput, QrError}; use crate::delivery::{QrCodeOutput, QrError};
use crate::domain::{ClientAddresses, PublicKey}; use crate::domain::{ClientAddresses, PublicKey};
use crate::runtime::RuntimeObservation; use crate::runtime::RuntimeObservation;
@@ -287,385 +284,10 @@ pub trait OperationExecutor: Send + 'static {
} }
} }
#[derive(Default)] mod executor;
pub struct ApplicationExecutor { pub use executor::ApplicationExecutor;
prepared_destination: Option<( #[cfg(test)]
PublicKey, use executor::{destination_generation_matches, restore_preview_generation_matches};
bool,
bool,
FirstIssueAttempt,
u64,
PreparedDestination,
)>,
restore_preview: Option<(String, u64, RestorePreview)>,
}
impl OperationExecutor for ApplicationExecutor {
fn execute(
&mut self,
app: &mut Application,
command: UiCommand,
) -> Result<CommandResult, OperationError> {
self.execute_at(app, command, 0)
}
fn execute_at(
&mut self,
app: &mut Application,
command: UiCommand,
generation: u64,
) -> Result<CommandResult, OperationError> {
let delivery_continuation = matches!(command, UiCommand::DeliverFirstIssue { .. });
let restore_continuation = matches!(
command,
UiCommand::ConfirmRestore { .. } | UiCommand::CancelRestorePreview
);
if !delivery_continuation && !matches!(&command, UiCommand::CancelPreparedDelivery) {
self.prepared_destination = None;
}
if !restore_continuation && !matches!(&command, UiCommand::PreviewRestore { .. }) {
self.restore_preview = None;
}
let result = match command {
UiCommand::CreateClient {
alias,
manual_addresses,
created_utc,
} => app
.prepare_new_client(&alias, manual_addresses, created_utc.as_deref())
.map(CommandResult::ClientCreated),
UiCommand::RenameClient { identity, alias } => app
.rename_client(identity, &alias)
.map(|()| CommandResult::ClientRenamed),
UiCommand::DeleteClient { identity } => app
.delete_client(identity)
.map(|record_removed| CommandResult::ClientDeleted { record_removed }),
UiCommand::DiscardDraft => app.discard_draft().map(|()| CommandResult::DraftDiscarded),
UiCommand::Commit => app.commit().map(CommandResult::Committed),
UiCommand::CreateBackup => app.create_manual_backup().map(CommandResult::BackupCreated),
UiCommand::ListBackups => app.list_backups().map(CommandResult::Backups),
UiCommand::ImportSelectedFile { file_name } => app
.import_selected_file(&file_name)
.map(CommandResult::SelectedFileImported),
UiCommand::PreviewRestart => app.preview_restart().map(|preview| {
CommandResult::RestartPreview(RestartPreviewSummary {
has_draft: preview.has_draft(),
interface: preview.interface().to_owned(),
systemd_unit: preview.systemd_unit().to_owned(),
})
}),
UiCommand::Restart(choice) => {
let result = app
.preview_restart()
.map(|preview| app.restart(preview, choice));
result.map(CommandResult::Restarted)
}
UiCommand::PrepareDelivery {
identity,
path,
include_qr,
reissue,
attempt,
} => app
.prepare_first_issue_destination(path)
.map_err(crate::app::AppError::FileOutput)
.map(|destination| {
let summary = DeliveryDestinationSummary {
identity,
path: destination.path().to_path_buf(),
overwrite_required: destination.overwrite_confirmation_required(),
include_qr,
reissue,
attempt,
prepared_generation: generation,
};
self.prepared_destination = Some((
identity,
include_qr,
reissue,
attempt,
generation,
destination,
));
CommandResult::DeliveryDestination(summary)
}),
UiCommand::DeliverFirstIssue {
attempt,
overwrite_confirmed,
prepared_generation,
} => {
let (
identity,
include_qr,
reissue,
prepared_attempt,
destination_generation,
mut destination,
) = self
.prepared_destination
.take()
.ok_or_else(|| OperationError {
message: "No prepared output destination is available.".to_owned(),
})?;
if reissue
|| prepared_attempt != attempt
|| !destination_generation_matches(
destination_generation,
prepared_generation,
generation,
)
{
return Err(OperationError {
message: "Prepared output destination is stale; select it again."
.to_owned(),
});
}
if destination.overwrite_confirmation_required() {
if !overwrite_confirmed {
return Err(OperationError {
message: "Overwrite confirmation was not supplied.".to_owned(),
});
}
destination = destination.confirm_overwrite().map_err(operation_error)?;
}
if include_qr {
app.deliver_first_issue_file_and_qr(identity, attempt, destination)
.map(|outcome| match outcome {
FirstIssueDeliveryOutcome::Delivered(delivery) => {
let (qr, qr_output) = qr_result(delivery.qr);
CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Delivered,
retry: None,
activation_warning: delivery.activation_warning,
qr,
qr_output,
})
}
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
value,
..
} => {
let (qr, qr_output) = qr_result(value.qr);
CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::PublishedButBookkeepingUncertain,
retry: None,
activation_warning: value.activation_warning,
qr,
qr_output,
})
}
FirstIssueDeliveryOutcome::Failed {
error,
retry,
activation_warning,
} => CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Failed(error),
retry: Some(retry),
activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
}),
})
} else {
app.deliver_first_issue_file(identity, attempt, destination)
.map(|outcome| match outcome {
FirstIssueDeliveryOutcome::Delivered(delivery) => {
CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Delivered,
retry: None,
activation_warning: delivery.activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
})
}
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
value,
..
} => CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::PublishedButBookkeepingUncertain,
retry: None,
activation_warning: value.activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
}),
FirstIssueDeliveryOutcome::Failed {
error,
retry,
activation_warning,
} => CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Failed(error),
retry: Some(retry),
activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
}),
})
}
}
UiCommand::PreviewRestore { file_name } => {
app.preview_restore(&file_name).map(|preview| {
let changes = preview.change_summary(app.saved_bytes());
let summary = RestorePreviewSummary {
file_name: file_name.clone(),
created_at_utc: preview.created_at_utc().to_owned(),
reason: preview.reason(),
historical_apply: preview.historical_apply().clone(),
changes,
preview_generation: generation,
};
self.restore_preview = Some((file_name, generation, preview));
CommandResult::RestorePreview(summary)
})
}
UiCommand::CancelPreparedDelivery => {
self.prepared_destination = None;
Ok(CommandResult::Cancelled)
}
UiCommand::CancelRestorePreview => {
self.restore_preview = None;
Ok(CommandResult::Cancelled)
}
UiCommand::ConfirmRestore { preview_generation } => {
let (_, stored_generation, preview) =
self.restore_preview.take().ok_or_else(|| OperationError {
message:
"Restore preview is no longer available; select the archive again."
.to_owned(),
})?;
if !restore_preview_generation_matches(
stored_generation,
preview_generation,
generation,
) {
return Err(OperationError {
message: "Restore preview is stale; select the archive again.".to_owned(),
});
}
app.confirm_restore(preview).map(CommandResult::Restored)
}
};
result.map_err(|error| OperationError {
message: error.to_string(),
})
}
fn execute_sensitive(
&mut self,
app: &mut Application,
command: SensitiveCommand,
) -> Result<CommandResult, OperationError> {
match command {
SensitiveCommand::PrepareInitialSetup(password) => app
.prepare_initial_setup_from_sources(password.expose())
.map(CommandResult::InitialSetupPrepared)
.map_err(|error| OperationError {
message: error.to_string(),
}),
SensitiveCommand::ReissueClient {
identity,
password,
overwrite_confirmed,
prepared_generation,
} => {
let (
prepared_identity,
_include_qr,
reissue,
_attempt,
destination_generation,
mut destination,
) = self
.prepared_destination
.take()
.ok_or_else(|| OperationError {
message: "No prepared output destination is available.".to_owned(),
})?;
if prepared_identity != identity
|| !reissue
|| destination_generation != prepared_generation
{
return Err(OperationError {
message: "Prepared output destination is stale; select it again."
.to_owned(),
});
}
if destination.overwrite_confirmation_required() {
if !overwrite_confirmed {
return Err(OperationError {
message: "Overwrite confirmation was not supplied.".to_owned(),
});
}
destination = destination.confirm_overwrite().map_err(operation_error)?;
}
let delivery = app
.reissue_client_file(identity, password.expose(), destination, _include_qr)
.map_err(|error| OperationError {
message: error.to_string(),
})?;
let (qr, qr_output) = match delivery.qr {
None => (QrStatus::NotRequested, None),
Some(result) => qr_result(result),
};
Ok(CommandResult::Reissued(ReissueSummary {
activation_warning: delivery.activation_warning,
qr,
qr_output,
}))
}
}
}
fn execute_sensitive_at(
&mut self,
app: &mut Application,
command: SensitiveCommand,
generation: u64,
) -> Result<CommandResult, OperationError> {
match &command {
SensitiveCommand::ReissueClient {
prepared_generation,
..
} if *prepared_generation != generation => {
return Err(OperationError {
message: "Prepared output destination is stale; select it again.".to_owned(),
});
}
_ => {}
}
if matches!(command, SensitiveCommand::PrepareInitialSetup(_)) {
self.prepared_destination = None;
self.restore_preview = None;
}
self.execute_sensitive(app, command)
}
}
fn operation_error(error: impl std::fmt::Display) -> OperationError {
OperationError {
message: error.to_string(),
}
}
fn destination_generation_matches(stored: u64, requested: u64, current: u64) -> bool {
stored == requested && stored == current
}
fn restore_preview_generation_matches(stored: u64, requested: u64, current: u64) -> bool {
stored == requested && current == requested.saturating_add(1)
}
fn qr_result(result: Result<QrCodeOutput, QrError>) -> (QrStatus, Option<QrCodeOutput>) {
match result {
Ok(output) => (QrStatus::Ready, Some(output)),
Err(error) => (QrStatus::Failed(error), None),
}
}
pub trait StatisticsReader: Send + 'static { pub trait StatisticsReader: Send + 'static {
/// Implementations must return within `timeout`; unavailable runtime data /// Implementations must return within `timeout`; unavailable runtime data
@@ -1088,6 +710,99 @@ mod tests {
assert!(!restore_preview_generation_matches(8, 8, 8)); assert!(!restore_preview_generation_matches(8, 8, 8));
} }
#[test]
fn prepared_delivery_is_cleared_by_cancel_and_unrelated_commands() {
let (_root, mut app) = application();
let identity = PublicKey::from_bytes([44; 32]);
let mut executor = ApplicationExecutor::default();
let path = _root.path().join("prepared.conf");
let prepare = || UiCommand::PrepareDelivery {
identity,
path: path.clone(),
include_qr: false,
reissue: false,
attempt: FirstIssueAttempt::Initial,
};
let deliver = || UiCommand::DeliverFirstIssue {
attempt: FirstIssueAttempt::Initial,
overwrite_confirmed: false,
prepared_generation: 7,
};
assert!(matches!(
executor.execute_at(&mut app, prepare(), 7),
Ok(CommandResult::DeliveryDestination(_))
));
assert!(matches!(
executor.execute_at(&mut app, UiCommand::CancelPreparedDelivery, 7),
Ok(CommandResult::Cancelled)
));
assert_eq!(
executor
.execute_at(&mut app, deliver(), 7)
.unwrap_err()
.message,
"No prepared output destination is available."
);
assert!(matches!(
executor.execute_at(&mut app, prepare(), 7),
Ok(CommandResult::DeliveryDestination(_))
));
assert!(matches!(
executor.execute_at(&mut app, UiCommand::ListBackups, 7),
Ok(CommandResult::Backups(_))
));
assert_eq!(
executor
.execute_at(&mut app, deliver(), 7)
.unwrap_err()
.message,
"No prepared output destination is available."
);
}
#[test]
fn stale_delivery_confirmation_consumes_prepared_destination_without_delivery() {
let (root, mut app) = application();
let identity = PublicKey::from_bytes([45; 32]);
let path = root.path().join("prepared.conf");
let mut executor = ApplicationExecutor::default();
assert!(matches!(
executor.execute_at(
&mut app,
UiCommand::PrepareDelivery {
identity,
path,
include_qr: false,
reissue: false,
attempt: FirstIssueAttempt::Initial,
},
7,
),
Ok(CommandResult::DeliveryDestination(_))
));
let stale = UiCommand::DeliverFirstIssue {
attempt: FirstIssueAttempt::Initial,
overwrite_confirmed: false,
prepared_generation: 7,
};
assert_eq!(
executor
.execute_at(&mut app, stale.clone(), 8)
.unwrap_err()
.message,
"Prepared output destination is stale; select it again."
);
assert_eq!(
executor.execute_at(&mut app, stale, 7).unwrap_err().message,
"No prepared output destination is available."
);
assert!(!root.path().join("prepared.conf").exists());
}
struct FakeStatistics { struct FakeStatistics {
started: Option<mpsc::SyncSender<()>>, started: Option<mpsc::SyncSender<()>>,
release: Option<Receiver<()>>, release: Option<Receiver<()>>,
+404
View File
@@ -0,0 +1,404 @@
use super::{
CommandResult, DeliveryDestinationSummary, FirstIssueAttempt, FirstIssueStatus,
FirstIssueSummary, OperationError, OperationExecutor, QrStatus, ReissueSummary,
RestartPreviewSummary, RestorePreviewSummary, SensitiveCommand, UiCommand,
};
use crate::app::{Application, FirstIssueDeliveryOutcome, RestorePreview};
use crate::delivery::files::PreparedDestination;
use crate::delivery::{QrCodeOutput, QrError};
use crate::domain::PublicKey;
enum DeliveryMode {
FirstIssue,
Reissue,
}
struct PreparedDestinationContext {
identity: PublicKey,
include_qr: bool,
mode: DeliveryMode,
attempt: FirstIssueAttempt,
generation: u64,
destination: PreparedDestination,
}
struct RestorePreviewContext {
_archive_name: String,
generation: u64,
preview: RestorePreview,
}
#[derive(Default)]
pub struct ApplicationExecutor {
prepared_destination: Option<PreparedDestinationContext>,
restore_preview: Option<RestorePreviewContext>,
}
impl OperationExecutor for ApplicationExecutor {
fn execute(
&mut self,
app: &mut Application,
command: UiCommand,
) -> Result<CommandResult, OperationError> {
self.execute_at(app, command, 0)
}
fn execute_at(
&mut self,
app: &mut Application,
command: UiCommand,
generation: u64,
) -> Result<CommandResult, OperationError> {
let delivery_continuation = matches!(command, UiCommand::DeliverFirstIssue { .. });
let restore_continuation = matches!(
command,
UiCommand::ConfirmRestore { .. } | UiCommand::CancelRestorePreview
);
if !delivery_continuation && !matches!(&command, UiCommand::CancelPreparedDelivery) {
self.prepared_destination = None;
}
if !restore_continuation && !matches!(&command, UiCommand::PreviewRestore { .. }) {
self.restore_preview = None;
}
let result = match command {
UiCommand::CreateClient {
alias,
manual_addresses,
created_utc,
} => app
.prepare_new_client(&alias, manual_addresses, created_utc.as_deref())
.map(CommandResult::ClientCreated),
UiCommand::RenameClient { identity, alias } => app
.rename_client(identity, &alias)
.map(|()| CommandResult::ClientRenamed),
UiCommand::DeleteClient { identity } => app
.delete_client(identity)
.map(|record_removed| CommandResult::ClientDeleted { record_removed }),
UiCommand::DiscardDraft => app.discard_draft().map(|()| CommandResult::DraftDiscarded),
UiCommand::Commit => app.commit().map(CommandResult::Committed),
UiCommand::CreateBackup => app.create_manual_backup().map(CommandResult::BackupCreated),
UiCommand::ListBackups => app.list_backups().map(CommandResult::Backups),
UiCommand::ImportSelectedFile { file_name } => app
.import_selected_file(&file_name)
.map(CommandResult::SelectedFileImported),
UiCommand::PreviewRestart => app.preview_restart().map(|preview| {
CommandResult::RestartPreview(RestartPreviewSummary {
has_draft: preview.has_draft(),
interface: preview.interface().to_owned(),
systemd_unit: preview.systemd_unit().to_owned(),
})
}),
UiCommand::Restart(choice) => {
let result = app
.preview_restart()
.map(|preview| app.restart(preview, choice));
result.map(CommandResult::Restarted)
}
UiCommand::PrepareDelivery {
identity,
path,
include_qr,
reissue,
attempt,
} => app
.prepare_first_issue_destination(path)
.map_err(crate::app::AppError::FileOutput)
.map(|destination| {
let summary = DeliveryDestinationSummary {
identity,
path: destination.path().to_path_buf(),
overwrite_required: destination.overwrite_confirmation_required(),
include_qr,
reissue,
attempt,
prepared_generation: generation,
};
self.prepared_destination = Some(PreparedDestinationContext {
identity,
include_qr,
mode: if reissue {
DeliveryMode::Reissue
} else {
DeliveryMode::FirstIssue
},
attempt,
generation,
destination,
});
CommandResult::DeliveryDestination(summary)
}),
UiCommand::DeliverFirstIssue {
attempt,
overwrite_confirmed,
prepared_generation,
} => {
let prepared = self
.prepared_destination
.take()
.ok_or_else(|| OperationError {
message: "No prepared output destination is available.".to_owned(),
})?;
if !matches!(prepared.mode, DeliveryMode::FirstIssue)
|| prepared.attempt != attempt
|| !destination_generation_matches(
prepared.generation,
prepared_generation,
generation,
)
{
return Err(OperationError {
message: "Prepared output destination is stale; select it again."
.to_owned(),
});
}
let identity = prepared.identity;
let include_qr = prepared.include_qr;
let mut destination = prepared.destination;
if destination.overwrite_confirmation_required() {
if !overwrite_confirmed {
return Err(OperationError {
message: "Overwrite confirmation was not supplied.".to_owned(),
});
}
destination = destination.confirm_overwrite().map_err(operation_error)?;
}
if include_qr {
app.deliver_first_issue_file_and_qr(identity, attempt, destination)
.map(|outcome| match outcome {
FirstIssueDeliveryOutcome::Delivered(delivery) => {
let (qr, qr_output) = qr_result(delivery.qr);
CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Delivered,
retry: None,
activation_warning: delivery.activation_warning,
qr,
qr_output,
})
}
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
value,
..
} => {
let (qr, qr_output) = qr_result(value.qr);
CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::PublishedButBookkeepingUncertain,
retry: None,
activation_warning: value.activation_warning,
qr,
qr_output,
})
}
FirstIssueDeliveryOutcome::Failed {
error,
retry,
activation_warning,
} => CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Failed(error),
retry: Some(retry),
activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
}),
})
} else {
app.deliver_first_issue_file(identity, attempt, destination)
.map(|outcome| match outcome {
FirstIssueDeliveryOutcome::Delivered(delivery) => {
CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Delivered,
retry: None,
activation_warning: delivery.activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
})
}
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
value,
..
} => CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::PublishedButBookkeepingUncertain,
retry: None,
activation_warning: value.activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
}),
FirstIssueDeliveryOutcome::Failed {
error,
retry,
activation_warning,
} => CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Failed(error),
retry: Some(retry),
activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
}),
})
}
}
UiCommand::PreviewRestore { file_name } => {
app.preview_restore(&file_name).map(|preview| {
let changes = preview.change_summary(app.saved_bytes());
let summary = RestorePreviewSummary {
file_name: file_name.clone(),
created_at_utc: preview.created_at_utc().to_owned(),
reason: preview.reason(),
historical_apply: preview.historical_apply().clone(),
changes,
preview_generation: generation,
};
self.restore_preview = Some(RestorePreviewContext {
_archive_name: file_name,
generation,
preview,
});
CommandResult::RestorePreview(summary)
})
}
UiCommand::CancelPreparedDelivery => {
self.prepared_destination = None;
Ok(CommandResult::Cancelled)
}
UiCommand::CancelRestorePreview => {
self.restore_preview = None;
Ok(CommandResult::Cancelled)
}
UiCommand::ConfirmRestore { preview_generation } => {
let context = self.restore_preview.take().ok_or_else(|| OperationError {
message: "Restore preview is no longer available; select the archive again."
.to_owned(),
})?;
if !restore_preview_generation_matches(
context.generation,
preview_generation,
generation,
) {
return Err(OperationError {
message: "Restore preview is stale; select the archive again.".to_owned(),
});
}
app.confirm_restore(context.preview)
.map(CommandResult::Restored)
}
};
result.map_err(|error| OperationError {
message: error.to_string(),
})
}
fn execute_sensitive(
&mut self,
app: &mut Application,
command: SensitiveCommand,
) -> Result<CommandResult, OperationError> {
match command {
SensitiveCommand::PrepareInitialSetup(password) => app
.prepare_initial_setup_from_sources(password.expose())
.map(CommandResult::InitialSetupPrepared)
.map_err(|error| OperationError {
message: error.to_string(),
}),
SensitiveCommand::ReissueClient {
identity,
password,
overwrite_confirmed,
prepared_generation,
} => {
let prepared = self
.prepared_destination
.take()
.ok_or_else(|| OperationError {
message: "No prepared output destination is available.".to_owned(),
})?;
if prepared.identity != identity
|| !matches!(prepared.mode, DeliveryMode::Reissue)
|| prepared.generation != prepared_generation
{
return Err(OperationError {
message: "Prepared output destination is stale; select it again."
.to_owned(),
});
}
let include_qr = prepared.include_qr;
let mut destination = prepared.destination;
if destination.overwrite_confirmation_required() {
if !overwrite_confirmed {
return Err(OperationError {
message: "Overwrite confirmation was not supplied.".to_owned(),
});
}
destination = destination.confirm_overwrite().map_err(operation_error)?;
}
let delivery = app
.reissue_client_file(identity, password.expose(), destination, include_qr)
.map_err(|error| OperationError {
message: error.to_string(),
})?;
let (qr, qr_output) = match delivery.qr {
None => (QrStatus::NotRequested, None),
Some(result) => qr_result(result),
};
Ok(CommandResult::Reissued(ReissueSummary {
activation_warning: delivery.activation_warning,
qr,
qr_output,
}))
}
}
}
fn execute_sensitive_at(
&mut self,
app: &mut Application,
command: SensitiveCommand,
generation: u64,
) -> Result<CommandResult, OperationError> {
match &command {
SensitiveCommand::ReissueClient {
prepared_generation,
..
} if *prepared_generation != generation => {
return Err(OperationError {
message: "Prepared output destination is stale; select it again.".to_owned(),
});
}
_ => {}
}
if matches!(command, SensitiveCommand::PrepareInitialSetup(_)) {
self.prepared_destination = None;
self.restore_preview = None;
}
self.execute_sensitive(app, command)
}
}
fn operation_error(error: impl std::fmt::Display) -> OperationError {
OperationError {
message: error.to_string(),
}
}
pub(super) fn destination_generation_matches(stored: u64, requested: u64, current: u64) -> bool {
stored == requested && stored == current
}
pub(super) fn restore_preview_generation_matches(
stored: u64,
requested: u64,
current: u64,
) -> bool {
stored == requested && current == requested.saturating_add(1)
}
fn qr_result(result: Result<QrCodeOutput, QrError>) -> (QrStatus, Option<QrCodeOutput>) {
match result {
Ok(output) => (QrStatus::Ready, Some(output)),
Err(error) => (QrStatus::Failed(error), None),
}
}
+524 -168
View File
@@ -208,7 +208,9 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
add("p", "preview", HintKind::Action); add("p", "preview", HintKind::Action);
add("c", "Commit", HintKind::Action); add("c", "Commit", HintKind::Action);
add("x", "discard", HintKind::Destructive); add("x", "discard", HintKind::Destructive);
add("R", "restart", HintKind::Destructive); if restart_available(state) {
add("R", "restart", HintKind::Destructive);
}
add("b", "backups / restore", HintKind::Action); add("b", "backups / restore", HintKind::Action);
add("B", "backup", HintKind::Action); add("B", "backup", HintKind::Action);
add("!", "issues", HintKind::Action); add("!", "issues", HintKind::Action);
@@ -219,22 +221,21 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
{ {
add("s", "setup", HintKind::Action); add("s", "setup", HintKind::Action);
} }
if state.selected_client_mutable() { if state.selected_rename_available() {
add("r", "rename", HintKind::Action); add("r", "rename", HintKind::Action);
}
if state.selected_delete_available() {
add("d", "delete", HintKind::Destructive); add("d", "delete", HintKind::Destructive);
let saved = state }
.selected_identity if state.selected_first_delivery_available() {
.and_then(|id| state.view.as_ref()?.client_states.get(&id)) add("o", "first file", HintKind::Action);
.is_some_and(|row| row.saved.is_some()); }
if saved { if state.selected_reissue_available() {
add("o", "first file", HintKind::Action); add("O", "password reissue", HintKind::Action);
add("O", "password reissue", HintKind::Action);
}
} }
} }
Screen::ClientList => { Screen::ClientList => {
let has_view = state.view.is_some(); let has_view = state.view.is_some();
let selected = state.selected_client_mutable();
add("↑/↓", "select", HintKind::Navigation); add("↑/↓", "select", HintKind::Navigation);
if state if state
.view .view
@@ -246,23 +247,22 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
add("?", "Help", HintKind::Navigation); add("?", "Help", HintKind::Navigation);
if has_view { if has_view {
add("n", "new", HintKind::Action); add("n", "new", HintKind::Action);
if selected { if state.selected_rename_available() {
add("r", "rename", HintKind::Action); add("r", "rename", HintKind::Action);
}
if state.selected_delete_available() {
add("d", "delete", HintKind::Destructive); add("d", "delete", HintKind::Destructive);
} }
add("p", "preview", HintKind::Action); add("p", "preview", HintKind::Action);
add("c", "Commit", HintKind::Action); add("c", "Commit", HintKind::Action);
add("x", "discard", HintKind::Destructive); add("x", "discard", HintKind::Destructive);
add("R", "restart", HintKind::Destructive); if restart_available(state) {
if let Some(id) = state.selected_identity add("R", "restart", HintKind::Destructive);
&& state }
.view if state.selected_first_delivery_available() {
.as_ref()
.and_then(|view| view.client_states.get(&id))
.is_some_and(|row| row.saved.is_some())
&& selected
{
add("o", "first file", HintKind::Action); add("o", "first file", HintKind::Action);
}
if state.selected_reissue_available() {
add("O", "password reissue", HintKind::Action); add("O", "password reissue", HintKind::Action);
} }
add("b", "backups / restore", HintKind::Action); add("b", "backups / restore", HintKind::Action);
@@ -286,6 +286,14 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
hints hints
} }
fn restart_available(state: &UiState) -> bool {
state
.view
.as_ref()
.and_then(SessionView::restart_eligibility)
.is_none_or(|eligibility| eligibility.available)
}
fn pack_hints(hints: &[Hint], width: usize) -> Vec<Vec<Hint>> { fn pack_hints(hints: &[Hint], width: usize) -> Vec<Vec<Hint>> {
let mut rows = Vec::<Vec<Hint>>::new(); let mut rows = Vec::<Vec<Hint>>::new();
let mut used = 0; let mut used = 0;
@@ -649,14 +657,11 @@ fn qr_state_summary(state: &UiState, width: usize) -> Option<String> {
}, },
view.saved_peer_count view.saved_peer_count
.map_or_else(|| "unknown".to_owned(), |count| count.to_string()), .map_or_else(|| "unknown".to_owned(), |count| count.to_string()),
view.client_states view.saved_managed_client_count(),
.values()
.filter(|row| row.saved.is_some())
.count(),
match view.applied { match view.applied {
super::state::AppliedState::Verified => "verified", crate::domain::ApplyStatus::Verified => "verified",
super::state::AppliedState::Unknown => "not verified", crate::domain::ApplyStatus::Unknown => "not verified",
super::state::AppliedState::Unavailable => "observation unavailable", crate::domain::ApplyStatus::Unavailable => "observation unavailable",
} }
)) ))
} }
@@ -827,37 +832,83 @@ fn draft_review_lines(state: &UiState) -> Vec<String> {
if view.has_draft { "yes" } else { "no" } if view.has_draft { "yes" } else { "no" }
), ),
]; ];
for (identity, row) in &view.client_states { if let Some(application) = &view.application {
match (&row.saved, &row.draft) { for peer in &application.clients {
(None, Some(draft)) => lines.push(format!( let identity = peer.identity;
"Added {identity}: {} (IPv4 {}, IPv6 {})", match (
safe_text(&draft.alias), peer.state_change,
draft.addresses.ipv4, peer.saved_client.as_ref(),
draft peer.draft_client.as_ref(),
.addresses ) {
.ipv6 (crate::app::ClientStateChange::Added, _, Some(draft)) => lines.push(format!(
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()) "Added {}: {} (IPv4 {}, IPv6 {})",
)), identity.map_or_else(|| "unknown key".to_owned(), |key| key.to_string()),
(Some(saved), None) => lines.push(format!( safe_text(&draft.alias),
"Removed {identity}: {} (saved removal remains unapplied until restart)", draft.addresses.ipv4,
safe_text(&saved.alias) draft
)), .addresses
(Some(saved), Some(draft)) if saved != draft => lines.push(format!( .ipv6
"Changed {identity}: alias {} → {}; addresses {} / {} → {} / {}", .map_or_else(|| "none".to_owned(), |ip| ip.to_string())
safe_text(&saved.alias), )),
safe_text(&draft.alias), (crate::app::ClientStateChange::Removed, _, _) => lines.push(format!(
saved.addresses.ipv4, "Removed {}: {} (saved removal remains unapplied until restart)",
saved identity.map_or_else(|| "unknown key".to_owned(), |key| key.to_string()),
.addresses safe_text(&peer.label)
.ipv6 )),
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()), (crate::app::ClientStateChange::Changed, Some(saved), Some(draft)) => {
draft.addresses.ipv4, lines.push(format!(
draft "Changed {}: alias {} → {}; addresses {} / {} → {} / {}",
.addresses identity.map_or_else(|| "unknown key".to_owned(), |key| key.to_string()),
.ipv6 safe_text(&saved.alias),
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()) safe_text(&draft.alias),
)), saved.addresses.ipv4,
_ => {} saved
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()),
draft.addresses.ipv4,
draft
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
))
}
_ => {}
}
}
} else {
for (identity, row) in &view.client_states {
match (&row.saved, &row.draft) {
(None, Some(draft)) => lines.push(format!(
"Added {identity}: {} (IPv4 {}, IPv6 {})",
safe_text(&draft.alias),
draft.addresses.ipv4,
draft
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
)),
(Some(saved), None) => lines.push(format!(
"Removed {identity}: {} (saved removal remains unapplied until restart)",
safe_text(&saved.alias)
)),
(Some(saved), Some(draft)) if saved != draft => lines.push(format!(
"Changed {identity}: alias {} → {}; addresses {} / {} → {} / {}",
safe_text(&saved.alias),
safe_text(&draft.alias),
saved.addresses.ipv4,
saved
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()),
draft.addresses.ipv4,
draft
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
)),
_ => {}
}
} }
} }
if lines.len() == 2 && view.has_draft { if lines.len() == 2 && view.has_draft {
@@ -884,25 +935,38 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta
.clients .clients
.iter() .iter()
.map(|client| { .map(|client| {
let visible_issues: Vec<_> = client.presented_issues().collect(); let visible_issues = view.presented_issues(client);
let identity = client let identity = client
.identity .identity
.map(|key| short_key(&key)) .map(|key| short_key(&key))
.unwrap_or_else(|| format!("peer at line {}", client.line)); .unwrap_or_else(|| format!("peer at line {}", client.line));
let (ipv4, ipv6, route_marker) = client_address_cells(client); let (ipv4, ipv6, route_marker) = client_address_cells(client);
let row_state = client let row_state = view.client_state_change(client).map_or_else(
.identity || {
.and_then(|identity| view.client_states.get(&identity)) client
.map_or_else( .identity
|| "Saved ?".to_owned(), .and_then(|identity| view.client_states.get(&identity))
|row| match (&row.saved, &row.draft) { .map_or_else(
(None, Some(_)) => "Draft added".to_owned(), || "Saved ?".to_owned(),
(Some(saved), Some(draft)) if saved != draft => "Draft changed".to_owned(), |row| match (&row.saved, &row.draft) {
(Some(_), None) => "Draft removed".to_owned(), (None, Some(_)) => "Draft added".to_owned(),
(Some(_), Some(_)) => "Saved".to_owned(), (Some(saved), Some(draft)) if saved != draft => {
(None, None) => "State unavailable".to_owned(), "Draft changed".to_owned()
}, }
); (Some(_), None) => "Draft removed".to_owned(),
(Some(_), Some(_)) => "Saved".to_owned(),
(None, None) => "State unavailable".to_owned(),
},
)
},
|change| match change {
crate::app::ClientStateChange::Added => "Draft added".to_owned(),
crate::app::ClientStateChange::Removed => "Draft removed".to_owned(),
crate::app::ClientStateChange::Changed => "Draft changed".to_owned(),
crate::app::ClientStateChange::Unchanged => "Saved".to_owned(),
crate::app::ClientStateChange::UnsupportedOrUnmanaged => "Saved ?".to_owned(),
},
);
let row_state = if layout.compact { let row_state = if layout.compact {
compact_state_label(&row_state).to_owned() compact_state_label(&row_state).to_owned()
} else { } else {
@@ -935,46 +999,94 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta
) )
}) })
.collect(); .collect();
rows.extend(view.client_states.iter().filter_map(|(identity, row)| { let removed_clients = if let Some(application) = &view.application {
let saved = row.saved.as_ref()?; application
if row.draft.is_some() .clients
|| view .iter()
.clients .filter_map(|peer| {
.iter() if peer.state_change != crate::app::ClientStateChange::Removed {
.any(|peer| peer.identity == Some(*identity)) return None;
{ }
return None; let identity = peer.identity?;
} let (ipv4, ipv6) = peer.saved_client.as_ref().map_or_else(
let ipv6 = saved || removed_peer_addresses(&peer.allowed_ips),
.addresses |saved| {
.ipv6 (
.map_or_else(|| "none".to_owned(), |address| address.to_string()); saved.addresses.ipv4.to_string(),
Some(client_table_row( saved
&layout.columns, .addresses
[ .ipv6
if layout.compact { .map_or_else(String::new, |ip| ip.to_string()),
"Removed".to_owned() )
} else { },
"Draft removed".to_owned() );
}, Some((
safe_text(&saved.alias), identity,
identity.to_string(), peer.label.clone(),
saved.addresses.ipv4.to_string(), ipv4,
ipv6, ipv6,
String::new(), if peer.saved_client.is_some() {
"Managed".to_owned(), "Managed"
address_summary( } else {
&saved.addresses.ipv4.to_string(), "Inspect only"
&saved },
))
})
.collect::<Vec<_>>()
} else {
view.client_states
.iter()
.filter_map(|(identity, row)| {
let saved = row.saved.as_ref()?;
if row.draft.is_some()
|| view
.clients
.iter()
.any(|peer| peer.identity == Some(*identity))
{
return None;
}
Some((
*identity,
saved.alias.clone(),
saved.addresses.ipv4.to_string(),
saved
.addresses .addresses
.ipv6 .ipv6
.map_or_else(String::new, |address| address.to_string()), .map_or_else(String::new, |ip| ip.to_string()),
"", "Managed",
), ))
], })
false, .collect::<Vec<_>>()
)) };
})); rows.extend(
removed_clients
.iter()
.map(|(identity, label, ipv4, ipv6, access)| {
client_table_row(
&layout.columns,
[
if layout.compact {
"Removed".to_owned()
} else {
"Draft removed".to_owned()
},
safe_text(label),
identity.to_string(),
ipv4.clone(),
if ipv6.is_empty() {
"none".to_owned()
} else {
ipv6.clone()
},
String::new(),
(*access).to_owned(),
address_summary(ipv4, ipv6, ""),
],
false,
)
}),
);
rows.extend(view.document_issues.iter().map(|issue| { rows.extend(view.document_issues.iter().map(|issue| {
client_table_row( client_table_row(
&layout.columns, &layout.columns,
@@ -1110,23 +1222,60 @@ fn render_client_screen(frame: &mut Frame<'_>, area: Rect, state: &UiState) {
fn selected_display(state: &UiState) -> Option<ClientDisplay> { fn selected_display(state: &UiState) -> Option<ClientDisplay> {
match state.selected_record()? { match state.selected_record()? {
VisibleRecord::Peer(client) => Some(client.clone()), VisibleRecord::Peer(client) => Some(client.clone()),
VisibleRecord::DraftRemoved { identity, client } => { VisibleRecord::DraftRemoved {
let mut allowed_ips = vec![format!("{}/32", client.addresses.ipv4)]; identity,
if let Some(ipv6) = client.addresses.ipv6 { peer: Some(peer),
allowed_ips.push(format!("{ipv6}/128")); ..
} } => Some(ClientDisplay {
Some(ClientDisplay { peer_index: usize::MAX,
peer_index: usize::MAX, line: peer.line,
line: 0, identity: Some(identity),
identity: Some(identity), label: peer.label.clone(),
label: client.alias.clone(), allowed_ips: peer.allowed_ips.clone(),
allowed_ips, has_subnet_routes: peer.has_subnet_routes,
has_subnet_routes: false, issues: peer.issues.clone(),
issues: Vec::new(), mutable_identity: None,
mutable_identity: None, }),
}) VisibleRecord::DraftRemoved {
identity,
client: Some(client),
..
} => Some(ClientDisplay {
peer_index: usize::MAX,
line: 0,
identity: Some(identity),
label: client.alias.clone(),
allowed_ips: client_routes(client),
has_subnet_routes: false,
issues: Vec::new(),
mutable_identity: None,
}),
VisibleRecord::DraftRemoved { .. } => None,
}
}
fn client_routes(client: &crate::domain::Client) -> Vec<String> {
let mut routes = vec![format!("{}/32", client.addresses.ipv4)];
if let Some(ipv6) = client.addresses.ipv6 {
routes.push(format!("{ipv6}/128"));
}
routes
}
fn removed_peer_addresses(allowed_ips: &[String]) -> (String, String) {
let mut ipv4 = String::new();
let mut ipv6 = String::new();
for route in allowed_ips {
let Some((address, _)) = route.split_once('/') else {
continue;
};
match address.parse::<std::net::IpAddr>() {
Ok(std::net::IpAddr::V4(address)) if ipv4.is_empty() => ipv4 = address.to_string(),
Ok(std::net::IpAddr::V6(address)) if ipv6.is_empty() => ipv6 = address.to_string(),
_ => {}
} }
} }
(ipv4, ipv6)
} }
fn persistent_detail_lines( fn persistent_detail_lines(
@@ -1204,15 +1353,11 @@ fn persistent_state_line(view: &SessionView) -> String {
let saved_peers = view let saved_peers = view
.saved_peer_count .saved_peer_count
.map_or_else(|| "unknown".to_owned(), |count| count.to_string()); .map_or_else(|| "unknown".to_owned(), |count| count.to_string());
let saved_managed = view let saved_managed = view.saved_managed_client_count();
.client_states
.values()
.filter(|row| row.saved.is_some())
.count();
let applied = match view.applied { let applied = match view.applied {
super::state::AppliedState::Verified => "verified", crate::domain::ApplyStatus::Verified => "verified",
super::state::AppliedState::Unknown => "not verified", crate::domain::ApplyStatus::Unknown => "not verified",
super::state::AppliedState::Unavailable => "unavailable", crate::domain::ApplyStatus::Unavailable => "unavailable",
}; };
format!( format!(
"Draft: {draft}; Saved peers: {saved_peers} / managed {saved_managed}; Applied(saved): {applied}" "Draft: {draft}; Saved peers: {saved_peers} / managed {saved_managed}; Applied(saved): {applied}"
@@ -1254,7 +1399,7 @@ fn inspection_summary(client: &ClientDisplay, view: &SessionView) -> String {
} else if client } else if client
.issues .issues
.iter() .iter()
.any(|issue| client.is_capability_limitation_issue(issue)) .any(|issue| view.is_capability_limitation_issue(client, issue))
{ {
"Inspect: address shape; Enter for details".to_owned() "Inspect: address shape; Enter for details".to_owned()
} else if !client.issues.is_empty() { } else if !client.issues.is_empty() {
@@ -1629,7 +1774,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
let has_capability_limitation = client let has_capability_limitation = client
.issues .issues
.iter() .iter()
.any(|issue| client.is_capability_limitation_issue(issue)); .any(|issue| view.is_capability_limitation_issue(client, issue));
if client.has_subnet_routes { if client.has_subnet_routes {
lines.push(Line::from( lines.push(Line::from(
"This peer contains subnet routes. Managing this configuration is not supported yet.", "This peer contains subnet routes. Managing this configuration is not supported yet.",
@@ -1640,18 +1785,26 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
)); ));
} }
if let Some(identity) = client.identity { if let Some(identity) = client.identity {
if let Some(row) = view.client_states.get(&identity) { let managed_state = view
.application_record_for_client(client)
.map(|peer| (&peer.draft_client, &peer.saved_client))
.or_else(|| {
view.client_states
.get(&identity)
.map(|row| (&row.draft, &row.saved))
});
if let Some((draft, saved)) = managed_state {
lines.push(Line::from(format!( lines.push(Line::from(format!(
"Draft managed record: {}", "Draft managed record: {}",
safe_text(row.draft.as_ref().map_or("absent", |c| c.alias.as_str())) safe_text(draft.as_ref().map_or("absent", |c| c.alias.as_str()))
))); )));
lines.push(Line::from(format!( lines.push(Line::from(format!(
"Saved managed record: {}", "Saved managed record: {}",
safe_text(row.saved.as_ref().map_or("absent", |c| c.alias.as_str())) safe_text(saved.as_ref().map_or("absent", |c| c.alias.as_str()))
))); )));
if row.draft != row.saved { if draft != saved {
lines.push(Line::from("Draft differs from saved state.")); lines.push(Line::from("Draft differs from saved state."));
if let Some(client) = &row.draft { if let Some(client) = draft {
lines.push(Line::from(format!( lines.push(Line::from(format!(
"Draft addresses: IPv4 {}, IPv6 {}", "Draft addresses: IPv4 {}, IPv6 {}",
client.addresses.ipv4, client.addresses.ipv4,
@@ -1661,7 +1814,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()) .map_or_else(|| "none".to_owned(), |ip| ip.to_string())
))); )));
} }
if let Some(client) = &row.saved { if let Some(client) = saved {
lines.push(Line::from(format!( lines.push(Line::from(format!(
"Saved addresses: IPv4 {}, IPv6 {}", "Saved addresses: IPv4 {}, IPv6 {}",
client.addresses.ipv4, client.addresses.ipv4,
@@ -1703,7 +1856,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
Line::from(issue_summary(view)), Line::from(issue_summary(view)),
Line::from("Handshake and counters are observations, not apply verification."), Line::from("Handshake and counters are observations, not apply verification."),
]); ]);
for issue in client.presented_issues() { for issue in view.presented_issues(client) {
lines.push(Line::from(format!( lines.push(Line::from(format!(
"Peer issue: {}", "Peer issue: {}",
safe_text(&format!("{issue:?}")) safe_text(&format!("{issue:?}"))
@@ -1767,10 +1920,7 @@ fn state_summary(view: &SessionView) -> String {
} }
fn managed_change_count(view: &SessionView) -> usize { fn managed_change_count(view: &SessionView) -> usize {
view.client_states view.managed_change_count()
.values()
.filter(|row| row.draft != row.saved)
.count()
} }
fn draft_status(view: &SessionView) -> String { fn draft_status(view: &SessionView) -> String {
@@ -1784,11 +1934,7 @@ fn draft_status(view: &SessionView) -> String {
} }
fn saved_status(view: &SessionView) -> String { fn saved_status(view: &SessionView) -> String {
let managed = view let managed = view.saved_managed_client_count();
.client_states
.values()
.filter(|row| row.saved.is_some())
.count();
match view.saved_peer_count { match view.saved_peer_count {
Some(peers) => format!("Saved: {peers} peer sections; {managed} managed clients"), Some(peers) => format!("Saved: {peers} peer sections; {managed} managed clients"),
None => format!("Saved: peer count unavailable; {managed} managed clients"), None => format!("Saved: peer count unavailable; {managed} managed clients"),
@@ -1797,16 +1943,16 @@ fn saved_status(view: &SessionView) -> String {
fn applied_status(view: &SessionView) -> String { fn applied_status(view: &SessionView) -> String {
match view.applied { match view.applied {
super::state::AppliedState::Verified if view.has_draft => { crate::domain::ApplyStatus::Verified if view.has_draft => {
"Applied: verified for saved configuration (draft excluded)".to_owned() "Applied: verified for saved configuration (draft excluded)".to_owned()
} }
super::state::AppliedState::Verified => { crate::domain::ApplyStatus::Verified => {
"Applied: verified for saved configuration".to_owned() "Applied: verified for saved configuration".to_owned()
} }
super::state::AppliedState::Unknown => { crate::domain::ApplyStatus::Unknown => {
"Applied: not verified for saved configuration".to_owned() "Applied: not verified for saved configuration".to_owned()
} }
super::state::AppliedState::Unavailable => "Applied: observation unavailable".to_owned(), crate::domain::ApplyStatus::Unavailable => "Applied: observation unavailable".to_owned(),
} }
} }
@@ -1814,7 +1960,7 @@ fn issue_summary(view: &SessionView) -> String {
let peer_problems = view let peer_problems = view
.clients .clients
.iter() .iter()
.filter(|peer| peer.presented_issues().next().is_some()) .filter(|peer| !view.presented_issues(peer).is_empty())
.count(); .count();
let capability_limits = view let capability_limits = view
.clients .clients
@@ -1824,7 +1970,7 @@ fn issue_summary(view: &SessionView) -> String {
|| peer || peer
.issues .issues
.iter() .iter()
.any(|issue| peer.is_capability_limitation_issue(issue)) .any(|issue| view.is_capability_limitation_issue(peer, issue))
}) })
.count(); .count();
format!( format!(
@@ -2329,14 +2475,150 @@ mod tests {
client_states: BTreeMap::new(), client_states: BTreeMap::new(),
saved_peer_count: Some(0), saved_peer_count: Some(0),
document_issues: Vec::new(), document_issues: Vec::new(),
applied: super::super::state::AppliedState::Unknown, applied: crate::domain::ApplyStatus::Unknown,
reserved_addresses: Default::default(), reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: true, has_draft: true,
vault_initialized: false, vault_initialized: false,
application: None,
} }
} }
#[test]
fn draft_review_shows_removed_unmanaged_authoritative_peer() {
let identity = PublicKey::from_bytes([45; 32]);
let mut view = empty_view();
view.application = Some(crate::app::ApplicationView {
clients: vec![crate::app::PeerRecord {
peer_index: None,
draft_peer_index: None,
saved_peer_index: Some(2),
line: 8,
identity: Some(identity),
label: "legacy route device".to_owned(),
allowed_ips: vec!["10.0.0.45/32".to_owned(), "fd00::45/128".to_owned()],
has_subnet_routes: false,
issues: Vec::new(),
classified_issues: Vec::new(),
mutable_identity: None,
draft_client: None,
saved_client: None,
state_change: crate::app::ClientStateChange::Removed,
actions: crate::app::ClientActions {
rename: crate::app::ActionEligibility {
available: false,
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
},
delete: crate::app::ActionEligibility {
available: false,
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
},
first_delivery: crate::app::FirstDeliveryEligibility::Unavailable,
reissue: crate::app::ActionEligibility {
available: false,
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
},
},
}],
document_issues: Vec::new(),
saved_peer_count: 1,
draft_client_count: 0,
saved_client_count: 0,
reservations: crate::app::ReservationFacts::default(),
applied: crate::domain::ApplyStatus::Unknown,
vault_initialized: false,
has_draft: true,
restart: crate::app::ActionEligibility {
available: true,
reason: None,
},
});
let state = UiState {
view: Some(view),
..UiState::default()
};
let lines = draft_review_lines(&state);
let removed = lines
.iter()
.find(|line| line.starts_with("Removed "))
.expect("removed peer appears in draft review");
assert!(removed.contains(&identity.to_string()));
assert!(removed.contains("legacy route device"));
assert!(removed.contains("unapplied until restart"));
}
#[test]
fn list_shows_removed_unprojectable_peer_as_inspection_only() {
let identity = PublicKey::from_bytes([46; 32]);
let mut view = empty_view();
view.application = Some(crate::app::ApplicationView {
clients: vec![crate::app::PeerRecord {
peer_index: None,
draft_peer_index: None,
saved_peer_index: Some(3),
line: 10,
identity: Some(identity),
label: "legacy route device".to_owned(),
allowed_ips: vec!["10.0.0.46/32".to_owned(), "fd00::46/128".to_owned()],
has_subnet_routes: false,
issues: Vec::new(),
classified_issues: Vec::new(),
mutable_identity: None,
draft_client: None,
saved_client: None,
state_change: crate::app::ClientStateChange::Removed,
actions: crate::app::ClientActions {
rename: crate::app::ActionEligibility {
available: false,
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
},
delete: crate::app::ActionEligibility {
available: false,
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
},
first_delivery: crate::app::FirstDeliveryEligibility::Unavailable,
reissue: crate::app::ActionEligibility {
available: false,
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
},
},
}],
document_issues: Vec::new(),
saved_peer_count: 1,
draft_client_count: 0,
saved_client_count: 0,
reservations: crate::app::ReservationFacts::default(),
applied: crate::domain::ApplyStatus::Unknown,
vault_initialized: false,
has_draft: true,
restart: crate::app::ActionEligibility {
available: true,
reason: None,
},
});
let state = UiState {
view: Some(view),
..UiState::default()
};
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
terminal
.draw(|frame| draw(frame, &state, &FormController::default()))
.unwrap();
let text = rendered_text(&terminal);
for expected in [
"Draft removed",
"legacy route device",
"10.0.0.46",
"fd00::46",
"Inspect only",
] {
assert!(text.contains(expected), "missing {expected:?}:\n{text}");
}
assert!(text.contains(&identity.to_string()));
}
#[test] #[test]
fn help_document_covers_nine_sections_and_distinguishes_case_sensitive_actions() { fn help_document_covers_nine_sections_and_distinguishes_case_sensitive_actions() {
let rows = help_document(178); let rows = help_document(178);
@@ -2545,11 +2827,12 @@ mod tests {
)]), )]),
saved_peer_count: Some(1), saved_peer_count: Some(1),
document_issues: Vec::new(), document_issues: Vec::new(),
applied: super::super::state::AppliedState::Unknown, applied: crate::domain::ApplyStatus::Unknown,
reserved_addresses: Default::default(), reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: false, has_draft: false,
vault_initialized: true, vault_initialized: true,
application: None,
} }
} }
@@ -2558,13 +2841,13 @@ mod tests {
let identity = PublicKey::from_bytes([61; 32]); let identity = PublicKey::from_bytes([61; 32]);
let mut view = saved_client_view(identity); let mut view = saved_client_view(identity);
view.has_draft = false; view.has_draft = false;
view.applied = super::super::state::AppliedState::Verified; view.applied = crate::domain::ApplyStatus::Verified;
let summary = state_summary(&view); let summary = state_summary(&view);
assert!(summary.contains("Draft: no changes")); assert!(summary.contains("Draft: no changes"));
assert!(summary.contains("Saved: 1 peer sections; 1 managed clients")); assert!(summary.contains("Saved: 1 peer sections; 1 managed clients"));
assert!(summary.contains("Applied: verified for saved configuration")); assert!(summary.contains("Applied: verified for saved configuration"));
view.applied = super::super::state::AppliedState::Unknown; view.applied = crate::domain::ApplyStatus::Unknown;
assert_eq!( assert_eq!(
applied_status(&view), applied_status(&view),
"Applied: not verified for saved configuration" "Applied: not verified for saved configuration"
@@ -2572,7 +2855,7 @@ mod tests {
assert!(!state_summary(&view).contains("restart pending")); assert!(!state_summary(&view).contains("restart pending"));
view.has_draft = true; view.has_draft = true;
view.applied = super::super::state::AppliedState::Verified; view.applied = crate::domain::ApplyStatus::Verified;
assert!( assert!(
draft_status(&view) draft_status(&view)
.contains("Draft: pending; draft peers: 1; managed-client changes: 0") .contains("Draft: pending; draft peers: 1; managed-client changes: 0")
@@ -2619,13 +2902,13 @@ mod tests {
); );
assert!(draft_status(&view).contains("managed-client changes: 2")); assert!(draft_status(&view).contains("managed-client changes: 2"));
view.applied = super::super::state::AppliedState::Unknown; view.applied = crate::domain::ApplyStatus::Unknown;
assert_eq!( assert_eq!(
applied_status(&view), applied_status(&view),
"Applied: not verified for saved configuration" "Applied: not verified for saved configuration"
); );
assert!(!state_summary(&view).contains("restart pending")); assert!(!state_summary(&view).contains("restart pending"));
view.applied = super::super::state::AppliedState::Unavailable; view.applied = crate::domain::ApplyStatus::Unavailable;
assert_eq!(applied_status(&view), "Applied: observation unavailable"); assert_eq!(applied_status(&view), "Applied: observation unavailable");
let mut state = UiState { let mut state = UiState {
@@ -2637,7 +2920,7 @@ mod tests {
assert_eq!(state_summary(state.view.as_ref().unwrap()), before); assert_eq!(state_summary(state.view.as_ref().unwrap()), before);
assert_eq!( assert_eq!(
state.view.as_ref().unwrap().applied, state.view.as_ref().unwrap().applied,
super::super::state::AppliedState::Unavailable crate::domain::ApplyStatus::Unavailable
); );
} }
@@ -2838,6 +3121,77 @@ mod tests {
); );
} }
#[test]
fn inspection_row_hints_follow_each_application_action_eligibility() {
let identity = PublicKey::from_bytes([44; 32]);
let mut view = empty_view();
let unavailable = crate::app::ActionEligibility {
available: false,
reason: Some(crate::app::ActionUnavailableReason::UnsafePeerOrDocument),
};
view.clients.push(ClientDisplay {
peer_index: 3,
line: 4,
identity: Some(identity),
label: "legacy peer".to_owned(),
allowed_ips: vec!["10.0.0.2/32".to_owned()],
has_subnet_routes: false,
issues: vec![crate::wireguard::PeerIssue::MissingIpv4HostRoute],
mutable_identity: None,
});
view.application = Some(crate::app::ApplicationView {
clients: vec![crate::app::PeerRecord {
peer_index: Some(3),
draft_peer_index: Some(3),
saved_peer_index: Some(3),
line: 4,
identity: Some(identity),
label: "legacy peer".to_owned(),
allowed_ips: vec!["10.0.0.2/32".to_owned()],
has_subnet_routes: false,
issues: vec![crate::wireguard::PeerIssue::MissingIpv4HostRoute],
classified_issues: vec![crate::app::ClassifiedPeerIssue {
issue: crate::wireguard::PeerIssue::MissingIpv4HostRoute,
class: crate::app::PeerIssueClass::UnsupportedCapability,
}],
mutable_identity: None,
draft_client: None,
saved_client: None,
state_change: crate::app::ClientStateChange::UnsupportedOrUnmanaged,
actions: crate::app::ClientActions {
rename: unavailable.clone(),
delete: crate::app::ActionEligibility {
available: true,
reason: None,
},
first_delivery: crate::app::FirstDeliveryEligibility::Unavailable,
reissue: unavailable.clone(),
},
}],
document_issues: Vec::new(),
saved_peer_count: 1,
draft_client_count: 0,
saved_client_count: 0,
reservations: Default::default(),
applied: crate::domain::ApplyStatus::Unknown,
vault_initialized: true,
has_draft: false,
restart: unavailable,
});
let state = UiState {
selected_identity: Some(identity),
selected_peer_index: Some(3),
view: Some(view),
..UiState::default()
};
let hints = keyboard_hints(&state, &FormController::default());
let available_keys = hints.iter().map(|hint| hint.key).collect::<Vec<_>>();
assert!(available_keys.contains(&"d"));
for unavailable_key in ["r", "o", "O", "R"] {
assert!(!available_keys.contains(&unavailable_key));
}
}
#[test] #[test]
fn standard_footer_is_four_rows_and_list_height_is_stable_across_states() { fn standard_footer_is_four_rows_and_list_height_is_stable_across_states() {
let identity = PublicKey::from_bytes([4; 32]); let identity = PublicKey::from_bytes([4; 32]);
@@ -3932,11 +4286,12 @@ mod tests {
)]), )]),
saved_peer_count: Some(1), saved_peer_count: Some(1),
document_issues: Vec::new(), document_issues: Vec::new(),
applied: super::super::state::AppliedState::Unknown, applied: crate::domain::ApplyStatus::Unknown,
reserved_addresses: Default::default(), reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: false, has_draft: false,
vault_initialized: true, vault_initialized: true,
application: None,
}; };
let state = UiState { let state = UiState {
screen: Screen::ClientDetails, screen: Screen::ClientDetails,
@@ -4055,11 +4410,12 @@ mod tests {
]), ]),
saved_peer_count: Some(2), saved_peer_count: Some(2),
document_issues: vec![], document_issues: vec![],
applied: super::super::state::AppliedState::Unknown, applied: crate::domain::ApplyStatus::Unknown,
reserved_addresses: Default::default(), reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: true, has_draft: true,
vault_initialized: true, vault_initialized: true,
application: None,
}; };
let mut state = UiState { let mut state = UiState {
view: Some(view), view: Some(view),
+504 -77
View File
@@ -3,9 +3,12 @@
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::net::IpAddr; use std::net::IpAddr;
use crate::app::{Application, ClientDisplay}; use crate::app::{
use crate::domain::{Client, PublicKey, RuntimeObservation}; ActionEligibility, Application, ApplicationView, ClientDisplay, FirstDeliveryEligibility,
use crate::runtime::RuntimeObservation as Statistics; PeerRecord,
};
use crate::domain::{ApplyStatus, Client, PublicKey};
use crate::runtime::RuntimeObservation;
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Screen { pub enum Screen {
@@ -34,13 +37,6 @@ pub enum DialogKind {
Exit, Exit,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AppliedState {
Unknown,
Unavailable,
Verified,
}
/// Public client values for both states. No configuration bytes or secrets are /// Public client values for both states. No configuration bytes or secrets are
/// retained in a view snapshot. /// retained in a view snapshot.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -58,55 +54,196 @@ pub struct SessionView {
/// saved bytes can be projected. This includes nonmanaged peers. /// saved bytes can be projected. This includes nonmanaged peers.
pub saved_peer_count: Option<usize>, pub saved_peer_count: Option<usize>,
pub document_issues: Vec<crate::wireguard::DocumentIssue>, pub document_issues: Vec<crate::wireguard::DocumentIssue>,
pub applied: AppliedState, pub applied: ApplyStatus,
pub reserved_addresses: BTreeSet<IpAddr>, pub reserved_addresses: BTreeSet<IpAddr>,
pub statistics: Statistics, pub statistics: RuntimeObservation,
pub has_draft: bool, pub has_draft: bool,
pub vault_initialized: bool, pub vault_initialized: bool,
/// Authoritative application facts used to present action availability.
/// The parallel fields above are the navigation/rendering projection.
pub application: Option<ApplicationView>,
} }
impl SessionView { impl SessionView {
pub fn from_application(app: &Application, generation: u64) -> Self { pub fn from_application(app: &Application, generation: u64) -> Self {
let states = app.states(); let application = app.read_model();
let mut identities = BTreeSet::new(); let mut client_states = std::collections::BTreeMap::new();
identities.extend(states.saved().clients().map(|client| client.identity)); for row in &application.clients {
identities.extend(states.draft().clients().map(|client| client.identity)); if let Some(identity) = row.identity {
let client_states = identities if row.saved_client.is_none() && row.draft_client.is_none() {
.into_iter() continue;
.map(|identity| { }
( let state = client_states
identity, .entry(identity)
ClientStateRow { .or_insert_with(|| ClientStateRow {
saved: states.saved().get(&identity).cloned(), saved: None,
draft: states.draft().get(&identity).cloned(), draft: None,
}, });
) if state.saved.is_none() {
}) state.saved.clone_from(&row.saved_client);
}
if state.draft.is_none() {
state.draft.clone_from(&row.draft_client);
}
}
}
let clients = application
.clients
.iter()
.filter_map(navigation_peer)
.collect(); .collect();
let applied = match states.applied() {
RuntimeObservation::Unknown => AppliedState::Unknown,
RuntimeObservation::Unavailable => AppliedState::Unavailable,
RuntimeObservation::Verified => AppliedState::Verified,
};
let projection = app.display_projection();
let saved_peer_count =
crate::wireguard::ConfigDocument::parse(&app.saved_bytes().server_config)
.ok()
.map(|document| document.peers().len());
Self { Self {
generation, generation,
clients: projection.clients, clients,
client_states, client_states,
saved_peer_count, saved_peer_count: Some(application.saved_peer_count),
document_issues: projection.document_issues, document_issues: application.document_issues.clone(),
applied, applied: application.applied,
reserved_addresses: states.reserved_addresses().clone(), reserved_addresses: application.reservations.committed.iter().copied().collect(),
statistics: Statistics::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: app.has_draft_changes(), has_draft: application.has_draft,
vault_initialized: app.vault_initialized(), vault_initialized: application.vault_initialized,
application: Some(application),
} }
} }
fn application_peer(
&self,
identity: PublicKey,
peer_index: Option<usize>,
) -> Option<&PeerRecord> {
let application = self.application.as_ref()?;
let mut matching = application
.clients
.iter()
.filter(|peer| peer.identity == Some(identity));
let peer = matching.next()?;
if matching.next().is_some() || peer.peer_index != peer_index {
return None;
}
Some(peer)
}
pub fn application_record_for_client(&self, client: &ClientDisplay) -> Option<&PeerRecord> {
let identity = client.identity?;
self.application_peer(identity, Some(client.peer_index))
}
pub fn selected_rename_eligibility(&self, identity: PublicKey, peer_index: usize) -> bool {
self.application_peer(identity, Some(peer_index))
.is_some_and(|peer| peer.actions.rename.available)
}
pub fn selected_delete_eligibility(&self, identity: PublicKey, peer_index: usize) -> bool {
self.application_peer(identity, Some(peer_index))
.is_some_and(|peer| peer.actions.delete.available)
}
pub fn selected_first_delivery_eligibility(
&self,
identity: PublicKey,
peer_index: usize,
) -> bool {
self.application_peer(identity, Some(peer_index))
.is_some_and(|peer| {
peer.actions.first_delivery != FirstDeliveryEligibility::Unavailable
})
}
pub fn selected_reissue_eligibility(&self, identity: PublicKey, peer_index: usize) -> bool {
self.application_peer(identity, Some(peer_index))
.is_some_and(|peer| peer.actions.reissue.available)
}
pub fn restart_eligibility(&self) -> Option<&ActionEligibility> {
self.application.as_ref().map(|view| &view.restart)
}
pub fn is_capability_limitation_issue(
&self,
client: &ClientDisplay,
issue: &crate::wireguard::PeerIssue,
) -> bool {
self.application
.as_ref()
.and_then(|application| {
application.clients.iter().find(|peer| {
peer.peer_index == Some(client.peer_index) && peer.identity == client.identity
})
})
.and_then(|peer| {
peer.classified_issues
.iter()
.find(|classified| &classified.issue == issue)
})
.map_or_else(
|| client.is_capability_limitation_issue(issue),
|classified| classified.class == crate::app::PeerIssueClass::UnsupportedCapability,
)
}
pub fn presented_issues<'a>(
&'a self,
client: &'a ClientDisplay,
) -> Vec<&'a crate::wireguard::PeerIssue> {
client
.issues
.iter()
.filter(|issue| !self.is_capability_limitation_issue(client, issue))
.collect()
}
pub fn client_state_change(
&self,
client: &ClientDisplay,
) -> Option<crate::app::ClientStateChange> {
self.application
.as_ref()?
.clients
.iter()
.find(|peer| {
peer.peer_index == Some(client.peer_index) && peer.identity == client.identity
})
.map(|peer| peer.state_change)
}
pub fn saved_managed_client_count(&self) -> usize {
self.application.as_ref().map_or_else(
|| {
self.client_states
.values()
.filter(|row| row.saved.is_some())
.count()
},
|application| application.saved_client_count,
)
}
pub fn managed_change_count(&self) -> usize {
self.application.as_ref().map_or_else(
|| {
self.client_states
.values()
.filter(|row| row.draft != row.saved)
.count()
},
|application| {
application
.clients
.iter()
.filter(|peer| {
matches!(
peer.state_change,
crate::app::ClientStateChange::Added
| crate::app::ClientStateChange::Removed
| crate::app::ClientStateChange::Changed
)
})
.count()
},
)
}
pub fn contains_identity(&self, identity: &PublicKey) -> bool { pub fn contains_identity(&self, identity: &PublicKey) -> bool {
self.clients self.clients
.iter() .iter()
@@ -121,31 +258,60 @@ impl SessionView {
.iter() .iter()
.map(VisibleRecord::Peer) .map(VisibleRecord::Peer)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
records.extend(self.client_states.iter().filter_map(|(identity, row)| { if let Some(application) = &self.application {
let client = row.saved.as_ref()?; records.extend(application.clients.iter().filter_map(|peer| {
if row.draft.is_some() if peer.state_change != crate::app::ClientStateChange::Removed {
|| self return None;
.clients }
.iter() Some(VisibleRecord::DraftRemoved {
.any(|peer| peer.identity == Some(*identity)) identity: peer.identity?,
{ peer: Some(peer),
return None; client: peer.saved_client.as_ref(),
} })
Some(VisibleRecord::DraftRemoved { }));
identity: *identity, } else {
client, records.extend(self.client_states.iter().filter_map(|(identity, row)| {
}) let client = row.saved.as_ref()?;
})); if row.draft.is_some()
|| self
.clients
.iter()
.any(|peer| peer.identity == Some(*identity))
{
return None;
}
Some(VisibleRecord::DraftRemoved {
identity: *identity,
peer: None,
client: Some(client),
})
}));
}
records records
} }
} }
fn navigation_peer(peer: &PeerRecord) -> Option<ClientDisplay> {
let peer_index = peer.peer_index?;
Some(ClientDisplay {
peer_index,
line: peer.line,
identity: peer.identity,
label: peer.label.clone(),
allowed_ips: peer.allowed_ips.clone(),
has_subnet_routes: peer.has_subnet_routes,
issues: peer.issues.clone(),
mutable_identity: peer.mutable_identity,
})
}
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub enum VisibleRecord<'a> { pub enum VisibleRecord<'a> {
Peer(&'a ClientDisplay), Peer(&'a ClientDisplay),
DraftRemoved { DraftRemoved {
identity: PublicKey, identity: PublicKey,
client: &'a Client, peer: Option<&'a PeerRecord>,
client: Option<&'a Client>,
}, },
} }
@@ -260,13 +426,8 @@ impl UiState {
self.select_first_record(view); self.select_first_record(view);
return; return;
} }
let removed = view.client_states.get(&identity).is_some_and(|row| { let removed = view.visible_records().iter().any(|record| {
row.saved.is_some() matches!(record, VisibleRecord::DraftRemoved { identity: removed, .. } if *removed == identity)
&& row.draft.is_none()
&& !view
.clients
.iter()
.any(|client| client.identity == Some(identity))
}); });
if removed { if removed {
self.selected_peer_index = None; self.selected_peer_index = None;
@@ -289,9 +450,9 @@ impl UiState {
} }
if self.selected_identity.is_some_and(|identity| { if self.selected_identity.is_some_and(|identity| {
view.client_states view.visible_records().iter().any(|record| {
.get(&identity) matches!(record, VisibleRecord::DraftRemoved { identity: removed, .. } if *removed == identity)
.is_some_and(|row| row.saved.is_some() && row.draft.is_none()) })
}) { }) {
return; return;
} }
@@ -318,7 +479,7 @@ impl UiState {
/// Apply only statistics sampled from the still-current saved/draft view. /// Apply only statistics sampled from the still-current saved/draft view.
/// This never changes the applied-state marker. /// This never changes the applied-state marker.
pub fn apply_statistics(&mut self, generation: u64, statistics: Statistics) -> bool { pub fn apply_statistics(&mut self, generation: u64, statistics: RuntimeObservation) -> bool {
if generation != self.generation { if generation != self.generation {
return false; return false;
} }
@@ -489,11 +650,75 @@ impl UiState {
&& client.mutable_identity == Some(identity) && client.mutable_identity == Some(identity)
}) })
} }
pub fn selected_rename_available(&self) -> bool {
self.view.as_ref().is_some_and(|view| {
view.application.as_ref().map_or_else(
|| self.selected_client_mutable(),
|_| self.selected_peer_action(|peer| peer.actions.rename.available),
)
})
}
pub fn selected_delete_available(&self) -> bool {
self.view.as_ref().is_some_and(|view| {
view.application.as_ref().map_or_else(
|| self.selected_client_mutable(),
|_| self.selected_peer_action(|peer| peer.actions.delete.available),
)
})
}
pub fn selected_first_delivery_available(&self) -> bool {
self.view.as_ref().is_some_and(|view| {
view.application.as_ref().map_or_else(
|| self.selected_client_is_saved(),
|_| {
self.selected_peer_action(|peer| {
peer.actions.first_delivery != FirstDeliveryEligibility::Unavailable
})
},
)
})
}
pub fn selected_reissue_available(&self) -> bool {
self.view.as_ref().is_some_and(|view| {
view.application.as_ref().map_or_else(
|| self.selected_client_is_saved(),
|_| self.selected_peer_action(|peer| peer.actions.reissue.available),
)
})
}
fn selected_client_is_saved(&self) -> bool {
self.selected_identity.is_some_and(|identity| {
self.view
.as_ref()
.and_then(|view| view.client_states.get(&identity))
.is_some_and(|row| row.saved.is_some())
})
}
fn selected_peer_action(&self, action: impl FnOnce(&PeerRecord) -> bool) -> bool {
let (Some(identity), Some(peer_index)) = (self.selected_identity, self.selected_peer_index)
else {
return false;
};
self.view
.as_ref()
.and_then(|view| view.application_peer(identity, Some(peer_index)))
.is_some_and(action)
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::app::{
ActionEligibility, ActionUnavailableReason, ClientActions, ClientStateChange,
FirstDeliveryEligibility, PeerIssueClass, ReservationFacts,
};
use crate::domain::PublicKey; use crate::domain::PublicKey;
use crate::wireguard::PeerIssue; use crate::wireguard::PeerIssue;
@@ -521,11 +746,77 @@ mod tests {
client_states: Default::default(), client_states: Default::default(),
saved_peer_count: None, saved_peer_count: None,
document_issues: Vec::new(), document_issues: Vec::new(),
applied: AppliedState::Unknown, applied: ApplyStatus::Unknown,
reserved_addresses: BTreeSet::new(), reserved_addresses: BTreeSet::new(),
statistics: Statistics::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: false, has_draft: false,
vault_initialized: true, vault_initialized: true,
application: None,
}
}
fn application_peer(
identity: PublicKey,
peer_index: usize,
actions: ClientActions,
) -> PeerRecord {
PeerRecord {
peer_index: Some(peer_index),
draft_peer_index: Some(peer_index),
saved_peer_index: Some(peer_index),
line: peer_index + 1,
identity: Some(identity),
label: "inspect-only peer".to_owned(),
allowed_ips: vec!["10.0.0.2/32".to_owned()],
has_subnet_routes: false,
issues: vec![PeerIssue::MissingIpv4HostRoute],
classified_issues: vec![crate::app::ClassifiedPeerIssue {
issue: PeerIssue::MissingIpv4HostRoute,
class: PeerIssueClass::MalformedOrAmbiguous,
}],
mutable_identity: None,
draft_client: None,
saved_client: None,
state_change: ClientStateChange::UnsupportedOrUnmanaged,
actions,
}
}
fn available_actions(delete: bool) -> ClientActions {
let unavailable = ActionEligibility {
available: false,
reason: Some(ActionUnavailableReason::UnsafePeerOrDocument),
};
ClientActions {
rename: unavailable.clone(),
delete: if delete {
ActionEligibility {
available: true,
reason: None,
}
} else {
unavailable.clone()
},
first_delivery: FirstDeliveryEligibility::Unavailable,
reissue: unavailable,
}
}
fn application_view(records: Vec<PeerRecord>) -> ApplicationView {
ApplicationView {
clients: records,
document_issues: Vec::new(),
saved_peer_count: 1,
draft_client_count: 0,
saved_client_count: 0,
reservations: ReservationFacts::default(),
applied: ApplyStatus::Unknown,
vault_initialized: false,
has_draft: false,
restart: ActionEligibility {
available: true,
reason: None,
},
} }
} }
@@ -610,6 +901,72 @@ mod tests {
assert!(!state.selected_client_mutable()); assert!(!state.selected_client_mutable());
} }
#[test]
fn action_eligibility_is_per_action_and_bound_to_the_selected_peer_position() {
let identity = PublicKey::from_bytes([40; 32]);
let mut peer = application_peer(identity, 6, available_actions(true));
let mut session = view(
0,
vec![ClientDisplay {
peer_index: 6,
line: 7,
identity: Some(identity),
label: peer.label.clone(),
allowed_ips: peer.allowed_ips.clone(),
has_subnet_routes: false,
issues: peer.issues.clone(),
mutable_identity: None,
}],
);
session.application = Some(application_view(vec![peer.clone()]));
let mut state = UiState {
view: Some(session),
..UiState::default()
};
assert!(state.select_peer_index(6));
assert!(!state.selected_client_mutable());
assert!(!state.selected_rename_available());
assert!(state.selected_delete_available());
assert!(!state.selected_first_delivery_available());
assert!(!state.selected_reissue_available());
state.selected_peer_index = Some(7);
assert!(!state.selected_delete_available());
peer.peer_index = Some(7);
if let Some(application) = state
.view
.as_mut()
.and_then(|view| view.application.as_mut())
{
application.clients.push(peer);
}
state.selected_peer_index = Some(6);
assert!(!state.selected_delete_available());
}
#[test]
fn malformed_peer_inspection_never_inherits_keyed_action_eligibility() {
let identity = PublicKey::from_bytes([41; 32]);
let keyed = application_peer(identity, 2, available_actions(true));
let mut malformed = keyed.clone();
malformed.peer_index = Some(9);
malformed.draft_peer_index = Some(9);
malformed.identity = None;
let mut inspection = row(identity, "peer", false);
inspection.peer_index = 9;
inspection.identity = None;
let mut state_view = view(0, vec![inspection]);
state_view.application = Some(application_view(vec![keyed.clone(), malformed]));
let mut state = UiState {
view: Some(state_view),
..UiState::default()
};
assert!(state.select_peer_index(9));
assert_eq!(state.selected_identity, None);
assert!(!state.selected_delete_available());
}
#[test] #[test]
fn removed_saved_client_is_visible_and_never_mutable() { fn removed_saved_client_is_visible_and_never_mutable() {
let key = PublicKey::from_bytes([13; 32]); let key = PublicKey::from_bytes([13; 32]);
@@ -647,6 +1004,76 @@ mod tests {
assert_eq!(state.view.as_ref().unwrap().visible_records().len(), 1); assert_eq!(state.view.as_ref().unwrap().visible_records().len(), 1);
} }
#[test]
fn read_model_removed_rows_remain_visible_without_state_map_reconstruction() {
let identity = PublicKey::from_bytes([42; 32]);
let removed = Client::new(
identity,
"removed by draft",
crate::domain::ClientAddresses {
ipv4: "10.0.0.42".parse().unwrap(),
ipv6: None,
},
)
.unwrap();
let mut peer = application_peer(identity, 0, available_actions(false));
peer.peer_index = None;
peer.draft_peer_index = None;
peer.saved_client = Some(removed.clone());
peer.state_change = ClientStateChange::Removed;
let mut session = view(0, Vec::new());
session.application = Some(application_view(vec![peer]));
let mut state = UiState::default();
assert!(state.apply_view(session));
assert_eq!(state.selected_identity, Some(identity));
assert!(matches!(
state.selected_record(),
Some(VisibleRecord::DraftRemoved {
identity: selected,
client: Some(client),
..
}) if selected == identity && client == &removed
));
assert!(state.view.as_ref().unwrap().client_states.is_empty());
assert!(!state.selected_delete_available());
}
#[test]
fn removed_unprojectable_authoritative_peer_stays_selectable_without_actions() {
let identity = PublicKey::from_bytes([43; 32]);
let mut peer = application_peer(identity, 0, available_actions(true));
peer.peer_index = None;
peer.draft_peer_index = None;
peer.saved_peer_index = Some(4);
peer.label = "legacy route peer".to_owned();
peer.allowed_ips = vec!["10.0.0.43/32".to_owned()];
peer.saved_client = None;
peer.state_change = ClientStateChange::Removed;
let mut session = view(0, Vec::new());
session.application = Some(application_view(vec![peer]));
let mut state = UiState::default();
assert!(state.apply_view(session));
assert_eq!(state.selected_identity, Some(identity));
assert_eq!(state.selected_peer_index, None);
assert!(matches!(
state.selected_record(),
Some(VisibleRecord::DraftRemoved {
identity: selected,
peer: Some(record),
client: None,
}) if selected == identity && record.label == "legacy route peer"
));
assert!(!state.selected_client_mutable());
assert!(!state.selected_rename_available());
assert!(!state.selected_delete_available());
assert!(!state.selected_first_delivery_available());
assert!(!state.selected_reissue_available());
assert!(state.open_selected_record());
assert_eq!(state.screen, Screen::ClientDetails);
}
#[test] #[test]
fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() { fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() {
let mut state = UiState { let mut state = UiState {
@@ -669,9 +1096,9 @@ mod tests {
let key = PublicKey::from_bytes([4; 32]); let key = PublicKey::from_bytes([4; 32]);
let mut state = UiState::default(); let mut state = UiState::default();
assert!(state.apply_view(view(0, vec![row(key, "client", true)]))); assert!(state.apply_view(view(0, vec![row(key, "client", true)])));
assert!(state.apply_statistics(0, Statistics::unknown())); assert!(state.apply_statistics(0, RuntimeObservation::unknown()));
assert_eq!(state.view.as_ref().unwrap().applied, AppliedState::Unknown); assert_eq!(state.view.as_ref().unwrap().applied, ApplyStatus::Unknown);
state.generation = 1; state.generation = 1;
assert!(!state.apply_statistics(0, Statistics::unknown())); assert!(!state.apply_statistics(0, RuntimeObservation::unknown()));
} }
} }
+32 -18
View File
@@ -771,7 +771,7 @@ fn handle_key(
KeyCode::Char('n') if state.view.is_some() => forms.open_create(), KeyCode::Char('n') if state.view.is_some() => forms.open_create(),
KeyCode::Char('i') => forms.open_import(), KeyCode::Char('i') => forms.open_import(),
KeyCode::Char('r') => { KeyCode::Char('r') => {
if state.selected_client_mutable() { if state.selected_rename_available() {
let selected = state.selected_identity.and_then(|id| { let selected = state.selected_identity.and_then(|id| {
state state
.view .view
@@ -788,7 +788,7 @@ fn handle_key(
} }
} }
KeyCode::Char('d') => { KeyCode::Char('d') => {
if state.selected_client_mutable() { if state.selected_delete_available() {
if let (Some(id), Some(view)) = (state.selected_identity, state.view.as_ref()) { if let (Some(id), Some(view)) = (state.selected_identity, state.view.as_ref()) {
let label = view let label = view
.clients .clients
@@ -806,16 +806,25 @@ fn handle_key(
KeyCode::Char('p') => forms.open_draft_review(), KeyCode::Char('p') => forms.open_draft_review(),
KeyCode::Char('c') => forms.open_commit(), KeyCode::Char('c') => forms.open_commit(),
KeyCode::Char('x') => forms.open_discard(), KeyCode::Char('x') => forms.open_discard(),
KeyCode::Char('R') => submit_command(worker, UiCommand::PreviewRestart, state), KeyCode::Char('R') => {
KeyCode::Char('o') | KeyCode::Char('O') => { if state
if state.selected_client_mutable() .view
&& let Some(identity) = state.selected_identity .as_ref()
&& state.view.as_ref().is_some_and(|view| { .is_some_and(|view| view.restart_eligibility().is_none_or(|item| item.available))
view.client_states
.get(&identity)
.is_some_and(|row| row.saved.is_some())
})
{ {
submit_command(worker, UiCommand::PreviewRestart, state);
} else {
state.status_message =
Some("Restart preview is unavailable for the saved configuration.".to_owned());
}
}
KeyCode::Char('o') | KeyCode::Char('O') => {
let eligible = if code == KeyCode::Char('o') {
state.selected_first_delivery_available()
} else {
state.selected_reissue_available()
};
if eligible && let Some(identity) = state.selected_identity {
forms.open_delivery(identity, code == KeyCode::Char('O')); forms.open_delivery(identity, code == KeyCode::Char('O'));
} else { } else {
state.status_message = state.status_message =
@@ -960,9 +969,10 @@ mod tests {
use ratatui::backend::TestBackend; use ratatui::backend::TestBackend;
use crate::app::ClientDisplay; use crate::app::ClientDisplay;
use crate::domain::ApplyStatus;
use crate::runtime::RuntimeObservation; use crate::runtime::RuntimeObservation;
use crate::tui::events::OperationError; use crate::tui::events::OperationError;
use crate::tui::state::{AppliedState, OperationKind, SessionView}; use crate::tui::state::{OperationKind, SessionView};
use crate::wireguard::PeerIssue; use crate::wireguard::PeerIssue;
struct FakeWorker { struct FakeWorker {
@@ -1032,11 +1042,12 @@ mod tests {
client_states: Default::default(), client_states: Default::default(),
saved_peer_count: Some(0), saved_peer_count: Some(0),
document_issues: Vec::new(), document_issues: Vec::new(),
applied: AppliedState::Unknown, applied: ApplyStatus::Unknown,
reserved_addresses: Default::default(), reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: false, has_draft: false,
vault_initialized: true, vault_initialized: true,
application: None,
} }
} }
@@ -1163,7 +1174,7 @@ mod tests {
let identity = crate::domain::PublicKey::from_bytes([1; 32]); let identity = crate::domain::PublicKey::from_bytes([1; 32]);
let mut view = navigation_view(); let mut view = navigation_view();
view.has_draft = true; view.has_draft = true;
view.applied = AppliedState::Verified; view.applied = ApplyStatus::Verified;
let mut state = UiState { let mut state = UiState {
screen: Screen::Help, screen: Screen::Help,
selected_identity: Some(identity), selected_identity: Some(identity),
@@ -1192,7 +1203,7 @@ mod tests {
assert_eq!(state.selected_peer_index, selected_peer_index); assert_eq!(state.selected_peer_index, selected_peer_index);
assert_eq!(state.view, view_before); assert_eq!(state.view, view_before);
assert!(state.view.as_ref().unwrap().has_draft); assert!(state.view.as_ref().unwrap().has_draft);
assert_eq!(state.view.as_ref().unwrap().applied, AppliedState::Verified); assert_eq!(state.view.as_ref().unwrap().applied, ApplyStatus::Verified);
// The scrolling helper accepts no worker or command sink, so Help input // The scrolling helper accepts no worker or command sink, so Help input
// cannot submit work or mutate saved/applied application state. // cannot submit work or mutate saved/applied application state.
assert!(!handle_help_navigation_key( assert!(!handle_help_navigation_key(
@@ -1313,11 +1324,12 @@ mod tests {
document_issues: vec![crate::wireguard::DocumentIssue::MalformedSectionHeader { document_issues: vec![crate::wireguard::DocumentIssue::MalformedSectionHeader {
line: 1, line: 1,
}], }],
applied: AppliedState::Unknown, applied: ApplyStatus::Unknown,
reserved_addresses: Default::default(), reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: false, has_draft: false,
vault_initialized: true, vault_initialized: true,
application: None,
}), }),
..UiState::default() ..UiState::default()
}; };
@@ -1344,11 +1356,12 @@ mod tests {
client_states: Default::default(), client_states: Default::default(),
saved_peer_count: Some(2), saved_peer_count: Some(2),
document_issues: Vec::new(), document_issues: Vec::new(),
applied: AppliedState::Unknown, applied: ApplyStatus::Unknown,
reserved_addresses: Default::default(), reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: false, has_draft: false,
vault_initialized: true, vault_initialized: true,
application: None,
}), }),
..UiState::default() ..UiState::default()
}; };
@@ -1578,11 +1591,12 @@ mod tests {
client_states: Default::default(), client_states: Default::default(),
saved_peer_count: Some(0), saved_peer_count: Some(0),
document_issues: Vec::new(), document_issues: Vec::new(),
applied: AppliedState::Unknown, applied: ApplyStatus::Unknown,
reserved_addresses: Default::default(), reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(), statistics: RuntimeObservation::unknown(),
has_draft: false, has_draft: false,
vault_initialized: true, vault_initialized: true,
application: None,
}; };
let mut worker = FakeWorker { let mut worker = FakeWorker {
shutdown_calls: 0, shutdown_calls: 0,
+3 -5
View File
@@ -7,9 +7,7 @@ use wireguard_admin::app::{
}; };
use wireguard_admin::config::{ResolvedSettings, SettingsFile}; use wireguard_admin::config::{ResolvedSettings, SettingsFile};
use wireguard_admin::delivery::files::FileOutputError; use wireguard_admin::delivery::files::FileOutputError;
use wireguard_admin::domain::{ use wireguard_admin::domain::{ApplyStatus, Client, ClientAddresses, ClientStates, Snapshot};
Client, ClientAddresses, ClientStates, RuntimeObservation, Snapshot,
};
use wireguard_admin::persistence::SavedStore; use wireguard_admin::persistence::SavedStore;
use wireguard_admin::vault::import::{parse_client_config, reconcile}; use wireguard_admin::vault::import::{parse_client_config, reconcile};
use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey}; use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey};
@@ -59,7 +57,7 @@ fn fixture(server: String, states: ClientStates) -> Fixture {
} }
fn empty_states() -> ClientStates { fn empty_states() -> ClientStates {
ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown).expect("empty state") ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown).expect("empty state")
} }
fn open(fixture: &Fixture) -> Application { fn open(fixture: &Fixture) -> Application {
@@ -265,7 +263,7 @@ fn imported_ipv4_only_parameters_are_preserved_and_authenticated_conflict_blocks
let snapshot = Snapshot::new([client]).expect("snapshot"); let snapshot = Snapshot::new([client]).expect("snapshot");
let import_fixture = fixture( let import_fixture = fixture(
server.clone(), server.clone(),
ClientStates::new(snapshot, None, RuntimeObservation::Unknown).expect("client state"), ClientStates::new(snapshot, None, ApplyStatus::Unknown).expect("client state"),
); );
let document = ConfigDocument::parse(server.as_bytes()).expect("server document"); let document = ConfigDocument::parse(server.as_bytes()).expect("server document");
let psk = WireGuardPrivateKey::generate().expect("synthetic preshared key"); let psk = WireGuardPrivateKey::generate().expect("synthetic preshared key");
+6 -10
View File
@@ -6,7 +6,7 @@ use wireguard_admin::app::{AppError, Application};
use wireguard_admin::backup::{BackupReason, preview_archive, preview_archive_bytes}; use wireguard_admin::backup::{BackupReason, preview_archive, preview_archive_bytes};
use wireguard_admin::config::{ResolvedSettings, SettingsFile}; use wireguard_admin::config::{ResolvedSettings, SettingsFile};
use wireguard_admin::domain::{ use wireguard_admin::domain::{
Client, ClientAddresses, ClientStates, PublicKey, RuntimeObservation, Snapshot, ApplyStatus, Client, ClientAddresses, ClientStates, PublicKey, Snapshot,
}; };
use wireguard_admin::persistence::{PersistedState, SavedStore}; use wireguard_admin::persistence::{PersistedState, SavedStore};
use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey}; use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey};
@@ -75,12 +75,8 @@ fn fixture(with_client: bool) -> Fixture {
) )
.expect("server fixture is private"); .expect("server fixture is private");
let snapshot = Snapshot::new(clients).expect("initial snapshot"); let snapshot = Snapshot::new(clients).expect("initial snapshot");
let states = ClientStates::new( let states = ClientStates::new(snapshot.clone(), Some(snapshot), ApplyStatus::Verified)
snapshot.clone(), .expect("initial states");
Some(snapshot),
RuntimeObservation::Verified,
)
.expect("initial states");
let store = SavedStore::new(root.path(), settings.clone()).expect("saved store"); let store = SavedStore::new(root.path(), settings.clone()).expect("saved store");
Fixture { Fixture {
root, root,
@@ -240,7 +236,7 @@ fn restore_reserves_addresses_of_current_saved_clients_that_the_archive_removes(
.expect("new client"); .expect("new client");
app.add_client(client, None).expect("stage client"); app.add_client(client, None).expect("stage client");
app.commit().expect("save client"); app.commit().expect("save client");
assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
let preview = app let preview = app
.preview_restore(archive_name(&empty_archive)) .preview_restore(archive_name(&empty_archive))
@@ -264,7 +260,7 @@ fn restore_reserves_addresses_of_current_saved_clients_that_the_archive_removes(
.contains(&"fd20::9".to_owned()) .contains(&"fd20::9".to_owned())
); );
assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
assert!( assert!(
app.states() app.states()
.reserved_addresses() .reserved_addresses()
@@ -520,5 +516,5 @@ fn deletion_uses_public_key_and_removes_only_that_vault_record() {
.expect("vault parses"); .expect("vault parses");
assert!(!bundle.contains_record(fixture.key)); assert!(!bundle.contains_record(fixture.key));
assert!(bundle.contains_record(retained_key)); assert!(bundle.contains_record(retained_key));
assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
} }
+4 -6
View File
@@ -4,9 +4,7 @@ use std::path::Path;
use wireguard_admin::app::{Application, RestartChoice, RestartStage}; use wireguard_admin::app::{Application, RestartChoice, RestartStage};
use wireguard_admin::config::{ResolvedSettings, SettingsFile}; use wireguard_admin::config::{ResolvedSettings, SettingsFile};
use wireguard_admin::domain::{ use wireguard_admin::domain::{ApplyStatus, Client, ClientAddresses, ClientStates, Snapshot};
Client, ClientAddresses, ClientStates, RuntimeObservation, Snapshot,
};
use wireguard_admin::persistence::SavedStore; use wireguard_admin::persistence::SavedStore;
use wireguard_admin::vault::WireGuardPrivateKey; use wireguard_admin::vault::WireGuardPrivateKey;
@@ -67,7 +65,7 @@ fn fixture(service: &str, timeout: u64) -> Fixture {
} }
fn open(fixture: &Fixture) -> Application { fn open(fixture: &Fixture) -> Application {
let states = ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown) let states = ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown)
.expect("empty saved states"); .expect("empty saved states");
Application::open(fixture.store.clone(), states).expect("open synthetic application") Application::open(fixture.store.clone(), states).expect("open synthetic application")
} }
@@ -121,7 +119,7 @@ fn public_restart_preview_and_cancel_preserve_draft_and_saved_files() {
.expect("no cancellation backup") .expect("no cancellation backup")
.is_empty() .is_empty()
); );
assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
} }
#[test] #[test]
@@ -174,7 +172,7 @@ fn restore_changes_the_settings_used_by_the_next_restart_preview() {
app.saved_bytes().settings, app.saved_bytes().settings,
fixture.initial_settings.as_bytes() fixture.initial_settings.as_bytes()
); );
assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); assert_eq!(app.states().applied(), &ApplyStatus::Unknown);
let restart = app let restart = app
.preview_restart() .preview_restart()
.expect("preview restored generation without invoking adapters"); .expect("preview restored generation without invoking adapters");
+46 -13
View File
@@ -13,9 +13,9 @@ use crossterm::event::KeyCode;
use wireguard_admin::app::{FirstIssueAttempt, RestartChoice, RestartStage}; use wireguard_admin::app::{FirstIssueAttempt, RestartChoice, RestartStage};
use wireguard_admin::backup::BackupReason; use wireguard_admin::backup::BackupReason;
use wireguard_admin::config::{ResolvedSettings, SettingsFile}; use wireguard_admin::config::{ResolvedSettings, SettingsFile};
use wireguard_admin::domain::{ClientStates, PublicKey, RuntimeObservation, Snapshot}; use wireguard_admin::domain::{ApplyStatus, ClientStates, PublicKey, Snapshot};
use wireguard_admin::persistence::SavedStore; use wireguard_admin::persistence::SavedStore;
use wireguard_admin::runtime::{InterfaceObservation, RuntimeObservation as Statistics}; use wireguard_admin::runtime::{InterfaceObservation, RuntimeObservation};
use wireguard_admin::tui::events::{ use wireguard_admin::tui::events::{
ApplicationWorker, CommandResult, FirstIssueStatus, QrStatus, SecretText, SensitiveCommand, ApplicationWorker, CommandResult, FirstIssueStatus, QrStatus, SecretText, SensitiveCommand,
ShutdownProgress, StatisticsReader, UiCommand, WorkerConfig, WorkerEvent, ShutdownProgress, StatisticsReader, UiCommand, WorkerConfig, WorkerEvent,
@@ -102,13 +102,49 @@ fn saved_peer_projection_counts_legacy_peers_without_inventing_managed_state() {
assert!(!view.has_draft); assert!(!view.has_draft);
} }
#[test]
fn read_model_exposes_supported_exact_host_peer_actions_individually() {
let fixture = fixture();
let legacy_key = WireGuardPrivateKey::generate()
.expect("synthetic legacy peer key")
.public_key();
let mut server_config =
fs::read_to_string(&fixture.settings.server_config_path).expect("synthetic server config");
server_config.push_str(&format!(
"\n[Peer]\nPublicKey = {legacy_key}\nAllowedIPs = 10.20.0.2/32\n"
));
fs::write(&fixture.settings.server_config_path, server_config)
.expect("write synthetic exact-host peer");
let app = wireguard_admin::app::Application::open_from_saved_store(fixture.store.clone())
.expect("application opens with an inspect-only legacy peer");
let mut view = SessionView::from_application(&app, 0);
assert_eq!(view.clients.len(), 1);
assert_eq!(view.clients[0].mutable_identity, None);
assert_eq!(view.application.as_ref().unwrap().clients.len(), 1);
let mut state = wireguard_admin::tui::state::UiState {
view: Some(view),
..Default::default()
};
assert!(state.select_peer_index(0));
assert!(state.selected_rename_available());
assert!(state.selected_delete_available());
assert!(!state.selected_first_delivery_available());
assert!(!state.selected_reissue_available());
view = SessionView::from_application(&app, 0);
assert_eq!(view.saved_peer_count, Some(1));
assert!(view.client_states.is_empty());
}
#[derive(Clone, Default)] #[derive(Clone, Default)]
struct CountingStatistics(Arc<AtomicUsize>); struct CountingStatistics(Arc<AtomicUsize>);
impl StatisticsReader for CountingStatistics { impl StatisticsReader for CountingStatistics {
fn read(&mut self, _interface: &str, _timeout: Duration) -> Statistics { fn read(&mut self, _interface: &str, _timeout: Duration) -> RuntimeObservation {
self.0.fetch_add(1, Ordering::SeqCst); self.0.fetch_add(1, Ordering::SeqCst);
Statistics { RuntimeObservation {
observed_at: None, observed_at: None,
interface: InterfaceObservation::Unavailable, interface: InterfaceObservation::Unavailable,
} }
@@ -116,8 +152,8 @@ impl StatisticsReader for CountingStatistics {
} }
fn worker(fixture: &Fixture, reader: CountingStatistics) -> ApplicationWorker { fn worker(fixture: &Fixture, reader: CountingStatistics) -> ApplicationWorker {
let states = ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown) let states =
.expect("empty states"); ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown).expect("empty states");
let app = wireguard_admin::app::Application::open(fixture.store.clone(), states) let app = wireguard_admin::app::Application::open(fixture.store.clone(), states)
.expect("application opens on synthetic files"); .expect("application opens on synthetic files");
ApplicationWorker::spawn( ApplicationWorker::spawn(
@@ -166,7 +202,7 @@ fn terminal_pty_smoke() {
const CHILD: &str = "WG_ADMIN_SYNTHETIC_PTY_CHILD"; const CHILD: &str = "WG_ADMIN_SYNTHETIC_PTY_CHILD";
if std::env::var_os(CHILD).is_some() { if std::env::var_os(CHILD).is_some() {
let fixture = fixture(); let fixture = fixture();
let states = ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown) let states = ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown)
.expect("empty states"); .expect("empty states");
let app = wireguard_admin::app::Application::open(fixture.store, states) let app = wireguard_admin::app::Application::open(fixture.store, states)
.expect("synthetic application opens"); .expect("synthetic application opens");
@@ -813,10 +849,7 @@ fn worker_backup_restore_preview_cancel_and_confirm_are_separate_saved_operation
match wait_for(&mut worker, restore.request_id) { match wait_for(&mut worker, restore.request_id) {
WorkerEvent::OperationFinished { result, view, .. } => { WorkerEvent::OperationFinished { result, view, .. } => {
assert!(matches!(result.unwrap(), CommandResult::Restored(_))); assert!(matches!(result.unwrap(), CommandResult::Restored(_)));
assert_eq!( assert_eq!(view.applied, wireguard_admin::domain::ApplyStatus::Unknown);
view.applied,
wireguard_admin::tui::state::AppliedState::Unknown
);
assert!(!view.has_draft); assert!(!view.has_draft);
} }
_ => unreachable!(), _ => unreachable!(),
@@ -916,8 +949,8 @@ fn worker_commit_stops_at_mandatory_backup_failure_and_keeps_draft_and_saved_byt
#[test] #[test]
fn worker_restore_refreshes_operational_allocation_settings_before_next_create() { fn worker_restore_refreshes_operational_allocation_settings_before_next_create() {
let fixture = fixture(); let fixture = fixture();
let states = ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown) let states =
.expect("empty states"); ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown).expect("empty states");
let mut original_app = wireguard_admin::app::Application::open(fixture.store.clone(), states) let mut original_app = wireguard_admin::app::Application::open(fixture.store.clone(), states)
.expect("original application opens"); .expect("original application opens");
original_app original_app