From 5418841b92c588ee286ac5f151d3f457dda83f36 Mon Sep 17 00:00:00 2001 From: sasa Date: Tue, 20 Oct 2026 00:13:39 +0300 Subject: [PATCH] Implement backups, commit and restoration --- AGENTS.md | 2 +- Cargo.lock | 80 +++ Cargo.toml | 6 +- PLANS.md | 393 ++++++++++- README.md | 27 +- docs/BACKUP_FORMAT.md | 116 +++ docs/PERSISTENCE.md | 105 +++ docs/VAULT_FORMAT.md | 7 +- src/app.rs | 1482 +++++++++++++++++++++++++++++++++++++++ src/backup.rs | 973 +++++++++++++++++++++++++ src/domain.rs | 21 + src/lib.rs | 3 + src/persistence.rs | 1299 ++++++++++++++++++++++++++++++++++ src/vault.rs | 7 + src/vault/source.rs | 17 +- tests/commit_restore.rs | 524 ++++++++++++++ tests/vault_import.rs | 25 +- 17 files changed, 5049 insertions(+), 38 deletions(-) create mode 100644 docs/BACKUP_FORMAT.md create mode 100644 docs/PERSISTENCE.md create mode 100644 src/app.rs create mode 100644 src/backup.rs create mode 100644 src/persistence.rs create mode 100644 tests/commit_restore.rs diff --git a/AGENTS.md b/AGENTS.md index f6a5a9a..f651d70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ These instructions apply to the entire repository. Keep documentation, code comments, and identifiers in English; use the user's language in conversation. -The project contains a Rust package with startup, settings validation, a pure client domain model, an in-memory, byte-preserving WireGuard configuration editor, and library APIs for an encrypted vault and read-only client import. The management interface, installed persistence, and VPN operations are not implemented yet. Do not report planned modules, tests, or commands as already working. +The project contains a Rust package with startup, settings validation, a pure client domain model, a byte-preserving WireGuard configuration editor, encrypted vault and read-only import APIs, saved-file persistence with recovery, backups, Commit, and previewed restore. The management interface and VPN runtime operations are not implemented yet. Do not report planned modules, tests, or commands as already working. - Read [SPEC.md](docs/SPEC.md) and [CONTEXT.md](CONTEXT.md) before implementing behavior. The approved specification is the product contract; do not change it to make tests pass. - For milestone execution, read and maintain [PLANS.md](PLANS.md), following [IMPLEMENTATION.md](docs/IMPLEMENTATION.md). Execute numbered milestones sequentially through implementation, independent review, repair, and acceptance with evidence. diff --git a/Cargo.lock b/Cargo.lock index 2629222..39e7213 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -305,6 +305,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -395,6 +404,16 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-crate" version = "0.6.3" @@ -770,6 +789,12 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "keccak" version = "0.1.6" @@ -865,6 +890,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "once_cell" version = "1.21.4" @@ -969,6 +1000,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1327,6 +1364,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.23.0" @@ -1380,6 +1427,37 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -1566,8 +1644,10 @@ dependencies = [ "ipnet", "rustix", "serde", + "tar", "tempfile", "thiserror 2.0.20", + "time", "toml 1.1.8+spec-1.1.0", "x25519-dalek", "zeroize", diff --git a/Cargo.toml b/Cargo.toml index 237e48a..a55c2b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,9 +21,9 @@ ipnet = "=2.11.0" rustix = { version = "=1.1.5", default-features = false, features = ["fs", "process", "std"] } serde = { version = "=1.0.229", features = ["derive"] } thiserror = "=2.0.20" +tar = { version = "=0.4.44", default-features = false } +tempfile = "=3.23.0" +time = { version = "=0.3.44", default-features = false, features = ["formatting", "parsing", "std"] } toml = "=1.1.8" x25519-dalek = { version = "=2.0.1", default-features = false, features = ["static_secrets", "zeroize"] } zeroize = "=1.9.0" - -[dev-dependencies] -tempfile = "=3.23.0" diff --git a/PLANS.md b/PLANS.md index f2aec7e..564a969 100644 --- a/PLANS.md +++ b/PLANS.md @@ -108,17 +108,17 @@ Actual systemd/D-Bus acceptance runs in a disposable VM. Never use production to Each row is a delivery milestone, not a completion claim. Expand its record before execution; retain its original ID and acceptance scope. -| ID | Milestone | Prerequisites | State | -| --- | -------------------------------------------------------- | ----------------------------------------- | ------------- | -| 1 | Rust foundation, settings, and domain states | None | accepted | -| 2 | Server configuration editing with byte preservation | 1 | needs_changes | -| 3 | Client keys, encrypted vault, and existing-client import | 2 | pending | -| 4 | Backups, safe Commit, and full restoration | 3 | pending | -| 5 | Runtime observation and systemd restart adapter | 4 | pending | -| 6 | Client configuration delivery and reissue lifecycle | 5 | pending | -| 7 | Complete terminal management workflow | 6 | pending | -| 8 | Alpine Testcontainers integration | 7; suitable disposable Docker environment | pending | -| 9 | Real systemd acceptance and release readiness | 8; disposable systemd VM | pending | +| ID | Milestone | Prerequisites | State | +| --- | -------------------------------------------------------- | ----------------------------------------- | -------- | +| 1 | Rust foundation, settings, and domain states | None | accepted | +| 2 | Server configuration editing with byte preservation | 1 | accepted | +| 3 | Client keys, encrypted vault, and existing-client import | 2 | accepted | +| 4 | Backups, safe Commit, and full restoration | 3 | accepted | +| 5 | Runtime observation and systemd restart adapter | 4 | pending | +| 6 | Client configuration delivery and reissue lifecycle | 5 | pending | +| 7 | Complete terminal management workflow | 6 | pending | +| 8 | Alpine Testcontainers integration | 7; suitable disposable Docker environment | pending | +| 9 | Real systemd acceptance and release readiness | 8; disposable systemd VM | pending | ### Milestone 1 — Rust foundation, settings, and domain states @@ -1025,9 +1025,369 @@ occurred. ### Milestone 4 — Backups, safe Commit, and full restoration -Implement versioned tar archives/manifests, mandatory pre-operation backups, internal state persistence, Commit orchestration, and previewed full restoration without decryption. Define partial-write handling and archive validation. Persist address reservations and verified-state bookkeeping as needed. +Status: accepted (2026-10-19T21:10:25Z; independent R5 closed all findings and final coordinator gates passed). +Prerequisites: milestones 1–3 accepted; pinned native Rust environment and +available approved dependencies. This milestone uses synthetic temporary +filesystems and injected failures, not root, production files, or running services. -Acceptance: backup failure prevents protected mutations; Commit/restore never change the running VPN; archive composition matches the specification; malformed/traversal/link entries are rejected; restore returns saved settings/configuration/vault without a password. Tests demonstrate draft cancellation and deletion of current secrets while older archives remain unchanged. +#### Outcome and scope + +Deliver library/application operations that install prepared M3 vault data, +save an M2-edited server document through Commit, preserve internal allocation +state, create/list backups, and preview/confirm full restoration without a vault +password. Writes must be honest about partial failure and recoverable from the +mandatory pre-operation backup. Neither Commit nor restore applies VPN changes. + +Read SPEC.md Data locations, Data sources, Operations and states, Encrypted vault, +and Backups and restoration; all three ADRs; VAULT_FORMAT.md, CONFIG_FORMAT.md, +DEVELOPMENT.md, DEPENDENCIES.md, ENVIRONMENT.md, and TESTING.md. Distinguish the +application's Commit action from Git commits, which are not authorized here. + +Out of scope: TUI, output files/QR, password prompts, reissue reconciliation, +real WireGuard/systemd observation or restart, automatic failed-restart rollback, +concurrent editor detection/locking, compression, retention cleanup, migration to +different server paths, and production access. The startup management placeholder +remains safe until later milestones; expose testable APIs rather than a new CLI. + +#### Existing code and integration constraints + +- `config::ResolvedSettings` provides executable-relative server/client/data paths + and data/archive directories; settings live at `wireguard-admin.toml` beside + the executable. Use explicit resolved paths in adapters, never process cwd. +- M2 `ConfigDocument` and edits preserve original bytes and report unsupported + peers. Commit persists the prepared document without reserializing server + settings, silently projecting away anomalies, or rewriting unchanged comments. +- M3 `VaultBundle::to_toml`/`from_toml` encode/load encrypted v1 bundles without + password entry. VAULT_FORMAT.md fixes the intended path as data/vault.toml. + Preserve recipient, encrypted identity, records, and authenticated blocked flags; + backups/restore do not decrypt or rebuild them from plaintext imports. +- `PreparedImportSetup` contains bundle/report; read-only scans never installed + application state. M4 owns initial installation through the same persistence + and pre-Commit backup boundary as later prepared imports. +- Vault records are private and there is currently no public removal operation. + Add a narrow public-key removal API as needed; do not serialize/reparse internals + to bypass encapsulation or expose plaintext. Do not automatically purge file-only + imported records merely because they are absent from the server configuration. +- M1 `ClientStates::commit_draft` updates the in-memory saved snapshot and address + reservations only. Invoke/reflect that transition after successful disk commit, + never before it. Current reservation state is private and has no persistence + codec; add explicit validated state encode/load support, not generic secret or + whole-document serialization. Preserve unsupported M2 peers outside narrow Client. +- Historical verified/applied snapshots are not fresh runtime observations. On + reopening/restoring, preserve needed reservation/history data but mark current + runtime unknown until M5 verifies it. Restore never frees a pending deleted IP. +- Cargo already includes M1–M3 libraries and tempfile for tests. Approved `tar` + and `time` may be introduced with minimal features, and tempfile/rustix used + for secure writes. Moving tempfile to normal dependencies is allowed if needed. + No new hash/UUID/compression/database/framework dependency is implicitly approved. + +#### Context and implementation steps + +Use sequential tasks and one writer. GPT-6-Sol/medium coordinates; GPT-6-Luna/high +implements, reviews independently, and repairs. Supply explicit ownership, scope, +accepted interfaces, validation, and task report requirements on each dispatch. + +| Task | Owner | Files and deliverable | +| --- | --- | --- | +| M4.1 | implementer | `src/backup.rs` and child modules as needed, `docs/BACKUP_FORMAT.md`: versioned allowlisted archive, limits, inventory, validated preview | +| M4.2 | implementer | narrow persistence module, `src/config.rs`/`src/domain.rs` adapters as needed, `docs/PERSISTENCE.md`: safe path handling, validated state, staged writes and recovery contract | +| M4.3 | implementer | `src/app.rs`, `src/vault.rs` narrow deletion API, library exports: prepared change sets, initial setup/Commit and mandatory backup ordering | +| M4.4 | implementer | application/backup/persistence modules: manual and pre-restart backup APIs, previewed confirmed full restore, restart-history boundary | +| M4.5 | implementer | `tests/commit_restore.rs`, test-only failure adapters and README: round trips, fault injection, archive attacks, permission and reservation scenarios | +| M4.6 | reviewer, repairer | Independent integrated review, bounded repairs, re-review of failure paths and regressions | +| M4.7 | coordinator | Integrated gates, evidence/findings ledger and acceptance/handoff in PLANS.md | + +M4.1 defines a versioned uncompressed tar archive with a bounded TOML manifest. +Use logical fixed member names for settings, server configuration, internal state, +and encrypted vault; never restore directly to tar-supplied paths. Record UTC +creation time, operation reason (manual/pre-Commit/pre-restore/pre-restart), member +inventory/lengths, and historical verification status without inventing working +state. Define collision-safe archive naming without overwriting an existing file. +A new archive is visible in the list only when fully written and finalized. + +Capture the currently installed saved bytes, not the proposed draft or decrypted +records. Settings and server config may contain secrets: archives require 0600. +Exclude source client files, drafts, previous archives, staging/journal artifacts, +and delivered plaintext output. Initial setup can lack vault/internal state: +represent genuine absence explicitly, not as corruption or an empty fake vault. +Restoring that backup must restore absence for those owned components. Missing +required settings/server config or read errors are not optional absence. + +Bound archive/member size and entry count before reading/extracting everything. +Reject unsupported manifest versions, missing/duplicate/unexpected members, +absolute or traversal paths, links, device/special entries, inconsistent sizes, +truncation, and unsafe extension/header interpretations. Parse allowed files in +staging before mutation using existing settings/document/vault validators. Do not +reject a losslessly supported anomalous peer simply because project_client fails. +The tar checksum is not authentication; do not advertise tamper-proof backups or +silently add a signing/hash dependency. Bounds/structural validation remain required. + +M4.2 documents file layout and a versioned internal-state schema for reservations, +historical apply evidence, and operation recovery if needed. Keep aliases and +client configuration authoritative in the server file. Reject unknown or malformed +state rather than silently losing reservations. A file loaded from disk must not +self-assert current RuntimeObservation::Verified without runtime verification. + +Separate orchestration from a narrow filesystem adapter for deterministic errors. +Use safe no-follow/anchored path operations, regular-file checks and private +staging. Detect target aliasing/overlap (for example vault and server resolving to +the same file) before mutations. Do not traverse a symlink or use archive metadata +to chmod/chown arbitrary targets. Create files/directories with 0600/0700 from +creation; preserve protected directory access and handle existing permissions +explicitly. Do not recursively delete the data directory to implement restore. + +Use same-filesystem temporary files, complete writes, flush/sync, atomic rename, +and directory sync where applicable. A rename is not a transaction across server, +settings, and data directories. Document ordering, durable recovery markers or an +explicit equivalent, and recovery from interruption between replacements before +implementing it. Never claim multi-file atomicity just because each rename is +atomic. Backup-before-mutation and write recovery are not optimistic locking. + +On replacement failure, either leave the old set intact or return an explicit +incomplete/recovery-required result with the preserved pre-operation backup. +Do not publish success, advance saved memory, or accept another mutation over +unresolved partial state. Recovery must be deterministic and tested after reopening +from partial files. It may finish/restore a local write transaction as documented; +it must never apply runtime changes or become automatic failed-restart rollback. +Report cleanup failures honestly and keep recoverable evidence if cleanup is unsafe. + +M4.3 builds a prepared change set containing edited server bytes, staged encrypted +vault, and validated internal state. Preview/validate it before installation. +Complete the pre-Commit backup before the first protected destination replacement. +Failure to create/finalize the backup stops Commit and preserves installed bytes +and saved in-memory state. Then stage/install the set and only after success +advance saved snapshots and expose successful Commit. A failed Commit retains +retryable draft intent without treating possibly partial disk state as consistent. + +Deletion removes the explicitly selected client record and vault secret in the +same committed operation; unknown/missing vault records need clear behavior, since +server-only clients are legitimate. Unrelated imported/file-only records remain. +Earlier archives remain unchanged. Prepared imports/setup never overwrite an +existing vault identity silently and never clear authenticated reissue blocking. +Discarding an uncommitted preparation must not change installed files. Persistent +settings updates are not a new product feature; Commit handles agreed client and +internal changes while settings are included in backups/restoration. + +M4.4 exposes listing and manual backup plus a reusable pre-restart backup operation +for M5, without restarting anything here. Sorting/time labels do not imply a backup +is working. Accept historical verification only from an explicit verified-apply +boundary intended for M5; represent no verified state honestly. Any identity/token +used to associate a saved generation with verification must not be a fresh runtime +claim or a new crypto dependency. Never mark pre-restart bytes known-working based +on the backup reason alone. + +Restore has a read-only preview followed by explicit confirmation of that prepared +archive. Validate the chosen content and determine affected files before writes; +then complete the precautionary backup, stage all replacement/deletion actions, +and install. Restore settings, whole server file, internal state, and opaque vault +as a complete saved set, not a client merge. Whole-file restoration of the archived +server is intentional; ordinary Commit still obeys M2 client-only editing rules. + +Use current trusted destination mapping and enforce the installation's invariant +paths. Archived settings cannot redirect writes outside that mapping or switch data +roots during restore; report incompatible paths instead of adding migration logic. +Keep the selected validated archive stable between preview and confirmation so +confirmation cannot apply different bytes. Do not silently discard the active draft +until confirmed; cancellation/validation/backup failure preserves it. Successful +restore discards it and reloads the restored saved state, retaining conservative +pending reservations across the still-unchanged runtime boundary. Historical +restored state cannot assert the current interface matches it. Caller never needs +or supplies a password for archive listing, backup, preview or restore. + +M4.5 injects failures at backup creation/write/finalization, staging writes, sync, +each destination replacement, recovery and cleanup. Verify exact file sets and +memory transitions rather than only error messages. Avoid permission tests that +pass accidentally as root; run unprivileged and inject deterministic failures. +Use temporary private directories and generated encrypted bundles. Do not log +server fixture contents or decrypted data in failing assertions. + +#### Acceptance and validation + +- [x] M4-A1: documented versioned archive/state layouts round-trip settings, + server bytes, encrypted vault and internal state without any password/decryption; + initial missing vault/state is represented and restored explicitly. +- [x] M4-A2: complete backups precede Commit/restore and the future restart boundary; + backup/read/finalization failure prevents protected replacements and successful + memory transitions. Manual backup/listing works without mutations to saved data. +- [x] M4-A3: successful Commit writes the prepared client changes, vault and state; + untouched server spans remain identical; saved memory advances only on success, + runtime stays unchanged/unknown, and draft discard does not write files. +- [x] M4-A4: explicit client deletion removes only its current vault record while + server-only and file-only anomalies remain supported; old archives are unchanged. +- [x] M4-A5: confirmed restore fully replaces owned saved components, including + removal of components absent in the archive, without merging or reading plaintext + client sources. Preview/cancel does not mutate or silently discard the draft. +- [x] M4-A6: original client source files, unrelated files and all archives outside + the selected/pre-operation artifacts are protected from traversal, symlink/link + escapes, target aliasing and unsafe tar entries; test bounded parsing and rejection. +- [x] M4-A7: 0600 secret-bearing files/archives and 0700 private directories apply + from creation, including staging; errors and Debug redact secret-bearing bytes. +- [x] M4-A8: injected failures/interruption between multi-file replacements produce + no false success or premature saved-memory change; reopening detects incomplete + work and follows the documented deterministic recovery path before new mutations. +- [x] M4-A9: saved/deleted address reservations survive reopen and restore across + unapplied changes; neither action releases them or trusts historical verification + as current runtime. Missing/malformed state cannot silently permit address reuse. +- [x] M4-A10: listing records UTC reason and explicit historical apply status; + pre-Commit/pre-restart archives and newest archives are not automatically working. +- [x] M4-A11: incompatible archived destination settings are rejected; restored + server/config/vault stay at trusted invariant paths, and no whole-directory wipe + or recursive archive inclusion occurs. +- [x] M4-A12: M1–M3 regressions and integrated gates pass; independent review closes + actionable persistence, archive, backup-order and failure-recovery findings. + +Planned commands from the repository root after M4 targets and lockfile exist: + +```sh +cargo test --locked backup +cargo test --locked persistence +cargo test --locked --test commit_restore +cargo test --locked --test vault_import +cargo test --locked --test config_preservation +cargo fmt --all -- --check +cargo clippy --locked --all-targets -- -D warnings +cargo test --locked +cargo build --locked --release +./target/release/wireguard-admin --help +./target/release/wireguard-admin --version +git diff --check +``` + +Filters must execute actual focused tests. All commands should exit zero; +negative cases are assertions within passing tests. Resolve approved added crates +and update Cargo.lock before locked checks. Record actual counts, commands and +failure-injection coverage. Tests are unprivileged filesystem/library checks only; +no container/VM or live restart acceptance is required or claimed in this milestone. + +#### Progress + +- [x] Description prepared from accepted M1–M3 interfaces and current documents. +- [x] M4.1 — `/root/implementer_m4_1` added `src/backup.rs`, + `docs/BACKUP_FORMAT.md`, approved `tar`/`time` dependencies, and six focused + archive tests (2026-10-19T20:23:23Z). Coordinator inspected the actual code; + final integrated review and gates subsequently passed. +- [x] M4.2 — `/root/implementer_m4_2` added `src/persistence.rs`, + `docs/PERSISTENCE.md`, and validated reservation restoration in `src/domain.rs` + (2026-10-19T20:30:34Z). Integrated review subsequently passed. +- [x] M4.3 — `/root/implementer_m4_3` added `src/app.rs`, a narrow vault + record-removal API, and one trusted archive-directory accessor (2026-10-19T20:35:58Z). + Commit backup/install ordering and draft tests passed integrated review. +- [x] M4.4 — `/root/implementer_m4_4` added list/manual/pre-restart backup + boundaries and byte-bound restore preview/confirmation, with path-invariant + validation and focused tests (2026-10-19T20:42:20Z); integrated review passed. +- [x] M4.5 — `/root/implementer_m4_5` added nine cross-module tests and fixed + restore reservations plus interface-name invariant (2026-10-19T20:49:14Z). + Documentation was synchronized; independent review passed. +- [x] M4.6 — fresh `/root/reviewer_m4_r1` reviewed the integrated result; + separate `/root/repairer_m4_r1` repaired R1 and R4 findings; R2/R3/R5 + re-review closed all with no new actionable findings (2026-10-19T21:10:25Z). +- [x] M4.7 — coordinator ran all planned integrated gates and accepted M4 + (2026-10-19T21:10:25Z). + +#### Review and repair + +| Finding | Iteration | Location and impact | Owner | Resolution and evidence | State | +| --- | --- | --- | --- | --- | --- | +| M4-R1-01 | R1/R2 | `src/persistence.rs` saved reads accepted existing secret-bearing files with broad permissions. | `/root/repairer_m4_r1` | R2 confirmed fail-closed server/vault mode checks and regression tests. | closed | +| M4-R1-02 | R1/R2/R3 | Archive and installation failure paths lacked deterministic evidence. | `/root/repairer_m4_r1` | R3 confirmed partial restore interruption after server replacement, unchanged session saved/draft/applied state, failed rollback and fresh reopen recovery, and intact pre-restore archive; focused test passed. | closed | +| M4-R4-01 | R4/R5 | `src/app.rs` lost verified saved-generation history on reopen and mislabeled pre-Commit backups. | `/root/repairer_m4_r1` | R5 confirmed durable history after mandatory backup, truthful archive labeling, reopen history with runtime Unknown, and failure/clear tests. | closed | + +R1 independently inspected the integrated code; `cargo test --locked --test +commit_restore` passed 9 tests and `git diff --check` was clean. Reviewer did not +claim Alpine, VM, live WireGuard, or host-service evidence. R2/R3/R5 completed +the required re-reviews and closed all findings. + +#### Evidence and handoff + +Planning inspected Cargo.toml, M3 vault serialization and source/import APIs, +VAULT_FORMAT.md, M1 state methods, M3 recorded acceptance and governing documents. +M3 records independent R7 review, 64 unit tests plus 9 preservation and 5 vault +import integration tests, and passing coordinator gates. These are historical +results; this planning task did not rerun tests or modify application code, +dependencies, files in configured runtime directories, or installed vault state. + +M4.1 worker reported `cargo test --locked --offline` (70 unit, 9 preservation, +5 vault-import), `cargo test --locked --offline backup` (6), Clippy, format, +and `git diff --check` passing. These are worker results, not final integrated +acceptance. The archive API accepts explicit saved bytes and validates a bounded +fixed-member UStar format without extraction; app-level semantic validation and +trusted path handling remain for M4.2–M4.4. M4.2 worker reported 10 persistence +tests, full suite (80 unit + 9 preservation + 5 vault-import), Clippy, release +build, format and diff checks passing. It supplies `SavedStore` with validated +read/install/recover, a private journal, and explicit recovery-required results; +per-syscall fault injection remains for M4.5. These are worker results only. +M4.3 worker reported formatting, Clippy, full tests (89 unit + 14 integration), +release build, and diff checks passing. Its `Application` stages public-key edits, +pre-Commit backups exact installed bytes, and advances saved memory only after +install. Broad-route legacy deletion fails closed because the persisted reservation +format holds single addresses; exact-host legacy routes are retained. This is +worker evidence, not integrated acceptance. M4.4 worker +reported 95 unit + 14 integration tests, formatting, Clippy, offline release +build and diff checks passing. Coordinator inspection identified additional +reservation/path failure cases for M4.5 tests and independent review. M4.5 +worker reported 95 unit + 23 integration tests, focused backup (11), persistence +(10), and commit_restore (9), plus format, Clippy, release build, help/version, +and diff checks passing. Its tests reproduced and fixed restore dropping current +saved-client reservations and accepting an archived interface-name switch. +Syscall-level write/fsync fault injection is not yet exhaustive; this remains +an explicit review concern. These are worker results, not final acceptance. +Next action: fresh independent M4 reviewer, then repair/re-review as needed. +R1 returned M4-R1-01 and M4-R1-02 as actionable. Next action: a separate +repairer fixes those findings, then the reviewer rechecks. Repairer reported +both fixed, with 102 unit + 23 integration tests and formatting, Clippy, +release build and diff checks passing (2026-10-19T20:57:23Z). The coordinator +has not yet accepted those claims; next action is independent R2 re-review. +R2 (2026-10-19T20:58:52Z) closed M4-R1-01 and partially closed M4-R1-02. +Focused persistence (15), backup (12 unit + 2 integration), commit_restore (9), +and injected Commit test (1) passed. Reviewer requires a restore partial-install +failure/recovery test at the application boundary. Next action: targeted repair +and R3 re-review; milestone remains in progress. +Targeted repair added a private restore-install test seam and regression. R3 +(2026-10-19T21:01:08Z) closed M4-R1-02, found no new actionable defects, +and passed the focused test plus `git diff --check`. Next action: coordinator +integrated gates and A1–A12 decision. +Coordinator gates passed on the R3 revision, but a targeted historical-status +audit found M4-R4-01 (2026-10-19T21:03:13Z). Those gate results will be rerun +after repair. The milestone remains in review; next action is targeted repair +and independent re-review. + +Final integrated evidence after R5 repair (coordinator, 2026-10-19T21:10:25Z): +`cargo test --locked backup` passed 14 unit + 2 integration matches; +`cargo test --locked persistence` passed 15; `cargo test --locked --test +commit_restore` passed 9; `cargo test --locked --test vault_import` passed 5; +`cargo test --locked --test config_preservation` passed 9. `cargo fmt --all +-- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo +test --locked` (108 library unit + 9 Commit/restore + 9 preservation + 5 vault +import, 0 doctests), `cargo build --locked --release`, release `--help` and +`--version`, and `git diff --check` all exited zero. All three findings are +closed by independent R2/R3/R5; R5 found no new actionable issue. Evidence is +unprivileged synthetic/unit-fixture only. No Alpine, disposable systemd VM, +live VPN, host service, production file or production key was accessed or +claimed. The terminal workflow and real runtime/systemd checks belong to later +milestones. M4 has no remaining blocker. + +#### Decisions and discoveries + +Vault v1 already fixes data/vault.toml and authenticated conflict behavior. +Preserve that format and opaque ciphertext. New state/backup formats belong in +their own documents rather than altering vault or managed-comment v1 without need. + +The persistence layer spans directories and cannot assume a single atomic rename. +Interruption recovery and conservative address reservations are required write +integrity, not concurrent editing support or automatic failed-restart rollback. +Confirmed archive restoration replaces server settings as part of explicit recovery; +normal client Commit continues to preserve administrator-owned bytes. +The v1 internal-state historical timestamp follows the application's +single-administrator save boundary; it is not a digest check against manual +out-of-band file changes. Reopen treats history as historical, not current +runtime verification. Restoration carries forward pending address reservations +for safety while replacing archived client/settings/vault saved content. + +#### Outcome + +Accepted after independent R5 review, closure of M4-R1-01/M4-R1-02/M4-R4-01, +and all M4-A1–A12 gates. Stop after M4. Begin M5 only on a separate request; +no Git commit, deployment or production access occurred. ### Milestone 5 — Runtime observation and systemd restart adapter @@ -1061,13 +1421,14 @@ Acceptance: real D-Bus job handling, separate restart verification, address rele ## Current progress and resume point -- Milestones 1, 2 and 3 are accepted; milestones 4–9 remain pending. +- Milestones 1–4 are accepted; milestones 5–9 remain pending. - The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md). - Milestone 1 is expanded and the Rust environment is pinned. - Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests. - Milestone 3 delivered secret-safe key handling, a versioned encrypted vault, caller-buffer reconciliation, and a bounded read-only source-directory import adapter. -- Next action: stop after accepted M3; begin M4 only if separately requested. -- M1 integrated gates and independent re-review passed. No container or VM results are claimed. +- Milestone 4 delivered versioned private backups, journaled saved-file Commit and recovery, previewed full restore, durable address reservations and historical verification bookkeeping, with independent R5 closure and coordinator gates. +- Next action: stop after accepted M4; begin M5 only if separately requested. +- M1–M4 integrated gates and independent reviews passed. No Alpine or VM results are claimed. ## Plan maintenance diff --git a/README.md b/README.md index 0ece4da..a556eb7 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,9 @@ This repository contains the Rust package, startup boundary, validated settings loader, client domain model, a byte-preserving in-memory WireGuard configuration editor, and library APIs for encrypted client vaults and read-only client import. -The terminal management interface, installed vault persistence, backups, and VPN -operations have not been implemented yet. +The library also provides saved-file persistence with recovery, private backups, +prepared Commit, and previewed full restore. The terminal management interface +and WireGuard/systemd runtime operations have not been implemented yet. The package requires Rust 1.97.1 or newer and uses the Rust 2024 edition. From the repository root, run: @@ -67,7 +68,27 @@ let bytes_to_review = updated.render(); // no persistence or VPN operation ``` Management startup still exits with an explicit “not implemented” error after -its root check; the library APIs are not yet connected to a management session. +its root check. The application library APIs are usable by callers, but are not +yet connected to a management session or runtime VPN adapter. + +## Saved state, Commit, and backups + +wireguard_admin::app::Application opens saved files through +wireguard_admin::persistence::SavedStore, stages client and vault changes, +creates manual and mandatory operation backups, and previews/confirms a full +restore. Commit writes saved files only. Restore also changes saved files only; +neither operation reloads or restarts WireGuard. The server file is kept at its +trusted configured path, while internal state and the encrypted vault are kept +under Backup/data by default. Archives are stored under Backup/archives and +contain fixed logical members, not source client files or prior archives. + +The persistence journal recovers interrupted file replacement on reopen and +returns a recovery error while earlier work remains unresolved. Restoring a +backup does not restore runtime state: the running interface is unknown until a +future explicit runtime verification. Pending deleted-client addresses remain +reserved through Commit, reopen, and restore. See +[docs/BACKUP_FORMAT.md](docs/BACKUP_FORMAT.md) and +[docs/PERSISTENCE.md](docs/PERSISTENCE.md) for archive and recovery details. ## Vault and existing-client import library diff --git a/docs/BACKUP_FORMAT.md b/docs/BACKUP_FORMAT.md new file mode 100644 index 0000000..e6a7b0f --- /dev/null +++ b/docs/BACKUP_FORMAT.md @@ -0,0 +1,116 @@ +# Backup archive format + +Backups are uncompressed tar archives with a versioned TOML manifest. Version 1 +uses only fixed logical member names and is parsed without extracting files. +The archive is an opaque saved-state copy; its tar checksum detects accidental +header damage but does not authenticate contents or make the archive tamper-proof. + +## Members and limits + +The first member is always `manifest.toml`. The only permitted payload names are +`settings.toml`, `server.conf`, `internal-state.toml`, and `vault.toml`, in that +order when present. Settings and server configuration are required. Internal +state and vault are optional components: a missing component is recorded as +`present = false` and `length = none`, which means restore removes that owned +component. Absence is not represented by an empty placeholder file. + +| Member | Maximum bytes | Presence | +| --- | ---: | --- | +| `manifest.toml` | 64 KiB | Required, first | +| `settings.toml` | 1 MiB | Required | +| `server.conf` | 4 MiB | Required | +| `internal-state.toml` | 2 MiB | Optional | +| `vault.toml` | 16 MiB | Optional | + +The archive is limited to 26 MiB and five entries. Only checksummed UStar regular +files are accepted. Absolute names, path separators, traversal, links, device or +special entries, sparse entries, GNU/PAX extension headers, nonzero path prefixes, +unexpected metadata fields, duplicate/missing/unexpected members, size mismatches, +truncation, nonzero padding, and nonzero trailing data are rejected. The reader +checks declared sizes before allocating payload buffers and never follows a +tar-supplied path. It does not restore files or validate their application +semantics; restoration must run settings, configuration, state, and vault +validators before mutation. + +## Manifest v1 + +The manifest is UTF-8 TOML with unknown fields denied: + +```toml +version = 1 +created_at_utc = "2026-10-19T12:30:00Z" +reason = "pre-commit" + +[historical_apply] +status = "unknown" + +[[members]] +name = "settings.toml" +present = true +length = 420 + +[[members]] +name = "server.conf" +present = true +length = 640 + +[[members]] +name = "internal-state.toml" +present = false + +[[members]] +name = "vault.toml" +present = true +length = 8192 +``` + +`reason` is one of `manual`, `pre-commit`, `pre-restore`, or `pre-restart`. +Timestamps are canonical RFC 3339 UTC values with whole-second precision. The +historical apply record is either `status = "unknown"` or +`status = "verified"` with a `verified_at_utc` timestamp. It is historical +metadata only: a pre-operation reason or a verified marker does not establish +that these bytes match the currently running interface. Application-created +backups derive this record from the timestamp stored in the internal-state bytes +captured with the saved set. A successful Commit or restore clears that timestamp +for its new saved generation; a failed operation leaves it intact. Reopening may +show the saved generation's historical verification time, but runtime observation +always begins as unknown. + +The inventory always has exactly four records in the specified order. Required +members have `present = true` and an exact byte `length`; optional absence has +`present = false` and omits `length` (TOML has no null value). Present optional members follow the required +members in fixed order. The manifest itself is excluded from its payload +inventory. Readers reject unsupported versions and any discrepancy between +inventory and tar entries. + +## Publication and trust boundary + +Creation receives exact saved bytes from a trusted caller. It does not discover +files, inspect the current working directory, recurse into the archive directory, +or read client source/output directories. The caller must supply installed bytes, +never a proposed draft or decrypted vault records. New archives are fully +serialized and synced in a private temporary file, then published without +replacement under a timestamped collision-safe name; the archive directory is +synced after publication. The archive file is mode `0600` from temporary-file +creation. A write or file-sync failure removes the unpublished temporary file; +a publication or directory-sync failure returns an error and blocks the protected +operation. A complete archive may already be visible if failure occurs after its +atomic publication, and a failed temporary cleanup may leave a hidden `0600` +temporary file. Neither condition is reported as backup success. Readers should +expose the list and preview without passwords, but must keep payload bytes out of +Debug output and logs. + +The application restore preview validates both the archive structure and its +settings, server document, optional internal state, and opaque vault before it +returns an opaque confirmation value. That value owns the validated payload +bytes, so confirmation applies the exact content shown during preview even if +the selected archive path changes afterward. Confirmation first completes a +`pre-restore` archive and then uses the saved-file transaction journal for the +full replacement. Dropping the preview is cancellation and does not discard a +draft. Historical verification metadata is informational; opening or restoring +an archive always leaves current runtime state unknown. + +This module only creates, lists, and validates archives. It does not write saved +state, apply WireGuard changes, or restore content. A backup is not encrypted: +it can contain the server's private configuration and the encrypted vault, so +filesystem access must remain restricted to root. diff --git a/docs/PERSISTENCE.md b/docs/PERSISTENCE.md new file mode 100644 index 0000000..60406f5 --- /dev/null +++ b/docs/PERSISTENCE.md @@ -0,0 +1,105 @@ +# Saved state persistence + +The persistence adapter reads and installs the application's saved files at the +paths in `config::ResolvedSettings`. Settings remain beside the executable, +the server configuration stays at its configured invariant path, and internal +state and the opaque encrypted vault live in `data_directory`. No path is +resolved from the process working directory. Archive member names never become +filesystem paths. + +## Internal state v1 + +`internal-state.toml` is optional. Its absence is valid only during initial +setup; once an installed state file exists, malformed or unsupported data is an +error and cannot be treated as an empty reservation set. Version 1 contains no +alias registry and does not duplicate server client metadata: + +```toml +version = 1 +reserved_addresses = ["10.0.0.8", "fd00::8"] +last_verified_at_utc = "2026-10-19T12:30:00Z" +``` + +`reserved_addresses` records addresses retained after saved deletion until a +later explicit runtime verification. It is canonically sorted and duplicate +free. `last_verified_at_utc` records when this saved generation was last verified +against the runtime. It is historical bookkeeping only: loading it always +produces `RuntimeObservation::Unknown` and never establishes that the current +interface matches saved files. The timestamp is stored in the same journaled +internal-state component as the saved set it describes. Successful Commit and +restore write a new generation with this timestamp cleared; failed operations do +not advance or clear it. M5 records verification only after a completed +pre-restart backup, successful runtime verification, and successful journaled +state write. If that write fails, the in-memory applied state remains unchanged. +This binding follows the application's single-administrator save boundary; it is +not a digest that detects manual edits made outside the application. +Unknown fields, duplicate addresses, invalid addresses/timestamps, and +unsupported versions are rejected. + +Settings bytes are parsed and semantically validated, server bytes are parsed +with the byte-preserving `ConfigDocument`, and a present vault is parsed by +`VaultBundle::from_toml` without unlocking it. Saved reads are bounded and +return exact bytes; their `Debug` representations redact content. The server +parser reports its existing anomalies without rewriting or normalizing bytes. +The server configuration and a present vault must already have mode `0600` or +stricter when read. A broader mode fails closed with an actionable permission +error; the application does not silently chmod an existing secret-bearing file. +The operator must restrict its mode and retry. Newly installed replacements +are created as `0600`. +During restore, operational settings such as endpoint, DNS, systemd unit, and +timeout may come from the archive. The configured interface name remains fixed +for this installation, along with the resolved server configuration, client +source, and data-root paths (including their derived data/archive directories). +This preserves the single-interface mapping while allowing an archive to restore +other settings without redirecting writes. + +## Destination and path safety + +The adapter accepts only the resolved settings path, configured server path, +`data_directory/internal-state.toml`, and `data_directory/vault.toml`. These +paths are checked for overlap/aliasing before a write. Every existing path +component is checked without following symlinks; saved files must be regular +files. Destination directories are private (`0700`), and replacement and +journal files are created `0600` from their creation. Symlinks, non-regular +files, unsafe directory components, and path aliasing fail closed. No archive +metadata controls destination names, permissions, or ownership. + +## Multi-file installation and recovery + +Installation is not a cross-directory atomic operation. The caller must first +complete the mandatory operation backup and supply the adapter's +`BackupComplete` capability. The adapter then validates the complete proposed +saved set and checks that no earlier transaction needs recovery. It creates a +fixed-name private transaction directory under the trusted data directory and +writes the prior bytes (including explicit absence) and proposed bytes under +fixed component names. Each payload and the transaction directory are synced, +then the journal metadata and a `prepared` marker are atomically published and +the journal and parent data directory are synced. Only then may destination +files be changed. An interrupted journal without a valid prepared marker is +safe to clean because the adapter cannot have started replacements yet. + +Replacement order is settings, server configuration, internal state, then +vault. A deletion of an optional state or vault is part of that order. Each +new file is written completely to a same-directory `0600` temporary file, +flushed and synced, atomically renamed, and followed by a parent-directory +sync. After all replacements, the adapter syncs all affected parents, records +the transaction as committed, syncs that marker and its parent, then removes +the transaction directory and syncs the data directory. Only a fully completed +sequence returns success. + +On reopen, an uncommitted journal means rollback: prior bytes are reinstalled +in reverse replacement order, previously absent optional files are removed, +and all affected parent directories are synced. If rollback or cleanup fails, +the adapter returns `RecoveryRequired` and rejects every new mutation. Recovery +can be retried after correcting the filesystem problem. A committed journal +means all replacements finished; reopening performs cleanup only and never +rolls back the completed set. If writing/syncing the committed marker or +cleaning the journal fails during installation, the result is explicitly +`RecoveryRequired`, not success. The next open determines rollback versus +cleanup from the durable marker. + +This journal repairs interrupted saved-file writes only. It never invokes a +WireGuard or systemd operation, does not provide concurrent-editor locking, +and is not automatic rollback after a failed runtime restart. The separate +pre-operation archive remains the recovery source if local journal data is +unusable; the adapter does not silently discard an invalid journal. diff --git a/docs/VAULT_FORMAT.md b/docs/VAULT_FORMAT.md index cf0c9b1..dd2d3b5 100644 --- a/docs/VAULT_FORMAT.md +++ b/docs/VAULT_FORMAT.md @@ -7,12 +7,11 @@ unknown fields, malformed labels, invalid Base64, and oversized bundles. ## Version 1 structure -For M4, the current encrypted bundle is intended to live at +The current encrypted bundle lives at `ResolvedSettings::data_directory.join("vault.toml")`. This stable filename is relative to the resolved data directory, whose default is `Backup/data` beside -the executable. M3 only prepares and serializes vault bytes in memory; it does -not create this file or install application state. M4 persistence will own that -write. +the executable. The application installs the opaque serialized bytes through +the saved-file transaction after completing its mandatory pre-Commit backup. The top level contains: diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..b0be340 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,1482 @@ +//! Application session operations for prepared client changes and saved state. +//! +//! This module owns the boundary between a byte-preserving configuration draft, +//! encrypted vault data, backups, and the recoverable saved-file adapter. It has +//! no runtime or systemd integration. + +use std::collections::BTreeSet; +use std::fmt; +use std::net::IpAddr; + +use thiserror::Error; + +use crate::backup::{self, BackupError, BackupReason, HistoricalApplyStatus, SavedBytes}; +use crate::domain::{Client, ClientStates, DomainError, PublicKey, RuntimeObservation, Snapshot}; +use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore}; +use crate::vault::source::{PreparedImportSetup, SourceImportReport}; +use crate::vault::{VaultBundle, VaultError}; +use crate::wireguard::{ConfigDocument, DocumentError, EditError}; + +/// An open, in-memory management session. The configuration and state drafts +/// change independently of installed bytes until [`Self::commit`] succeeds. +pub struct Application { + store: SavedStore, + saved: SavedBytes, + document: ConfigDocument, + states: ClientStates, + vault: Option, + draft_unsupported_reservations: BTreeSet, + historical_apply: HistoricalApplyStatus, + pre_restart_backup_pending: bool, +} + +/// Read-only restore details and an opaque, byte-bound confirmation value. +/// The value contains the validated archive payload; confirmation never rereads +/// a path whose contents may have changed since preview. +pub struct RestorePreview { + created_at_utc: String, + reason: BackupReason, + historical_apply: HistoricalApplyStatus, + saved_bytes: SavedBytes, +} + +impl RestorePreview { + pub fn created_at_utc(&self) -> &str { + &self.created_at_utc + } + + pub const fn reason(&self) -> BackupReason { + self.reason + } + + pub fn historical_apply(&self) -> &HistoricalApplyStatus { + &self.historical_apply + } +} + +impl fmt::Debug for RestorePreview { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RestorePreview") + .field("created_at_utc", &self.created_at_utc) + .field("reason", &self.reason) + .field("historical_apply", &self.historical_apply) + .field("saved_bytes", &self.saved_bytes) + .finish() + } +} + +impl fmt::Debug for Application { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Application") + .field("saved", &self.saved) + .field("document", &self.document) + .field("states", &self.states) + .field("vault_present", &self.vault.is_some()) + .field("historical_apply", &self.historical_apply) + .field( + "pending_reservation_count", + &self.draft_unsupported_reservations.len(), + ) + .finish() + } +} + +impl Application { + /// Open validated installed bytes and apply conservative persisted address + /// reservations to the caller's narrower client-state model. + pub fn open(store: SavedStore, mut states: ClientStates) -> Result { + let saved = store.read_saved()?; + states.mark_runtime_unknown(); + let document = ConfigDocument::parse(&saved.server_config)?; + let vault = saved + .vault + .as_deref() + .map(std::str::from_utf8) + .transpose() + .map_err(|_| AppError::InvalidSavedState)? + .map(VaultBundle::from_toml) + .transpose()?; + if let Some(bytes) = &saved.internal_state { + PersistedState::from_toml(bytes)?.apply_to(&mut states)?; + } + let historical_apply = historical_apply_from_saved(&saved)?; + Ok(Self { + store, + saved, + document, + states, + vault, + draft_unsupported_reservations: BTreeSet::new(), + historical_apply, + pre_restart_backup_pending: false, + }) + } + + pub fn saved_bytes(&self) -> &SavedBytes { + &self.saved + } + + pub fn document(&self) -> &ConfigDocument { + &self.document + } + + pub fn states(&self) -> &ClientStates { + &self.states + } + + /// Add one prepared public-key client to the byte-preserving server draft + /// and the narrower in-memory client draft. + pub fn add_client( + &mut self, + client: Client, + created_utc: Option<&str>, + ) -> Result<(), AppError> { + let edited = self.document.add_client(&client, created_utc)?; + self.states.draft_mut().insert(client)?; + self.document = edited; + Ok(()) + } + + pub fn vault(&self) -> Option<&VaultBundle> { + self.vault.as_ref() + } + + /// 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 replacement = Client::new(identity, alias, client.addresses)?; + self.states.draft_mut().insert(replacement)?; + } + self.document = edited; + Ok(()) + } + + /// Delete one explicitly selected peer and its current encrypted record, if + /// present. File-only vault records and unrelated records are retained. + pub fn delete_client(&mut self, identity: PublicKey) -> Result { + let saved_document = ConfigDocument::parse(&self.saved.server_config)?; + let saved_peer_exists = saved_document + .peers() + .iter() + .any(|peer| peer.identity == Some(identity)); + let mut additional_reservations = BTreeSet::new(); + if saved_peer_exists && self.states.saved().get(&identity).is_none() { + let peer = saved_document + .peers() + .iter() + .find(|peer| peer.identity == Some(identity)) + .ok_or(AppError::DeletedPeerAddressUnknown)?; + for network in &peer.allowed_ips { + match network { + ipnet::IpNet::V4(network) if network.prefix_len() == 32 => { + additional_reservations.insert(IpAddr::V4(network.addr())); + } + ipnet::IpNet::V6(network) if network.prefix_len() == 128 => { + additional_reservations.insert(IpAddr::V6(network.addr())); + } + _ => return Err(AppError::DeletedPeerAddressUnknown), + } + } + if peer.allowed_ips.is_empty() || additional_reservations.is_empty() { + return Err(AppError::DeletedPeerAddressUnknown); + } + } + let edited = self.document.delete_client(&identity)?; + let removed_record = match self.vault.as_mut() { + Some(vault) => vault.remove_record(identity)?, + None => false, + }; + self.states.draft_mut().remove(&identity); + self.draft_unsupported_reservations + .extend(additional_reservations); + self.document = edited; + 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() { + return Err(AppError::VaultAlreadyInstalled); + } + bundle.to_toml()?; + self.vault = Some(bundle); + Ok(()) + } + + /// Stage the bundle from M3's read-only initial import result and return + /// its anomaly report unchanged for the caller to present to the operator. + pub fn prepare_initial_setup( + &mut self, + prepared: PreparedImportSetup, + ) -> Result { + let PreparedImportSetup { bundle, report } = prepared; + self.prepare_initial_vault(bundle)?; + Ok(report) + } + + /// Drop all uncommitted edits and restore the in-memory drafts from the last + /// successfully installed session snapshot. No files are written. + pub fn discard_draft(&mut self) -> Result<(), AppError> { + self.saved = self.store.read_saved()?; + self.document = ConfigDocument::parse(&self.saved.server_config)?; + self.vault = self + .saved + .vault + .as_deref() + .map(std::str::from_utf8) + .transpose() + .map_err(|_| AppError::InvalidSavedState)? + .map(VaultBundle::from_toml) + .transpose()?; + self.states.discard_draft(); + self.draft_unsupported_reservations.clear(); + if let Some(bytes) = &self.saved.internal_state { + PersistedState::from_toml(bytes)?.apply_to(&mut self.states)?; + } else { + self.states.mark_runtime_unknown(); + } + self.historical_apply = historical_apply_from_saved(&self.saved)?; + Ok(()) + } + + /// Create the mandatory archive from current installed bytes without + /// changing the current session or saved files. + pub fn create_backup(&self, reason: BackupReason) -> Result { + let installed = self.store.read_saved()?; + let historical_apply = historical_apply_from_saved(&installed)?; + Ok(backup::create_archive( + self.store.archives_directory(), + reason, + historical_apply, + &installed, + )?) + } + + pub fn create_manual_backup(&self) -> Result { + self.create_backup(BackupReason::Manual) + } + + pub fn list_backups(&self) -> Result, AppError> { + match backup::list_archives(self.store.archives_directory()) { + Ok(archives) => Ok(archives), + Err(BackupError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(Vec::new()) + } + Err(error) => Err(error.into()), + } + } + + /// Reusable mandatory backup boundary for M5 before it invokes restart. + /// This method only archives saved bytes; it never calls a runtime adapter. + pub fn create_pre_restart_backup(&mut self) -> Result { + let path = self.create_backup(BackupReason::PreRestart)?; + self.pre_restart_backup_pending = true; + Ok(path) + } + + /// Validate a selected archive without changing saved files or the draft. + /// The returned value carries the exact validated bytes used at confirmation. + pub fn preview_restore(&self, file_name: &str) -> Result { + if !safe_archive_name(file_name) { + return Err(AppError::InvalidArchiveSelection); + } + let path = self.store.archives_directory().join(file_name); + let preview = backup::preview_archive(&path)?; + self.store.validate_saved(&preview.saved_bytes)?; + let mut proposed = preview.saved_bytes; + let document = ConfigDocument::parse(&proposed.server_config)?; + let snapshot = snapshot_from_document(&document); + let mut restored_states = ClientStates::new(snapshot, None, RuntimeObservation::Unknown)?; + let mut archived_reservations = BTreeSet::new(); + let mut reservations = self.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 + .saved() + .clients() + .flat_map(|client| client.addresses.iter()), + ); + if let Some(state_bytes) = &proposed.internal_state { + let archived = PersistedState::from_toml(state_bytes)?; + archived_reservations = archived + .reserved_addresses + .iter() + .map(|address| { + address + .parse::() + .map_err(|_| AppError::InvalidSavedState) + }) + .collect::, _>>()?; + PersistedState::new(archived_reservations.iter().map(ToString::to_string), None)? + .apply_to(&mut restored_states)?; + } + reservations.extend(archived_reservations); + reservations.retain(|address| { + !restored_states + .saved() + .clients() + .any(|client| client.addresses.iter().any(|saved| saved == *address)) + }); + if !reservations.is_empty() || proposed.internal_state.is_some() { + let state = PersistedState::new(reservations.iter().map(ToString::to_string), None)?; + state.apply_to(&mut restored_states)?; + proposed.internal_state = Some(state.to_toml()?); + } + self.store.validate_saved(&proposed)?; + Ok(RestorePreview { + created_at_utc: preview.created_at_utc, + reason: preview.reason, + historical_apply: preview.historical_apply, + saved_bytes: proposed, + }) + } + + /// Explicitly confirm a previously previewed restore. The pre-restore + /// archive is finalized before the journaled full replacement begins. + pub fn confirm_restore( + &mut self, + preview: RestorePreview, + ) -> Result { + self.confirm_restore_with_install(preview, |store, saved, _backup| { + store.install(saved, BackupComplete(())) + }) + } + + fn confirm_restore_with_install( + &mut self, + preview: RestorePreview, + install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, + ) -> Result { + self.store.validate_saved(&preview.saved_bytes)?; + let current = self.store.read_saved()?; + let archive = backup::create_archive( + self.store.archives_directory(), + BackupReason::PreRestore, + historical_apply_from_saved(¤t)?, + ¤t, + )?; + let resolved = self.store.resolved_settings_for(&preview.saved_bytes)?; + install(&self.store, &preview.saved_bytes, BackupComplete(()))?; + let document = ConfigDocument::parse(&preview.saved_bytes.server_config)?; + let vault = preview + .saved_bytes + .vault + .as_deref() + .map(std::str::from_utf8) + .transpose() + .map_err(|_| AppError::InvalidSavedState)? + .map(VaultBundle::from_toml) + .transpose()?; + let snapshot = snapshot_from_document(&document); + let mut states = ClientStates::new(snapshot, None, RuntimeObservation::Unknown)?; + if let Some(bytes) = &preview.saved_bytes.internal_state { + PersistedState::from_toml(bytes)?.apply_to(&mut states)?; + } + self.saved = preview.saved_bytes; + self.store.set_resolved_settings(resolved); + self.document = document; + self.vault = vault; + self.states = states; + self.draft_unsupported_reservations.clear(); + self.historical_apply = HistoricalApplyStatus::Unknown; + self.pre_restart_backup_pending = false; + Ok(archive) + } + + /// M5 calls this only after successful systemd job completion and runtime + /// verification. The saved generation and its pre-restart backup must still + /// match before the verification timestamp can be persisted. + #[allow(dead_code)] // Reserved as the narrow M5 adapter handoff boundary. + pub(crate) fn mark_verified_after_runtime_check( + &mut self, + observed: Snapshot, + verified_at_utc: String, + ) -> Result<(), AppError> { + self.mark_verified_with_install(observed, verified_at_utc, |store, saved, backup| { + store.install(saved, backup) + }) + } + + fn mark_verified_with_install( + &mut self, + observed: Snapshot, + verified_at_utc: String, + install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, + ) -> Result<(), AppError> { + if !self.pre_restart_backup_pending { + return Err(AppError::PreRestartBackupRequired); + } + let installed = self.store.read_saved()?; + if !same_saved_bytes(&installed, &self.saved) { + return Err(AppError::SavedGenerationChanged); + } + let mut next_states = self.states.clone(); + next_states.mark_applied_after_verification(observed)?; + let mut persisted = match &installed.internal_state { + Some(bytes) => PersistedState::from_toml(bytes)?, + None => PersistedState::new( + next_states + .reserved_addresses() + .iter() + .map(ToString::to_string), + None, + )?, + }; + persisted.reserved_addresses = next_states + .reserved_addresses() + .iter() + .map(ToString::to_string) + .collect(); + let historical_apply = HistoricalApplyStatus::Verified { + verified_at_utc: verified_at_utc.clone(), + }; + persisted.last_verified_at_utc = Some(verified_at_utc); + let mut proposed = installed; + proposed.internal_state = Some(persisted.to_toml()?); + self.store.validate_saved(&proposed)?; + install(&self.store, &proposed, BackupComplete(()))?; + self.saved = proposed; + self.states = next_states; + self.historical_apply = historical_apply; + self.pre_restart_backup_pending = false; + Ok(()) + } + + /// Commit current configuration, vault, and reservations. The archive is + /// complete before `SavedStore::install` receives its capability. In-memory + /// saved state advances only after the adapter reports full success. + pub fn commit(&mut self) -> Result { + self.commit_with_adapters(backup::create_archive, |store, proposed, _backup| { + store.install(proposed, BackupComplete(())) + }) + } + + fn commit_with_adapters( + &mut self, + create_backup: impl FnOnce( + &std::path::Path, + BackupReason, + HistoricalApplyStatus, + &SavedBytes, + ) -> Result, + install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, + ) -> Result { + let installed = self.store.read_saved()?; + if let Some(existing_bytes) = &installed.vault { + let existing_text = + std::str::from_utf8(existing_bytes).map_err(|_| AppError::InvalidSavedState)?; + let existing = VaultBundle::from_toml(existing_text)?; + if self + .vault + .as_ref() + .is_none_or(|vault| existing.recipient() != vault.recipient()) + { + return Err(AppError::VaultIdentityChange); + } + } + + let mut next_states = self.states.clone(); + next_states.commit_draft(); + if !self.draft_unsupported_reservations.is_empty() { + let reservations = next_states + .reserved_addresses() + .iter() + .copied() + .chain(self.draft_unsupported_reservations.iter().copied()) + .map(|address| address.to_string()) + .collect::>(); + let persisted = PersistedState::new(reservations, None)?; + persisted.apply_to(&mut next_states)?; + } + let reservations = next_states + .reserved_addresses() + .iter() + .map(IpAddr::to_string) + .collect::>(); + let internal_state = Some(PersistedState::new(reservations, None)?.to_toml()?); + let vault_bytes = self + .vault + .as_ref() + .map(VaultBundle::to_toml) + .transpose()? + .map(String::into_bytes); + let proposed = SavedBytes { + settings: installed.settings.clone(), + server_config: self.document.render().to_vec(), + internal_state, + vault: vault_bytes, + }; + self.store.validate_saved(&proposed)?; + + let archive = create_backup( + self.store.archives_directory(), + BackupReason::PreCommit, + historical_apply_from_saved(&installed)?, + &installed, + )?; + install(&self.store, &proposed, BackupComplete(()))?; + + self.saved = proposed; + self.states = next_states; + self.draft_unsupported_reservations.clear(); + self.document = ConfigDocument::parse(&self.saved.server_config)?; + self.historical_apply = HistoricalApplyStatus::Unknown; + self.pre_restart_backup_pending = false; + Ok(archive) + } +} + +fn safe_archive_name(name: &str) -> bool { + name.starts_with("wireguard-admin-") + && name.ends_with(".tar") + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.')) +} + +fn historical_apply_from_saved(saved: &SavedBytes) -> Result { + let Some(bytes) = &saved.internal_state else { + return Ok(HistoricalApplyStatus::Unknown); + }; + Ok( + match PersistedState::from_toml(bytes)?.last_verified_at_utc { + Some(verified_at_utc) => HistoricalApplyStatus::Verified { verified_at_utc }, + None => HistoricalApplyStatus::Unknown, + }, + ) +} + +fn same_saved_bytes(left: &SavedBytes, right: &SavedBytes) -> bool { + left.settings == right.settings + && left.server_config == right.server_config + && left.internal_state == right.internal_state + && left.vault == right.vault +} + +fn snapshot_from_document(document: &ConfigDocument) -> Snapshot { + let mut snapshot = Snapshot::default(); + for client in document.peers().iter().filter_map(|peer| { + let identity = peer.identity?; + document.project_client(&identity).ok() + }) { + let _ = snapshot.insert(client); + } + snapshot +} + +#[derive(Debug, Error)] +pub enum AppError { + #[error("saved state is invalid or incompatible with this session")] + InvalidSavedState, + #[error("a vault is already installed")] + VaultAlreadyInstalled, + #[error("the installed vault identity cannot be replaced")] + VaultIdentityChange, + #[error("the selected backup name is invalid")] + InvalidArchiveSelection, + #[error("the selected unsupported peer has no exact host route to reserve safely")] + DeletedPeerAddressUnknown, + #[error("runtime verification cannot be recorded without a completed pre-restart backup")] + PreRestartBackupRequired, + #[error("installed saved bytes changed after this session opened")] + SavedGenerationChanged, + #[error(transparent)] + Backup(#[from] BackupError), + #[error(transparent)] + Persistence(#[from] PersistenceError), + #[error(transparent)] + Vault(#[from] VaultError), + #[error(transparent)] + Document(#[from] DocumentError), + #[error(transparent)] + Edit(#[from] EditError), + #[error(transparent)] + Domain(#[from] DomainError), +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::net::{Ipv4Addr, Ipv6Addr}; + use std::os::unix::fs::PermissionsExt; + use std::path::Path; + + use crate::backup::{ArchiveCheckpoint, create_archive_with_hook}; + use crate::backup::{BackupReason, HistoricalApplyStatus, create_archive, preview_archive}; + use crate::config::SettingsFile; + use crate::domain::{ + Client, ClientAddresses, ClientStates, PublicKey, RuntimeObservation, Snapshot, + }; + use crate::persistence::{ + BackupComplete, InstallCheckpoint, PersistedState, PersistenceError, SavedStore, + }; + use crate::vault::{VaultBundle, WireGuardPrivateKey}; + use crate::wireguard::ConfigDocument; + + use super::{AppError, Application, snapshot_from_document}; + + const SETTINGS: &str = r#" +interface_name = "wg0" +server_config_path = "server.conf" +client_source_directory = "clients" +server_endpoint = "vpn.example.test:51820" +ipv4_allocation_range = "10.20.0.0/24" +ipv6_allocation_range = "fd20::/64" +dns_addresses = ["10.20.0.1"] +systemd_unit = "wg-quick@wg0.service" +restart_timeout_seconds = 30 +"#; + + fn setup( + root: &Path, + archive_as_file: bool, + ) -> (SavedStore, ClientStates, PublicKey, WireGuardPrivateKey) { + fs::create_dir_all(root).expect("temporary root should be created"); + let settings_file = SettingsFile::parse_toml(SETTINGS) + .expect("settings fixture parses") + .validate() + .expect("settings fixture validates"); + let resolved = settings_file.resolve_paths(root); + fs::write(root.join("wireguard-admin.toml"), SETTINGS).expect("settings installed"); + let secret = WireGuardPrivateKey::generate().expect("synthetic peer key"); + let key = secret.public_key(); + let server = format!( + "[Interface]\nPrivateKey = synthetic\nAddress = 10.20.0.1/24\n\n# admin note\n[Peer]\nPublicKey = {key}\nAllowedIPs = 10.20.0.2/32\n" + ); + fs::write(root.join("server.conf"), server.as_bytes()).expect("server installed"); + fs::set_permissions(root.join("server.conf"), fs::Permissions::from_mode(0o600)) + .expect("server fixture private"); + if archive_as_file { + fs::create_dir_all(root.join("Backup")).expect("backup root created"); + fs::write(root.join("Backup/archives"), b"block archive directory").expect("blocker"); + } + let saved_client = Client::new( + key, + "legacy-view", + ClientAddresses { + ipv4: Ipv4Addr::new(10, 20, 0, 2), + ipv6: Some("fd20::2".parse::().expect("IPv6 fixture")), + }, + ) + .expect("state client valid"); + let snapshot = Snapshot::new([saved_client]).expect("snapshot valid"); + let states = ClientStates::new( + snapshot.clone(), + Some(snapshot), + RuntimeObservation::Verified, + ) + .expect("initial applied state valid"); + let store = SavedStore::new(root, resolved).expect("saved store mapping valid"); + (store, states, key, secret) + } + + #[test] + fn backup_failure_preserves_installed_files_and_draft() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, key, _) = setup(root.path(), true); + let before = store.read_saved().expect("installed bytes readable"); + let mut app = Application::open(store.clone(), states).expect("application opens"); + app.rename_client(key, "new alias") + .expect("rename is staged"); + + assert!(matches!(app.commit(), Err(AppError::Backup(_)))); + assert_eq!( + store + .read_saved() + .expect("saved remains readable") + .server_config, + before.server_config + ); + assert!( + String::from_utf8_lossy(app.document().render()) + .contains("# wg-admin:alias=bmV3IGFsaWFz") + ); + assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!( + app.states().saved().get(&key).expect("saved peer").alias, + "legacy-view" + ); + } + + #[test] + fn injected_archive_and_install_failures_preserve_bytes_and_session_saved_state() { + let root = tempfile::tempdir().expect("temporary directory"); + let (store, states, key, _) = setup(root.path(), false); + let original = store.read_saved().expect("original bytes"); + for point in [ + ArchiveCheckpoint::Write, + ArchiveCheckpoint::Sync, + ArchiveCheckpoint::Publish, + ArchiveCheckpoint::TemporaryCleanup, + ArchiveCheckpoint::DirectorySync, + ] { + let mut app = Application::open(store.clone(), states.clone()).expect("open"); + app.rename_client(key, "draft alias").expect("stage alias"); + let result = app.commit_with_adapters( + |directory, reason, history, saved| { + create_archive_with_hook(directory, reason, history, saved, |current| { + if current == point { + Err(std::io::Error::other("archive fault")) + } else { + Ok(()) + } + }) + }, + |store, proposed, _backup| store.install(proposed, BackupComplete(())), + ); + assert!(result.is_err()); + let on_disk = store.read_saved().expect("saved files stay readable"); + assert_eq!(on_disk.server_config, original.server_config); + assert_eq!(app.saved_bytes().server_config, original.server_config); + assert_eq!(app.states().saved().get(&key).unwrap().alias, "legacy-view"); + assert_eq!(app.states().draft().get(&key).unwrap().alias, "draft alias"); + } + + for point in [ + InstallCheckpoint::StageWrite(1), + InstallCheckpoint::StageSync(1), + InstallCheckpoint::Replaced(1), + ] { + let mut app = Application::open(store.clone(), states.clone()).expect("open"); + app.rename_client(key, "draft alias").expect("stage alias"); + let result = app.commit_with_adapters(create_archive, |store, proposed, _backup| { + store.install_with_hook(proposed, |current| { + if current == point { + Err(std::io::Error::other("install fault")) + } else { + Ok(()) + } + }) + }); + assert!(result.is_err()); + let on_disk = store.read_saved().expect("reopen recovers transaction"); + assert_eq!(on_disk.settings, original.settings); + assert_eq!(on_disk.server_config, original.server_config); + assert_eq!(on_disk.internal_state, original.internal_state); + assert_eq!(on_disk.vault, original.vault); + assert_eq!(app.saved_bytes().server_config, original.server_config); + assert_eq!(app.states().saved().get(&key).unwrap().alias, "legacy-view"); + assert_eq!(app.states().draft().get(&key).unwrap().alias, "draft alias"); + } + } + + #[test] + fn commit_advances_saved_state_without_applying_and_backup_contains_old_bytes() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, key, _) = setup(root.path(), false); + let original = store.read_saved().expect("installed bytes readable"); + let mut app = Application::open(store.clone(), states).expect("application opens"); + app.rename_client(key, "new alias") + .expect("rename is staged"); + let archive = app.commit().expect("commit should complete"); + + let saved = store.read_saved().expect("new bytes readable"); + assert!( + String::from_utf8_lossy(&saved.server_config).contains("# wg-admin:alias=bmV3IGFsaWFz") + ); + assert!( + !String::from_utf8_lossy(&original.server_config) + .contains("# wg-admin:alias=bmV3IGFsaWFz") + ); + assert_eq!( + app.states().saved().get(&key).expect("saved peer").alias, + "new alias" + ); + assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert!(app.states().applied_snapshot().is_some()); + let backup = preview_archive(&archive).expect("pre-commit backup parses"); + assert_eq!(backup.reason, BackupReason::PreCommit); + assert_eq!(backup.saved_bytes.server_config, original.server_config); + assert_eq!(backup.saved_bytes.settings, original.settings); + assert!(String::from_utf8_lossy(&saved.server_config).contains("# admin note\n")); + } + + #[test] + fn discard_restores_draft_without_writing_installed_bytes() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, key, _) = setup(root.path(), false); + let original = store.read_saved().expect("installed bytes readable"); + let mut app = Application::open(store.clone(), states).expect("application opens"); + app.rename_client(key, "temporary") + .expect("rename is staged"); + app.discard_draft().expect("draft can be discarded"); + + assert_eq!( + store.read_saved().expect("saved readable").server_config, + original.server_config + ); + assert!(!String::from_utf8_lossy(app.document().render()).contains("temporary")); + assert_eq!(app.states().draft(), app.states().saved()); + } + + #[test] + fn initial_vault_is_prepared_then_installed_only_after_commit() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, _, _) = setup(root.path(), false); + let mut app = Application::open(store.clone(), states).expect("application opens"); + let secret = WireGuardPrivateKey::generate().expect("synthetic secret"); + let bundle = VaultBundle::create("synthetic password").expect("synthetic vault"); + app.prepare_initial_vault(bundle).expect("vault is staged"); + assert!(store.read_saved().expect("saved readable").vault.is_none()); + app.commit().expect("initial vault commit"); + let installed = store.read_saved().expect("saved readable"); + let text = std::str::from_utf8(installed.vault.as_deref().expect("vault installed")) + .expect("vault UTF-8"); + assert_eq!( + VaultBundle::from_toml(text) + .expect("vault loads") + .record_count(), + 0 + ); + assert_eq!(secret.public_key().as_bytes().len(), 32); + } + + #[test] + fn deletion_only_removes_selected_current_record_and_retains_other_records() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, selected, selected_secret) = setup(root.path(), false); + let mut app = Application::open(store.clone(), states).expect("application opens"); + let mut bundle = VaultBundle::create("synthetic password").expect("vault setup"); + bundle + .encrypt_record(selected, &selected_secret) + .expect("selected record"); + let retained_secret = WireGuardPrivateKey::generate().expect("secret"); + let retained = retained_secret.public_key(); + bundle + .encrypt_record(retained, &retained_secret) + .expect("file-only record"); + app.prepare_initial_vault(bundle).expect("vault staged"); + app.commit().expect("initial vault commit"); + + assert!( + app.delete_client(selected) + .expect("selected record removed") + ); + app.commit().expect("deletion committed"); + let installed = store.read_saved().expect("saved readable"); + let bundle = VaultBundle::from_toml( + std::str::from_utf8(installed.vault.as_deref().expect("vault")).expect("utf8"), + ) + .expect("vault parses"); + assert!(!bundle.contains_record(selected)); + assert!(bundle.contains_record(retained)); + let state = PersistedState::from_toml( + installed + .internal_state + .as_deref() + .expect("state is installed on commit"), + ) + .expect("state parses"); + assert!(state.reserved_addresses.contains(&"10.20.0.2".to_owned())); + assert!(state.reserved_addresses.contains(&"fd20::2".to_owned())); + + let reopened_states = + ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown) + .expect("empty supported snapshot is valid"); + let reopened = Application::open(store, reopened_states).expect("session reopens"); + assert_eq!(reopened.states().applied(), &RuntimeObservation::Unknown); + assert!( + reopened + .states() + .reserved_addresses() + .contains(&"10.20.0.2".parse().expect("IPv4 address")) + ); + assert!( + reopened + .states() + .reserved_addresses() + .contains(&"fd20::2".parse().expect("IPv6 address")) + ); + } + + #[test] + fn deletion_of_unprojectable_peer_persists_its_exact_host_route_reservation() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, _, _) = setup(root.path(), false); + let unsupported = PublicKey::from_bytes([9; 32]); + let mut server = + fs::read_to_string(root.path().join("server.conf")).expect("server fixture reads"); + server.push_str(&format!( + "\n# legacy peer remains outside the narrow state projection\n[Peer]\nPublicKey = {unsupported}\nAllowedIPs = 10.20.0.9/32\n" + )); + fs::write(root.path().join("server.conf"), server).expect("server fixture updated"); + let mut app = Application::open(store.clone(), states).expect("application opens"); + + assert!( + !app.delete_client(unsupported) + .expect("selected server-only peer deleted") + ); + assert!(app.states().reserved_addresses().is_empty()); + app.commit() + .expect("commit preserves unsupported peer address"); + + let saved = store.read_saved().expect("saved bytes readable"); + let state = PersistedState::from_toml( + saved + .internal_state + .as_deref() + .expect("state is installed on commit"), + ) + .expect("reservation state parses"); + assert_eq!(state.reserved_addresses, ["10.20.0.9"]); + assert!(!String::from_utf8_lossy(&saved.server_config).contains(&unsupported.to_string())); + } + + #[test] + fn deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, _, _) = setup(root.path(), false); + let unsupported = PublicKey::from_bytes([9; 32]); + let mut server = + fs::read_to_string(root.path().join("server.conf")).expect("server fixture reads"); + server.push_str(&format!( + "\n[Peer]\nPublicKey = {unsupported}\nAllowedIPs = fd20::/64\n" + )); + fs::write(root.path().join("server.conf"), server).expect("server fixture updated"); + let mut app = Application::open(store.clone(), states).expect("application opens"); + let before = app.document().render().to_vec(); + + assert!(matches!( + app.delete_client(unsupported), + Err(super::AppError::DeletedPeerAddressUnknown) + )); + assert_eq!(app.document().render(), before); + assert_eq!( + store.read_saved().expect("saved readable").server_config, + before + ); + } + + #[test] + fn existing_vault_identity_cannot_be_replaced() { + 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.prepare_initial_vault(VaultBundle::create("first password").expect("vault")) + .expect("first vault prepared"); + app.commit().expect("first vault installed"); + assert!(matches!( + app.prepare_initial_vault(VaultBundle::create("second password").expect("vault")), + Err(AppError::VaultAlreadyInstalled) + )); + } + + #[test] + fn state_file_round_trip_keeps_pending_addresses() { + let state = PersistedState::new(["10.20.0.9".to_owned()], None).expect("reservation valid"); + assert!( + PersistedState::from_toml(&state.to_toml().expect("state serializes")) + .expect("state reloads") + .reserved_addresses + .contains(&"10.20.0.9".to_owned()) + ); + } + + #[test] + fn preview_is_read_only_and_confirm_restores_without_unlocking_vault() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, key, _) = setup(root.path(), false); + let mut app = Application::open(store.clone(), states).expect("application opens"); + app.rename_client(key, "Laptop") + .expect("managed alias is staged"); + app.commit().expect("managed alias commit"); + app.prepare_initial_vault(VaultBundle::create("synthetic password").expect("vault")) + .expect("vault staged"); + app.commit().expect("vault commit"); + let saved = store.read_saved().expect("saved bytes"); + let selected = create_archive( + store.archives_directory(), + BackupReason::Manual, + HistoricalApplyStatus::Unknown, + &saved, + ) + .expect("selected archive"); + app.rename_client(key, "draft alias").expect("draft rename"); + let before = store.read_saved().expect("installed bytes before preview"); + let preview = app + .preview_restore(selected.file_name().unwrap().to_str().unwrap()) + .expect("archive preview validates"); + assert_eq!( + store + .read_saved() + .expect("preview is read only") + .server_config, + before.server_config + ); + assert_eq!( + app.states().draft().get(&key).expect("draft client").alias, + "draft alias" + ); + + // Replacing the selected path after preview cannot change the confirmed bytes. + fs::write(&selected, b"changed after preview").expect("mutate selected archive path"); + app.confirm_restore(preview) + .expect("restore uses previewed bytes"); + assert!(!String::from_utf8_lossy(app.document().render()).contains("draft alias")); + assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert!(app.vault().is_some()); + assert!(app.states().saved().get(&key).is_some()); + } + + #[test] + fn partial_restore_failure_preserves_session_and_pre_restore_backup_then_reopens_to_old_set() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, key, _) = setup(root.path(), false); + let server_config_path = SettingsFile::parse_toml(SETTINGS) + .unwrap() + .validate() + .unwrap() + .resolve_paths(root.path()) + .server_config_path; + let mut app = Application::open(store.clone(), states).expect("application opens"); + let selected_saved = store.read_saved().expect("initial saved bytes"); + let selected = create_archive( + store.archives_directory(), + BackupReason::Manual, + HistoricalApplyStatus::Unknown, + &selected_saved, + ) + .expect("selected restore archive"); + + app.rename_client(key, "currently saved") + .expect("stage current alias"); + app.commit().expect("save current alias"); + let before_restore = store.read_saved().expect("current saved bytes"); + app.rename_client(key, "draft survives failed restore") + .expect("stage draft"); + let applied_before = app.states().applied().clone(); + let preview = app + .preview_restore(selected.file_name().unwrap().to_str().unwrap()) + .expect("selected archive preview"); + + let result = app.confirm_restore_with_install(preview, |store, saved, _backup| { + store.install_with_hook(saved, |point| { + if point == InstallCheckpoint::Replaced(2) { + Err(std::io::Error::other("interrupt after restore replacement")) + } else { + Ok(()) + } + }) + }); + assert!(matches!( + result, + Err(AppError::Persistence(PersistenceError::RecoveryRequired)) + )); + + // The server file was replaced before the injected failure. The app + // session still exposes its old saved snapshot and retains its draft. + assert_eq!( + fs::read(&server_config_path).unwrap(), + selected_saved.server_config + ); + assert_eq!(app.saved_bytes().settings, before_restore.settings); + assert_eq!( + app.saved_bytes().server_config, + before_restore.server_config + ); + assert_eq!( + app.saved_bytes().internal_state, + before_restore.internal_state + ); + assert_eq!(app.saved_bytes().vault, before_restore.vault); + assert_eq!( + app.states().saved().get(&key).unwrap().alias, + "currently saved" + ); + assert_eq!( + app.states().draft().get(&key).unwrap().alias, + "draft survives failed restore" + ); + assert_eq!(app.states().applied(), &applied_before); + + // A failed rollback remains blocked. A fresh store open retries the + // journal recovery and restores the exact pre-restore file set. + assert!(matches!( + store.recover_with_hook(|point| { + if point == InstallCheckpoint::RollbackReplace(3) { + Err(std::io::Error::other("injected rollback failure")) + } else { + Ok(()) + } + }), + Err(PersistenceError::RecoveryRequired) + )); + let resolved = SettingsFile::parse_toml(SETTINGS) + .unwrap() + .validate() + .unwrap() + .resolve_paths(root.path()); + let reopened = SavedStore::new(root.path(), resolved).expect("store reopens"); + let recovered = reopened.read_saved().expect("journal rolls back on reopen"); + assert_eq!(recovered.settings, before_restore.settings); + assert_eq!(recovered.server_config, before_restore.server_config); + assert_eq!(recovered.internal_state, before_restore.internal_state); + assert_eq!(recovered.vault, before_restore.vault); + + let pre_restore = app + .list_backups() + .expect("backups list after interrupted restore") + .into_iter() + .find(|backup| backup.reason == BackupReason::PreRestore) + .expect("precautionary backup preserved"); + let archived = preview_archive(&store.archives_directory().join(pre_restore.file_name)) + .expect("pre-restore archive remains valid"); + assert_eq!(archived.saved_bytes.settings, before_restore.settings); + assert_eq!( + archived.saved_bytes.server_config, + before_restore.server_config + ); + assert_eq!( + archived.saved_bytes.internal_state, + before_restore.internal_state + ); + assert_eq!(archived.saved_bytes.vault, before_restore.vault); + assert_eq!(app.states().applied(), &applied_before); + } + + #[test] + fn restore_accepts_changed_non_path_settings_and_rejects_changed_paths() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, _, _) = setup(root.path(), false); + let app = Application::open(store.clone(), states).expect("application opens"); + let mut saved = store.read_saved().expect("saved bytes"); + saved.settings = String::from_utf8(saved.settings) + .expect("settings utf8") + .replace("vpn.example.test:51820", "vpn2.example.test:51821") + .replace("10.20.0.1\"", "10.20.0.2\"") + .into_bytes(); + let accepted = create_archive( + store.archives_directory(), + BackupReason::Manual, + HistoricalApplyStatus::Unknown, + &saved, + ) + .expect("archive with changed operational settings"); + assert!( + app.preview_restore(accepted.file_name().unwrap().to_str().unwrap()) + .is_ok() + ); + + saved.settings = String::from_utf8(saved.settings) + .expect("settings utf8") + .replace("server.conf", "elsewhere.conf") + .into_bytes(); + let rejected = create_archive( + store.archives_directory(), + BackupReason::Manual, + HistoricalApplyStatus::Unknown, + &saved, + ) + .expect("archive with incompatible path"); + assert!(matches!( + app.preview_restore(rejected.file_name().unwrap().to_str().unwrap()), + Err(super::AppError::Persistence(_)) + )); + } + + #[test] + fn restoring_absent_optional_components_removes_installed_vault_and_state() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, _, _) = setup(root.path(), false); + let mut app = Application::open(store.clone(), states).expect("application opens"); + let initial = store.read_saved().expect("initial bytes"); + let selected = create_archive( + store.archives_directory(), + BackupReason::Manual, + HistoricalApplyStatus::Unknown, + &initial, + ) + .expect("initial-state archive"); + app.prepare_initial_vault(VaultBundle::create("synthetic password").expect("vault")) + .expect("vault staged"); + app.commit().expect("vault installed"); + assert!(store.read_saved().expect("installed").vault.is_some()); + + let preview = app + .preview_restore(selected.file_name().unwrap().to_str().unwrap()) + .expect("preview initial state"); + app.confirm_restore(preview).expect("restore initial state"); + let restored = store.read_saved().expect("restored state"); + assert!(restored.vault.is_none()); + let state = PersistedState::from_toml( + restored + .internal_state + .as_deref() + .expect("pending current address is retained"), + ) + .expect("restored reservation state parses"); + assert!(state.reserved_addresses.contains(&"fd20::2".to_owned())); + assert!(app.vault().is_none()); + assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + } + + #[test] + fn failed_pre_restore_backup_preserves_installed_files_and_draft() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, key, _) = setup(root.path(), false); + let mut app = Application::open(store.clone(), states).expect("application opens"); + let saved = store.read_saved().expect("saved"); + let selected = create_archive( + store.archives_directory(), + BackupReason::Manual, + HistoricalApplyStatus::Unknown, + &saved, + ) + .expect("archive"); + app.rename_client(key, "draft retained") + .expect("draft rename"); + let installed = store.read_saved().expect("installed state"); + let preview = app + .preview_restore(selected.file_name().unwrap().to_str().unwrap()) + .expect("preview"); + + let archive_dir = store.archives_directory(); + fs::remove_dir_all(archive_dir).expect("remove synthetic archive directory"); + fs::write(archive_dir, b"cannot create a backup here").expect("replace with file"); + assert!(app.confirm_restore(preview).is_err()); + assert_eq!( + store.read_saved().expect("files preserved").server_config, + installed.server_config + ); + assert_eq!( + app.states().draft().get(&key).expect("draft client").alias, + "draft retained" + ); + } + + #[test] + fn manual_and_pre_restart_backups_are_listed_without_runtime_calls() { + 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.create_manual_backup().expect("manual backup"); + app.create_pre_restart_backup().expect("pre-restart backup"); + let listed = app.list_backups().expect("backup listing"); + assert_eq!(listed.len(), 2); + assert!( + listed + .iter() + .any(|item| item.reason == BackupReason::Manual) + ); + assert!( + listed + .iter() + .any(|item| item.reason == BackupReason::PreRestart) + ); + assert!( + listed + .iter() + .all(|item| item.historical_apply == HistoricalApplyStatus::Unknown) + ); + assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + } + + #[test] + fn verified_history_persists_across_reopen_without_marking_runtime_applied() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, _, _) = setup(root.path(), false); + let mut app = Application::open(store.clone(), states.clone()).expect("application opens"); + let verified_at = "2026-10-20T08:15:00Z"; + let observed = app.states().saved().clone(); + app.create_pre_restart_backup() + .expect("pre-restart backup completes"); + app.mark_verified_after_runtime_check(observed, verified_at.to_owned()) + .expect("verified generation persists"); + + let saved = store.read_saved().expect("verified saved bytes"); + assert_eq!( + PersistedState::from_toml(saved.internal_state.as_deref().unwrap()) + .unwrap() + .last_verified_at_utc + .as_deref(), + Some(verified_at) + ); + let reopened = Application::open(store, states).expect("application reopens"); + assert_eq!( + reopened.historical_apply, + HistoricalApplyStatus::Verified { + verified_at_utc: verified_at.to_owned() + } + ); + assert_eq!(reopened.states().applied(), &RuntimeObservation::Unknown); + } + + #[test] + fn failed_verified_history_write_does_not_advance_memory_or_reopen_as_verified() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, _, _) = setup(root.path(), false); + let mut app = Application::open(store.clone(), states.clone()).expect("application opens"); + let before = store.read_saved().expect("original saved bytes"); + let observed = app.states().saved().clone(); + app.create_pre_restart_backup() + .expect("pre-restart backup completes"); + let result = app.mark_verified_with_install( + observed, + "2026-10-20T08:30:00Z".to_owned(), + |store, saved, _backup| { + store.install_with_hook(saved, |point| { + if point == InstallCheckpoint::Replaced(3) { + Err(std::io::Error::other("history-state install interruption")) + } else { + Ok(()) + } + }) + }, + ); + assert!(matches!( + result, + Err(AppError::Persistence(PersistenceError::RecoveryRequired)) + )); + assert_eq!(app.historical_apply, HistoricalApplyStatus::Unknown); + assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.saved_bytes().internal_state, before.internal_state); + + let resolved = SettingsFile::parse_toml(SETTINGS) + .unwrap() + .validate() + .unwrap() + .resolve_paths(root.path()); + let reopened_store = SavedStore::new(root.path(), resolved).expect("store reopens"); + let recovered = reopened_store + .read_saved() + .expect("interrupted history write rolls back"); + assert_eq!(recovered.internal_state, before.internal_state); + let reopened = Application::open(reopened_store, states).expect("application reopens"); + assert_eq!(reopened.historical_apply, HistoricalApplyStatus::Unknown); + assert_eq!(reopened.states().applied(), &RuntimeObservation::Unknown); + } + + #[test] + fn precommit_archive_records_verified_installed_generation_and_success_clears_it() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, key, _) = setup(root.path(), false); + let mut app = Application::open(store.clone(), states.clone()).expect("application opens"); + let verified_at = "2026-10-20T09:00:00Z"; + let observed = app.states().saved().clone(); + app.create_pre_restart_backup() + .expect("pre-restart backup completes"); + app.mark_verified_after_runtime_check(observed, verified_at.to_owned()) + .expect("verified generation persists"); + app.rename_client(key, "next saved alias") + .expect("draft alias"); + let archive = app.commit().expect("commit succeeds"); + let archived = preview_archive(&archive).expect("pre-commit archive is valid"); + assert_eq!( + archived.historical_apply, + HistoricalApplyStatus::Verified { + verified_at_utc: verified_at.to_owned() + } + ); + assert_eq!(app.historical_apply, HistoricalApplyStatus::Unknown); + assert_eq!( + PersistedState::from_toml( + store + .read_saved() + .unwrap() + .internal_state + .as_deref() + .unwrap() + ) + .unwrap() + .last_verified_at_utc, + None + ); + let reopened = Application::open(store, states).expect("committed state reopens"); + assert_eq!(reopened.historical_apply, HistoricalApplyStatus::Unknown); + assert_eq!(reopened.states().applied(), &RuntimeObservation::Unknown); + } + + #[test] + fn failed_commit_retains_verified_history_and_retryable_draft() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, key, _) = setup(root.path(), false); + let mut app = Application::open(store.clone(), states).expect("application opens"); + let verified_at = "2026-10-20T10:00:00Z"; + let observed = app.states().saved().clone(); + app.create_pre_restart_backup() + .expect("pre-restart backup completes"); + app.mark_verified_after_runtime_check(observed, verified_at.to_owned()) + .expect("verified generation persists"); + let before = store.read_saved().expect("verified saved bytes"); + app.rename_client(key, "retryable draft") + .expect("draft alias"); + + let archives = store.archives_directory().to_path_buf(); + fs::remove_dir_all(&archives).expect("remove synthetic archive directory"); + fs::write(&archives, b"block commit backup").expect("install backup blocker"); + assert!(matches!(app.commit(), Err(AppError::Backup(_)))); + let persisted = PersistedState::from_toml(before.internal_state.as_deref().unwrap()) + .expect("prior state parses"); + assert_eq!(persisted.last_verified_at_utc.as_deref(), Some(verified_at)); + assert_eq!( + app.historical_apply, + HistoricalApplyStatus::Verified { + verified_at_utc: verified_at.to_owned() + } + ); + assert_eq!(app.saved_bytes().server_config, before.server_config); + assert_eq!( + app.states().draft().get(&key).unwrap().alias, + "retryable draft" + ); + assert_eq!(app.states().saved().get(&key).unwrap().alias, "legacy-view"); + } + + #[test] + fn successful_restore_clears_verified_current_history_but_backup_retains_it() { + let root = tempfile::tempdir().expect("temp directory"); + let (store, states, _, _) = setup(root.path(), false); + let mut app = Application::open(store.clone(), states.clone()).expect("application opens"); + let selected = app.create_manual_backup().expect("select initial archive"); + let verified_at = "2026-10-20T11:00:00Z"; + let observed = app.states().saved().clone(); + app.create_pre_restart_backup() + .expect("pre-restart backup completes"); + app.mark_verified_after_runtime_check(observed, verified_at.to_owned()) + .expect("verified generation persists"); + let preview = app + .preview_restore(selected.file_name().unwrap().to_str().unwrap()) + .expect("restore preview validates"); + let pre_restore = app.confirm_restore(preview).expect("restore succeeds"); + + assert_eq!(app.historical_apply, HistoricalApplyStatus::Unknown); + assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + let archived = preview_archive(&pre_restore).expect("pre-restore backup validates"); + assert_eq!( + archived.historical_apply, + HistoricalApplyStatus::Verified { + verified_at_utc: verified_at.to_owned() + } + ); + let restored = store.read_saved().expect("restored saved bytes parse"); + assert_eq!( + PersistedState::from_toml(restored.internal_state.as_deref().unwrap()) + .unwrap() + .last_verified_at_utc, + None + ); + let restored_document = ConfigDocument::parse(&restored.server_config).unwrap(); + let reopened_states = ClientStates::new( + snapshot_from_document(&restored_document), + None, + RuntimeObservation::Unknown, + ) + .unwrap(); + let reopened = Application::open(store, reopened_states).expect("restored state reopens"); + assert_eq!(reopened.historical_apply, HistoricalApplyStatus::Unknown); + assert_eq!(reopened.states().applied(), &RuntimeObservation::Unknown); + } +} diff --git a/src/backup.rs b/src/backup.rs new file mode 100644 index 0000000..4930027 --- /dev/null +++ b/src/backup.rs @@ -0,0 +1,973 @@ +//! Bounded, versioned backups of installed saved bytes. +//! +//! This module never extracts archive paths and never performs runtime VPN operations. + +use std::fmt; +use std::fs::{self, File}; +use std::io::{self, Read, Write}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Component, Path, PathBuf}; + +use rustix::fs::{self as rfs, AtFlags, Mode, OFlags}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +const VERSION: u32 = 1; +const BLOCK: usize = 512; +const MAX_ARCHIVE: usize = 26 * 1024 * 1024; +const MAX_SETTINGS: usize = 1024 * 1024; +const MAX_SERVER: usize = 4 * 1024 * 1024; +const MAX_STATE: usize = 2 * 1024 * 1024; +const MAX_VAULT: usize = 16 * 1024 * 1024; +const MAX_LISTING_ENTRIES: usize = 4096; +const MANIFEST: &str = "manifest.toml"; +const SETTINGS: &str = "settings.toml"; +const SERVER: &str = "server.conf"; +const STATE: &str = "internal-state.toml"; +const VAULT: &str = "vault.toml"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ArchiveCheckpoint { + Write, + Sync, + Publish, + TemporaryCleanup, + DirectorySync, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum BackupReason { + Manual, + PreCommit, + PreRestore, + PreRestart, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "status", rename_all = "kebab-case", deny_unknown_fields)] +pub enum HistoricalApplyStatus { + Unknown, + Verified { verified_at_utc: String }, +} + +/// Exact installed bytes captured by a trusted caller. `None` means the owned +/// vault/state component was genuinely absent at capture time. +pub struct SavedBytes { + pub settings: Vec, + pub server_config: Vec, + pub internal_state: Option>, + pub vault: Option>, +} + +impl fmt::Debug for SavedBytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SavedBytes") + .field("settings_len", &self.settings.len()) + .field("server_config_len", &self.server_config.len()) + .field("internal_state_present", &self.internal_state.is_some()) + .field("vault_present", &self.vault.is_some()) + .field("contents", &"[REDACTED]") + .finish() + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct Manifest { + version: u32, + created_at_utc: String, + reason: BackupReason, + historical_apply: HistoricalApplyStatus, + members: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct MemberRecord { + name: String, + present: bool, + length: Option, +} + +/// Validated read-only archive contents. Byte payloads are deliberately omitted by Debug. +pub struct ArchivePreview { + pub created_at_utc: String, + pub reason: BackupReason, + pub historical_apply: HistoricalApplyStatus, + pub saved_bytes: SavedBytes, +} + +impl fmt::Debug for ArchivePreview { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ArchivePreview") + .field("created_at_utc", &self.created_at_utc) + .field("reason", &self.reason) + .field("historical_apply", &self.historical_apply) + .field("saved_bytes", &self.saved_bytes) + .finish() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArchiveSummary { + pub file_name: String, + pub created_at_utc: String, + pub reason: BackupReason, + pub historical_apply: HistoricalApplyStatus, +} + +#[derive(Debug, Error)] +pub enum BackupError { + #[error("backup input exceeds a size limit")] + TooLarge, + #[error("backup archive is invalid or incomplete")] + InvalidArchive, + #[error("backup manifest version is not supported")] + UnsupportedVersion, + #[error("backup timestamp is invalid")] + InvalidTimestamp, + #[error("backup filesystem operation failed")] + Io(#[source] io::Error), + #[error("backup archive serialization failed")] + Serialization, +} + +impl From for BackupError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} +impl From for BackupError { + fn from(error: rustix::io::Errno) -> Self { + Self::Io(error.into()) + } +} + +/// Write a complete archive to a private temporary file and publish it without replacement. +pub(crate) fn create_archive( + archive_directory: &Path, + reason: BackupReason, + historical_apply: HistoricalApplyStatus, + saved: &SavedBytes, +) -> Result { + create_archive_with_hook(archive_directory, reason, historical_apply, saved, |_| { + Ok(()) + }) +} + +pub(crate) fn create_archive_with_hook( + archive_directory: &Path, + reason: BackupReason, + historical_apply: HistoricalApplyStatus, + saved: &SavedBytes, + mut checkpoint: impl FnMut(ArchiveCheckpoint) -> io::Result<()>, +) -> Result { + validate_saved(saved)?; + validate_status(&historical_apply)?; + let created_at_utc = OffsetDateTime::now_utc() + .replace_nanosecond(0) + .map_err(|_| BackupError::InvalidTimestamp)? + .format(&Rfc3339) + .map_err(|_| BackupError::InvalidTimestamp)?; + let bytes = encode_archive(&created_at_utc, reason, historical_apply, saved)?; + let directory = ensure_archive_directory(archive_directory)?; + + let stamp = created_at_utc.replace([':', '-'], ""); + for suffix in 0_u32..1000 { + let name = if suffix == 0 { + format!("wireguard-admin-{stamp}.tar") + } else { + format!("wireguard-admin-{stamp}-{suffix}.tar") + }; + let target = archive_directory.join(name); + let temporary = format!( + ".wireguard-admin-backup-{}-{}", + std::process::id(), + getrandom::u32().map_err(|_| BackupError::Serialization)? + ); + let fd = rfs::openat( + &directory, + temporary.as_str(), + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::from_bits_truncate(0o600), + )?; + let mut file = File::from(fd); + let write_result = (|| -> Result<(), BackupError> { + checkpoint(ArchiveCheckpoint::Write)?; + file.write_all(&bytes)?; + checkpoint(ArchiveCheckpoint::Sync)?; + file.sync_all()?; + Ok(()) + })(); + if let Err(error) = write_result { + let _ = rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty()); + return Err(error); + } + let target_name = target.file_name().ok_or(BackupError::InvalidArchive)?; + if let Err(error) = checkpoint(ArchiveCheckpoint::Publish) { + let _ = rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty()); + return Err(BackupError::Io(error)); + } + match rfs::linkat( + &directory, + temporary.as_str(), + &directory, + target_name, + AtFlags::empty(), + ) { + Ok(()) => { + checkpoint(ArchiveCheckpoint::TemporaryCleanup)?; + rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty())?; + checkpoint(ArchiveCheckpoint::DirectorySync)?; + File::from(directory.try_clone()?).sync_all()?; + return Ok(target); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty())?; + continue; + } + Err(error) => { + let _ = rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty()); + return Err(BackupError::Io(error.into())); + } + } + } + Err(BackupError::Io(io::Error::new( + io::ErrorKind::AlreadyExists, + "archive name collisions", + ))) +} + +/// Parse and validate a complete bounded archive without extracting any member. +pub fn preview_archive_bytes(input: &[u8]) -> Result { + if input.len() > MAX_ARCHIVE || input.len() < BLOCK * 4 || !input.len().is_multiple_of(BLOCK) { + return Err(BackupError::InvalidArchive); + } + let mut offset = 0; + let mut records = Vec::new(); + let mut previous_name: Option = None; + loop { + let header = input + .get(offset..offset + BLOCK) + .ok_or(BackupError::InvalidArchive)?; + if header.iter().all(|byte| *byte == 0) { + let second = input + .get(offset + BLOCK..offset + BLOCK * 2) + .ok_or(BackupError::InvalidArchive)?; + if second.iter().any(|byte| *byte != 0) + || input[offset + BLOCK * 2..].iter().any(|byte| *byte != 0) + { + return Err(BackupError::InvalidArchive); + } + break; + } + if records.len() >= 5 { + return Err(BackupError::InvalidArchive); + } + let (name, size) = parse_header(header)?; + if previous_name + .as_ref() + .is_some_and(|previous| previous == &name) + { + return Err(BackupError::InvalidArchive); + } + previous_name = Some(name.clone()); + let data_start = offset + BLOCK; + let size_usize = usize::try_from(size).map_err(|_| BackupError::InvalidArchive)?; + let padded = size_usize + .checked_add(BLOCK - 1) + .ok_or(BackupError::InvalidArchive)? + / BLOCK + * BLOCK; + let data_end = data_start + .checked_add(size_usize) + .ok_or(BackupError::InvalidArchive)?; + let next = data_start + .checked_add(padded) + .ok_or(BackupError::InvalidArchive)?; + let body = input + .get(data_start..data_end) + .ok_or(BackupError::InvalidArchive)?; + if input + .get(data_end..next) + .ok_or(BackupError::InvalidArchive)? + .iter() + .any(|byte| *byte != 0) + { + return Err(BackupError::InvalidArchive); + } + records.push((name, body.to_vec())); + offset = next; + } + let first = records.first().ok_or(BackupError::InvalidArchive)?; + if first.0 != MANIFEST { + return Err(BackupError::InvalidArchive); + } + let manifest: Manifest = + toml::from_str(std::str::from_utf8(&first.1).map_err(|_| BackupError::InvalidArchive)?) + .map_err(|_| BackupError::InvalidArchive)?; + if manifest.version != VERSION { + return Err(BackupError::UnsupportedVersion); + } + validate_timestamp(&manifest.created_at_utc)?; + validate_status(&manifest.historical_apply)?; + let expected_names = [SETTINGS, SERVER, STATE, VAULT]; + if manifest.members.len() != expected_names.len() { + return Err(BackupError::InvalidArchive); + } + let mut payloads: Vec>> = vec![None; 4]; + for (idx, expected) in expected_names.iter().enumerate() { + let spec = &manifest.members[idx]; + if spec.name != *expected || spec.present != spec.length.is_some() { + return Err(BackupError::InvalidArchive); + } + let max = limit_for(expected); + if spec.length.is_some_and(|len| len > max as u64) { + return Err(BackupError::TooLarge); + } + } + if records.len() != 1 + manifest.members.iter().filter(|m| m.present).count() { + return Err(BackupError::InvalidArchive); + } + let mut last_payload_index = None; + for (name, body) in records.iter().skip(1) { + let idx = expected_names + .iter() + .position(|expected| *expected == name) + .ok_or(BackupError::InvalidArchive)?; + if last_payload_index.is_some_and(|last| idx <= last) { + return Err(BackupError::InvalidArchive); + } + last_payload_index = Some(idx); + let spec = &manifest.members[idx]; + if !spec.present || spec.length != Some(body.len() as u64) || body.len() > limit_for(name) { + return Err(BackupError::InvalidArchive); + } + if payloads[idx].replace(body.clone()).is_some() { + return Err(BackupError::InvalidArchive); + } + } + for (idx, spec) in manifest.members.iter().enumerate() { + if spec.present != payloads[idx].is_some() { + return Err(BackupError::InvalidArchive); + } + } + Ok(ArchivePreview { + created_at_utc: manifest.created_at_utc, + reason: manifest.reason, + historical_apply: manifest.historical_apply, + saved_bytes: SavedBytes { + settings: payloads[0].take().ok_or(BackupError::InvalidArchive)?, + server_config: payloads[1].take().ok_or(BackupError::InvalidArchive)?, + internal_state: payloads[2].take(), + vault: payloads[3].take(), + }, + }) +} + +pub fn preview_archive(path: &Path) -> Result { + validate_path_components(path)?; + let metadata = fs::symlink_metadata(path)?; + if !metadata.file_type().is_file() || metadata.len() > MAX_ARCHIVE as u64 { + return Err(BackupError::InvalidArchive); + } + let file = fs::OpenOptions::new() + .read(true) + .custom_flags(0o400000) + .open(path)?; + if !file.metadata()?.file_type().is_file() { + return Err(BackupError::InvalidArchive); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take((MAX_ARCHIVE + 1) as u64) + .read_to_end(&mut bytes)?; + preview_archive_bytes(&bytes) +} + +/// List only validated archives. Invalid files are omitted rather than trusted for display. +pub fn list_archives(directory: &Path) -> Result, BackupError> { + validate_path_components(directory)?; + if !fs::symlink_metadata(directory)?.is_dir() { + return Err(BackupError::InvalidArchive); + } + let mut summaries = Vec::new(); + let mut inspected = 0; + for entry in fs::read_dir(directory)? { + inspected += 1; + if inspected > MAX_LISTING_ENTRIES { + return Err(BackupError::TooLarge); + } + let entry = entry?; + let file_type = entry.file_type()?; + let name = entry.file_name(); + let Some(name_text) = name.to_str() else { + continue; + }; + if !file_type.is_file() + || !name_text.starts_with("wireguard-admin-") + || !name_text.ends_with(".tar") + || !name_text + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.')) + { + continue; + } + if let Ok(preview) = preview_archive(&entry.path()) { + summaries.push(ArchiveSummary { + file_name: name_text.to_owned(), + created_at_utc: preview.created_at_utc, + reason: preview.reason, + historical_apply: preview.historical_apply, + }); + } + } + summaries.sort_by(|a, b| { + b.created_at_utc + .cmp(&a.created_at_utc) + .then_with(|| a.file_name.cmp(&b.file_name)) + }); + Ok(summaries) +} + +fn ensure_archive_directory(path: &Path) -> Result { + if !path.is_absolute() { + return Err(BackupError::InvalidArchive); + } + let mut directory = rfs::open( + "/", + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + for component in path.components().skip(1) { + let Component::Normal(name) = component else { + return Err(BackupError::InvalidArchive); + }; + match rfs::mkdirat(&directory, name, Mode::from_bits_truncate(0o700)) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(BackupError::Io(error.into())), + } + directory = rfs::openat( + &directory, + name, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + } + rfs::fchmod(&directory, Mode::from_bits_truncate(0o700))?; + File::from(directory.try_clone()?).sync_all()?; + Ok(directory) +} + +fn validate_path_components(path: &Path) -> Result<(), BackupError> { + if !path.is_absolute() { + return Err(BackupError::InvalidArchive); + } + let mut directory = rfs::open( + "/", + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + let components = path.components().skip(1).collect::>(); + for (index, component) in components.iter().enumerate() { + let Component::Normal(name) = component else { + return Err(BackupError::InvalidArchive); + }; + if index + 1 == components.len() { + // Final item may be a regular archive file, so reject symlinks via + // no-follow open at the call site instead of requiring a directory. + match rfs::openat( + &directory, + *name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(fd) => { + let metadata = File::from(fd).metadata()?; + if !metadata.is_file() && !metadata.is_dir() { + return Err(BackupError::InvalidArchive); + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(BackupError::Io(error.into())), + } + } else { + directory = rfs::openat( + &directory, + *name, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + } + } + Ok(()) +} + +fn validate_saved(saved: &SavedBytes) -> Result<(), BackupError> { + for (bytes, max) in [ + (&saved.settings, MAX_SETTINGS), + (&saved.server_config, MAX_SERVER), + ] { + if bytes.len() > max { + return Err(BackupError::TooLarge); + } + } + if saved + .internal_state + .as_ref() + .is_some_and(|bytes| bytes.len() > MAX_STATE) + || saved + .vault + .as_ref() + .is_some_and(|bytes| bytes.len() > MAX_VAULT) + { + return Err(BackupError::TooLarge); + } + Ok(()) +} + +fn validate_status(status: &HistoricalApplyStatus) -> Result<(), BackupError> { + if let HistoricalApplyStatus::Verified { verified_at_utc } = status { + validate_timestamp(verified_at_utc)?; + } + Ok(()) +} + +fn validate_timestamp(value: &str) -> Result<(), BackupError> { + let parsed = + OffsetDateTime::parse(value, &Rfc3339).map_err(|_| BackupError::InvalidTimestamp)?; + if parsed.offset().whole_seconds() != 0 + || parsed.nanosecond() != 0 + || parsed + .format(&Rfc3339) + .map_err(|_| BackupError::InvalidTimestamp)? + != value + { + return Err(BackupError::InvalidTimestamp); + } + Ok(()) +} + +fn encode_archive( + created_at: &str, + reason: BackupReason, + status: HistoricalApplyStatus, + saved: &SavedBytes, +) -> Result, BackupError> { + encode_archive_version(VERSION, created_at, reason, status, saved) +} + +fn encode_archive_version( + version: u32, + created_at: &str, + reason: BackupReason, + status: HistoricalApplyStatus, + saved: &SavedBytes, +) -> Result, BackupError> { + let members = vec![ + MemberRecord { + name: SETTINGS.into(), + present: true, + length: Some(saved.settings.len() as u64), + }, + MemberRecord { + name: SERVER.into(), + present: true, + length: Some(saved.server_config.len() as u64), + }, + MemberRecord { + name: STATE.into(), + present: saved.internal_state.is_some(), + length: saved.internal_state.as_ref().map(|v| v.len() as u64), + }, + MemberRecord { + name: VAULT.into(), + present: saved.vault.is_some(), + length: saved.vault.as_ref().map(|v| v.len() as u64), + }, + ]; + let manifest = toml::to_string(&Manifest { + version, + created_at_utc: created_at.into(), + reason, + historical_apply: status, + members, + }) + .map_err(|_| BackupError::Serialization)?; + let mut builder = tar::Builder::new(Vec::new()); + builder.mode(tar::HeaderMode::Deterministic); + append(&mut builder, MANIFEST, manifest.as_bytes())?; + append(&mut builder, SETTINGS, &saved.settings)?; + append(&mut builder, SERVER, &saved.server_config)?; + if let Some(state) = &saved.internal_state { + append(&mut builder, STATE, state)?; + } + if let Some(vault) = &saved.vault { + append(&mut builder, VAULT, vault)?; + } + builder.finish().map_err(|_| BackupError::Serialization)?; + let bytes = builder + .into_inner() + .map_err(|_| BackupError::Serialization)?; + if bytes.len() > MAX_ARCHIVE { + return Err(BackupError::TooLarge); + } + Ok(bytes) +} + +fn append( + builder: &mut tar::Builder>, + name: &str, + bytes: &[u8], +) -> Result<(), BackupError> { + let mut header = tar::Header::new_ustar(); + header.set_entry_type(tar::EntryType::Regular); + header + .set_path(name) + .map_err(|_| BackupError::Serialization)?; + header.set_size(bytes.len() as u64); + header.set_mode(0o600); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_cksum(); + builder + .append(&header, bytes) + .map_err(|_| BackupError::Serialization) +} + +fn parse_header(header: &[u8]) -> Result<(String, u64), BackupError> { + if header.len() != BLOCK + || &header[257..263] != b"ustar\0" + || &header[263..265] != b"00" + || header[156] != 0 && header[156] != b'0' + || header[157..257].iter().any(|byte| *byte != 0) + || header[265..345].iter().any(|byte| *byte != 0) + || header[345..500].iter().any(|byte| *byte != 0) + || header[500..512].iter().any(|byte| *byte != 0) + { + return Err(BackupError::InvalidArchive); + } + let stored_checksum = parse_octal(&header[148..156])?; + let checksum = header[..148] + .iter() + .chain(&header[156..]) + .map(|byte| *byte as u64) + .sum::() + + 8 * b' ' as u64; + if stored_checksum != checksum { + return Err(BackupError::InvalidArchive); + } + if parse_octal(&header[100..108])? != 0o600 + || parse_octal(&header[108..116])? != 0 + || parse_octal(&header[116..124])? != 0 + || parse_octal(&header[136..148])? != 0 + { + return Err(BackupError::InvalidArchive); + } + let raw_name = &header[..100]; + let end = raw_name + .iter() + .position(|byte| *byte == 0) + .unwrap_or(raw_name.len()); + if raw_name[end..].iter().any(|byte| *byte != 0) { + return Err(BackupError::InvalidArchive); + } + let name = std::str::from_utf8(&raw_name[..end]) + .map_err(|_| BackupError::InvalidArchive)? + .to_owned(); + if ![MANIFEST, SETTINGS, SERVER, STATE, VAULT].contains(&name.as_str()) { + return Err(BackupError::InvalidArchive); + } + let size = parse_octal(&header[124..136])?; + if size > limit_for(&name) as u64 && name != MANIFEST { + return Err(BackupError::TooLarge); + } + if name == MANIFEST && size > 64 * 1024 { + return Err(BackupError::TooLarge); + } + Ok((name, size)) +} + +fn parse_octal(field: &[u8]) -> Result { + if field.is_empty() || field[0] & 0x80 != 0 { + return Err(BackupError::InvalidArchive); + } + let text = std::str::from_utf8(field).map_err(|_| BackupError::InvalidArchive)?; + let text = text.trim_matches(['\0', ' ']); + if text.is_empty() || !text.bytes().all(|b| (b'0'..=b'7').contains(&b)) { + return Err(BackupError::InvalidArchive); + } + u64::from_str_radix(text, 8).map_err(|_| BackupError::InvalidArchive) +} + +fn limit_for(name: &str) -> usize { + match name { + SETTINGS => MAX_SETTINGS, + SERVER => MAX_SERVER, + STATE => MAX_STATE, + VAULT => MAX_VAULT, + _ => 64 * 1024, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + + fn fixture() -> SavedBytes { + SavedBytes { + settings: b"settings fixture".to_vec(), + server_config: b"[Interface]\nPrivateKey = synthetic\n".to_vec(), + internal_state: None, + vault: Some(b"encrypted fixture".to_vec()), + } + } + + #[test] + fn archive_round_trip_preserves_bytes_and_absence() { + let saved = fixture(); + let bytes = encode_archive( + "2026-10-19T12:30:00Z", + BackupReason::PreCommit, + HistoricalApplyStatus::Unknown, + &saved, + ) + .expect("archive fixture"); + let preview = preview_archive_bytes(&bytes).expect("preview"); + assert_eq!(preview.saved_bytes.settings, saved.settings); + assert_eq!(preview.saved_bytes.server_config, saved.server_config); + assert_eq!(preview.saved_bytes.internal_state, None); + assert_eq!(preview.saved_bytes.vault, saved.vault); + assert_eq!(preview.reason, BackupReason::PreCommit); + } + + #[test] + fn archive_publication_is_private_and_listing_validates() { + let dir = tempfile::tempdir().expect("temporary directory"); + let path = create_archive( + dir.path(), + BackupReason::Manual, + HistoricalApplyStatus::Unknown, + &fixture(), + ) + .expect("create"); + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).expect("metadata").permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(list_archives(dir.path()).expect("list").len(), 1); + } + + #[test] + fn injected_archive_write_and_finalization_failures_never_report_success() { + let saved = fixture(); + for point in [ + ArchiveCheckpoint::Write, + ArchiveCheckpoint::Sync, + ArchiveCheckpoint::Publish, + ArchiveCheckpoint::TemporaryCleanup, + ArchiveCheckpoint::DirectorySync, + ] { + let dir = tempfile::tempdir().expect("temporary directory"); + let result = create_archive_with_hook( + dir.path(), + BackupReason::PreCommit, + HistoricalApplyStatus::Unknown, + &saved, + |current| { + if current == point { + Err(io::Error::other("archive fault injection")) + } else { + Ok(()) + } + }, + ); + assert!(result.is_err(), "{point:?} must not report archive success"); + let entries = fs::read_dir(dir.path()) + .expect("archive directory remains readable") + .map(|entry| entry.unwrap().path()) + .collect::>(); + assert!(entries.iter().all(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.starts_with(".") || name.starts_with("wireguard-admin-") + }) + })); + for path in entries.iter().filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("wireguard-admin-")) + }) { + assert!( + preview_archive(path).is_ok(), + "published archive is complete" + ); + } + if matches!( + point, + ArchiveCheckpoint::Write | ArchiveCheckpoint::Sync | ArchiveCheckpoint::Publish + ) { + assert!( + entries.is_empty(), + "pre-publication failure leaves no artifact" + ); + } + } + } + + #[test] + fn rejects_truncation_and_nonzero_trailing_bytes() { + let saved = fixture(); + let mut bytes = encode_archive( + "2026-10-19T12:30:00Z", + BackupReason::Manual, + HistoricalApplyStatus::Unknown, + &saved, + ) + .expect("archive"); + assert!(matches!( + preview_archive_bytes(&bytes[..bytes.len() - BLOCK]), + Err(BackupError::InvalidArchive) + )); + let last = bytes.len() - 1; + bytes[last] = 1; + assert!(preview_archive_bytes(&bytes).is_err()); + } + + #[test] + fn rejects_unsupported_version_and_extensions() { + let saved = fixture(); + let unsupported = encode_archive_version( + 99, + "2026-10-19T12:30:00Z", + BackupReason::Manual, + HistoricalApplyStatus::Unknown, + &saved, + ) + .expect("archive"); + assert!(matches!( + preview_archive_bytes(&unsupported), + Err(BackupError::UnsupportedVersion) + )); + let mut bytes = encode_archive( + "2026-10-19T12:30:00Z", + BackupReason::Manual, + HistoricalApplyStatus::Unknown, + &saved, + ) + .expect("archive"); + bytes[156] = b'x'; + let checksum = bytes[..148] + .iter() + .chain(&bytes[156..BLOCK]) + .map(|byte| *byte as u64) + .sum::() + + 8 * b' ' as u64; + let checksum_text = format!("{checksum:06o}\0 "); + bytes[148..156].copy_from_slice(checksum_text.as_bytes()); + assert!(preview_archive_bytes(&bytes).is_err()); + } + + #[test] + fn rejects_traversal_and_link_headers_even_when_checksums_match() { + let saved = fixture(); + let original = encode_archive( + "2026-10-19T12:30:00Z", + BackupReason::Manual, + HistoricalApplyStatus::Unknown, + &saved, + ) + .expect("archive"); + for (offset, field) in [(0, 0), (0, 1)] { + let mut bytes = original.clone(); + match field { + 0 => { + bytes[offset..offset + 100].fill(0); + bytes[offset..offset + 14].copy_from_slice(b"../server.conf"); + } + _ => bytes[offset + 156] = b'2', + } + let checksum = bytes[offset..offset + 148] + .iter() + .chain(&bytes[offset + 156..offset + BLOCK]) + .map(|byte| *byte as u64) + .sum::() + + 8 * b' ' as u64; + let checksum_text = format!("{checksum:06o}\0 "); + bytes[offset + 148..offset + 156].copy_from_slice(checksum_text.as_bytes()); + assert!(preview_archive_bytes(&bytes).is_err()); + } + } + + #[test] + fn repeated_creation_does_not_overwrite_and_absence_is_explicit() { + let dir = tempfile::tempdir().expect("temporary directory"); + let saved = SavedBytes { + settings: b"settings".to_vec(), + server_config: b"server".to_vec(), + internal_state: None, + vault: None, + }; + let first = create_archive( + dir.path(), + BackupReason::PreRestart, + HistoricalApplyStatus::Unknown, + &saved, + ) + .expect("first archive"); + let second = create_archive( + dir.path(), + BackupReason::PreRestart, + HistoricalApplyStatus::Unknown, + &saved, + ) + .expect("second archive"); + assert_ne!(first, second); + let preview = preview_archive(&second).expect("preview"); + assert_eq!(preview.saved_bytes.internal_state, None); + assert_eq!(preview.saved_bytes.vault, None); + assert_eq!(list_archives(dir.path()).expect("list").len(), 2); + } + + #[test] + fn archive_directory_and_selected_file_symlinks_are_rejected() { + let root = tempfile::tempdir().expect("temporary directory"); + let real_directory = root.path().join("real-archives"); + fs::create_dir(&real_directory).expect("archive target directory"); + let linked_directory = root.path().join("archives-link"); + symlink(&real_directory, &linked_directory).expect("directory symlink"); + assert!( + create_archive( + &linked_directory, + BackupReason::Manual, + HistoricalApplyStatus::Unknown, + &fixture(), + ) + .is_err() + ); + assert!( + fs::read_dir(&real_directory) + .expect("real directory is readable") + .next() + .is_none() + ); + + let archive = create_archive( + &real_directory, + BackupReason::Manual, + HistoricalApplyStatus::Unknown, + &fixture(), + ) + .expect("regular archive"); + let linked_archive = root.path().join("archive-link.tar"); + symlink(archive, &linked_archive).expect("archive symlink"); + assert!(preview_archive(&linked_archive).is_err()); + } +} diff --git a/src/domain.rs b/src/domain.rs index 7c9eeeb..2277fa6 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -211,6 +211,25 @@ impl ClientStates { &self.reserved_addresses } + /// Restore conservative reservations from saved metadata without asserting + /// that the running interface matches the saved snapshot. + pub fn restore_persisted_reservations( + &mut self, + reservations: impl IntoIterator, + ) -> Result<(), DomainError> { + let reservations = reservations.into_iter().collect::>(); + if reservations + .iter() + .any(|address| self.saved.addresses().any(|saved| saved == *address)) + { + return Err(DomainError::ReservationConflictsWithSavedClient); + } + self.reserved_addresses = reservations; + self.applied_snapshot = None; + self.applied = RuntimeObservation::Unknown; + Ok(()) + } + pub fn draft_mut(&mut self) -> &mut Snapshot { &mut self.draft } @@ -297,6 +316,8 @@ pub enum DomainError { AddressUnavailable, #[error("verified applied clients do not match the saved snapshot")] AppliedSnapshotMismatch, + #[error("persisted reservation conflicts with a saved client address")] + ReservationConflictsWithSavedClient, } /// Return the lowest available host in `range` without walking the subnet. diff --git a/src/lib.rs b/src/lib.rs index 552f75a..0dac691 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,8 @@ +pub mod app; +pub mod backup; pub mod config; pub mod domain; +pub mod persistence; pub mod startup; pub mod vault; pub mod wireguard; diff --git a/src/persistence.rs b/src/persistence.rs new file mode 100644 index 0000000..0c38b05 --- /dev/null +++ b/src/persistence.rs @@ -0,0 +1,1299 @@ +//! Trusted saved-file access and recoverable multi-file installation. + +use std::fmt; +use std::fs::{self, File}; +use std::io::{self, Read, Write}; +use std::os::fd::OwnedFd; +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::path::{Component, Path, PathBuf}; + +use rustix::fs::{self as rfs, AtFlags, Mode, OFlags}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +use crate::backup::SavedBytes; +use crate::config::{ResolvedSettings, SettingsFile}; +use crate::vault::VaultBundle; +use crate::wireguard::ConfigDocument; + +const MAX_SETTINGS: usize = 1024 * 1024; +const MAX_SERVER: usize = 4 * 1024 * 1024; +const MAX_STATE: usize = 2 * 1024 * 1024; +const MAX_VAULT: usize = 16 * 1024 * 1024; +const STATE_VERSION: u32 = 1; +const JOURNAL: &str = ".wg-admin-transaction"; +const JOURNAL_META: &str = "transaction.toml"; + +/// Capability passed by the application after its mandatory backup completed. +/// Its constructor is crate-private so filesystem installation cannot be +/// called from an external library consumer without crossing the app boundary. +pub struct BackupComplete(pub(crate) ()); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InstallCheckpoint { + JournalPayloadWrite(usize), + JournalPayloadSync(usize), + StageWrite(usize), + StageSync(usize), + ParentSync(usize), + PreparedMarker, + PreparedMarkerSync, + CommittedMarker, + CommittedMarkerSync, + RollbackReplace(usize), + JournalCleanup, + Staged, + Prepared, + Replaced(usize), + CommitMarked, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PersistedState { + version: u32, + pub reserved_addresses: Vec, + pub last_verified_at_utc: Option, +} + +impl PersistedState { + pub fn new( + reserved_addresses: impl IntoIterator, + last_verified_at_utc: Option, + ) -> Result { + let mut addresses = reserved_addresses.into_iter().collect::>(); + addresses.sort(); + if addresses.windows(2).any(|pair| pair[0] == pair[1]) + || addresses.iter().any(|address| !canonical_ip(address)) + { + return Err(PersistenceError::InvalidState); + } + if let Some(timestamp) = &last_verified_at_utc { + validate_timestamp(timestamp)?; + } + Ok(Self { + version: STATE_VERSION, + reserved_addresses: addresses, + last_verified_at_utc, + }) + } + + pub fn to_toml(&self) -> Result, PersistenceError> { + validate_state(self)?; + toml::to_string(self) + .map(String::into_bytes) + .map_err(|_| PersistenceError::InvalidState) + } + + pub fn from_toml(bytes: &[u8]) -> Result { + let source = std::str::from_utf8(bytes).map_err(|_| PersistenceError::InvalidState)?; + let state: Self = toml::from_str(source).map_err(|_| PersistenceError::InvalidState)?; + validate_state(&state)?; + Ok(state) + } + + pub fn apply_to( + &self, + states: &mut crate::domain::ClientStates, + ) -> Result<(), PersistenceError> { + validate_state(self)?; + let addresses = self + .reserved_addresses + .iter() + .map(|address| { + address + .parse::() + .map_err(|_| PersistenceError::InvalidState) + }) + .collect::, _>>()?; + states + .restore_persisted_reservations(addresses) + .map_err(|_| PersistenceError::InvalidState) + } +} + +fn validate_state(state: &PersistedState) -> Result<(), PersistenceError> { + if state.version != STATE_VERSION + || state + .reserved_addresses + .windows(2) + .any(|pair| pair[0] >= pair[1]) + || state + .reserved_addresses + .iter() + .any(|address| !canonical_ip(address)) + { + return Err(PersistenceError::InvalidState); + } + if let Some(timestamp) = &state.last_verified_at_utc { + validate_timestamp(timestamp)?; + } + Ok(()) +} + +fn canonical_ip(value: &str) -> bool { + value + .parse::() + .is_ok_and(|address| address.to_string() == value) +} + +fn validate_timestamp(value: &str) -> Result<(), PersistenceError> { + let parsed = + OffsetDateTime::parse(value, &Rfc3339).map_err(|_| PersistenceError::InvalidState)?; + if parsed.offset() != time::UtcOffset::UTC + || parsed.nanosecond() != 0 + || parsed + .format(&Rfc3339) + .map_err(|_| PersistenceError::InvalidState)? + != value + { + return Err(PersistenceError::InvalidState); + } + Ok(()) +} + +#[derive(Debug, Error)] +pub enum PersistenceError { + #[error("saved state is invalid or incompatible with the configured installation")] + InvalidState, + #[error("saved path is unsafe, aliased, or is not a regular file")] + UnsafePath, + #[error("saved server configuration or vault has permissions broader than 0600")] + InsecurePermissions, + #[error("saved file exceeds its size limit")] + TooLarge, + #[error("saved-file transaction needs recovery before this operation")] + RecoveryRequired, + #[error("saved-file operation failed")] + Io(#[source] io::Error), +} + +impl From for PersistenceError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} +impl From for PersistenceError { + fn from(error: rustix::io::Errno) -> Self { + Self::Io(error.into()) + } +} + +/// Paths are derived only from executable-relative resolved settings. +#[derive(Clone)] +pub struct SavedStore { + executable_directory: PathBuf, + settings: ResolvedSettings, +} + +impl SavedStore { + pub fn new( + executable_directory: impl Into, + settings: ResolvedSettings, + ) -> Result { + let executable_directory = executable_directory.into(); + let store = Self { + executable_directory, + settings, + }; + store.check_mapping()?; + Ok(store) + } + + /// Trusted archive destination paired with this store's resolved settings. + pub(crate) fn archives_directory(&self) -> &Path { + &self.settings.archives_directory + } + + pub fn read_saved(&self) -> Result { + self.recover()?; + self.check_mapping()?; + let settings = read_regular(&self.settings_path(), MAX_SETTINGS)?; + let server_config = read_private_regular(&self.settings.server_config_path, MAX_SERVER)?; + let internal_state = read_optional(&self.state_path(), MAX_STATE)?; + let vault = read_optional_private(&self.vault_path(), MAX_VAULT)?; + let saved = SavedBytes { + settings, + server_config, + internal_state, + vault, + }; + self.validate_saved(&saved)?; + Ok(saved) + } + + pub fn validate_saved(&self, saved: &SavedBytes) -> Result<(), PersistenceError> { + self.resolved_settings_for(saved)?; + ConfigDocument::parse(&saved.server_config).map_err(|_| PersistenceError::InvalidState)?; + if let Some(bytes) = &saved.internal_state { + PersistedState::from_toml(bytes)?; + } + if let Some(bytes) = &saved.vault { + let source = std::str::from_utf8(bytes).map_err(|_| PersistenceError::InvalidState)?; + VaultBundle::from_toml(source).map_err(|_| PersistenceError::InvalidState)?; + } + Ok(()) + } + + pub(crate) fn resolved_settings_for( + &self, + saved: &SavedBytes, + ) -> Result { + let settings_text = + std::str::from_utf8(&saved.settings).map_err(|_| PersistenceError::InvalidState)?; + let resolved = SettingsFile::parse_toml(settings_text) + .map_err(|_| PersistenceError::InvalidState)? + .validate() + .map_err(|_| PersistenceError::InvalidState)? + .resolve_paths(&self.executable_directory); + if !same_installation_paths(&resolved, &self.settings) { + return Err(PersistenceError::InvalidState); + } + Ok(resolved) + } + + pub(crate) fn set_resolved_settings(&mut self, settings: ResolvedSettings) { + self.settings = settings; + } + + pub fn recover(&self) -> Result<(), PersistenceError> { + self.recover_with_hook(|_| Ok(())) + } + + pub(crate) fn recover_with_hook( + &self, + mut checkpoint: impl FnMut(InstallCheckpoint) -> io::Result<()>, + ) -> Result<(), PersistenceError> { + self.check_mapping()?; + let journal = self.settings.data_directory.join(JOURNAL); + match fs::symlink_metadata(&journal) { + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + Ok(meta) if !meta.file_type().is_dir() => Err(PersistenceError::RecoveryRequired), + Ok(_) => self.recover_journal(&journal, &mut checkpoint), + } + } + + pub fn install( + &self, + saved: &SavedBytes, + _backup: BackupComplete, + ) -> Result<(), PersistenceError> { + self.install_with_hook(saved, |_| Ok(())) + } + + pub(crate) fn install_with_hook( + &self, + saved: &SavedBytes, + mut checkpoint: impl FnMut(InstallCheckpoint) -> io::Result<()>, + ) -> Result<(), PersistenceError> { + self.validate_saved(saved)?; + self.check_mapping()?; + self.recover()?; + let old = self.read_unvalidated_current()?; + self.validate_saved(&old)?; + self.ensure_private_dir(&self.settings.data_directory)?; + let journal = self.settings.data_directory.join(JOURNAL); + create_private_dir(&journal).map_err(|error| { + if error.kind() == io::ErrorKind::AlreadyExists { + PersistenceError::RecoveryRequired + } else { + error.into() + } + })?; + let prepare = (|| -> Result<(), PersistenceError> { + for (index, bytes) in component_bytes(&old).into_iter().enumerate() { + write_journal_payload(&journal, &format!("old-{index}"), bytes, |sync| { + checkpoint(if sync { + InstallCheckpoint::JournalPayloadSync(index) + } else { + InstallCheckpoint::JournalPayloadWrite(index) + }) + })?; + } + for (index, bytes) in component_bytes(saved).into_iter().enumerate() { + write_journal_payload(&journal, &format!("new-{index}"), bytes, |sync| { + checkpoint(if sync { + InstallCheckpoint::JournalPayloadSync(index + 4) + } else { + InstallCheckpoint::JournalPayloadWrite(index + 4) + }) + })?; + } + checkpoint(InstallCheckpoint::Staged)?; + let metadata = JournalMetadata { + version: 1, + old_present: presence(&old), + }; + checkpoint(InstallCheckpoint::PreparedMarker)?; + write_private_atomic( + &journal.join(JOURNAL_META), + &toml::to_string(&metadata) + .map_err(|_| PersistenceError::InvalidState)? + .into_bytes(), + )?; + checkpoint(InstallCheckpoint::PreparedMarkerSync)?; + File::open(&journal)?.sync_all()?; + File::open(&self.settings.data_directory)?.sync_all()?; + write_private_atomic(&journal.join("prepared"), b"prepared\n")?; + File::open(&journal)?.sync_all()?; + File::open(&self.settings.data_directory)?.sync_all()?; + checkpoint(InstallCheckpoint::Prepared)?; + Ok(()) + })(); + if let Err(error) = prepare { + return match fs::remove_dir_all(&journal) { + Ok(()) => Err(error), + Err(_) => Err(PersistenceError::RecoveryRequired), + }; + } + + let targets = self.targets(); + let proposed_components = component_bytes(saved); + for index in 0..4 { + let bytes = proposed_components[index]; + replace_file_with_hook(&targets[index], bytes, index, &mut checkpoint) + .map_err(|_| PersistenceError::RecoveryRequired)?; + checkpoint(InstallCheckpoint::Replaced(index + 1)) + .map_err(|_| PersistenceError::RecoveryRequired)?; + } + sync_target_parents_with_hook(&targets, &mut checkpoint) + .map_err(|_| PersistenceError::RecoveryRequired)?; + checkpoint(InstallCheckpoint::CommittedMarker) + .map_err(|_| PersistenceError::RecoveryRequired)?; + write_private_atomic(&journal.join("committed"), b"committed\n") + .map_err(|_| PersistenceError::RecoveryRequired)?; + checkpoint(InstallCheckpoint::CommitMarked) + .map_err(|_| PersistenceError::RecoveryRequired)?; + checkpoint(InstallCheckpoint::CommittedMarkerSync) + .map_err(|_| PersistenceError::RecoveryRequired)?; + File::open(&journal) + .and_then(|dir| dir.sync_all()) + .map_err(|_| PersistenceError::RecoveryRequired)?; + checkpoint(InstallCheckpoint::JournalCleanup) + .map_err(|_| PersistenceError::RecoveryRequired)?; + fs::remove_dir_all(&journal).map_err(|_| PersistenceError::RecoveryRequired)?; + File::open(&self.settings.data_directory) + .and_then(|dir| dir.sync_all()) + .map_err(|_| PersistenceError::RecoveryRequired)?; + Ok(()) + } + + fn recover_journal( + &self, + journal: &Path, + checkpoint: &mut impl FnMut(InstallCheckpoint) -> io::Result<()>, + ) -> Result<(), PersistenceError> { + match read_regular(&journal.join("prepared"), 64) { + Ok(bytes) if bytes == b"prepared\n" => {} + Err(PersistenceError::Io(error)) if error.kind() == io::ErrorKind::NotFound => { + // No destination is touched until the prepared marker and all + // prior-state payloads have been synced. + checkpoint(InstallCheckpoint::JournalCleanup) + .map_err(|_| PersistenceError::RecoveryRequired)?; + fs::remove_dir_all(journal).map_err(|_| PersistenceError::RecoveryRequired)?; + File::open(&self.settings.data_directory)?.sync_all()?; + return Ok(()); + } + _ => return Err(PersistenceError::RecoveryRequired), + } + let meta = match read_regular(&journal.join(JOURNAL_META), 64 * 1024) { + Ok(meta) => meta, + Err(PersistenceError::Io(error)) if error.kind() == io::ErrorKind::NotFound => { + // The journal is durable before the first destination write. A + // missing metadata file therefore means preparation never + // crossed the mutation boundary. + checkpoint(InstallCheckpoint::JournalCleanup) + .map_err(|_| PersistenceError::RecoveryRequired)?; + fs::remove_dir_all(journal).map_err(|_| PersistenceError::RecoveryRequired)?; + File::open(&self.settings.data_directory)?.sync_all()?; + return Ok(()); + } + Err(_) => return Err(PersistenceError::RecoveryRequired), + }; + let metadata: JournalMetadata = toml::from_str( + std::str::from_utf8(&meta).map_err(|_| PersistenceError::RecoveryRequired)?, + ) + .map_err(|_| PersistenceError::RecoveryRequired)?; + if metadata.version != 1 { + return Err(PersistenceError::RecoveryRequired); + } + match read_regular(&journal.join("committed"), 64) { + Ok(bytes) if bytes == b"committed\n" => { + checkpoint(InstallCheckpoint::JournalCleanup) + .map_err(|_| PersistenceError::RecoveryRequired)?; + fs::remove_dir_all(journal).map_err(|_| PersistenceError::RecoveryRequired)?; + File::open(&self.settings.data_directory)?.sync_all()?; + return Ok(()); + } + Err(PersistenceError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {} + _ => return Err(PersistenceError::RecoveryRequired), + } + let targets = self.targets(); + for index in (0..4).rev() { + checkpoint(InstallCheckpoint::RollbackReplace(index)) + .map_err(|_| PersistenceError::RecoveryRequired)?; + let old = if metadata.old_present[index] { + Some( + read_regular( + &journal.join(format!("old-{index}")), + component_limit(index), + ) + .map_err(|_| PersistenceError::RecoveryRequired)?, + ) + } else { + None + }; + replace_file(&targets[index], old.as_deref()) + .map_err(|_| PersistenceError::RecoveryRequired)?; + } + sync_target_parents_with_hook(&targets, checkpoint) + .map_err(|_| PersistenceError::RecoveryRequired)?; + checkpoint(InstallCheckpoint::JournalCleanup) + .map_err(|_| PersistenceError::RecoveryRequired)?; + fs::remove_dir_all(journal).map_err(|_| PersistenceError::RecoveryRequired)?; + File::open(&self.settings.data_directory)?.sync_all()?; + Ok(()) + } + + fn read_unvalidated_current(&self) -> Result { + Ok(SavedBytes { + settings: read_regular(&self.settings_path(), MAX_SETTINGS)?, + server_config: read_private_regular(&self.settings.server_config_path, MAX_SERVER)?, + internal_state: read_optional(&self.state_path(), MAX_STATE)?, + vault: read_optional_private(&self.vault_path(), MAX_VAULT)?, + }) + } + + fn check_mapping(&self) -> Result<(), PersistenceError> { + let targets = self.targets(); + for path in &targets { + check_path_components(path, true)?; + } + for a in 0..targets.len() { + for b in a + 1..targets.len() { + if lexical_normalize(&targets[a]) == lexical_normalize(&targets[b]) { + return Err(PersistenceError::UnsafePath); + } + if let (Ok(ma), Ok(mb)) = ( + fs::symlink_metadata(&targets[a]), + fs::symlink_metadata(&targets[b]), + ) { + use std::os::unix::fs::MetadataExt; + if ma.dev() == mb.dev() && ma.ino() == mb.ino() { + return Err(PersistenceError::UnsafePath); + } + } + } + } + Ok(()) + } + + fn ensure_private_dir(&self, path: &Path) -> Result<(), PersistenceError> { + create_path_dirs(path)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + Ok(()) + } + + fn settings_path(&self) -> PathBuf { + self.executable_directory + .join(crate::config::SETTINGS_FILE_NAME) + } + fn state_path(&self) -> PathBuf { + self.settings.data_directory.join("internal-state.toml") + } + fn vault_path(&self) -> PathBuf { + self.settings.data_directory.join("vault.toml") + } + fn targets(&self) -> [PathBuf; 4] { + [ + self.settings_path(), + self.settings.server_config_path.clone(), + self.state_path(), + self.vault_path(), + ] + } +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct JournalMetadata { + version: u32, + old_present: [bool; 4], +} + +fn same_installation_paths(a: &ResolvedSettings, b: &ResolvedSettings) -> bool { + a.interface_name == b.interface_name + && a.server_config_path == b.server_config_path + && a.client_source_directory == b.client_source_directory + && a.data_root == b.data_root + && a.data_directory == b.data_directory + && a.archives_directory == b.archives_directory +} +fn presence(saved: &SavedBytes) -> [bool; 4] { + [ + true, + true, + saved.internal_state.is_some(), + saved.vault.is_some(), + ] +} +fn component_bytes(saved: &SavedBytes) -> [Option<&[u8]>; 4] { + [ + Some(&saved.settings), + Some(&saved.server_config), + saved.internal_state.as_deref(), + saved.vault.as_deref(), + ] +} +fn component_limit(index: usize) -> usize { + [MAX_SETTINGS, MAX_SERVER, MAX_STATE, MAX_VAULT][index] +} +fn write_journal_payload( + dir: &Path, + name: &str, + bytes: Option<&[u8]>, + mut checkpoint: impl FnMut(bool) -> io::Result<()>, +) -> Result<(), PersistenceError> { + if let Some(bytes) = bytes { + write_private(&dir.join(name), bytes, &mut checkpoint)?; + } + Ok(()) +} +fn write_private( + path: &Path, + bytes: &[u8], + checkpoint: &mut impl FnMut(bool) -> io::Result<()>, +) -> Result<(), PersistenceError> { + let parent = path.parent().ok_or(PersistenceError::UnsafePath)?; + let name = path.file_name().ok_or(PersistenceError::UnsafePath)?; + let dir = open_dir(parent)?; + let fd = rfs::openat( + &dir, + name, + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::from_bits_truncate(0o600), + )?; + let mut file = File::from(fd); + checkpoint(false)?; + file.write_all(bytes)?; + checkpoint(true)?; + file.sync_all()?; + Ok(()) +} + +fn write_private_atomic(path: &Path, bytes: &[u8]) -> Result<(), PersistenceError> { + let parent = path.parent().ok_or(PersistenceError::UnsafePath)?; + let dir = open_dir(parent)?; + let name = path.file_name().ok_or(PersistenceError::UnsafePath)?; + let temporary = format!( + ".wg-admin-journal-{}-{}", + std::process::id(), + getrandom::u32().map_err(|_| PersistenceError::InvalidState)? + ); + let fd = rfs::openat( + &dir, + temporary.as_str(), + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::from_bits_truncate(0o600), + )?; + let mut file = File::from(fd); + file.write_all(bytes)?; + file.sync_all()?; + rfs::renameat(&dir, temporary.as_str(), &dir, name)?; + File::from(dir).sync_all()?; + Ok(()) +} + +fn create_private_dir(path: &Path) -> io::Result<()> { + let parent = path + .parent() + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?; + let name = path + .file_name() + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?; + let dir = open_dir(parent).map_err(|error| match error { + PersistenceError::Io(error) => error, + _ => io::Error::from(io::ErrorKind::InvalidInput), + })?; + rfs::mkdirat(&dir, name, Mode::from_bits_truncate(0o700)).map_err(io::Error::from) +} + +fn read_regular(path: &Path, limit: usize) -> Result, PersistenceError> { + read_regular_impl(path, limit, false) +} +fn read_private_regular(path: &Path, limit: usize) -> Result, PersistenceError> { + read_regular_impl(path, limit, true) +} +fn read_regular_impl( + path: &Path, + limit: usize, + require_private: bool, +) -> Result, PersistenceError> { + check_path_components(path, false)?; + let parent = path.parent().ok_or(PersistenceError::UnsafePath)?; + let name = path.file_name().ok_or(PersistenceError::UnsafePath)?; + let dir = open_dir(parent)?; + let fd = rfs::openat( + &dir, + name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + let file = File::from(fd); + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(PersistenceError::UnsafePath); + } + if require_private && metadata.mode() & 0o077 != 0 { + return Err(PersistenceError::InsecurePermissions); + } + if metadata.len() > limit as u64 { + return Err(PersistenceError::TooLarge); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take((limit + 1) as u64).read_to_end(&mut bytes)?; + if bytes.len() > limit { + return Err(PersistenceError::TooLarge); + } + Ok(bytes) +} +fn read_optional(path: &Path, limit: usize) -> Result>, PersistenceError> { + match read_regular(path, limit) { + Ok(bytes) => Ok(Some(bytes)), + Err(PersistenceError::Io(error)) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} +fn read_optional_private(path: &Path, limit: usize) -> Result>, PersistenceError> { + match read_private_regular(path, limit) { + Ok(bytes) => Ok(Some(bytes)), + Err(PersistenceError::Io(error)) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} +fn replace_file(target: &Path, bytes: Option<&[u8]>) -> Result<(), PersistenceError> { + let mut checkpoint = |_| Ok(()); + replace_file_with_hook(target, bytes, 0, &mut checkpoint) +} +fn replace_file_with_hook( + target: &Path, + bytes: Option<&[u8]>, + index: usize, + checkpoint: &mut impl FnMut(InstallCheckpoint) -> io::Result<()>, +) -> Result<(), PersistenceError> { + let parent = target.parent().ok_or(PersistenceError::UnsafePath)?; + check_path_components(parent, true)?; + check_path_components(target, true)?; + let dir = open_dir(parent)?; + let name = target.file_name().ok_or(PersistenceError::UnsafePath)?; + if let Some(bytes) = bytes { + let temp_name = format!( + ".wg-admin-stage-{}-{}", + std::process::id(), + getrandom::u32().map_err(|_| PersistenceError::InvalidState)? + ); + let fd = rfs::openat( + &dir, + temp_name.as_str(), + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::from_bits_truncate(0o600), + )?; + let mut temp = File::from(fd); + checkpoint(InstallCheckpoint::StageWrite(index))?; + temp.write_all(bytes)?; + checkpoint(InstallCheckpoint::StageSync(index))?; + temp.sync_all()?; + rfs::renameat(&dir, temp_name.as_str(), &dir, name)?; + } else { + match rfs::unlinkat(&dir, name, AtFlags::empty()) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + File::from(dir).sync_all()?; + Ok(()) +} +fn sync_target_parents_with_hook( + targets: &[PathBuf; 4], + checkpoint: &mut impl FnMut(InstallCheckpoint) -> io::Result<()>, +) -> Result<(), PersistenceError> { + for (index, target) in targets.iter().enumerate() { + checkpoint(InstallCheckpoint::ParentSync(index))?; + let parent = target.parent().ok_or(PersistenceError::UnsafePath)?; + File::from(open_dir(parent)?).sync_all()?; + } + Ok(()) +} + +fn open_dir(path: &Path) -> Result { + if !path.is_absolute() { + return Err(PersistenceError::UnsafePath); + } + let mut dir = rfs::open( + "/", + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + for component in path.components().skip(1) { + let Component::Normal(name) = component else { + return Err(PersistenceError::UnsafePath); + }; + dir = rfs::openat( + &dir, + name, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + } + Ok(dir) +} +fn check_path_components(path: &Path, allow_missing_final: bool) -> Result<(), PersistenceError> { + if !path.is_absolute() { + return Err(PersistenceError::UnsafePath); + } + let mut cursor = PathBuf::from("/"); + let components = path + .components() + .filter(|c| !matches!(c, Component::RootDir)); + let all = components.collect::>(); + for (index, component) in all.iter().enumerate() { + let Component::Normal(name) = component else { + return Err(PersistenceError::UnsafePath); + }; + cursor.push(name); + match fs::symlink_metadata(&cursor) { + Ok(meta) if meta.file_type().is_symlink() => return Err(PersistenceError::UnsafePath), + Ok(meta) if index + 1 < all.len() && !meta.is_dir() => { + return Err(PersistenceError::UnsafePath); + } + Ok(_) => {} + Err(error) + if error.kind() == io::ErrorKind::NotFound + && (allow_missing_final || index + 1 < all.len()) => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} +fn create_path_dirs(path: &Path) -> Result<(), PersistenceError> { + if !path.is_absolute() { + return Err(PersistenceError::UnsafePath); + } + let mut directory = rfs::open( + "/", + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + for component in path.components().skip(1) { + let Component::Normal(name) = component else { + return Err(PersistenceError::UnsafePath); + }; + match rfs::mkdirat(&directory, name, Mode::from_bits_truncate(0o700)) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error.into()), + } + directory = rfs::openat( + &directory, + name, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + } + Ok(()) +} +fn lexical_normalize(path: &Path) -> PathBuf { + let mut result = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + result.pop(); + } + other => result.push(other.as_os_str()), + } + } + result +} + +impl fmt::Debug for SavedStore { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SavedStore") + .field("settings_path", &self.settings_path()) + .field("server_config_path", &self.settings.server_config_path) + .field("data_directory", &self.settings.data_directory) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + const SETTINGS: &str = r#"interface_name = "wg0" +server_config_path = "wg0.conf" +client_source_directory = "clients" +server_endpoint = "vpn.example:51820" +ipv4_allocation_range = "10.8.0.0/24" +ipv6_allocation_range = "fd00::/64" +dns_addresses = ["10.8.0.1"] +systemd_unit = "wg-quick@wg0.service" +restart_timeout_seconds = 30 +"#; + + fn fixture() -> (TempDir, SavedStore, SavedBytes) { + let temp = tempfile::tempdir().expect("temporary fixture directory"); + let app = temp.path().join("app"); + fs::create_dir(&app).expect("app directory"); + let server_path = app.join("wg0.conf"); + let settings = SettingsFile::parse_toml(SETTINGS) + .unwrap() + .validate() + .unwrap() + .resolve_paths(&app); + fs::write(app.join(crate::config::SETTINGS_FILE_NAME), SETTINGS).unwrap(); + fs::write( + &server_path, + b"[Interface]\nPrivateKey = fixture\nAddress = 10.8.0.1/24\n", + ) + .unwrap(); + fs::set_permissions(&server_path, fs::Permissions::from_mode(0o600)).unwrap(); + let store = SavedStore::new(&app, settings).unwrap(); + let saved = SavedBytes { + settings: SETTINGS.as_bytes().to_vec(), + server_config: b"[Interface]\nPrivateKey = next\nAddress = 10.8.0.1/24\n".to_vec(), + internal_state: None, + vault: None, + }; + (temp, store, saved) + } + + #[test] + fn state_codec_is_canonical_and_rejects_bad_or_unsupported_state() { + let state = + PersistedState::new(["fd00::8".to_owned(), "10.0.0.8".to_owned()], None).unwrap(); + assert_eq!(state.reserved_addresses, ["10.0.0.8", "fd00::8"]); + let decoded = PersistedState::from_toml(&state.to_toml().unwrap()).unwrap(); + assert_eq!(decoded, state); + assert!(PersistedState::from_toml(b"version = 2\nreserved_addresses = []\n").is_err()); + assert!( + PersistedState::from_toml( + b"version = 1\nreserved_addresses = [\"10.0.0.1\", \"10.0.0.1\"]\n" + ) + .is_err() + ); + assert!( + PersistedState::from_toml(b"version = 1\nreserved_addresses = []\nextra = true\n") + .is_err() + ); + } + + #[test] + fn persisted_history_never_marks_the_runtime_verified() { + let client = crate::domain::Client::new( + crate::domain::PublicKey::from_bytes([7; 32]), + "alias", + crate::domain::ClientAddresses { + ipv4: "10.0.0.2".parse().unwrap(), + ipv6: None, + }, + ) + .unwrap(); + let saved = crate::domain::Snapshot::new([client]).unwrap(); + let mut states = crate::domain::ClientStates::new( + saved, + None, + crate::domain::RuntimeObservation::Unknown, + ) + .unwrap(); + let state = PersistedState::new( + ["10.0.0.8".to_owned()], + Some("2026-10-19T12:30:00Z".to_owned()), + ) + .unwrap(); + state.apply_to(&mut states).unwrap(); + assert_eq!( + states.applied(), + &crate::domain::RuntimeObservation::Unknown + ); + assert!( + states + .reserved_addresses() + .contains(&"10.0.0.8".parse().unwrap()) + ); + } + + #[test] + fn read_returns_exact_saved_bytes_and_optional_absence() { + let (_temp, store, saved) = fixture(); + let actual = store.read_saved().unwrap(); + assert_eq!(actual.settings, saved.settings); + assert_eq!( + actual.server_config, + b"[Interface]\nPrivateKey = fixture\nAddress = 10.8.0.1/24\n" + ); + assert_eq!(actual.internal_state, None); + assert_eq!(actual.vault, None); + assert!(!format!("{actual:?}").contains("PrivateKey = fixture")); + } + + #[test] + fn existing_secret_bearing_saved_files_with_broad_modes_fail_closed() { + for (file, mode) in [("wg0.conf", 0o640), ("vault.toml", 0o644)] { + let (_temp, store, _saved) = fixture(); + if file == "vault.toml" { + let vault = VaultBundle::create("fixture password") + .unwrap() + .to_toml() + .unwrap(); + let path = store.vault_path(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, vault).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap(); + } else { + fs::set_permissions( + &store.settings.server_config_path, + fs::Permissions::from_mode(mode), + ) + .unwrap(); + } + assert!(matches!( + store.read_saved(), + Err(PersistenceError::InsecurePermissions) + )); + } + } + + #[test] + fn symlink_destinations_are_rejected_without_reading_the_target() { + let (temp, store, _saved) = fixture(); + let external = temp.path().join("outside"); + fs::write(&external, b"[Interface]\n").unwrap(); + let server = store.settings.server_config_path.clone(); + fs::remove_file(&server).unwrap(); + std::os::unix::fs::symlink(&external, &server).unwrap(); + assert!(matches!( + store.read_saved(), + Err(PersistenceError::UnsafePath) + )); + } + + #[test] + fn destination_aliases_and_hard_links_are_rejected() { + let (temp, store, _saved) = fixture(); + let app = temp.path().join("app"); + fs::remove_file(&store.settings.server_config_path).unwrap(); + fs::hard_link(store.settings_path(), &store.settings.server_config_path).unwrap(); + assert!(matches!( + store.read_saved(), + Err(PersistenceError::UnsafePath) + )); + + let aliased_settings = SETTINGS.replace( + "server_config_path = \"wg0.conf\"", + "server_config_path = \"wireguard-admin.toml\"", + ); + let resolved = SettingsFile::parse_toml(&aliased_settings) + .unwrap() + .validate() + .unwrap() + .resolve_paths(&app); + assert!(matches!( + SavedStore::new(&app, resolved), + Err(PersistenceError::UnsafePath) + )); + } + + #[test] + fn reopening_after_each_partial_replacement_rolls_back_before_reading() { + for fail_after in 1..=4 { + let (_temp, store, proposed) = fixture(); + let mut replacement_count = 0; + let result = store.install_with_hook(&proposed, |checkpoint| { + if let InstallCheckpoint::Replaced(count) = checkpoint { + replacement_count = count; + if replacement_count == fail_after { + return Err(io::Error::other("injected interruption")); + } + } + Ok(()) + }); + assert!(matches!(result, Err(PersistenceError::RecoveryRequired))); + assert!(matches!(store.recover(), Ok(()))); + let restored = store.read_saved().unwrap(); + assert_eq!(restored.settings, SETTINGS.as_bytes()); + assert!( + restored + .server_config + .windows(b"PrivateKey = fixture".len()) + .any(|v| v == b"PrivateKey = fixture") + ); + assert_eq!(restored.internal_state, None); + assert_eq!(restored.vault, None); + } + } + + #[test] + fn staging_failure_leaves_the_old_set_and_cleans_unprepared_evidence() { + let (_temp, store, proposed) = fixture(); + let result = store.install_with_hook(&proposed, |checkpoint| { + if checkpoint == InstallCheckpoint::Staged { + Err(io::Error::other("injected staging interruption")) + } else { + Ok(()) + } + }); + assert!(matches!(result, Err(PersistenceError::Io(_)))); + assert!(!store.settings.data_directory.join(JOURNAL).exists()); + assert!( + store + .read_saved() + .unwrap() + .server_config + .windows(b"PrivateKey = fixture".len()) + .any(|v| v == b"PrivateKey = fixture") + ); + } + + #[test] + fn injected_journal_payload_write_sync_and_marker_failures_leave_old_bytes() { + let points = [ + InstallCheckpoint::JournalPayloadWrite(0), + InstallCheckpoint::JournalPayloadSync(0), + InstallCheckpoint::JournalPayloadWrite(1), + InstallCheckpoint::JournalPayloadSync(1), + InstallCheckpoint::JournalPayloadWrite(4), + InstallCheckpoint::JournalPayloadSync(4), + InstallCheckpoint::JournalPayloadWrite(5), + InstallCheckpoint::JournalPayloadSync(5), + InstallCheckpoint::PreparedMarker, + InstallCheckpoint::PreparedMarkerSync, + ]; + for point in points { + let (_temp, store, proposed) = fixture(); + let old = store.read_saved().unwrap(); + assert!( + store + .install_with_hook(&proposed, |current| { + if current == point { + Err(io::Error::other("fault injection")) + } else { + Ok(()) + } + }) + .is_err() + ); + assert!(!store.settings.data_directory.join(JOURNAL).exists()); + assert_eq!(store.read_saved().unwrap().server_config, old.server_config); + assert_eq!(store.read_saved().unwrap().settings, old.settings); + } + } + + #[test] + fn each_destination_write_and_sync_failure_recovers_exact_old_set() { + for index in 0..4 { + for point in [ + InstallCheckpoint::StageWrite(index), + InstallCheckpoint::StageSync(index), + InstallCheckpoint::ParentSync(index), + ] { + let (_temp, store, mut proposed) = fixture(); + proposed.internal_state = Some( + PersistedState::new(Vec::::new(), None) + .unwrap() + .to_toml() + .unwrap(), + ); + proposed.vault = Some( + VaultBundle::create("fault-test password") + .unwrap() + .to_toml() + .unwrap() + .into_bytes(), + ); + let old = store.read_saved().unwrap(); + assert!(matches!( + store.install_with_hook(&proposed, |current| { + if current == point { + Err(io::Error::other("fault injection")) + } else { + Ok(()) + } + }), + Err(PersistenceError::RecoveryRequired) + )); + assert_eq!(store.read_saved().unwrap().settings, old.settings); + assert_eq!(store.read_saved().unwrap().server_config, old.server_config); + assert_eq!( + store.read_saved().unwrap().internal_state, + old.internal_state + ); + assert_eq!(store.read_saved().unwrap().vault, old.vault); + } + } + } + + #[test] + fn rollback_and_cleanup_faults_keep_recovery_required_until_reopen_retry() { + let (_temp, store, proposed) = fixture(); + let old = store.read_saved().unwrap(); + assert!(matches!( + store.install_with_hook(&proposed, |point| { + if point == InstallCheckpoint::Replaced(1) { + Err(io::Error::other("interrupt after replacement")) + } else { + Ok(()) + } + }), + Err(PersistenceError::RecoveryRequired) + )); + assert!(matches!( + store.recover_with_hook(|point| { + if point == InstallCheckpoint::RollbackReplace(3) { + Err(io::Error::other("rollback fault")) + } else { + Ok(()) + } + }), + Err(PersistenceError::RecoveryRequired) + )); + assert_eq!(store.read_saved().unwrap().settings, old.settings); + assert_eq!(store.read_saved().unwrap().server_config, old.server_config); + + let replacement = SavedBytes { + settings: old.settings.clone(), + server_config: b"[Interface]\nPrivateKey = replacement\n".to_vec(), + internal_state: old.internal_state.clone(), + vault: old.vault.clone(), + }; + assert!(matches!( + store.install_with_hook(&replacement, |point| { + if point == InstallCheckpoint::Replaced(1) { + Err(io::Error::other("interrupt after replacement")) + } else { + Ok(()) + } + }), + Err(PersistenceError::RecoveryRequired) + )); + assert!(matches!( + store.recover_with_hook(|point| { + if point == InstallCheckpoint::JournalCleanup { + Err(io::Error::other("cleanup fault")) + } else { + Ok(()) + } + }), + Err(PersistenceError::RecoveryRequired) + )); + assert_eq!(store.read_saved().unwrap().server_config, old.server_config); + } + + #[test] + fn committed_marker_and_cleanup_failures_are_not_false_success() { + for point in [ + InstallCheckpoint::CommittedMarker, + InstallCheckpoint::CommittedMarkerSync, + InstallCheckpoint::JournalCleanup, + ] { + let (_temp, store, proposed) = fixture(); + let old = store.read_saved().unwrap(); + assert!(matches!( + store.install_with_hook(&proposed, |current| { + if current == point { + Err(io::Error::other("fault injection")) + } else { + Ok(()) + } + }), + Err(PersistenceError::RecoveryRequired) + )); + let reopened = store.read_saved().unwrap(); + if point == InstallCheckpoint::CommittedMarker { + assert_eq!(reopened.server_config, old.server_config); + } else { + assert_eq!(reopened.server_config, proposed.server_config); + } + } + } + + #[test] + fn reopening_after_durable_commit_marker_only_cleans_journal() { + let (_temp, store, proposed) = fixture(); + let result = store.install_with_hook(&proposed, |checkpoint| { + if checkpoint == InstallCheckpoint::CommitMarked { + Err(io::Error::other("injected cleanup interruption")) + } else { + Ok(()) + } + }); + assert!(matches!(result, Err(PersistenceError::RecoveryRequired))); + store.recover().unwrap(); + let installed = store.read_saved().unwrap(); + assert_eq!(installed.server_config, proposed.server_config); + assert!(!store.settings.data_directory.join(JOURNAL).exists()); + } + + #[test] + fn complete_install_publishes_components_with_private_modes() { + let (_temp, store, mut proposed) = fixture(); + proposed.internal_state = Some( + PersistedState::new(["10.8.0.9".to_owned()], None) + .unwrap() + .to_toml() + .unwrap(), + ); + store.install(&proposed, BackupComplete(())).unwrap(); + assert_eq!( + store.read_saved().unwrap().server_config, + proposed.server_config + ); + for path in store.targets() { + if path.exists() { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + assert_eq!( + fs::metadata(&store.settings.data_directory) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert!(!store.settings.data_directory.join(JOURNAL).exists()); + } + + #[test] + fn invalid_recovery_evidence_blocks_saved_reads() { + let (_temp, store, _saved) = fixture(); + fs::create_dir_all(store.settings.data_directory.join(JOURNAL)).unwrap(); + fs::write( + store + .settings + .data_directory + .join(JOURNAL) + .join(JOURNAL_META), + b"invalid\n", + ) + .unwrap(); + fs::write( + store.settings.data_directory.join(JOURNAL).join("prepared"), + b"prepared\n", + ) + .unwrap(); + assert!(matches!( + store.read_saved(), + Err(PersistenceError::RecoveryRequired) + )); + } +} diff --git a/src/vault.rs b/src/vault.rs index 1f21a1e..0b6eaaa 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -258,6 +258,13 @@ impl VaultBundle { self.records.contains_key(&label.to_string()) } + /// Remove only the record identified by its canonical WireGuard public key. + /// The encrypted identity and every other record remain unchanged. + pub fn remove_record(&mut self, label: PublicKey) -> Result { + self.validate_shape()?; + Ok(self.records.remove(&label.to_string()).is_some()) + } + /// Retrieve a single record, freshly unlocking and verifying the identity on every call. pub fn retrieve_record( &self, diff --git a/src/vault/source.rs b/src/vault/source.rs index c7ee8b2..8d6b06d 100644 --- a/src/vault/source.rs +++ b/src/vault/source.rs @@ -366,6 +366,11 @@ mod tests { use super::*; use crate::vault::WireGuardPrivateKey; use std::fs; + use std::os::unix::fs::PermissionsExt; + + fn make_private(path: &Path) { + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); + } #[test] fn entry_enumeration_error_is_visible_and_other_files_still_import() { @@ -386,7 +391,9 @@ mod tests { client_key.to_base64(), server_key.public_key() ); - fs::write(directory.path().join("valid.conf"), &client_bytes).unwrap(); + let source_file = directory.path().join("valid.conf"); + fs::write(&source_file, &client_bytes).unwrap(); + make_private(&source_file); let dirfd = open_source_directory(directory.path()).unwrap(); let entries = [ Ok(OsString::from("valid.conf")), @@ -424,13 +431,16 @@ mod tests { client_key.to_base64(), server_key.public_key() ); - fs::write(source.join("client.conf"), &bytes).unwrap(); + let client_file = source.join("client.conf"); + fs::write(&client_file, &bytes).unwrap(); + make_private(&client_file); let directory = open_source_directory(&source).unwrap(); fs::rename(&source, &pinned_path).unwrap(); fs::create_dir(&source).unwrap(); + let replacement = source.join("replacement.conf"); fs::write( - source.join("replacement.conf"), + &replacement, format!( "[Interface]\nPrivateKey = {}\nAddress = 10.8.0.3/32\n\n[Peer]\nPublicKey = {}\n", decoy_key.to_base64(), @@ -438,6 +448,7 @@ mod tests { ), ) .unwrap(); + make_private(&replacement); let entries = rfs::Dir::read_from(&directory).unwrap(); let mut bundle = VaultBundle::create("password").unwrap(); diff --git a/tests/commit_restore.rs b/tests/commit_restore.rs new file mode 100644 index 0000000..643a155 --- /dev/null +++ b/tests/commit_restore.rs @@ -0,0 +1,524 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use wireguard_admin::app::{AppError, Application}; +use wireguard_admin::backup::{BackupReason, preview_archive, preview_archive_bytes}; +use wireguard_admin::config::{ResolvedSettings, SettingsFile}; +use wireguard_admin::domain::{ + Client, ClientAddresses, ClientStates, PublicKey, RuntimeObservation, Snapshot, +}; +use wireguard_admin::persistence::{PersistedState, SavedStore}; +use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey}; +use wireguard_admin::wireguard::ConfigDocument; + +const SETTINGS: &str = r#" +interface_name = "wg0" +server_config_path = "server.conf" +client_source_directory = "clients" +server_endpoint = "vpn.example.test:51820" +ipv4_allocation_range = "10.20.0.0/24" +ipv6_allocation_range = "fd20::/64" +dns_addresses = ["10.20.0.1"] +systemd_unit = "wg-quick@wg0.service" +restart_timeout_seconds = 30 +"#; + +struct Fixture { + root: tempfile::TempDir, + store: SavedStore, + settings: ResolvedSettings, + states: ClientStates, + key: PublicKey, + secret: WireGuardPrivateKey, +} + +fn fixture(with_client: bool) -> Fixture { + let root = tempfile::tempdir().expect("temporary fixture directory"); + let settings = SettingsFile::parse_toml(SETTINGS) + .expect("settings parse") + .validate() + .expect("settings validate") + .resolve_paths(root.path()); + fs::create_dir_all(&settings.client_source_directory).expect("source directory"); + fs::write( + ResolvedSettings::settings_path(root.path()), + SETTINGS.as_bytes(), + ) + .expect("settings file"); + let secret = WireGuardPrivateKey::generate().expect("synthetic key"); + let key = secret.public_key(); + let mut server = b"[Interface]\nPrivateKey = fixture-server-secret\nAddress = 10.20.0.1/24\n\n# Keep this administrator note byte-for-byte.\n".to_vec(); + let clients = if with_client { + server.extend_from_slice( + format!("[Peer]\nPublicKey = {key}\nAllowedIPs = 10.20.0.2/32, fd20::2/128\n") + .as_bytes(), + ); + vec![ + Client::new( + key, + "fixture client", + ClientAddresses { + ipv4: "10.20.0.2".parse().expect("IPv4 fixture"), + ipv6: Some("fd20::2".parse().expect("IPv6 fixture")), + }, + ) + .expect("client"), + ] + } else { + Vec::new() + }; + fs::write(&settings.server_config_path, &server).expect("server configuration"); + fs::set_permissions( + &settings.server_config_path, + fs::Permissions::from_mode(0o600), + ) + .expect("server fixture is private"); + let snapshot = Snapshot::new(clients).expect("initial snapshot"); + let states = ClientStates::new( + snapshot.clone(), + Some(snapshot), + RuntimeObservation::Verified, + ) + .expect("initial states"); + let store = SavedStore::new(root.path(), settings.clone()).expect("saved store"); + Fixture { + root, + store, + settings, + states, + key, + secret, + } +} + +fn open(fixture: &Fixture) -> Application { + Application::open(fixture.store.clone(), fixture.states.clone()).expect("application opens") +} + +fn archive_name(path: &Path) -> &str { + path.file_name() + .and_then(|name| name.to_str()) + .expect("UTF-8 archive name") +} + +fn update_checksum(header: &mut [u8]) { + header[148..156].fill(b' '); + let checksum = header.iter().map(|byte| usize::from(*byte)).sum::(); + let encoded = format!("{checksum:06o}\0 "); + assert_eq!(encoded.len(), 8); + header[148..156].copy_from_slice(encoded.as_bytes()); +} + +fn archive_with_header_change( + source: &[u8], + entry: usize, + change: impl FnOnce(&mut [u8]), +) -> Vec { + let mut bytes = source.to_vec(); + let header_start = entry * 512; + let header = bytes + .get_mut(header_start..header_start + 512) + .expect("header exists"); + change(header); + update_checksum(header); + bytes +} + +#[test] +fn archive_round_trip_records_exact_saved_bytes_absence_and_does_not_rewrite_old_archives() { + let fixture = fixture(false); + let mut app = open(&fixture); + let original = fixture.store.read_saved().expect("saved bytes"); + let path = app.create_manual_backup().expect("manual backup"); + let archived_bytes = fs::read(&path).expect("archive bytes"); + let preview = preview_archive(&path).expect("archive round trip"); + + assert_eq!(preview.reason, BackupReason::Manual); + assert_eq!(preview.saved_bytes.settings, original.settings); + assert_eq!(preview.saved_bytes.server_config, original.server_config); + assert_eq!(preview.saved_bytes.internal_state, None); + assert_eq!(preview.saved_bytes.vault, None); + + let secret = WireGuardPrivateKey::generate().expect("new synthetic key"); + app.prepare_initial_vault(VaultBundle::create("test password").expect("vault")) + .expect("prepare vault"); + app.add_client( + Client::new( + secret.public_key(), + "added later", + ClientAddresses { + ipv4: "10.20.0.3".parse().expect("IPv4"), + ipv6: Some("fd20::3".parse().expect("IPv6")), + }, + ) + .expect("client"), + None, + ) + .expect("prepare peer"); + app.commit().expect("commit later change"); + + assert_eq!( + fs::read(path).expect("old archive remains readable"), + archived_bytes + ); + let reopened = fixture.store.read_saved().expect("new saved bytes"); + assert!(reopened.vault.is_some()); + assert!(reopened.internal_state.is_some()); +} + +#[test] +fn initial_setup_import_and_commit_leave_original_source_files_unchanged() { + let fixture = fixture(true); + let source = fixture + .settings + .client_source_directory + .join("existing.conf"); + let server_identity = WireGuardPrivateKey::generate().expect("synthetic server identity"); + let source_bytes = format!( + "[Interface]\nPrivateKey = {}\nAddress = 10.20.0.2/32\nDNS = 10.20.0.1\n\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example.test:51820\nAllowedIPs = 0.0.0.0/0, ::/0\n", + fixture.secret.to_base64(), + server_identity.public_key() + ) + .into_bytes(); + fs::write(&source, &source_bytes).expect("source fixture"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o600)) + .expect("source fixture is private"); + let server = ConfigDocument::parse(&fixture.store.read_saved().expect("saved").server_config) + .expect("server document"); + let prepared = wireguard_admin::vault::source::prepare_initial_setup( + &fixture.settings, + &server, + "synthetic password", + ) + .expect("initial import setup"); + let mut app = open(&fixture); + app.prepare_initial_setup(prepared) + .expect("stage initial import"); + assert_eq!( + fs::read(&source).expect("source remains unchanged"), + source_bytes + ); + assert!( + fixture + .store + .read_saved() + .expect("before commit") + .vault + .is_none() + ); + + app.commit().expect("install initial vault"); + let installed = fixture.store.read_saved().expect("installed state"); + let vault = VaultBundle::from_toml( + std::str::from_utf8(installed.vault.as_deref().expect("vault installed")) + .expect("vault UTF-8"), + ) + .expect("vault parses without unlocking"); + assert_eq!(vault.record_count(), 1); + assert!(vault.contains_record(fixture.key)); + assert_eq!( + fs::read(&source).expect("source still unchanged"), + source_bytes + ); +} + +#[test] +fn restore_reserves_addresses_of_current_saved_clients_that_the_archive_removes() { + let fixture = fixture(false); + let mut app = open(&fixture); + let empty_archive = app.create_manual_backup().expect("archive empty state"); + + let client = Client::new( + fixture.key, + "created after archive", + ClientAddresses { + ipv4: "10.20.0.9".parse().expect("IPv4"), + ipv6: Some("fd20::9".parse().expect("IPv6")), + }, + ) + .expect("new client"); + app.add_client(client, None).expect("stage client"); + app.commit().expect("save client"); + assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + + let preview = app + .preview_restore(archive_name(&empty_archive)) + .expect("preview archived empty set"); + app.confirm_restore(preview).expect("confirm restore"); + let archived_state = PersistedState::from_toml( + app.saved_bytes() + .internal_state + .as_deref() + .expect("restore carries pending reservations"), + ) + .expect("restored reservation state"); + assert!( + archived_state + .reserved_addresses + .contains(&"10.20.0.9".to_owned()) + ); + assert!( + archived_state + .reserved_addresses + .contains(&"fd20::9".to_owned()) + ); + + assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert!( + app.states() + .reserved_addresses() + .contains(&"10.20.0.9".parse().expect("IPv4")) + ); + assert!( + app.states() + .reserved_addresses() + .contains(&"fd20::9".parse().expect("IPv6")) + ); +} + +#[test] +fn restore_rejects_an_archive_that_switches_the_installations_interface() { + let fixture = fixture(true); + let app = open(&fixture); + let archive_path = app.create_manual_backup().expect("valid source archive"); + let mut archive_bytes = fs::read(archive_path).expect("archive bytes"); + let settings_member = 1536; // Manifest data occupies one 512-byte block. + let settings_end = archive_bytes[settings_member..] + .iter() + .position(|byte| *byte == 0) + .map(|length| settings_member + length) + .expect("settings data terminates before padding"); + let settings = + std::str::from_utf8(&archive_bytes[settings_member..settings_end]).expect("settings UTF-8"); + let changed = settings.replace("interface_name = \"wg0\"", "interface_name = \"wg1\""); + assert_ne!(changed, settings); + archive_bytes[settings_member..settings_end].copy_from_slice(changed.as_bytes()); + let changed_archive = fixture + .settings + .archives_directory + .join("wireguard-admin-interface-switch.tar"); + fs::write(&changed_archive, archive_bytes).expect("write mutated synthetic archive"); + + assert!(matches!( + app.preview_restore(archive_name(&changed_archive)), + Err(AppError::Persistence(_)) + )); + assert!(matches!( + app.preview_restore("../outside.tar"), + Err(AppError::InvalidArchiveSelection) + )); +} + +#[test] +fn archive_parser_rejects_traversal_links_duplicates_and_oversized_members() { + let fixture = fixture(false); + let path = open(&fixture) + .create_manual_backup() + .expect("manual archive"); + let archive = fs::read(&path).expect("archive bytes"); + + let traversal = archive_with_header_change(&archive, 0, |header| { + header[..100].fill(0); + header[..11].copy_from_slice(b"../manifest"); + }); + assert!(preview_archive_bytes(&traversal).is_err()); + + let link = archive_with_header_change(&archive, 0, |header| header[156] = b'2'); + assert!(preview_archive_bytes(&link).is_err()); + let special = archive_with_header_change(&archive, 0, |header| header[156] = b'3'); + assert!(preview_archive_bytes(&special).is_err()); + + let duplicate = archive_with_header_change(&archive, 1, |header| { + header[..100].fill(0); + header[..11].copy_from_slice(b"server.conf"); + }); + assert!(preview_archive_bytes(&duplicate).is_err()); + + let oversized = archive_with_header_change(&archive, 1, |header| { + header[124..136].fill(b'0'); + header[124..136].copy_from_slice(b"00050000000\0"); + }); + assert!(preview_archive_bytes(&oversized).is_err()); + assert!(preview_archive_bytes(&archive[..archive.len() - 512]).is_err()); + + let symlink = fixture.root.path().join("wireguard-admin-symlink.tar"); + std::os::unix::fs::symlink(&path, &symlink).expect("archive symlink"); + assert!(preview_archive(&symlink).is_err()); +} + +#[test] +fn backup_and_installed_secret_bearing_files_are_private_and_debug_is_redacted() { + let fixture = fixture(true); + let mut app = open(&fixture); + app.prepare_initial_vault(VaultBundle::create("fixture-only password").expect("vault")) + .expect("stage vault"); + let backup = app.create_manual_backup().expect("backup"); + app.commit().expect("commit"); + + let archive_mode = fs::metadata(&backup) + .expect("archive metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(archive_mode, 0o600); + let saved = fixture.store.read_saved().expect("saved bytes"); + assert_eq!( + fs::metadata(fixture.settings.data_directory.join("vault.toml")) + .expect("vault metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(&fixture.settings.data_directory) + .expect("data directory metadata") + .permissions() + .mode() + & 0o777, + 0o700 + ); + + let debug = format!("{saved:?} {:?}", preview_archive(&backup).expect("preview")); + assert!(!debug.contains("fixture-server-secret")); + assert!(!debug.contains(&fixture.secret.to_base64())); + assert!(!debug.contains("fixture-only password")); +} + +#[test] +fn path_aliases_and_symlinked_destinations_fail_closed() { + let fixture = fixture(true); + let server = fixture.settings.server_config_path.clone(); + let target = fixture.root.path().join("target.conf"); + fs::rename(&server, &target).expect("move synthetic server fixture"); + std::os::unix::fs::symlink(&target, &server).expect("server symlink"); + assert!(fixture.store.read_saved().is_err()); + + let mut aliased_settings = SettingsFile::parse_toml(SETTINGS).expect("settings parse"); + aliased_settings.server_config_path = PathBuf::from("wireguard-admin.toml"); + let aliased = aliased_settings + .validate() + .expect("settings validate") + .resolve_paths(fixture.root.path()); + assert!(SavedStore::new(fixture.root.path(), aliased).is_err()); +} + +#[test] +fn backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes() { + let primary = fixture(true); + let mut app = open(&primary); + let original = primary.store.read_saved().expect("saved bytes before"); + app.rename_client(primary.key, "draft alias") + .expect("stage rename"); + + let archive_directory = primary.settings.archives_directory.clone(); + fs::create_dir_all(archive_directory.parent().expect("archive parent")) + .expect("create archive parent"); + fs::write(&archive_directory, b"block backup creation").expect("block archive creation"); + assert!(app.commit().is_err()); + assert_eq!( + primary + .store + .read_saved() + .expect("saved bytes unchanged") + .server_config, + original.server_config + ); + assert_eq!( + app.states() + .draft() + .get(&primary.key) + .expect("draft remains") + .alias, + "draft alias" + ); + assert_eq!( + app.states() + .saved() + .get(&primary.key) + .expect("saved remains") + .alias, + "fixture client" + ); + + let second = fixture(false); + let mut restore_app = open(&second); + let selected = restore_app + .create_manual_backup() + .expect("selected restore archive"); + let preview = restore_app + .preview_restore(archive_name(&selected)) + .expect("preview archive"); + restore_app + .add_client( + Client::new( + second.key, + "draft survives failed restore", + ClientAddresses { + ipv4: "10.20.0.4".parse().expect("IPv4"), + ipv6: Some("fd20::4".parse().expect("IPv6")), + }, + ) + .expect("draft client"), + None, + ) + .expect("stage draft"); + let before_restore = second.store.read_saved().expect("saved before restore"); + fs::remove_dir_all(&second.settings.archives_directory).expect("remove archives"); + fs::write( + &second.settings.archives_directory, + b"block precautionary backup", + ) + .expect("block pre-restore backup"); + assert!(restore_app.confirm_restore(preview).is_err()); + assert_eq!( + second + .store + .read_saved() + .expect("saved unchanged") + .server_config, + before_restore.server_config + ); + assert_eq!( + restore_app + .states() + .draft() + .get(&second.key) + .expect("draft survives") + .alias, + "draft survives failed restore" + ); +} + +#[test] +fn deletion_uses_public_key_and_removes_only_that_vault_record() { + let fixture = fixture(true); + let mut app = open(&fixture); + let mut vault = VaultBundle::create("fixture password").expect("vault"); + vault + .encrypt_record(fixture.key, &fixture.secret) + .expect("selected record"); + let retained_secret = WireGuardPrivateKey::generate().expect("file-only key"); + let retained_key = retained_secret.public_key(); + vault + .encrypt_record(retained_key, &retained_secret) + .expect("unrelated file-only record"); + app.prepare_initial_vault(vault).expect("stage vault"); + app.commit().expect("install vault"); + + assert!( + app.delete_client(fixture.key) + .expect("delete selected public key") + ); + app.commit().expect("commit deletion"); + let saved = fixture.store.read_saved().expect("saved bytes"); + let bundle = VaultBundle::from_toml( + std::str::from_utf8(saved.vault.as_deref().expect("vault remains installed")) + .expect("vault UTF-8"), + ) + .expect("vault parses"); + assert!(!bundle.contains_record(fixture.key)); + assert!(bundle.contains_record(retained_key)); + assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); +} diff --git a/tests/vault_import.rs b/tests/vault_import.rs index 4220509..be20af4 100644 --- a/tests/vault_import.rs +++ b/tests/vault_import.rs @@ -1,4 +1,5 @@ use std::fs; +use std::os::unix::fs::PermissionsExt; use std::path::Path; use wireguard_admin::config::{ResolvedSettings, SettingsFile}; @@ -32,6 +33,10 @@ restart_timeout_seconds = 30 .resolve_paths(Path::new("/synthetic/app")) } +fn make_private(path: &Path) { + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); +} + fn server_and_client() -> (ConfigDocument, WireGuardPrivateKey, PublicKey) { let server_key = WireGuardPrivateKey::generate().unwrap(); let client_key = WireGuardPrivateKey::generate().unwrap(); @@ -73,15 +78,21 @@ fn initial_setup_encrypts_valid_files_and_leaves_sources_and_server_unchanged() let (server, client, identity) = server_and_client(); let original_server = server.render(); let source_bytes = client_file(&client, &server); - fs::write(directory.path().join("laptop.conf"), &source_bytes).unwrap(); + let laptop = directory.path().join("laptop.conf"); + fs::write(&laptop, &source_bytes).unwrap(); + make_private(&laptop); fs::write( directory.path().join("broken.conf"), b"not a WireGuard file", ) .unwrap(); - fs::write(directory.path().join("ignored.txt"), &source_bytes).unwrap(); + let ignored = directory.path().join("ignored.txt"); + fs::write(&ignored, &source_bytes).unwrap(); + make_private(&ignored); fs::create_dir(directory.path().join("nested")).unwrap(); - fs::write(directory.path().join("nested/server.conf"), &source_bytes).unwrap(); + let nested = directory.path().join("nested/server.conf"); + fs::write(&nested, &source_bytes).unwrap(); + make_private(&nested); let prepared = prepare_initial_setup(&settings(directory.path()), &server, "setup password").unwrap(); @@ -200,11 +211,9 @@ fn explicit_import_uses_existing_identity_and_rejects_duplicate_without_replacem prepare_initial_setup(&settings(initial_source.path()), &server, "password").unwrap(); let original_recipient = initial.bundle.recipient().to_owned(); let mut bundle = initial.bundle; - fs::write( - directory.path().join("client.conf"), - client_file(&client, &server), - ) - .unwrap(); + let client_path = directory.path().join("client.conf"); + fs::write(&client_path, client_file(&client, &server)).unwrap(); + make_private(&client_path); let report = import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap(); assert_eq!(bundle.recipient(), original_recipient);