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 |
| 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 | pending |
| 22 | Transactional draft editing and client creation vertical slice | 21 | accepted |
| 23 | Readable saved-state Commit and restore scenarios | 22 | pending |
| 24 | Session-owned first delivery and password reissue scenarios | 23 | 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
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
@@ -3993,9 +3993,9 @@ Give document/client/vault drafts one coordinated publication boundary and make
| Task | Owner | State | Bounded deliverable |
| --- | --- | --- | --- |
| M22.1 | implementer | pending | Internal draft owner and staged replacement API |
| M22.2 | implementer | pending | Create/rename/delete/discard and setup/import migration |
| M22.3 | reviewer; repairer; coordinator | pending | Vertical-slice/failure review, repair and RUST acceptance |
| 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.
@@ -4006,12 +4006,12 @@ Give document/client/vault drafts one coordinated publication boundary and make
#### 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.
- [ ] 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.
- [ ] M22-A4: independent review checks authority/secret lifetimes; RUST passes with unchanged formats/dependencies.
- [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.
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
@@ -4159,7 +4159,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af
## 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.
@@ -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 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
+4 -1
View File
@@ -30,6 +30,8 @@ src/
config.rs # Application settings and path resolution
domain.rs # Clients, identities, address allocation/reservations, change states
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/
document.rs # Parsing and edits preserving original text
@@ -49,7 +51,8 @@ src/
tests/
config_preservation.rs # Untouched server configuration remains identical
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
fixtures/ # Synthetic configuration and archive inputs
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
//! explicit runtime-restart confirmation boundary.
mod draft;
use draft::DraftChanges;
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::fmt;
@@ -78,9 +82,7 @@ pub struct SessionDisplay {
pub struct Application {
store: SavedStore,
saved: SavedBytes,
document: ConfigDocument,
states: ClientStates,
vault: Option<VaultBundle>,
draft: DraftChanges,
historical_apply: HistoricalApplyStatus,
verified_saved_projection: Option<SavedPeerProjection>,
active_restart_attempt: Option<u64>,
@@ -466,9 +468,9 @@ impl fmt::Debug for Application {
formatter
.debug_struct("Application")
.field("saved", &self.saved)
.field("document", &self.document)
.field("states", &self.states)
.field("vault_present", &self.vault.is_some())
.field("document", &self.draft.document)
.field("states", &self.draft.states)
.field("vault_present", &self.draft.vault.is_some())
.field("historical_apply", &self.historical_apply)
.field(
"restart_attempt_active",
@@ -477,6 +479,7 @@ impl fmt::Debug for Application {
.field(
"pending_reservation_count",
&self
.draft
.states
.address_reservations()
.pending_host_routes()
@@ -531,9 +534,7 @@ impl Application {
Ok(Self {
store,
saved,
document,
states,
vault,
draft: DraftChanges::new(document, states, vault),
historical_apply,
verified_saved_projection: None,
active_restart_attempt: None,
@@ -548,24 +549,25 @@ impl Application {
}
pub fn document(&self) -> &ConfigDocument {
&self.document
&self.draft.document
}
pub fn states(&self) -> &ClientStates {
&self.states
&self.draft.states
}
/// Return a secret-free projection that includes unsupported and
/// ambiguous peers instead of silently omitting them from the client list.
pub fn display_projection(&self) -> SessionDisplay {
let clients = self
.draft
.document
.peers()
.iter()
.map(|peer| {
let projected = peer
.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 {
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
Some(metadata.alias.clone())
@@ -588,14 +590,14 @@ impl Application {
has_subnet_routes: peer.has_subnet_routes,
issues: peer.issues.clone(),
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),
}
})
.collect();
SessionDisplay {
clients,
document_issues: self.document.issues().to_vec(),
document_issues: self.draft.document.issues().to_vec(),
}
}
@@ -606,10 +608,18 @@ impl Application {
client: Client,
created_utc: Option<&str>,
) -> Result<(), AppError> {
let edited = self.document.add_client(&client, created_utc)?;
self.states.draft_mut().insert(client)?;
let edited = ConfigDocument::parse(
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.document = edited;
Ok(())
}
@@ -628,14 +638,20 @@ impl Application {
let key = WireGuardPrivateKey::generate().map_err(|_| AppError::KeyGenerationFailed)?;
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
.draft
.states
.addresses_in_use()
.into_iter()
.collect::<Vec<_>>();
occupied.extend(document_host_addresses(&self.document));
let reserved = self.states.address_reservations().all().collect::<Vec<_>>();
occupied.extend(document_host_addresses(&self.draft.document));
let reserved = self
.draft
.states
.address_reservations()
.all()
.collect::<Vec<_>>();
let availability = AddressAvailability {
server: &server_addresses,
occupied: &occupied,
@@ -668,10 +684,11 @@ impl Application {
// Prepare every fallible replacement off to the side. The live app is
// mutated only after document editing, state insertion, and encryption
// have all succeeded.
let mut next_document = self.document.add_client(&client, created_utc)?;
let mut next_states = self.states.clone();
next_states.draft_mut().insert(client)?;
let next_document = self.draft.document.add_client(&client, created_utc)?;
let mut next_states = self.draft.states.clone();
next_states.stage_client(client.clone())?;
let existing_vault = self
.draft
.vault
.as_ref()
.ok_or(AppError::VaultMissingForCreation)?;
@@ -680,12 +697,14 @@ impl Application {
next_vault.encrypt_record(identity, &key)?;
// Parse once before publication so a malformed editor result cannot
// 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 replacement =
DraftChanges::stage_clients_and_vault(next_document, next_states, Some(next_vault));
self.invalidate_active_issue_authorizations();
self.document = next_document;
self.states = next_states;
self.vault = Some(next_vault);
self.draft.publish_replacement(replacement);
self.initial_issues.insert(
identity,
InitialIssue {
@@ -977,7 +996,7 @@ impl Application {
let Ok((projection, _)) = restart_projection_from_document(document) else {
return false;
};
self.states.applied() == &ApplyStatus::Verified
self.draft.states.applied() == &ApplyStatus::Verified
&& self.verified_saved_projection.as_ref() == Some(&projection)
}
@@ -1213,13 +1232,13 @@ impl Application {
}
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.
/// This reveals only setup availability, never vault contents.
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.
@@ -1235,24 +1254,44 @@ impl Application {
selected_name: &OsStr,
) -> Result<SelectedFileImportReport, AppError> {
let settings = self.store.resolved_settings_for(&self.saved)?;
let vault = self
let existing_vault = self
.draft
.vault
.as_mut()
.as_ref()
.ok_or(AppError::VaultMissingForCreation)?;
crate::vault::source::import_selected_file(vault, &settings, &self.document, selected_name)
.map_err(AppError::SourceImport)
let encoded = existing_vault.to_toml()?;
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.
/// A peer outside the narrower M1 state projection remains editable.
pub fn rename_client(&mut self, identity: PublicKey, alias: &str) -> Result<(), AppError> {
let edited = self.document.rename_client(&identity, alias)?;
if let Some(client) = self.states.draft().get(&identity).cloned() {
let edited = ConfigDocument::parse(
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)?;
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.document = edited;
Ok(())
}
@@ -1264,8 +1303,8 @@ impl Application {
.peers()
.iter()
.any(|peer| peer.identity == Some(identity));
let mut next_states = self.states.clone();
if saved_peer_exists && self.states.saved().get(&identity).is_none() {
let mut next_states = self.draft.states.clone();
if saved_peer_exists && self.draft.states.saved().get(&identity).is_none() {
let peer = saved_document
.peers()
.iter()
@@ -1275,33 +1314,42 @@ impl Application {
.stage_exact_host_routes(peer.allowed_ips.iter().copied())
.map_err(|_| AppError::DeletedPeerAddressUnknown)?;
}
let edited = self.document.delete_client(&identity)?;
let removed_record = match self.vault.as_mut() {
let edited = ConfigDocument::parse(self.draft.document.delete_client(&identity)?.render())?;
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)?,
None => false,
};
next_states.draft_mut().remove(&identity);
self.states = next_states;
self.document = edited;
if let Some(vault) = &next_vault {
vault.to_toml()?;
}
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.initial_issues.remove(&identity);
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
/// replace an installed vault and does not change files until Commit.
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);
}
bundle.to_toml()?;
self.vault = Some(bundle);
let replacement = DraftChanges::stage_vault(Some(bundle));
self.draft.publish_replacement(replacement);
Ok(())
}
@@ -1324,8 +1372,11 @@ impl Application {
vault_password: &str,
) -> Result<SourceImportReport, AppError> {
let settings = self.store.resolved_settings_for(&self.saved)?;
let prepared =
crate::vault::source::prepare_initial_setup(&settings, &self.document, vault_password)
let prepared = crate::vault::source::prepare_initial_setup(
&settings,
&self.draft.document,
vault_password,
)
.map_err(AppError::SourceImport)?;
self.prepare_initial_setup(prepared)
}
@@ -1334,13 +1385,8 @@ impl Application {
/// successfully installed session snapshot. No files are written.
pub fn discard_draft(&mut self) -> Result<(), AppError> {
let installed = self.store.read_saved()?;
if !same_saved_bytes(&self.saved, &installed) {
self.verified_saved_projection = None;
}
self.saved = installed;
self.document = ConfigDocument::parse(&self.saved.server_config)?;
self.vault = self
.saved
let next_document = ConfigDocument::parse(&installed.server_config)?;
let next_vault = installed
.vault
.as_deref()
.map(std::str::from_utf8)
@@ -1348,19 +1394,29 @@ impl Application {
.map_err(|_| AppError::InvalidSavedState)?
.map(VaultBundle::from_toml)
.transpose()?;
self.states.discard_draft();
if let Some(bytes) = &self.saved.internal_state {
PersistedState::from_toml(bytes)?.apply_to(&mut self.states)?;
let mut next_states = self.draft.states.clone();
next_states.discard_draft();
if let Some(bytes) = &installed.internal_state {
PersistedState::from_toml(bytes)?.apply_to(&mut next_states)?;
} 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
.retain(|_, issue| issue.stage == IssueStage::AwaitingFirstFile);
retain_matching_issue_map(
&mut self.initial_issues,
&self.document,
self.vault.as_ref(),
&self.draft.document,
self.draft.vault.as_ref(),
);
Ok(())
}
@@ -1421,7 +1477,8 @@ impl Application {
}
let (projection, expected_clients) =
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 {
saved: clone_saved(&installed),
interface: settings.interface_name,
@@ -1434,13 +1491,14 @@ impl Application {
}
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,
None => self.saved.vault.is_some(),
};
self.states.draft() != self.states.saved()
|| self.document.render() != self.saved.server_config
self.draft.states.draft() != self.draft.states.saved()
|| self.draft.document.render() != self.saved.server_config
|| self
.draft
.states
.address_reservations()
.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
|| settings.systemd_unit != preview.unit
|| 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 execution = service(&settings.systemd_unit, deadline);
let job = execution.outcome;
@@ -1847,12 +1906,13 @@ impl Application {
let snapshot = snapshot_from_document(&document);
let mut restored_states = ClientStates::new(snapshot, None, ApplyStatus::Unknown)?;
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
// addresses were not yet in the persisted reservation set. Runtime state
// remains unchanged, so carry those addresses across the replacement.
reservations.extend(
self.states
self.draft
.states
.saved()
.clients()
.flat_map(|client| client.addresses.iter()),
@@ -1938,9 +1998,9 @@ impl Application {
}
self.saved = preview.saved_bytes;
self.store.set_resolved_settings(resolved);
self.document = document;
self.vault = vault;
self.states = states;
self.draft.document = document;
self.draft.vault = vault;
self.draft.states = states;
self.initial_issues.clear();
self.historical_apply = HistoricalApplyStatus::Unknown;
self.verified_saved_projection = None;
@@ -1966,7 +2026,7 @@ impl Application {
{
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)?;
let mut persisted = match &installed.internal_state {
Some(bytes) => PersistedState::from_toml(bytes)?,
@@ -2015,8 +2075,9 @@ impl Application {
return Err(error.into());
}
self.saved = proposed;
self.states = next_states;
self.verified_saved_projection = Some(SavedPeerProjection::from_document(&self.document));
self.draft.states = next_states;
self.verified_saved_projection =
Some(SavedPeerProjection::from_document(&self.draft.document));
self.historical_apply = historical_apply;
self.active_restart_attempt = None;
Ok(())
@@ -2047,6 +2108,7 @@ impl Application {
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())
@@ -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();
let reservations = next_states
.reserved_addresses()
@@ -2064,6 +2126,7 @@ impl Application {
.collect::<Vec<_>>();
let internal_state = Some(PersistedState::new(reservations, None)?.to_toml()?);
let vault_bytes = self
.draft
.vault
.as_ref()
.map(VaultBundle::to_toml)
@@ -2071,7 +2134,7 @@ impl Application {
.map(String::into_bytes);
let proposed = SavedBytes {
settings: installed.settings.clone(),
server_config: self.document.render().to_vec(),
server_config: self.draft.document.render().to_vec(),
internal_state,
vault: vault_bytes,
};
@@ -2108,13 +2171,13 @@ impl Application {
}
self.saved = proposed;
self.states = next_states;
self.document = ConfigDocument::parse(&self.saved.server_config)?;
self.draft.states = next_states;
self.draft.document = ConfigDocument::parse(&self.saved.server_config)?;
self.invalidate_active_issue_authorizations();
promote_matching_issue_map(
&mut self.initial_issues,
&self.document,
self.vault.as_ref(),
&self.draft.document,
self.draft.vault.as_ref(),
);
self.historical_apply = HistoricalApplyStatus::Unknown;
self.verified_saved_projection = None;
@@ -2415,6 +2478,13 @@ fn snapshot_from_document(document: &ConfigDocument) -> 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)]
pub enum AppError {
#[error("saved state is invalid or incompatible with this session")]
@@ -2476,6 +2546,11 @@ mod tests {
use std::path::Path;
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::{BackupReason, HistoricalApplyStatus, create_archive, preview_archive};
use crate::config::SettingsFile;
@@ -2492,13 +2567,6 @@ mod tests {
use crate::service::{RestartExecution, RestartFailure, RestartOutcome};
use crate::vault::{VaultBundle, WireGuardPrivateKey};
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#"
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]
fn selected_file_import_uses_the_existing_vault_draft_without_saving() {
let root = tempfile::tempdir().expect("temp directory");
@@ -2717,6 +2804,36 @@ restart_timeout_seconds = 30
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]
fn manual_addresses_reject_occupied_and_out_of_range_without_partial_staging() {
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]
fn deletion_and_discard_revoke_only_session_bound_initial_material() {
let root = tempfile::tempdir().expect("temp directory");
@@ -3677,7 +3763,7 @@ restart_timeout_seconds = 30
let root = tempfile::tempdir().expect("temp directory");
let (store, states, _, _) = setup(root.path(), false);
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");
@@ -3724,6 +3810,34 @@ restart_timeout_seconds = 30
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]
fn initial_vault_is_prepared_then_installed_only_after_commit() {
let root = tempfile::tempdir().expect("temp directory");
@@ -4925,7 +5039,7 @@ restart_timeout_seconds = 30
.install(&saved, BackupComplete(()))
.expect("install synthetic unsafe generation");
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.rename_client(key, "draft")
.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(())
}
pub fn draft_mut(&mut self) -> &mut Snapshot {
&mut self.draft
pub(crate) fn stage_client(&mut self, client: Client) -> Result<(), DomainError> {
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.
@@ -726,11 +730,11 @@ mod tests {
.unwrap();
let mut states =
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();
assert_eq!(states.draft(), &saved);
states.draft_mut().remove(&key(2));
states.remove_draft_client(&key(2));
states.commit_draft();
assert_eq!(states.saved().clients().count(), 1);
assert_eq!(states.applied(), &ApplyStatus::Unknown);
@@ -758,7 +762,7 @@ mod tests {
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 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
@@ -840,7 +844,7 @@ mod tests {
assert_eq!(states.applied(), &ApplyStatus::Unknown);
assert_eq!(states.applied_snapshot(), Some(&initial));
states.draft_mut().remove(&key(1));
states.remove_draft_client(&key(1));
states.commit_draft();
assert!(!states.reserved_addresses().is_empty());
let saved = states.saved().clone();