Skip to content

Commit 61a56fb

Browse files
UnbreakableMJclaude
andcommitted
feat(vault): M4 write-path increment (remove + generate) + first green CI
## M4 write-path increment - `vault generate` — pure-local password generator (`vault-core::generate`): `GenerateOptions` (length + per-class toggles) → `Zeroizing<String>`, OS `getrandom` with 64-bit rejection sampling (no modulo bias), one-per-enabled- class seed then Fisher–Yates shuffle. CLI: `vault generate [--length N] [--symbols] [--no-lowercase] [--no-uppercase] [--no-digits] [--json]`. - `vault remove` — soft-delete via `DELETE /api/ciphers/{id}`, dropped from the in-memory cache. Selector matches `Cipher.id` exactly first, else a case-insensitive decrypted-name match; multiple name matches refuse with `IpcError::AmbiguousItem { name, ids }` (CLI exit 10). Non-TTY stdin requires `--force`. New `Response::Removed { id, name }`; `vault-agent::Vault` now owns the authenticated `BitwardenClient` (retiring the dead `access_token` field). (add / edit remain pending for the rest of M4.) ## First green CI (M0–M3 had been red on every push) Four jobs were failing independently of behaviour; all four now pass locally against `cargo {fmt,clippy -D warnings,test} --workspace`, `cargo deny`, and the `--version` gate: - clippy: resolved workspace-wide pedantic + nursery + restriction debt that had never been seen (clippy never completed locally). `# Errors`/`# Panics` docs, `const fn`, `#[must_use]`, let-chains, `map_or`/`sort_by_key`, derived `Default`, `Send`/`Sync` bounds on the transport generics, and justified `#[allow]`s on the infallible HMAC/RNG `expect`s and the civil-calendar casts. - `vault --version`: now emits the §13.2 block via `long_version` (clap's `after_help` only shows on `--help`). Mirrored in `vault-agent`. - rustfmt: `cargo fmt --all`. Policy decisions (called out, not slipped in): - deny.toml: allow `CDLA-Permissive-2.0` — webpki-roots ≥ 1.0 ships Mozilla's CA bundle under it; a permissive *data* licence, GPL-compatible, no copyleft on the linking program. - The eight `vault-*` crates are `publish = false` (internal app crates, never released to crates.io) + `allow-wildcard-paths = true`, so intra-workspace `path` deps stop tripping the wildcard ban. - clippy.toml: `unwrap`/`expect`/`panic` permitted in tests only. Note: `.cargo/config.toml` (local Arch `-flto=auto` workaround) is deliberately left untracked — its `force`d CFLAGS would break the macOS CI runner. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4017860 commit 61a56fb

39 files changed

