Implement transactional draft editing and client creation vertical slice

This commit is contained in:
2026-10-29 23:15:55 +02:00
parent 2766c6254a
commit c1d4f935c8
6 changed files with 396 additions and 149 deletions
+12 -12
View File
@@ -131,7 +131,7 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo
| 19 | Refactoring contract, terminology and behavior map | 18 | accepted | | 19 | Refactoring contract, terminology and behavior map | 18 | accepted |
| 20 | Explicit vocabulary and named operation contexts | 19 | accepted | | 20 | Explicit vocabulary and named operation contexts | 19 | accepted |
| 21 | Domain ownership of reservations and apply knowledge | 20 | accepted | | 21 | Domain ownership of reservations and apply knowledge | 20 | accepted |
| 22 | Transactional draft editing and client creation vertical slice | 21 | pending | | 22 | Transactional draft editing and client creation vertical slice | 21 | accepted |
| 23 | Readable saved-state Commit and restore scenarios | 22 | pending | | 23 | Readable saved-state Commit and restore scenarios | 22 | pending |
| 24 | Session-owned first delivery and password reissue scenarios | 23 | pending | | 24 | Session-owned first delivery and password reissue scenarios | 23 | pending |
| 25 | Explicit restart verification scenario | 24 | pending | | 25 | Explicit restart verification scenario | 24 | pending |
@@ -3985,7 +3985,7 @@ M21.1 and M21.2 focused checks passed. Independent reviewer found M21-R1: `commi
### Milestone 22 — Transactional draft editing and client creation vertical slice ### Milestone 22 — Transactional draft editing and client creation vertical slice
Status: pending. Prerequisite: M21 accepted. 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 #### Outcome and scope
@@ -3993,9 +3993,9 @@ Give document/client/vault drafts one coordinated publication boundary and make
| Task | Owner | State | Bounded deliverable | | Task | Owner | State | Bounded deliverable |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| M22.1 | implementer | pending | Internal draft owner and staged replacement API | | 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 | pending | Create/rename/delete/discard and setup/import migration | | M22.2 | implementer `/root/m22_draft_owner` | complete | Create/rename/delete/discard and setup/import use staged publication; focused checks passed |
| M22.3 | reviewer; repairer; coordinator | pending | Vertical-slice/failure review, repair and RUST acceptance | | 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. 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. 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.
@@ -4006,12 +4006,12 @@ Give document/client/vault drafts one coordinated publication boundary and make
#### Acceptance and validation #### Acceptance and validation
- [ ] M22-A1: create/rename/delete/import/setup/discard use coordinated draft publication; intermediate errors leave no partial draft or newly authorized issue. - [x] M22-A1: create/rename/delete/import/setup/discard use coordinated draft publication; intermediate errors leave no partial draft or newly authorized issue.
- [ ] M22-A2: source bytes outside allowed edits, existing client parameters, unsupported records and original import files remain preserved. - [x] M22-A2: source bytes outside allowed edits, existing client parameters, unsupported records and original import files remain preserved.
- [ ] 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-A3: synthetic create -> preview -> Commit -> first delivery -> explicit fixture restart and deletion/discard flows still pass; no live runtime operation occurs during draft/Commit.
- [ ] M22-A4: independent review checks authority/secret lifetimes; RUST passes with unchanged formats/dependencies. - [x] M22-A4: independent review checks authority/secret lifetimes; RUST passes with unchanged formats/dependencies.
Focused commands: `cargo test --locked app::`, `cargo test --locked --test config_preservation`, `cargo test --locked --test client_delivery`, `cargo test --locked --test vault_import`, `cargo test --locked --test tui_workflow`. Evidence: docs/evidence/milestone-22.md. Outcome: not accepted. Handoff: M23 after acceptance. 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 ### Milestone 23 — Readable saved-state Commit and restore scenarios
@@ -4159,7 +4159,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af
## Current progress and resume point ## Current progress and resume point
- M21 is accepted for host/synthetic scope: domain reservation and apply-knowledge ownership, M21-R1 repair, independent re-review and required Rust gates passed. [M21 evidence](docs/evidence/milestone-21.md) records final inputs and limits. M22–M27 remain pending; M9 remains retired/unaccepted and its real-system limitations remain unchanged. - M22 is accepted for host/synthetic scope: coordinated draft ownership, edit/setup/import migration, failure tests, fresh independent review and required Rust gates passed. [M22 evidence](docs/evidence/milestone-22.md) records final inputs and limits. M23–M27 remain pending; M9 remains retired/unaccepted and its real-system limitations remain unchanged.
- M18 was accepted on 2026-10-29: structured, accurate in-app Help, responsive wrapping and bounded scrolling passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M18 evidence](docs/evidence/milestone-18.md) records final inputs and limits. At that handoff, M19 had not started. - M18 was accepted on 2026-10-29: structured, accurate in-app Help, responsive wrapping and bounded scrolling passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M18 evidence](docs/evidence/milestone-18.md) records final inputs and limits. At that handoff, M19 had not started.
@@ -4188,7 +4188,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af
- Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope. - Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope.
- Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked. - Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked.
- M18–M21 handoffs remain complete. Stop after M21; begin M22.1 only on a separate request. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work. - M18–M22 handoffs remain complete. Stop after M22; begin M23.1 only on a separate request. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work.
## Plan maintenance ## Plan maintenance
+4 -1
View File
@@ -30,6 +30,8 @@ src/
config.rs # Application settings and path resolution config.rs # Application settings and path resolution
domain.rs # Clients, identities, address allocation/reservations, change states domain.rs # Clients, identities, address allocation/reservations, change states
app.rs # Application operations and orchestration app.rs # Application operations and orchestration
app/
draft.rs # Coordinated in-memory document, client-state, and vault drafts
wireguard.rs # Server configuration module boundary wireguard.rs # Server configuration module boundary
wireguard/ wireguard/
document.rs # Parsing and edits preserving original text document.rs # Parsing and edits preserving original text
@@ -49,7 +51,8 @@ src/
tests/ tests/
config_preservation.rs # Untouched server configuration remains identical config_preservation.rs # Untouched server configuration remains identical
commit_restore.rs # Persistence, backups, restore, runtime isolation commit_restore.rs # Persistence, backups, restore, runtime isolation
client_lifecycle.rs # Import, creation, delivery, deletion, state transitions client_delivery.rs # Client file delivery and lifecycle workflows
vault_import.rs # Existing-client import and reconciliation workflows
tui_workflow.rs # Synthetic worker-level TUI workflows tui_workflow.rs # Synthetic worker-level TUI workflows
fixtures/ # Synthetic configuration and archive inputs fixtures/ # Synthetic configuration and archive inputs
support/ support/
+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.
+243 -129
View File
@@ -4,6 +4,10 @@
//! encrypted vault data, backups, the recoverable saved-file adapter, and the //! encrypted vault data, backups, the recoverable saved-file adapter, and the
//! explicit runtime-restart confirmation boundary. //! explicit runtime-restart confirmation boundary.
mod draft;
use draft::DraftChanges;
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fmt; use std::fmt;
@@ -78,9 +82,7 @@ pub struct SessionDisplay {
pub struct Application { pub struct Application {
store: SavedStore, store: SavedStore,
saved: SavedBytes, saved: SavedBytes,
document: ConfigDocument, draft: DraftChanges,
states: ClientStates,
vault: Option<VaultBundle>,
historical_apply: HistoricalApplyStatus, historical_apply: HistoricalApplyStatus,
verified_saved_projection: Option<SavedPeerProjection>, verified_saved_projection: Option<SavedPeerProjection>,
active_restart_attempt: Option<u64>, active_restart_attempt: Option<u64>,
@@ -466,9 +468,9 @@ impl fmt::Debug for Application {
formatter formatter
.debug_struct("Application") .debug_struct("Application")
.field("saved", &self.saved) .field("saved", &self.saved)
.field("document", &self.document) .field("document", &self.draft.document)
.field("states", &self.states) .field("states", &self.draft.states)
.field("vault_present", &self.vault.is_some()) .field("vault_present", &self.draft.vault.is_some())
.field("historical_apply", &self.historical_apply) .field("historical_apply", &self.historical_apply)
.field( .field(
"restart_attempt_active", "restart_attempt_active",
@@ -477,6 +479,7 @@ impl fmt::Debug for Application {
.field( .field(
"pending_reservation_count", "pending_reservation_count",
&self &self
.draft
.states .states
.address_reservations() .address_reservations()
.pending_host_routes() .pending_host_routes()
@@ -531,9 +534,7 @@ impl Application {
Ok(Self { Ok(Self {
store, store,
saved, saved,
document, draft: DraftChanges::new(document, states, vault),
states,
vault,
historical_apply, historical_apply,
verified_saved_projection: None, verified_saved_projection: None,
active_restart_attempt: None, active_restart_attempt: None,
@@ -548,24 +549,25 @@ impl Application {
} }
pub fn document(&self) -> &ConfigDocument { pub fn document(&self) -> &ConfigDocument {
&self.document &self.draft.document
} }
pub fn states(&self) -> &ClientStates { pub fn states(&self) -> &ClientStates {
&self.states &self.draft.states
} }
/// Return a secret-free projection that includes unsupported and /// Return a secret-free projection that includes unsupported and
/// ambiguous peers instead of silently omitting them from the client list. /// ambiguous peers instead of silently omitting them from the client list.
pub fn display_projection(&self) -> SessionDisplay { pub fn display_projection(&self) -> SessionDisplay {
let clients = self let clients = self
.draft
.document .document
.peers() .peers()
.iter() .iter()
.map(|peer| { .map(|peer| {
let projected = peer let projected = peer
.identity .identity
.and_then(|identity| self.document.project_client(&identity).ok()); .and_then(|identity| self.draft.document.project_client(&identity).ok());
let metadata_label = match &peer.metadata { let metadata_label = match &peer.metadata {
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => { crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
Some(metadata.alias.clone()) Some(metadata.alias.clone())
@@ -588,14 +590,14 @@ impl Application {
has_subnet_routes: peer.has_subnet_routes, has_subnet_routes: peer.has_subnet_routes,
issues: peer.issues.clone(), issues: peer.issues.clone(),
mutable_identity: projected mutable_identity: projected
.filter(|client| self.states.draft().get(&client.identity).is_some()) .filter(|client| self.draft.states.draft().get(&client.identity).is_some())
.map(|client| client.identity), .map(|client| client.identity),
} }
}) })
.collect(); .collect();
SessionDisplay { SessionDisplay {
clients, clients,
document_issues: self.document.issues().to_vec(), document_issues: self.draft.document.issues().to_vec(),
} }
} }
@@ -606,10 +608,18 @@ impl Application {
client: Client, client: Client,
created_utc: Option<&str>, created_utc: Option<&str>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let edited = self.document.add_client(&client, created_utc)?; let edited = ConfigDocument::parse(
self.states.draft_mut().insert(client)?; self.draft
.document
.add_client(&client, created_utc)?
.render(),
)?;
ensure_document_client(&edited, &client)?;
let mut next_states = self.draft.states.clone();
next_states.stage_client(client)?;
let replacement = DraftChanges::stage_clients(edited, next_states);
self.draft.publish_replacement(replacement);
self.invalidate_active_issue_authorizations(); self.invalidate_active_issue_authorizations();
self.document = edited;
Ok(()) Ok(())
} }
@@ -628,14 +638,20 @@ impl Application {
let key = WireGuardPrivateKey::generate().map_err(|_| AppError::KeyGenerationFailed)?; let key = WireGuardPrivateKey::generate().map_err(|_| AppError::KeyGenerationFailed)?;
let identity = key.public_key(); let identity = key.public_key();
let server_addresses = server_interface_addresses(&self.document)?; let server_addresses = server_interface_addresses(&self.draft.document)?;
let mut occupied = self let mut occupied = self
.draft
.states .states
.addresses_in_use() .addresses_in_use()
.into_iter() .into_iter()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
occupied.extend(document_host_addresses(&self.document)); occupied.extend(document_host_addresses(&self.draft.document));
let reserved = self.states.address_reservations().all().collect::<Vec<_>>(); let reserved = self
.draft
.states
.address_reservations()
.all()
.collect::<Vec<_>>();
let availability = AddressAvailability { let availability = AddressAvailability {
server: &server_addresses, server: &server_addresses,
occupied: &occupied, occupied: &occupied,
@@ -668,10 +684,11 @@ impl Application {
// Prepare every fallible replacement off to the side. The live app is // Prepare every fallible replacement off to the side. The live app is
// mutated only after document editing, state insertion, and encryption // mutated only after document editing, state insertion, and encryption
// have all succeeded. // have all succeeded.
let mut next_document = self.document.add_client(&client, created_utc)?; let next_document = self.draft.document.add_client(&client, created_utc)?;
let mut next_states = self.states.clone(); let mut next_states = self.draft.states.clone();
next_states.draft_mut().insert(client)?; next_states.stage_client(client.clone())?;
let existing_vault = self let existing_vault = self
.draft
.vault .vault
.as_ref() .as_ref()
.ok_or(AppError::VaultMissingForCreation)?; .ok_or(AppError::VaultMissingForCreation)?;
@@ -680,12 +697,14 @@ impl Application {
next_vault.encrypt_record(identity, &key)?; next_vault.encrypt_record(identity, &key)?;
// Parse once before publication so a malformed editor result cannot // Parse once before publication so a malformed editor result cannot
// leave the three in-memory drafts out of sync. // leave the three in-memory drafts out of sync.
next_document = ConfigDocument::parse(next_document.render())?; let next_document = ConfigDocument::parse(next_document.render())?;
ensure_document_client(&next_document, &client)?;
next_vault.to_toml()?;
let context = issue_context(&next_document, &next_vault, identity)?; let context = issue_context(&next_document, &next_vault, identity)?;
let replacement =
DraftChanges::stage_clients_and_vault(next_document, next_states, Some(next_vault));
self.invalidate_active_issue_authorizations(); self.invalidate_active_issue_authorizations();
self.document = next_document; self.draft.publish_replacement(replacement);
self.states = next_states;
self.vault = Some(next_vault);
self.initial_issues.insert( self.initial_issues.insert(
identity, identity,
InitialIssue { InitialIssue {
@@ -977,7 +996,7 @@ impl Application {
let Ok((projection, _)) = restart_projection_from_document(document) else { let Ok((projection, _)) = restart_projection_from_document(document) else {
return false; return false;
}; };
self.states.applied() == &ApplyStatus::Verified self.draft.states.applied() == &ApplyStatus::Verified
&& self.verified_saved_projection.as_ref() == Some(&projection) && self.verified_saved_projection.as_ref() == Some(&projection)
} }
@@ -1213,13 +1232,13 @@ impl Application {
} }
pub fn vault(&self) -> Option<&VaultBundle> { pub fn vault(&self) -> Option<&VaultBundle> {
self.vault.as_ref() self.draft.vault.as_ref()
} }
/// Whether a vault is present in either saved state or the current draft. /// Whether a vault is present in either saved state or the current draft.
/// This reveals only setup availability, never vault contents. /// This reveals only setup availability, never vault contents.
pub fn vault_initialized(&self) -> bool { pub fn vault_initialized(&self) -> bool {
self.vault.is_some() self.draft.vault.is_some()
} }
/// Whether the in-memory client/document/vault draft differs from saved. /// Whether the in-memory client/document/vault draft differs from saved.
@@ -1235,24 +1254,44 @@ impl Application {
selected_name: &OsStr, selected_name: &OsStr,
) -> Result<SelectedFileImportReport, AppError> { ) -> Result<SelectedFileImportReport, AppError> {
let settings = self.store.resolved_settings_for(&self.saved)?; let settings = self.store.resolved_settings_for(&self.saved)?;
let vault = self let existing_vault = self
.draft
.vault .vault
.as_mut() .as_ref()
.ok_or(AppError::VaultMissingForCreation)?; .ok_or(AppError::VaultMissingForCreation)?;
crate::vault::source::import_selected_file(vault, &settings, &self.document, selected_name) let encoded = existing_vault.to_toml()?;
.map_err(AppError::SourceImport) let mut next_vault = VaultBundle::from_toml(&encoded)?;
let report = crate::vault::source::import_selected_file(
&mut next_vault,
&settings,
&self.draft.document,
selected_name,
)
.map_err(AppError::SourceImport)?;
next_vault.to_toml()?;
let replacement = DraftChanges::stage_vault(Some(next_vault));
self.draft.publish_replacement(replacement);
Ok(report)
} }
/// Replace a client's alias through the span-preserving config editor. /// Replace a client's alias through the span-preserving config editor.
/// A peer outside the narrower M1 state projection remains editable. /// A peer outside the narrower M1 state projection remains editable.
pub fn rename_client(&mut self, identity: PublicKey, alias: &str) -> Result<(), AppError> { pub fn rename_client(&mut self, identity: PublicKey, alias: &str) -> Result<(), AppError> {
let edited = self.document.rename_client(&identity, alias)?; let edited = ConfigDocument::parse(
if let Some(client) = self.states.draft().get(&identity).cloned() { self.draft
.document
.rename_client(&identity, alias)?
.render(),
)?;
let mut next_states = self.draft.states.clone();
if let Some(client) = next_states.draft().get(&identity).cloned() {
let replacement = Client::new(identity, alias, client.addresses)?; let replacement = Client::new(identity, alias, client.addresses)?;
self.states.draft_mut().insert(replacement)?; next_states.stage_client(replacement.clone())?;
ensure_document_client(&edited, &replacement)?;
} }
let replacement = DraftChanges::stage_clients(edited, next_states);
self.draft.publish_replacement(replacement);
self.invalidate_active_issue_authorizations(); self.invalidate_active_issue_authorizations();
self.document = edited;
Ok(()) Ok(())
} }
@@ -1264,8 +1303,8 @@ impl Application {
.peers() .peers()
.iter() .iter()
.any(|peer| peer.identity == Some(identity)); .any(|peer| peer.identity == Some(identity));
let mut next_states = self.states.clone(); let mut next_states = self.draft.states.clone();
if saved_peer_exists && self.states.saved().get(&identity).is_none() { if saved_peer_exists && self.draft.states.saved().get(&identity).is_none() {
let peer = saved_document let peer = saved_document
.peers() .peers()
.iter() .iter()
@@ -1275,33 +1314,42 @@ impl Application {
.stage_exact_host_routes(peer.allowed_ips.iter().copied()) .stage_exact_host_routes(peer.allowed_ips.iter().copied())
.map_err(|_| AppError::DeletedPeerAddressUnknown)?; .map_err(|_| AppError::DeletedPeerAddressUnknown)?;
} }
let edited = self.document.delete_client(&identity)?; let edited = ConfigDocument::parse(self.draft.document.delete_client(&identity)?.render())?;
let removed_record = match self.vault.as_mut() { if edited
.peers()
.iter()
.any(|peer| peer.identity == Some(identity))
{
return Err(AppError::InvalidSavedState);
}
let mut next_vault = match self.draft.vault.as_ref() {
Some(vault) => Some(VaultBundle::from_toml(&vault.to_toml()?)?),
None => None,
};
let removed_record = match next_vault.as_mut() {
Some(vault) => vault.remove_record(identity)?, Some(vault) => vault.remove_record(identity)?,
None => false, None => false,
}; };
next_states.draft_mut().remove(&identity); if let Some(vault) = &next_vault {
self.states = next_states; vault.to_toml()?;
self.document = edited; }
next_states.remove_draft_client(&identity);
let replacement = DraftChanges::stage_clients_and_vault(edited, next_states, next_vault);
self.draft.publish_replacement(replacement);
self.invalidate_active_issue_authorizations(); self.invalidate_active_issue_authorizations();
self.initial_issues.remove(&identity); self.initial_issues.remove(&identity);
Ok(removed_record) Ok(removed_record)
} }
/// Edit the in-memory vault draft without exposing or replacing its identity.
/// The next commit validates that an installed identity is not changed.
pub fn vault_draft_mut(&mut self) -> Option<&mut VaultBundle> {
self.vault.as_mut()
}
/// Stage an initial vault from a prepared read-only source import. It cannot /// Stage an initial vault from a prepared read-only source import. It cannot
/// replace an installed vault and does not change files until Commit. /// replace an installed vault and does not change files until Commit.
pub fn prepare_initial_vault(&mut self, bundle: VaultBundle) -> Result<(), AppError> { pub fn prepare_initial_vault(&mut self, bundle: VaultBundle) -> Result<(), AppError> {
if self.vault.is_some() || self.saved.vault.is_some() { if self.draft.vault.is_some() || self.saved.vault.is_some() {
return Err(AppError::VaultAlreadyInstalled); return Err(AppError::VaultAlreadyInstalled);
} }
bundle.to_toml()?; bundle.to_toml()?;
self.vault = Some(bundle); let replacement = DraftChanges::stage_vault(Some(bundle));
self.draft.publish_replacement(replacement);
Ok(()) Ok(())
} }
@@ -1324,8 +1372,11 @@ impl Application {
vault_password: &str, vault_password: &str,
) -> Result<SourceImportReport, AppError> { ) -> Result<SourceImportReport, AppError> {
let settings = self.store.resolved_settings_for(&self.saved)?; let settings = self.store.resolved_settings_for(&self.saved)?;
let prepared = let prepared = crate::vault::source::prepare_initial_setup(
crate::vault::source::prepare_initial_setup(&settings, &self.document, vault_password) &settings,
&self.draft.document,
vault_password,
)
.map_err(AppError::SourceImport)?; .map_err(AppError::SourceImport)?;
self.prepare_initial_setup(prepared) self.prepare_initial_setup(prepared)
} }
@@ -1334,13 +1385,8 @@ impl Application {
/// successfully installed session snapshot. No files are written. /// successfully installed session snapshot. No files are written.
pub fn discard_draft(&mut self) -> Result<(), AppError> { pub fn discard_draft(&mut self) -> Result<(), AppError> {
let installed = self.store.read_saved()?; let installed = self.store.read_saved()?;
if !same_saved_bytes(&self.saved, &installed) { let next_document = ConfigDocument::parse(&installed.server_config)?;
self.verified_saved_projection = None; let next_vault = installed
}
self.saved = installed;
self.document = ConfigDocument::parse(&self.saved.server_config)?;
self.vault = self
.saved
.vault .vault
.as_deref() .as_deref()
.map(std::str::from_utf8) .map(std::str::from_utf8)
@@ -1348,19 +1394,29 @@ impl Application {
.map_err(|_| AppError::InvalidSavedState)? .map_err(|_| AppError::InvalidSavedState)?
.map(VaultBundle::from_toml) .map(VaultBundle::from_toml)
.transpose()?; .transpose()?;
self.states.discard_draft(); let mut next_states = self.draft.states.clone();
if let Some(bytes) = &self.saved.internal_state { next_states.discard_draft();
PersistedState::from_toml(bytes)?.apply_to(&mut self.states)?; if let Some(bytes) = &installed.internal_state {
PersistedState::from_toml(bytes)?.apply_to(&mut next_states)?;
} else { } else {
self.states.runtime_knowledge_unknown(); next_states.runtime_knowledge_unknown();
} }
self.historical_apply = historical_apply_from_saved(&self.saved)?; let next_historical_apply = historical_apply_from_saved(&installed)?;
let replacement =
DraftChanges::stage_clients_and_vault(next_document, next_states, next_vault);
let saved_changed = !same_saved_bytes(&self.saved, &installed);
self.saved = installed;
self.draft.publish_replacement(replacement);
if saved_changed {
self.verified_saved_projection = None;
}
self.historical_apply = next_historical_apply;
self.initial_issues self.initial_issues
.retain(|_, issue| issue.stage == IssueStage::AwaitingFirstFile); .retain(|_, issue| issue.stage == IssueStage::AwaitingFirstFile);
retain_matching_issue_map( retain_matching_issue_map(
&mut self.initial_issues, &mut self.initial_issues,
&self.document, &self.draft.document,
self.vault.as_ref(), self.draft.vault.as_ref(),
); );
Ok(()) Ok(())
} }
@@ -1421,7 +1477,8 @@ impl Application {
} }
let (projection, expected_clients) = let (projection, expected_clients) =
restart_projection_from_document(&document).map_err(AppError::UnsafeSavedPeers)?; restart_projection_from_document(&document).map_err(AppError::UnsafeSavedPeers)?;
let expected_clients = expected_clients.filter(|snapshot| snapshot == self.states.saved()); let expected_clients =
expected_clients.filter(|snapshot| snapshot == self.draft.states.saved());
Ok(RestartPreview { Ok(RestartPreview {
saved: clone_saved(&installed), saved: clone_saved(&installed),
interface: settings.interface_name, interface: settings.interface_name,
@@ -1434,13 +1491,14 @@ impl Application {
} }
fn has_draft(&self) -> bool { fn has_draft(&self) -> bool {
let vault_differs = match &self.vault { let vault_differs = match &self.draft.vault {
Some(vault) => vault.to_toml().map(String::into_bytes).ok() != self.saved.vault, Some(vault) => vault.to_toml().map(String::into_bytes).ok() != self.saved.vault,
None => self.saved.vault.is_some(), None => self.saved.vault.is_some(),
}; };
self.states.draft() != self.states.saved() self.draft.states.draft() != self.draft.states.saved()
|| self.document.render() != self.saved.server_config || self.draft.document.render() != self.saved.server_config
|| self || self
.draft
.states .states
.address_reservations() .address_reservations()
.pending_host_routes() .pending_host_routes()
@@ -1622,7 +1680,8 @@ impl Application {
); );
} }
}; };
let expected_clients = expected_clients.filter(|snapshot| snapshot == self.states.saved()); let expected_clients =
expected_clients.filter(|snapshot| snapshot == self.draft.states.saved());
if settings.interface_name != preview.interface if settings.interface_name != preview.interface
|| settings.systemd_unit != preview.unit || settings.systemd_unit != preview.unit
|| settings.restart_timeout != preview.timeout || settings.restart_timeout != preview.timeout
@@ -1696,7 +1755,7 @@ impl Application {
); );
} }
self.states.runtime_knowledge_unknown(); self.draft.states.runtime_knowledge_unknown();
let deadline = RestartDeadline::new(now(), settings.restart_timeout); let deadline = RestartDeadline::new(now(), settings.restart_timeout);
let execution = service(&settings.systemd_unit, deadline); let execution = service(&settings.systemd_unit, deadline);
let job = execution.outcome; let job = execution.outcome;
@@ -1847,12 +1906,13 @@ impl Application {
let snapshot = snapshot_from_document(&document); let snapshot = snapshot_from_document(&document);
let mut restored_states = ClientStates::new(snapshot, None, ApplyStatus::Unknown)?; let mut restored_states = ClientStates::new(snapshot, None, ApplyStatus::Unknown)?;
let mut archived_reservations = BTreeSet::new(); let mut archived_reservations = BTreeSet::new();
let mut reservations = self.states.reserved_addresses().clone(); let mut reservations = self.draft.states.reserved_addresses().clone();
// A restore can remove clients that are currently saved even when their // A restore can remove clients that are currently saved even when their
// addresses were not yet in the persisted reservation set. Runtime state // addresses were not yet in the persisted reservation set. Runtime state
// remains unchanged, so carry those addresses across the replacement. // remains unchanged, so carry those addresses across the replacement.
reservations.extend( reservations.extend(
self.states self.draft
.states
.saved() .saved()
.clients() .clients()
.flat_map(|client| client.addresses.iter()), .flat_map(|client| client.addresses.iter()),
@@ -1938,9 +1998,9 @@ impl Application {
} }
self.saved = preview.saved_bytes; self.saved = preview.saved_bytes;
self.store.set_resolved_settings(resolved); self.store.set_resolved_settings(resolved);
self.document = document; self.draft.document = document;
self.vault = vault; self.draft.vault = vault;
self.states = states; self.draft.states = states;
self.initial_issues.clear(); self.initial_issues.clear();
self.historical_apply = HistoricalApplyStatus::Unknown; self.historical_apply = HistoricalApplyStatus::Unknown;
self.verified_saved_projection = None; self.verified_saved_projection = None;
@@ -1966,7 +2026,7 @@ impl Application {
{ {
return Err(AppError::SavedGenerationChanged); return Err(AppError::SavedGenerationChanged);
} }
let mut next_states = self.states.clone(); let mut next_states = self.draft.states.clone();
next_states.prepare_technically_verified_result(observed_management_snapshot)?; next_states.prepare_technically_verified_result(observed_management_snapshot)?;
let mut persisted = match &installed.internal_state { let mut persisted = match &installed.internal_state {
Some(bytes) => PersistedState::from_toml(bytes)?, Some(bytes) => PersistedState::from_toml(bytes)?,
@@ -2015,8 +2075,9 @@ impl Application {
return Err(error.into()); return Err(error.into());
} }
self.saved = proposed; self.saved = proposed;
self.states = next_states; self.draft.states = next_states;
self.verified_saved_projection = Some(SavedPeerProjection::from_document(&self.document)); self.verified_saved_projection =
Some(SavedPeerProjection::from_document(&self.draft.document));
self.historical_apply = historical_apply; self.historical_apply = historical_apply;
self.active_restart_attempt = None; self.active_restart_attempt = None;
Ok(()) Ok(())
@@ -2047,6 +2108,7 @@ impl Application {
std::str::from_utf8(existing_bytes).map_err(|_| AppError::InvalidSavedState)?; std::str::from_utf8(existing_bytes).map_err(|_| AppError::InvalidSavedState)?;
let existing = VaultBundle::from_toml(existing_text)?; let existing = VaultBundle::from_toml(existing_text)?;
if self if self
.draft
.vault .vault
.as_ref() .as_ref()
.is_none_or(|vault| existing.recipient() != vault.recipient()) .is_none_or(|vault| existing.recipient() != vault.recipient())
@@ -2055,7 +2117,7 @@ impl Application {
} }
} }
let mut next_states = self.states.clone(); let mut next_states = self.draft.states.clone();
next_states.commit_draft(); next_states.commit_draft();
let reservations = next_states let reservations = next_states
.reserved_addresses() .reserved_addresses()
@@ -2064,6 +2126,7 @@ impl Application {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let internal_state = Some(PersistedState::new(reservations, None)?.to_toml()?); let internal_state = Some(PersistedState::new(reservations, None)?.to_toml()?);
let vault_bytes = self let vault_bytes = self
.draft
.vault .vault
.as_ref() .as_ref()
.map(VaultBundle::to_toml) .map(VaultBundle::to_toml)
@@ -2071,7 +2134,7 @@ impl Application {
.map(String::into_bytes); .map(String::into_bytes);
let proposed = SavedBytes { let proposed = SavedBytes {
settings: installed.settings.clone(), settings: installed.settings.clone(),
server_config: self.document.render().to_vec(), server_config: self.draft.document.render().to_vec(),
internal_state, internal_state,
vault: vault_bytes, vault: vault_bytes,
}; };
@@ -2108,13 +2171,13 @@ impl Application {
} }
self.saved = proposed; self.saved = proposed;
self.states = next_states; self.draft.states = next_states;
self.document = ConfigDocument::parse(&self.saved.server_config)?; self.draft.document = ConfigDocument::parse(&self.saved.server_config)?;
self.invalidate_active_issue_authorizations(); self.invalidate_active_issue_authorizations();
promote_matching_issue_map( promote_matching_issue_map(
&mut self.initial_issues, &mut self.initial_issues,
&self.document, &self.draft.document,
self.vault.as_ref(), self.draft.vault.as_ref(),
); );
self.historical_apply = HistoricalApplyStatus::Unknown; self.historical_apply = HistoricalApplyStatus::Unknown;
self.verified_saved_projection = None; self.verified_saved_projection = None;
@@ -2415,6 +2478,13 @@ fn snapshot_from_document(document: &ConfigDocument) -> Snapshot {
snapshot snapshot
} }
fn ensure_document_client(document: &ConfigDocument, client: &Client) -> Result<(), AppError> {
if document.project_client(&client.identity)? != *client {
return Err(AppError::InvalidSavedState);
}
Ok(())
}
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum AppError { pub enum AppError {
#[error("saved state is invalid or incompatible with this session")] #[error("saved state is invalid or incompatible with this session")]
@@ -2476,6 +2546,11 @@ mod tests {
use std::path::Path; use std::path::Path;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use super::{
AppError, Application, FileOutputError, FirstIssueAttempt, FirstIssueDeliveryFailure,
FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartFailureKind,
RestartStage, clone_saved, snapshot_from_document,
};
use crate::backup::{ArchiveCheckpoint, create_archive_with_hook}; use crate::backup::{ArchiveCheckpoint, create_archive_with_hook};
use crate::backup::{BackupReason, HistoricalApplyStatus, create_archive, preview_archive}; use crate::backup::{BackupReason, HistoricalApplyStatus, create_archive, preview_archive};
use crate::config::SettingsFile; use crate::config::SettingsFile;
@@ -2492,13 +2567,6 @@ mod tests {
use crate::service::{RestartExecution, RestartFailure, RestartOutcome}; use crate::service::{RestartExecution, RestartFailure, RestartOutcome};
use crate::vault::{VaultBundle, WireGuardPrivateKey}; use crate::vault::{VaultBundle, WireGuardPrivateKey};
use crate::wireguard::ConfigDocument; use crate::wireguard::ConfigDocument;
use zeroize::Zeroizing;
use super::{
AppError, Application, FileOutputError, FirstIssueAttempt, FirstIssueDeliveryFailure,
FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartFailureKind,
RestartStage, clone_saved, snapshot_from_document,
};
const SETTINGS: &str = r#" const SETTINGS: &str = r#"
interface_name = "wg0" interface_name = "wg0"
@@ -2687,6 +2755,25 @@ restart_timeout_seconds = 30
)); ));
} }
#[test]
fn missing_vault_after_client_preparation_does_not_publish_any_draft_or_issue() {
let root = tempfile::tempdir().expect("temp directory");
let (store, states, _, _) = setup(root.path(), false);
let mut app = Application::open(store, states).expect("application opens");
let before_document = app.document().render().to_vec();
let before_draft = app.states().draft().clone();
assert!(matches!(
app.prepare_new_client("missing vault", None, None),
Err(AppError::VaultMissingForCreation)
));
assert_eq!(app.document().render(), before_document);
assert_eq!(app.states().draft(), &before_draft);
assert!(app.vault().is_none());
assert!(app.initial_issues.is_empty());
}
#[test] #[test]
fn selected_file_import_uses_the_existing_vault_draft_without_saving() { fn selected_file_import_uses_the_existing_vault_draft_without_saving() {
let root = tempfile::tempdir().expect("temp directory"); let root = tempfile::tempdir().expect("temp directory");
@@ -2717,6 +2804,36 @@ restart_timeout_seconds = 30
assert_eq!(app.saved_bytes().vault, saved_vault); assert_eq!(app.saved_bytes().vault, saved_vault);
} }
#[test]
fn selected_import_error_keeps_vault_and_pending_issue_unchanged() {
let root = tempfile::tempdir().expect("temp directory");
let mut app = setup_with_vault(root.path());
let identity = app
.prepare_new_client("pending import", None, None)
.expect("client stages");
let before_document = app.document().render().to_vec();
let before_states = app.states().clone();
let before_vault = app.vault().expect("vault exists").to_toml().unwrap();
assert!(matches!(
app.import_selected_file(OsStr::new("../invalid.conf")),
Err(AppError::SourceImport(
crate::vault::source::SourceImportError::InvalidSelection
))
));
assert_eq!(app.document().render(), before_document);
assert_eq!(app.states(), &before_states);
assert_eq!(
app.vault()
.expect("vault remains present")
.to_toml()
.unwrap(),
before_vault
);
assert!(app.initial_issues.contains_key(&identity));
}
#[test] #[test]
fn manual_addresses_reject_occupied_and_out_of_range_without_partial_staging() { fn manual_addresses_reject_occupied_and_out_of_range_without_partial_staging() {
let root = tempfile::tempdir().expect("temp directory"); let root = tempfile::tempdir().expect("temp directory");
@@ -3471,37 +3588,6 @@ restart_timeout_seconds = 30
); );
} }
#[test]
fn replacing_the_encrypted_record_invalidates_committed_first_issue() {
let root = tempfile::tempdir().expect("temp directory");
let mut app = setup_with_vault(root.path());
let identity = app
.prepare_new_client("replacement", None, None)
.expect("client stages");
app.commit().expect("client commits");
let replacement_bytes = Zeroizing::new(
app.initial_issues
.get(&identity)
.expect("session key retained")
.private_key
.to_base64(),
);
let replacement_key = WireGuardPrivateKey::parse_base64(&replacement_bytes)
.expect("same synthetic key parses");
let vault = app.vault_draft_mut().expect("vault exists");
assert!(vault.remove_record(identity).expect("record removed"));
vault
.encrypt_record(identity, &replacement_key)
.expect("replacement record encrypted");
app.commit().expect("replacement commits");
assert!(matches!(
app.deliver_first_issue(identity, FirstIssueAttempt::Initial, |_| Ok::<_, ((), _)>(
()
)),
Err(AppError::FirstIssueUnavailable)
));
}
#[test] #[test]
fn deletion_and_discard_revoke_only_session_bound_initial_material() { fn deletion_and_discard_revoke_only_session_bound_initial_material() {
let root = tempfile::tempdir().expect("temp directory"); let root = tempfile::tempdir().expect("temp directory");
@@ -3677,7 +3763,7 @@ restart_timeout_seconds = 30
let root = tempfile::tempdir().expect("temp directory"); let root = tempfile::tempdir().expect("temp directory");
let (store, states, _, _) = setup(root.path(), false); let (store, states, _, _) = setup(root.path(), false);
let mut app = Application::open(store, states).expect("application opens"); let mut app = Application::open(store, states).expect("application opens");
app.states.runtime_knowledge_unavailable(); app.draft.states.runtime_knowledge_unavailable();
app.commit().expect("unchanged commit completes"); app.commit().expect("unchanged commit completes");
@@ -3724,6 +3810,34 @@ restart_timeout_seconds = 30
assert_eq!(app.states().draft(), app.states().saved()); assert_eq!(app.states().draft(), app.states().saved());
} }
#[test]
fn discard_parse_failure_preserves_every_live_draft_component_and_issue() {
let root = tempfile::tempdir().expect("temp directory");
let mut app = setup_with_vault(root.path());
let issue = app
.prepare_new_client("discard failure", None, None)
.expect("new client stages");
let before_saved = clone_saved(app.saved_bytes());
let before_document = app.document().render().to_vec();
let before_states = app.states().clone();
let before_vault = app.vault().expect("vault exists").to_toml().unwrap();
fs::write(root.path().join("server.conf"), [0xff]).expect("corrupt synthetic saved file");
assert!(app.discard_draft().is_err());
assert!(super::same_saved_bytes(app.saved_bytes(), &before_saved));
assert_eq!(app.document().render(), before_document);
assert_eq!(app.states(), &before_states);
assert_eq!(
app.vault()
.expect("vault remains present")
.to_toml()
.unwrap(),
before_vault
);
assert!(app.initial_issues.contains_key(&issue));
}
#[test] #[test]
fn initial_vault_is_prepared_then_installed_only_after_commit() { fn initial_vault_is_prepared_then_installed_only_after_commit() {
let root = tempfile::tempdir().expect("temp directory"); let root = tempfile::tempdir().expect("temp directory");
@@ -4925,7 +5039,7 @@ restart_timeout_seconds = 30
.install(&saved, BackupComplete(())) .install(&saved, BackupComplete(()))
.expect("install synthetic unsafe generation"); .expect("install synthetic unsafe generation");
let reopened_store = app.store.clone(); let reopened_store = app.store.clone();
let states = app.states.clone(); let states = app.draft.states.clone();
app = Application::open(reopened_store, states).expect("reopen fixture"); app = Application::open(reopened_store, states).expect("reopen fixture");
app.rename_client(key, "draft") app.rename_client(key, "draft")
.expect("stage a draft for preservation"); .expect("stage a draft for preservation");
+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;
}
}
}
+10 -6
View File
@@ -351,8 +351,12 @@ impl ClientStates {
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.
@@ -726,11 +730,11 @@ mod tests {
.unwrap(); .unwrap();
let mut states = let mut states =
ClientStates::new(saved.clone(), Some(saved.clone()), ApplyStatus::Verified).unwrap(); ClientStates::new(saved.clone(), Some(saved.clone()), ApplyStatus::Verified).unwrap();
states.draft_mut().remove(&key(2)); states.remove_draft_client(&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(), &ApplyStatus::Unknown); assert_eq!(states.applied(), &ApplyStatus::Unknown);
@@ -758,7 +762,7 @@ mod tests {
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, ApplyStatus::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
@@ -840,7 +844,7 @@ mod tests {
assert_eq!(states.applied(), &ApplyStatus::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();