Lines changed: 1093 additions & 132 deletions

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44
/target/
55
**/*.rs.bk
66
*.pdb
7+
/rust_out
8+
9+
# Local-only cargo config. On Arch, /etc/makepkg.conf leaks `-flto=auto` into
10+
# CFLAGS, which breaks ring's C build under ld.lld; .cargo/config.toml overrides
11+
# CFLAGS to fix it. Kept untracked — its force'd flags (e.g. -fcf-protection)
12+
# would break non-Arch CI runners (notably macOS / Apple clang).
13+
/.cargo/
714

815
# Editor / IDE
916
.idea/

CHANGELOG.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,58 @@ range may break in any release.
88

99
## [Unreleased]
1010

11+
### Fixed
12+
13+
- **CI is green for the first time (M0–M3 had been red on every push).** Four
14+
jobs were failing independently of the code's behaviour:
15+
- **`clippy -D warnings`** — the workspace lints enable `clippy::pedantic` +
16+
`clippy::nursery` and deny `unwrap`/`expect`/`panic`, but clippy had never
17+
run to completion locally (a stale-artifact issue), so the debt was never
18+
seen. Resolved across every crate: `# Errors` / `# Panics` doc sections,
19+
`const fn`, `#[must_use]`, let-chains for collapsible `if`s, `map_or`,
20+
`sort_by_key`, derived `Default`, `Send`/`Sync` bounds on the transport
21+
generics (`future_not_send`), and justified `#[allow]`s on the
22+
infallible-HMAC/RNG `expect`s and the civil-calendar casts in
23+
`vault-agent::unlock`.
24+
- **`vault --version`** now actually emits the §13.2 attribution block. clap
25+
only surfaces `after_help` on `--help`, so the block was missing from
26+
`--version`; it now rides in `long_version` (mirrored in `vault-agent`).
27+
- **rustfmt**`cargo fmt --all` applied.
28+
- **cargo-deny** — two policy decisions, called out explicitly: (1) allow
29+
`CDLA-Permissive-2.0` (webpki-roots ≥ 1.0 ships Mozilla's CA bundle under
30+
it — a permissive *data* licence, GPL-compatible, no copyleft on the
31+
linking program); (2) mark the eight `vault-*` crates `publish = false` and
32+
set `allow-wildcard-paths = true` so intra-workspace `path` deps stop
33+
tripping the wildcard ban. A `clippy.toml` permits `unwrap`/`expect`/`panic`
34+
in tests only.
35+
1136
### Added
1237

38+
- **M4 (slice 2) — `vault remove`.** Soft-deletes a cipher via
39+
`DELETE /api/ciphers/{id}` and drops it from the in-memory cache. CLI:
40+
`vault remove <selector> [-f|--force] [--json]`. The selector matches
41+
`Cipher.id` exactly first, then falls back to a case-insensitive
42+
decrypted-name match; if a name resolves to more than one cipher the agent
43+
refuses with `AmbiguousItem` (CLI exit 10) and prints the matching ids so
44+
the caller can retry with the explicit UUID. Interactive callers must
45+
re-type the selector to confirm; non-TTY stdin requires `--force`.
46+
`vault-agent::Vault` now owns the authenticated `BitwardenClient`
47+
(replacing the dead-code `access_token` field) so future M4 verbs reuse
48+
one session. New `IpcError::AmbiguousItem { name, ids }` variant; new
49+
`Response::Removed { id, name }`. Three new tests:
50+
`resolve_cipher_matches_by_id_then_name`,
51+
`resolve_cipher_rejects_ambiguous_name` (agent), and an `#[ignore]`d
52+
wiremock test `delete_cipher_sends_authorized_delete` (api) that asserts
53+
the `Bearer` header and surfaces 404 as `ServerStatus`.
54+
- **M4 (slice 1) — `vault generate`.** Pure-local password generator with no
55+
agent or server interaction. `vault-core::generate::generate_password`
56+
takes a `GenerateOptions` (length + per-class toggles for lowercase,
57+
uppercase, digits, symbols) and returns a `Zeroizing<String>`. Sampling
58+
uses OS `getrandom` with 64-bit rejection sampling to avoid modulo bias;
59+
output is seeded with one character from each enabled class then
60+
Fisher–Yates shuffled. CLI verb `vault generate [--length N] [--symbols]
61+
[--no-lowercase] [--no-uppercase] [--no-digits] [--json]`. 8 integration
62+
tests in `crates/vault-core/tests/generate.rs`.
1363
- M0 scaffolding: Cargo workspace, eight member crates (`vault-core`,
1464
`vault-api`, `vault-store`, `vault-agent`, `vault-ipc`, `vault-cli`,
1565
`vault-tui`, `vault-theme`).

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ members = [
1515

1616
[workspace.package]
1717
version = "0.0.1"
18+
# Internal application crates — distributed as the `vault` / `vault-agent`
19+
# binaries, never published to crates.io as libraries. Keeps cargo-deny's
20+
# wildcard check happy with intra-workspace `path` deps (allow-wildcard-paths).
21+
publish = false
1822
edition = "2024"
1923
rust-version = "1.95"
2024
license = "GPL-3.0-or-later"

clippy.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
# Clippy configuration for the Vault workspace.
3+
#
4+
# The workspace lints (`[workspace.lints]` in Cargo.toml) deny `unwrap_used`,
5+
# `expect_used`, and `panic` so production code surfaces every failure through
6+
# the typed error path. Test code is exempt: an `unwrap()`/`expect()`/`panic!`
7+
# in a `#[test]` is the idiomatic way to assert, and a panic there fails the
8+
# test rather than a user's session.
9+
allow-unwrap-in-tests = true
10+
allow-expect-in-tests = true
11+
allow-panic-in-tests = true

crates/vault-agent/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
name = "vault-agent"
55
description = "Vault long-lived daemon — master-key custody, UDS server"
66
version.workspace = true
7+
publish.workspace = true
78
edition.workspace = true
89
rust-version.workspace = true
910
license.workspace = true

crates/vault-agent/src/main.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,21 @@ Maintained by Mohamed Hammad <Mohamed.Hammad@SpacecraftSoftware.org>
2929
Copyright (C) 2026 Mohamed Hammad & Spacecraft Software | License: GPL-3.0-or-later
3030
https://Vault.SpacecraftSoftware.org/";
3131

32+
/// `--version` payload — mirrors `vault`'s: clap shows `after_help` only on
33+
/// `--help`, so the §13.2 attribution block rides in `long_version`.
34+
const LONG_VERSION: &str = concat!(
35+
env!("CARGO_PKG_VERSION"),
36+
"\n",
37+
"Maintained by Mohamed Hammad <Mohamed.Hammad@SpacecraftSoftware.org>\n",
38+
"Copyright (C) 2026 Mohamed Hammad & Spacecraft Software | License: GPL-3.0-or-later\n",
39+
"https://Vault.SpacecraftSoftware.org/",
40+
);
41+
3242
#[derive(Parser, Debug)]
3343
#[command(
3444
name = "vault-agent",
3545
version = env!("CARGO_PKG_VERSION"),
46+
long_version = LONG_VERSION,
3647
about = "Vault agent — holds the unwrapped user key behind a Unix socket",
3748
after_help = ATTRIBUTION,
3849
after_long_help = ATTRIBUTION,

crates/vault-agent/src/server.rs

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,7 @@ async fn handle_conn(stream: UnixStream, state: Arc<Mutex<AgentState>>) {
7171

7272
async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
7373
match req {
74-
Request::Ping => {
75-
let s = state.lock().await;
76-
Response::Status(s.status_snapshot())
77-
}
78-
Request::Status => {
74+
Request::Ping | Request::Status => {
7975
let s = state.lock().await;
8076
Response::Status(s.status_snapshot())
8177
}
@@ -84,10 +80,10 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
8480
email,
8581
password,
8682
} => {
83+
// Wrap the password so it is zeroised on drop no matter how
84+
// perform_unlock fares; deref coercion hands it to the API as &[u8].
85+
let password = zeroize::Zeroizing::new(password);
8786
let unlock_res = perform_unlock(&server, &email, &password).await;
88-
// Wipe password no matter how perform_unlock fared.
89-
let mut password = password;
90-
password.iter_mut().for_each(|b| *b = 0);
9187
match unlock_res {
9288
Ok(vault) => {
9389
let mut s = state.lock().await;
@@ -108,16 +104,18 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
108104
// M3 keeps Sync minimal: the agent already pulled /sync during
109105
// unlock. A standalone re-sync lands in M4 when the cache reload
110106
// path is split out of the unlock flow.
111-
let s = state.lock().await;
112-
if !s.is_unlocked() {
113-
return Response::Error(IpcError::Locked);
107+
let unlocked = state.lock().await.is_unlocked();
108+
if unlocked {
109+
Response::Ok
110+
} else {
111+
Response::Error(IpcError::Locked)
114112
}
115-
Response::Ok
116113
}
117114
Request::List => {
118115
let mut s = state.lock().await;
119116
let res = s.list_entries();
120117
s.touch();
118+
drop(s);
121119
match res {
122120
Ok(items) => Response::List(items),
123121
Err(e) => Response::Error(e),
@@ -128,11 +126,25 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
128126
let f = field.unwrap_or_default();
129127
let res = s.get_item(&name, f);
130128
s.touch();
129+
drop(s);
131130
match res {
132131
Ok(item) => Response::Item(item),
133132
Err(e) => Response::Error(e),
134133
}
135134
}
135+
Request::Remove { selector } => {
136+
// Hold the agent mutex across the network call. Vault is
137+
// single-user / single-agent, so request concurrency is low and
138+
// a coarse lock keeps the cache + server in lock-step.
139+
let mut s = state.lock().await;
140+
let res = s.remove_cipher(&selector).await;
141+
s.touch();
142+
drop(s);
143+
match res {
144+
Ok(removed) => Response::Removed(removed),
145+
Err(e) => Response::Error(e),
146+
}
147+
}
136148
Request::Quit => {
137149
let mut s = state.lock().await;
138150
s.lock();

0 commit comments

Comments
 (0)