chore(salvage): snapshot org-vault tail uncommitted work before worktree cleanup

org_audit.rs (B8 verified-signer test) + the two uncommitted org.rs diffs
(item-CRUD B9-B13, status/audit B8) from the wf_22020aea first-run worktrees.
All superseded by v0.8.0 main; also committed on the -r2 branches. Kept so
nothing is lost when the stale worktrees are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5JvzEse4xUxLZKhofyeCD
This commit is contained in:
adlee-was-taken
2026-06-20 16:58:28 -04:00
parent f27dc72e96
commit 3774047298
4 changed files with 1605 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
# Salvage — org-vault tail worktrees (2026-06-20)
Snapshot taken before cleaning up stale worktrees ahead of the v0.8.1 parity lift.
Everything here is **superseded by what shipped in v0.8.0** (`50b5c01`) and is kept
only so nothing is irrecoverably lost when the source worktrees are removed.
## Provenance
The v0.8.0 org-vault build had a first run (`wf_22020aea-*`, worktrees under
`.claude/worktrees/`) that left work **uncommitted**, and a second run
(`wf_e65cb9c3-*`, branches `feature/org-vault-tail-{itemcrud,statusaudit}-r2`)
that **committed** the same work. Main ultimately landed equivalent functionality
through the canonical v0.8.0 merge, leaving the `-r2` branches unmerged.
| File | Source | What it is | Status in main |
|---|---|---|---|
| `org_audit.f3e-2.rs` | untracked `tests/org_audit.rs` in `wf_22020aea-f3e-2` | B8 integration test: verified-signer attribution + non-member rejection against a real signed repo | **Superseded**`org_lifecycle.rs` + `org_init_signing.rs` cover verified-signer attribution / non-member rejection; `org_lifecycle.rs::audit_format_json_is_valid_and_has_actions` covers the `org audit` command. Also committed (slightly older variant) on `feature/org-vault-tail-statusaudit-r2`. |
| `f3e-1-org.rs.uncommitted.patch` | uncommitted diff in `wf_22020aea-f3e-1` | +884 lines: org item CRUD handlers (B9B13) | **Shipped** — item CRUD merged in v0.8.0; also committed on `feature/org-vault-tail-itemcrud-r2` (`a3f0777`). |
| `f3e-2-statusaudit.uncommitted.patch` | uncommitted diff in `wf_22020aea-f3e-2` | +476 lines: status + audit handlers (B8) | **Shipped** — status/audit merged in v0.8.0; also committed on `feature/org-vault-tail-statusaudit-r2` (`57fe10e`, `b6d6db0`). |
## Why it's safe to remove the source worktrees
- The committed copies live on the `-r2` branches (preserved) and the canonical
functionality is in `main`.
- These three artifacts pin the only *uncommitted* bytes that existed nowhere else.
If a future audit wants the dedicated `org_audit.rs` test back as a distinct
integration file, restore it from `org_audit.f3e-2.rs` and re-verify it compiles
against the current `commands::org` surface before adding it to `tests/`.

View File

@@ -0,0 +1,899 @@
diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs
index b0f1bf8..3b40610 100644
--- a/crates/relicario-cli/src/commands/org.rs
+++ b/crates/relicario-cli/src/commands/org.rs
@@ -329,6 +329,503 @@ fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result<MemberId> {
}
}
+// ═══════════ Item CRUD (B9-B13) ═══════════
+//
+// `org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge` for items
+// stored under `items/<collection-slug>/<id>.enc`. Each public `run_org_*`
+// wrapper opens the org vault, resolves the calling member by device key, then
+// delegates the actual work to an inner `*_with` fn that takes an already-opened
+// `UnlockedOrgVault` + the caller's `OrgMember`. The split keeps the CRUD logic
+// testable in-process without device-fingerprint plumbing.
+//
+// Supported builders for `org add`/`org edit`: Login, SecureNote, Identity.
+// Card / Key / Document / Totp parity is deferred (those read secrets via
+// rpassword/stdin); see the follow-up note in the plan after B13.
+
+use relicario_core::{Item, ItemCore};
+
+use crate::org_session::UnlockedOrgVault;
+
+/// Item kinds `org add` supports without interactive prompts. This is the
+/// handler-side enum (no clap attributes, no `collection`/`tags` — those are
+/// threaded separately by B14's dispatch). Deliberately distinct from any
+/// clap-side enum so the handler stays unaware of clap.
+pub enum OrgAddKind {
+ Login {
+ title: String,
+ username: Option<String>,
+ url: Option<String>,
+ password: Option<String>,
+ },
+ SecureNote {
+ title: String,
+ body: String,
+ },
+ Identity {
+ title: String,
+ full_name: Option<String>,
+ email: Option<String>,
+ phone: Option<String>,
+ },
+}
+
+/// Build a typed `Item` from a non-interactive `OrgAddKind` plus tags.
+fn build_org_item(kind: OrgAddKind, tags: Vec<String>) -> Result<Item> {
+ use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore};
+ use zeroize::Zeroizing;
+
+ let mut item = match kind {
+ OrgAddKind::Login { title, username, url, password } => {
+ let parsed_url = match url {
+ Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
+ None => None,
+ };
+ let password = password.map(Zeroizing::new);
+ Item::new(title, ItemCore::Login(LoginCore {
+ username,
+ password,
+ url: parsed_url,
+ totp: None,
+ }))
+ }
+ OrgAddKind::SecureNote { title, body } => {
+ Item::new(title, ItemCore::SecureNote(SecureNoteCore {
+ body: Zeroizing::new(body),
+ }))
+ }
+ OrgAddKind::Identity { title, full_name, email, phone } => {
+ Item::new(title, ItemCore::Identity(IdentityCore {
+ full_name,
+ address: None,
+ phone,
+ email,
+ date_of_birth: None,
+ }))
+ }
+ };
+ item.tags = tags;
+ Ok(item)
+}
+
+/// Insert-or-replace an `OrgManifestEntry` (keyed by item id), mirroring the
+/// personal-vault `Manifest::upsert`. The collection slug is stored in plaintext
+/// inside the encrypted manifest.
+fn upsert_org_entry(
+ manifest: &mut relicario_core::OrgManifest,
+ item: &Item,
+ collection: &str,
+) {
+ let entry = relicario_core::OrgManifestEntry {
+ id: item.id.clone(),
+ r#type: item.r#type,
+ title: item.title.clone(),
+ tags: item.tags.clone(),
+ modified: item.modified,
+ trashed_at: item.trashed_at,
+ collection: collection.to_string(),
+ };
+ if let Some(slot) = manifest.entries.iter_mut().find(|e| e.id == item.id) {
+ *slot = entry;
+ } else {
+ manifest.entries.push(entry);
+ }
+}
+
+/// Resolve a query (exact id, else case-insensitive title substring) against an
+/// already-grant-filtered manifest.
+fn resolve_org_query<'a>(
+ manifest: &'a relicario_core::OrgManifest,
+ query: &str,
+) -> Result<&'a relicario_core::OrgManifestEntry> {
+ if let Some(entry) = manifest.entries.iter().find(|e| e.id.as_str() == query) {
+ return Ok(entry);
+ }
+ let needle = query.to_lowercase();
+ let hits: Vec<&relicario_core::OrgManifestEntry> = manifest.entries.iter()
+ .filter(|e| e.title.to_lowercase().contains(&needle))
+ .collect();
+ match hits.len() {
+ 0 => anyhow::bail!("no item matches `{query}`"),
+ 1 => Ok(hits[0]),
+ _ => {
+ let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect();
+ anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", "))
+ }
+ }
+}
+
+// ── add ──────────────────────────────────────────────────────────────────────
+
+/// `org add`: create a typed item in a collection the caller holds a grant for.
+pub fn run_org_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
+ let vault = crate::org_session::open_org_vault(Some(dir))?;
+ let caller = vault.current_member()?;
+ run_org_add_with(&vault, &caller, collection, kind, tags)
+}
+
+fn run_org_add_with(
+ vault: &UnlockedOrgVault,
+ caller: &OrgMember,
+ collection: &str,
+ kind: OrgAddKind,
+ tags: Vec<String>,
+) -> Result<()> {
+ // The slug must exist in collections.json…
+ let collections = vault.load_collections()?;
+ if !collections.contains_slug(collection) {
+ anyhow::bail!("collection `{collection}` does not exist — create it with `relicario org create-collection`");
+ }
+ // …and the caller must hold a grant for it.
+ UnlockedOrgVault::ensure_grant(caller, collection)?;
+
+ let item = build_org_item(kind, tags)?;
+ let item_rel = vault.save_item(collection, &item)?;
+
+ // Upsert the manifest entry, then re-encrypt the manifest.
+ let mut manifest = vault.load_manifest()?;
+ upsert_org_entry(&mut manifest, &item, collection);
+ vault.save_manifest(&manifest)?;
+
+ let subject = format!(
+ "org add: {} ({})",
+ crate::helpers::sanitize_for_commit(&item.title),
+ item.id.as_str()
+ );
+ let commit_msg = format!(
+ "{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-create\nRelicario-Collection: {}\nRelicario-Item: {}",
+ caller.display_name,
+ caller.member_id.as_str(),
+ collection,
+ item.id.as_str()
+ );
+ crate::org_session::org_git_run(
+ &vault.root,
+ &["add", &item_rel, "manifest.enc"],
+ "org add: git add",
+ )?;
+ crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?;
+
+ println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection);
+ Ok(())
+}
+
+// ── list ─────────────────────────────────────────────────────────────────────
+
+/// `org list`: list items in the caller's granted collections (filtered by
+/// `OrgManifest::filter_for_member`). `trashed` toggles between live + trashed.
+pub fn run_org_list(dir: &Path, trashed: bool) -> Result<()> {
+ let vault = crate::org_session::open_org_vault(Some(dir))?;
+ let caller = vault.current_member()?;
+ run_org_list_with(&vault, &caller, trashed)
+}
+
+fn run_org_list_with(vault: &UnlockedOrgVault, caller: &OrgMember, trashed: bool) -> Result<()> {
+ let manifest = vault.load_manifest()?;
+
+ // filter_for_member restricts to the caller's granted collections.
+ let visible = manifest.filter_for_member(caller);
+
+ let mut entries: Vec<_> = visible.entries.iter()
+ .filter(|e| if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() })
+ .collect();
+ entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
+
+ if entries.is_empty() {
+ eprintln!("(no items match)");
+ return Ok(());
+ }
+
+ println!("{:<16} {:<14} {:<12} TITLE", "ID", "TYPE", "COLLECTION");
+ for e in entries {
+ println!(
+ "{:<16} {:<14} {:<12} {}",
+ e.id.as_str(),
+ format!("{:?}", e.r#type),
+ e.collection,
+ e.title
+ );
+ }
+ Ok(())
+}
+
+// ── get ──────────────────────────────────────────────────────────────────────
+
+/// `org get`: print one item, masking secrets unless `show`. The query resolves
+/// over the caller-visible manifest only; the resolved collection's grant is
+/// re-checked (defense in depth).
+pub fn run_org_get(dir: &Path, query: &str, show: bool) -> Result<()> {
+ let vault = crate::org_session::open_org_vault(Some(dir))?;
+ let caller = vault.current_member()?;
+ run_org_get_with(&vault, &caller, query, show)
+}
+
+fn run_org_get_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str, show: bool) -> Result<()> {
+ use zeroize::Zeroizing;
+
+ let manifest = vault.load_manifest()?;
+ let visible = manifest.filter_for_member(caller);
+
+ let entry = resolve_org_query(&visible, query)?;
+ UnlockedOrgVault::ensure_grant(caller, &entry.collection)?;
+
+ let item = vault.load_item(&entry.collection, &entry.id)?;
+
+ println!("ID: {}", item.id.as_str());
+ println!("Title: {}", item.title);
+ println!("Type: {:?}", item.r#type);
+ println!("Collection: {}", entry.collection);
+ if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); }
+ println!("Modified: {}", crate::helpers::iso8601(item.modified));
+ if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); }
+ println!();
+
+ let primary_secret: Option<Zeroizing<String>> = match &item.core {
+ ItemCore::Login(l) => {
+ if let Some(u) = &l.username { println!("Username: {u}"); }
+ if let Some(u) = &l.url { println!("URL: {u}"); }
+ l.password.clone()
+ }
+ ItemCore::SecureNote(n) => {
+ if show { println!("Body:\n{}", n.body.as_str()); }
+ else { println!("Body: ********"); }
+ None
+ }
+ ItemCore::Identity(i) => {
+ if let Some(v) = &i.full_name { println!("Name: {v}"); }
+ if let Some(v) = &i.email { println!("Email: {v}"); }
+ if let Some(v) = &i.phone { println!("Phone: {v}"); }
+ None
+ }
+ ItemCore::Card(c) => {
+ if let Some(h) = &c.holder { println!("Holder: {h}"); }
+ c.number.clone()
+ }
+ ItemCore::Key(k) => {
+ if let Some(l) = &k.label { println!("Label: {l}"); }
+ Some(k.key_material.clone())
+ }
+ ItemCore::Document(d) => {
+ println!("Filename: {}", d.filename);
+ println!("MIME: {}", d.mime_type);
+ None
+ }
+ ItemCore::Totp(t) => {
+ if let Some(i) = &t.issuer { println!("Issuer: {i}"); }
+ if let Some(l) = &t.label { println!("Label: {l}"); }
+ None
+ }
+ };
+
+ if let Some(secret) = primary_secret {
+ if show {
+ println!("Secret: {}", secret.as_str());
+ } else {
+ println!("Secret: ******** (use --show to reveal)");
+ }
+ }
+ Ok(())
+}
+
+// ── edit ─────────────────────────────────────────────────────────────────────
+
+/// `org edit`: flag-driven field update for login / secure-note / identity.
+/// Blank flags keep their current value. The blob is re-saved in place, the
+/// manifest upserted, and the commit carries `Relicario-Action: item-update`.
+#[allow(clippy::too_many_arguments)]
+pub fn run_org_edit(
+ dir: &Path,
+ query: &str,
+ title: Option<String>,
+ username: Option<String>,
+ url: Option<String>,
+ password: Option<String>,
+ body: Option<String>,
+ email: Option<String>,
+ phone: Option<String>,
+ full_name: Option<String>,
+) -> Result<()> {
+ let vault = crate::org_session::open_org_vault(Some(dir))?;
+ let caller = vault.current_member()?;
+ run_org_edit_with(
+ &vault, &caller, query, title, username, url, password, body, email, phone, full_name,
+ )
+}
+
+#[allow(clippy::too_many_arguments)]
+fn run_org_edit_with(
+ vault: &UnlockedOrgVault,
+ caller: &OrgMember,
+ query: &str,
+ title: Option<String>,
+ username: Option<String>,
+ url: Option<String>,
+ password: Option<String>,
+ body: Option<String>,
+ email: Option<String>,
+ phone: Option<String>,
+ full_name: Option<String>,
+) -> Result<()> {
+ use relicario_core::now_unix;
+ use zeroize::Zeroizing;
+
+ let manifest = vault.load_manifest()?;
+ let visible = manifest.filter_for_member(caller);
+ let entry = resolve_org_query(&visible, query)?;
+ let collection = entry.collection.clone();
+ let id = entry.id.clone();
+ UnlockedOrgVault::ensure_grant(caller, &collection)?;
+
+ let mut item = vault.load_item(&collection, &id)?;
+
+ if let Some(t) = title { item.title = t; }
+
+ match &mut item.core {
+ ItemCore::Login(l) => {
+ if let Some(u) = username { l.username = Some(u); }
+ if let Some(u) = url {
+ l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?);
+ }
+ if let Some(p) = password { l.password = Some(Zeroizing::new(p)); }
+ }
+ ItemCore::SecureNote(n) => {
+ if let Some(b) = body { n.body = Zeroizing::new(b); }
+ }
+ ItemCore::Identity(i) => {
+ if let Some(v) = full_name { i.full_name = Some(v); }
+ if let Some(v) = email { i.email = Some(v); }
+ if let Some(v) = phone { i.phone = Some(v); }
+ }
+ _ => anyhow::bail!("org edit currently supports login, secure-note, and identity items"),
+ }
+
+ item.modified = now_unix();
+ let item_rel = vault.save_item(&collection, &item)?;
+
+ let mut manifest = vault.load_manifest()?;
+ upsert_org_entry(&mut manifest, &item, &collection);
+ vault.save_manifest(&manifest)?;
+
+ let subject = format!(
+ "org edit: {} ({})",
+ crate::helpers::sanitize_for_commit(&item.title),
+ item.id.as_str()
+ );
+ let commit_msg = format!(
+ "{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}",
+ caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
+ );
+ crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?;
+ crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?;
+
+ println!("Updated {}", item.id.as_str());
+ Ok(())
+}
+
+// ── trash lifecycle: rm / restore / purge ────────────────────────────────────
+
+/// Resolve a query to (collection, item) with grant enforcement. Shared by the
+/// trash-lifecycle commands.
+fn open_org_item(
+ vault: &UnlockedOrgVault,
+ caller: &OrgMember,
+ query: &str,
+) -> Result<(String, Item)> {
+ let manifest = vault.load_manifest()?;
+ let visible = manifest.filter_for_member(caller);
+ let entry = resolve_org_query(&visible, query)?;
+ let collection = entry.collection.clone();
+ let id = entry.id.clone();
+ UnlockedOrgVault::ensure_grant(caller, &collection)?;
+ let item = vault.load_item(&collection, &id)?;
+ Ok((collection, item))
+}
+
+/// `org rm`: soft-delete (sets `trashed_at`); reversible via `org restore`.
+pub fn run_org_rm(dir: &Path, query: &str) -> Result<()> {
+ let vault = crate::org_session::open_org_vault(Some(dir))?;
+ let caller = vault.current_member()?;
+ run_org_rm_with(&vault, &caller, query)
+}
+
+fn run_org_rm_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str) -> Result<()> {
+ let (collection, mut item) = open_org_item(vault, caller, query)?;
+
+ item.soft_delete();
+ let item_rel = vault.save_item(&collection, &item)?;
+ let mut manifest = vault.load_manifest()?;
+ upsert_org_entry(&mut manifest, &item, &collection);
+ vault.save_manifest(&manifest)?;
+
+ let commit_msg = format!(
+ "org trash: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-delete\nRelicario-Collection: {}\nRelicario-Item: {}",
+ crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(),
+ caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
+ );
+ crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org rm: git add")?;
+ crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org rm: git commit")?;
+ println!("Moved to trash: {}", item.title);
+ Ok(())
+}
+
+/// `org restore`: clear `trashed_at`, bringing the item back into the live list.
+pub fn run_org_restore(dir: &Path, query: &str) -> Result<()> {
+ let vault = crate::org_session::open_org_vault(Some(dir))?;
+ let caller = vault.current_member()?;
+ run_org_restore_with(&vault, &caller, query)
+}
+
+fn run_org_restore_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str) -> Result<()> {
+ let (collection, mut item) = open_org_item(vault, caller, query)?;
+
+ item.restore();
+ let item_rel = vault.save_item(&collection, &item)?;
+ let mut manifest = vault.load_manifest()?;
+ upsert_org_entry(&mut manifest, &item, &collection);
+ vault.save_manifest(&manifest)?;
+
+ let commit_msg = format!(
+ "org restore: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-restore\nRelicario-Collection: {}\nRelicario-Item: {}",
+ crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(),
+ caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
+ );
+ crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org restore: git add")?;
+ crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org restore: git commit")?;
+ println!("Restored: {}", item.title);
+ Ok(())
+}
+
+/// `org purge`: permanently delete the blob (git rm) and drop the manifest entry.
+pub fn run_org_purge(dir: &Path, query: &str) -> Result<()> {
+ let vault = crate::org_session::open_org_vault(Some(dir))?;
+ let caller = vault.current_member()?;
+ run_org_purge_with(&vault, &caller, query)
+}
+
+fn run_org_purge_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str) -> Result<()> {
+ let (collection, item) = open_org_item(vault, caller, query)?;
+ let title = item.title.clone();
+ let id = item.id.clone();
+
+ // Remove the blob from disk, drop the manifest entry, stage with git rm.
+ vault.remove_item(&collection, &id)?;
+ let mut manifest = vault.load_manifest()?;
+ manifest.entries.retain(|e| e.id != id);
+ vault.save_manifest(&manifest)?;
+
+ let item_rel = format!("items/{}/{}.enc", collection, id.as_str());
+ crate::helpers::git_rm(&vault.root, &[item_rel], "org purge: git rm")?;
+ crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?;
+
+ let commit_msg = format!(
+ "org purge: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-purge\nRelicario-Collection: {}\nRelicario-Item: {}",
+ crate::helpers::sanitize_for_commit(&title), id.as_str(),
+ caller.display_name, caller.member_id.as_str(), collection, id.as_str()
+ );
+ crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org purge: git commit")?;
+ println!("Purged: {title}");
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -386,3 +883,390 @@ mod tests {
assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string()));
}
}
+
+// ═══════════ Item CRUD tests (B9-B13) ═══════════
+//
+// `relicario-cli` is a binary-only crate, so integration tests in `tests/`
+// can only drive the compiled binary — and the item subcommands are not wired
+// into `Commands::Org` dispatch yet (that is B14). These in-process unit tests
+// therefore exercise the CRUD logic through the inner `*_with` helpers against a
+// directly-constructed `UnlockedOrgVault` over a real git repo, which needs no
+// device-fingerprint plumbing. The public `run_org_*` wrappers add only the
+// open-vault + resolve-caller preamble, which the `tests/org_*` integration
+// suites in the plan cover once B14 lands the CLI dispatch.
+#[cfg(test)]
+mod crud_tests {
+ use super::*;
+ use relicario_core::{
+ encrypt_org_manifest, CollectionDef, ItemId, MemberId, OrgCollections, OrgManifest,
+ OrgMember, OrgRole,
+ };
+ use std::path::Path;
+ use std::process::Command;
+ use tempfile::TempDir;
+ use zeroize::Zeroizing;
+
+ /// A throwaway org vault: a real (unsigned-commit) git repo with the org
+ /// scaffold written and an `UnlockedOrgVault` holding a known key.
+ struct Fixture {
+ _dir: TempDir,
+ vault: UnlockedOrgVault,
+ }
+
+ fn git(root: &Path, args: &[&str]) {
+ let out = Command::new("git").current_dir(root).args(args).output().unwrap();
+ assert!(out.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
+ }
+
+ impl Fixture {
+ fn new() -> Self {
+ let dir = TempDir::new().unwrap();
+ let root = dir.path().to_path_buf();
+ std::fs::create_dir_all(root.join("items")).unwrap();
+ std::fs::create_dir_all(root.join("keys")).unwrap();
+
+ let org_key = Zeroizing::new([7u8; 32]);
+
+ // Scaffold the non-encrypted control files.
+ std::fs::write(
+ root.join("collections.json"),
+ serde_json::to_string_pretty(&OrgCollections::new()).unwrap(),
+ )
+ .unwrap();
+ // Empty encrypted manifest.
+ let manifest = OrgManifest::new();
+ std::fs::write(
+ root.join("manifest.enc"),
+ encrypt_org_manifest(&manifest, &org_key).unwrap(),
+ )
+ .unwrap();
+
+ // A real git repo, but with signing disabled so commits succeed
+ // without a device key (signature verification is Dev-C's hook).
+ git(&root, &["init", "-q"]);
+ git(&root, &["config", "user.name", "Test"]);
+ git(&root, &["config", "user.email", "test@relicario.test"]);
+ git(&root, &["config", "commit.gpgsign", "false"]);
+ git(&root, &["add", "."]);
+ git(&root, &["commit", "-q", "-m", "scaffold"]);
+
+ let vault = UnlockedOrgVault { root, org_key };
+ Fixture { _dir: dir, vault }
+ }
+
+ /// Add a collection to collections.json and return a member granted it.
+ fn with_collection(&self, slug: &str) -> OrgMember {
+ let mut collections = self.vault.load_collections().unwrap();
+ collections.collections.push(CollectionDef {
+ slug: slug.to_string(),
+ display_name: slug.to_string(),
+ created_by: MemberId::new(),
+ created_at: 0,
+ });
+ self.vault.save_collections(&collections).unwrap();
+ self.member(vec![slug.to_string()])
+ }
+
+ fn member(&self, collections: Vec<String>) -> OrgMember {
+ OrgMember {
+ member_id: MemberId("0123456789abcdef".into()),
+ display_name: "Alice".into(),
+ role: OrgRole::Owner,
+ ed25519_pubkey: "ssh-ed25519 AAAA fake".into(),
+ collections,
+ added_at: 0,
+ added_by: MemberId("0123456789abcdef".into()),
+ }
+ }
+
+ fn head_body(&self) -> String {
+ let out = Command::new("git")
+ .current_dir(&self.vault.root)
+ .args(["log", "-1", "--format=%B"])
+ .output()
+ .unwrap();
+ String::from_utf8_lossy(&out.stdout).to_string()
+ }
+
+ fn manifest_entry_for<'a>(
+ &self,
+ m: &'a OrgManifest,
+ title: &str,
+ ) -> Option<&'a relicario_core::OrgManifestEntry> {
+ m.entries.iter().find(|e| e.title == title)
+ }
+ }
+
+ fn login(title: &str, user: &str, pw: &str) -> OrgAddKind {
+ OrgAddKind::Login {
+ title: title.into(),
+ username: Some(user.into()),
+ url: Some("https://example.com".into()),
+ password: Some(pw.into()),
+ }
+ }
+
+ // ── B10: add ──────────────────────────────────────────────────────────────
+
+ #[test]
+ fn add_writes_collection_scoped_blob_and_manifest_and_trailers() {
+ let f = Fixture::new();
+ let caller = f.with_collection("prod");
+
+ run_org_add_with(&f.vault, &caller, "prod", login("GitHub", "alice", "hunter2"), vec![])
+ .unwrap();
+
+ // Blob lives under items/prod/, not flat items/.
+ let prod_dir = f.vault.root.join("items").join("prod");
+ let blobs: Vec<_> = std::fs::read_dir(&prod_dir).unwrap().collect();
+ assert_eq!(blobs.len(), 1, "expected exactly one blob under items/prod/");
+ assert!(!f.vault.root.join("items").join("GitHub.enc").exists());
+
+ // Manifest entry recorded with the collection.
+ let manifest = f.vault.load_manifest().unwrap();
+ let entry = f.manifest_entry_for(&manifest, "GitHub").expect("manifest entry");
+ assert_eq!(entry.collection, "prod");
+
+ // Commit trailers.
+ let body = f.head_body();
+ assert!(body.contains("Relicario-Action: item-create"), "body: {body}");
+ assert!(body.contains("Relicario-Collection: prod"), "body: {body}");
+ assert!(body.contains(&format!("Relicario-Item: {}", entry.id.as_str())), "body: {body}");
+ assert!(body.contains("Relicario-Actor: Alice 0123456789abcdef"), "body: {body}");
+ }
+
+ #[test]
+ fn add_secure_note_and_identity_round_trip() {
+ let f = Fixture::new();
+ let caller = f.with_collection("prod");
+
+ run_org_add_with(
+ &f.vault,
+ &caller,
+ "prod",
+ OrgAddKind::SecureNote { title: "Notes".into(), body: "secret-body".into() },
+ vec!["tag1".into()],
+ )
+ .unwrap();
+ run_org_add_with(
+ &f.vault,
+ &caller,
+ "prod",
+ OrgAddKind::Identity {
+ title: "Me".into(),
+ full_name: Some("Alice Anderson".into()),
+ email: Some("a@example.com".into()),
+ phone: None,
+ },
+ vec![],
+ )
+ .unwrap();
+
+ let manifest = f.vault.load_manifest().unwrap();
+ assert_eq!(manifest.entries.len(), 2);
+ let note = f.manifest_entry_for(&manifest, "Notes").unwrap();
+ assert_eq!(note.tags, vec!["tag1".to_string()]);
+ let note_item = f.vault.load_item("prod", &note.id).unwrap();
+ match &note_item.core {
+ ItemCore::SecureNote(n) => assert_eq!(n.body.as_str(), "secret-body"),
+ _ => panic!("expected secure note"),
+ }
+ }
+
+ #[test]
+ fn add_rejects_ungranted_collection() {
+ let f = Fixture::new();
+ // Collection exists, but the caller holds no grant for it.
+ let _ = f.with_collection("secret");
+ let caller = f.member(vec![]); // no grants
+
+ let err = run_org_add_with(&f.vault, &caller, "secret", login("X", "u", "p"), vec![])
+ .unwrap_err();
+ let msg = format!("{err:#}");
+ assert!(msg.contains("access denied") || msg.contains("grant"), "msg: {msg}");
+ }
+
+ #[test]
+ fn add_rejects_unknown_collection() {
+ let f = Fixture::new();
+ let caller = f.member(vec!["ghost".into()]); // grant for a slug that doesn't exist
+
+ let err = run_org_add_with(&f.vault, &caller, "ghost", login("X", "u", "p"), vec![])
+ .unwrap_err();
+ let msg = format!("{err:#}");
+ assert!(msg.contains("does not exist") || msg.contains("ghost"), "msg: {msg}");
+ }
+
+ // ── B11: get + list ───────────────────────────────────────────────────────
+
+ #[test]
+ fn list_filters_to_granted_collections() {
+ let f = Fixture::new();
+ // Two collections exist; caller is granted only `prod`.
+ let _ = f.with_collection("prod");
+ let _ = f.with_collection("secret");
+ let prod_caller = f.member(vec!["prod".into()]);
+ let secret_caller = f.member(vec!["secret".into()]);
+
+ run_org_add_with(&f.vault, &prod_caller, "prod", login("InProd", "u", "p"), vec![]).unwrap();
+ run_org_add_with(&f.vault, &secret_caller, "secret", login("InSecret", "u", "p"), vec![])
+ .unwrap();
+
+ // The prod caller's visible manifest excludes the secret entry.
+ let manifest = f.vault.load_manifest().unwrap();
+ let visible = manifest.filter_for_member(&prod_caller);
+ let titles: Vec<&str> = visible.entries.iter().map(|e| e.title.as_str()).collect();
+ assert!(titles.contains(&"InProd"));
+ assert!(!titles.contains(&"InSecret"), "leaked ungranted entry: {titles:?}");
+
+ // run_org_list_with returns Ok and prints only granted entries.
+ run_org_list_with(&f.vault, &prod_caller, false).unwrap();
+ }
+
+ #[test]
+ fn get_resolves_by_id_and_title_substring() {
+ let f = Fixture::new();
+ let caller = f.with_collection("prod");
+ run_org_add_with(&f.vault, &caller, "prod", login("GitHub", "alice", "hunter2"), vec![])
+ .unwrap();
+
+ let manifest = f.vault.load_manifest().unwrap();
+ let id = manifest.entries[0].id.as_str().to_string();
+
+ // exact id, case-insensitive substring, masked default + --show all OK.
+ run_org_get_with(&f.vault, &caller, &id, false).unwrap();
+ run_org_get_with(&f.vault, &caller, "github", false).unwrap();
+ run_org_get_with(&f.vault, &caller, "GitHub", true).unwrap();
+ }
+
+ #[test]
+ fn get_unknown_query_errors() {
+ let f = Fixture::new();
+ let caller = f.with_collection("prod");
+ run_org_add_with(&f.vault, &caller, "prod", login("GitHub", "alice", "hunter2"), vec![])
+ .unwrap();
+ let err = run_org_get_with(&f.vault, &caller, "nope", false).unwrap_err();
+ assert!(format!("{err:#}").contains("no item matches"));
+ }
+
+ #[test]
+ fn resolve_org_query_reports_ambiguity() {
+ let mut manifest = OrgManifest::new();
+ for title in ["Mail Personal", "Mail Work"] {
+ manifest.entries.push(relicario_core::OrgManifestEntry {
+ id: ItemId::new(),
+ r#type: relicario_core::ItemType::Login,
+ title: title.into(),
+ tags: vec![],
+ modified: 0,
+ trashed_at: None,
+ collection: "prod".into(),
+ });
+ }
+ let err = resolve_org_query(&manifest, "mail").unwrap_err();
+ assert!(format!("{err:#}").contains("ambiguous"), "{err:#}");
+ }
+
+ // ── B12: edit ─────────────────────────────────────────────────────────────
+
+ #[test]
+ fn edit_updates_login_field_and_writes_update_trailer() {
+ let f = Fixture::new();
+ let caller = f.with_collection("prod");
+ run_org_add_with(&f.vault, &caller, "prod", login("Mail", "old", "pw"), vec![]).unwrap();
+
+ run_org_edit_with(
+ &f.vault, &caller, "Mail",
+ None, Some("new-user".into()), None, None, None, None, None, None,
+ )
+ .unwrap();
+
+ // The blob now carries the new username.
+ let manifest = f.vault.load_manifest().unwrap();
+ let entry = f.manifest_entry_for(&manifest, "Mail").unwrap();
+ let item = f.vault.load_item("prod", &entry.id).unwrap();
+ match &item.core {
+ ItemCore::Login(l) => assert_eq!(l.username.as_deref(), Some("new-user")),
+ _ => panic!("expected login"),
+ }
+
+ let body = f.head_body();
+ assert!(body.contains("Relicario-Action: item-update"), "body: {body}");
+ assert!(body.contains("Relicario-Collection: prod"), "body: {body}");
+ }
+
+ #[test]
+ fn edit_can_retitle_and_keeps_unset_fields() {
+ let f = Fixture::new();
+ let caller = f.with_collection("prod");
+ run_org_add_with(&f.vault, &caller, "prod", login("Mail", "old", "pw"), vec![]).unwrap();
+
+ run_org_edit_with(
+ &f.vault, &caller, "Mail",
+ Some("Webmail".into()), None, None, None, None, None, None, None,
+ )
+ .unwrap();
+
+ let manifest = f.vault.load_manifest().unwrap();
+ assert!(f.manifest_entry_for(&manifest, "Webmail").is_some());
+ let entry = f.manifest_entry_for(&manifest, "Webmail").unwrap();
+ let item = f.vault.load_item("prod", &entry.id).unwrap();
+ match &item.core {
+ // username untouched (we passed None), password untouched.
+ ItemCore::Login(l) => assert_eq!(l.username.as_deref(), Some("old")),
+ _ => panic!("expected login"),
+ }
+ }
+
+ // ── B13: rm / restore / purge ─────────────────────────────────────────────
+
+ #[test]
+ fn rm_restore_purge_cycle() {
+ let f = Fixture::new();
+ let caller = f.with_collection("prod");
+ run_org_add_with(
+ &f.vault,
+ &caller,
+ "prod",
+ OrgAddKind::SecureNote { title: "Recovery".into(), body: "codes-here".into() },
+ vec![],
+ )
+ .unwrap();
+
+ // rm → trashed_at set, item drops out of the live list, shows in --trashed.
+ run_org_rm_with(&f.vault, &caller, "Recovery").unwrap();
+ let manifest = f.vault.load_manifest().unwrap();
+ let entry = f.manifest_entry_for(&manifest, "Recovery").unwrap();
+ assert!(entry.trashed_at.is_some(), "rm should set trashed_at");
+ assert!(f.head_body().contains("Relicario-Action: item-delete"));
+
+ // restore → trashed_at cleared.
+ run_org_restore_with(&f.vault, &caller, "Recovery").unwrap();
+ let manifest = f.vault.load_manifest().unwrap();
+ let entry = f.manifest_entry_for(&manifest, "Recovery").unwrap();
+ assert!(entry.trashed_at.is_none(), "restore should clear trashed_at");
+ assert!(f.head_body().contains("Relicario-Action: item-restore"));
+
+ // purge → blob gone from disk, manifest entry dropped, purge trailer.
+ run_org_purge_with(&f.vault, &caller, "Recovery").unwrap();
+ let prod_dir = f.vault.root.join("items").join("prod");
+ let count = std::fs::read_dir(&prod_dir).map(|d| d.count()).unwrap_or(0);
+ assert_eq!(count, 0, "blob not purged from items/prod/");
+ let manifest = f.vault.load_manifest().unwrap();
+ assert!(f.manifest_entry_for(&manifest, "Recovery").is_none(), "manifest entry not dropped");
+ assert!(f.head_body().contains("Relicario-Action: item-purge"));
+ }
+
+ #[test]
+ fn rm_enforces_grant_via_visible_manifest() {
+ let f = Fixture::new();
+ // owner adds into prod
+ let owner = f.with_collection("prod");
+ run_org_add_with(&f.vault, &owner, "prod", login("Secret", "u", "p"), vec![]).unwrap();
+
+ // a caller with no grant cannot even resolve the item (filtered out).
+ let outsider = f.member(vec![]);
+ let err = run_org_rm_with(&f.vault, &outsider, "Secret").unwrap_err();
+ assert!(format!("{err:#}").contains("no item matches"), "{err:#}");
+ }
+}

View File

@@ -0,0 +1,521 @@
diff --git a/Cargo.lock b/Cargo.lock
index ffaf13f..5b9a869 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2172,6 +2172,7 @@ dependencies = [
"predicates",
"qrcode",
"rand",
+ "regex",
"relicario-core",
"reqwest",
"rpassword",
diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml
index db05181..928004c 100644
--- a/crates/relicario-cli/Cargo.toml
+++ b/crates/relicario-cli/Cargo.toml
@@ -31,10 +31,11 @@ rqrr = "0.7"
reqwest = { version = "0.12", features = ["blocking", "json"] }
qrcode = { version = "0.14", features = ["svg"] }
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
+regex = "1"
+tempfile = "3"
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
-tempfile = "3"
serde_json = "1"
ed25519-dalek = "2"
diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs
index b0f1bf8..799b14b 100644
--- a/crates/relicario-cli/src/commands/org.rs
+++ b/crates/relicario-cli/src/commands/org.rs
@@ -329,6 +329,285 @@ fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result<MemberId> {
}
}
+// ═══════════ Status / Audit (B8) ═══════════
+
+/// `org status`: print the org's members + collections with no decryption. Reads
+/// the three plaintext metadata files (org.json, members.json, collections.json)
+/// directly — the manifest stays encrypted and is never touched.
+pub fn run_org_status(dir: &Path) -> Result<()> {
+ let root = crate::org_session::org_dir(Some(dir))?;
+
+ let meta: relicario_core::OrgMeta = {
+ let s = fs::read_to_string(root.join("org.json")).context("read org.json")?;
+ serde_json::from_str(&s).context("parse org.json")?
+ };
+ let members: OrgMembers = {
+ let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
+ serde_json::from_str(&s).context("parse members.json")?
+ };
+ let collections: OrgCollections = {
+ let s = fs::read_to_string(root.join("collections.json"))
+ .context("read collections.json")?;
+ serde_json::from_str(&s).context("parse collections.json")?
+ };
+
+ println!("Org: {} ({})", meta.display_name, meta.org_id.as_str());
+ println!();
+ println!("Members ({}):", members.members.len());
+ for m in &members.members {
+ let colls = if m.collections.is_empty() {
+ "(no collections)".to_string()
+ } else {
+ m.collections.join(", ")
+ };
+ println!(
+ " {:?} {} {} [{}]",
+ m.role,
+ m.member_id.as_str(),
+ m.display_name,
+ colls
+ );
+ }
+ println!();
+ println!("Collections ({}):", collections.collections.len());
+ for c in &collections.collections {
+ println!(" {} — {}", c.slug, c.display_name);
+ }
+ Ok(())
+}
+
+/// One audited org-vault commit, attributed to a VERIFIED git signer.
+#[derive(Debug, serde::Serialize)]
+pub struct AuditEvent {
+ pub commit: String,
+ pub timestamp: String,
+ /// Actor as resolved from the VERIFIED signing key (authoritative).
+ pub actor_name: Option<String>,
+ pub actor_id: Option<String>,
+ /// Actor id as CLAIMED by the commit trailer (advisory; for tamper-checking).
+ pub trailer_actor_id: Option<String>,
+ pub action: Option<String>,
+ pub collection: Option<String>,
+ pub item_id: Option<String>,
+ pub device_id: Option<String>,
+ /// True when the trailer's claimed actor disagrees with the verified signer,
+ /// or when no current member matches the signing key.
+ pub tampered: bool,
+}
+
+/// Parse a commit's `Relicario-*` trailer block into an `AuditEvent`. The actor
+/// id captured here is the trailer's CLAIM (`trailer_actor_id`) — the
+/// authoritative `actor_id` is resolved later from the verified signature.
+fn parse_trailer_block(commit: &str, timestamp: &str, trailers: &str) -> AuditEvent {
+ let mut ev = AuditEvent {
+ commit: commit.to_string(),
+ timestamp: timestamp.to_string(),
+ actor_name: None,
+ actor_id: None,
+ trailer_actor_id: None,
+ action: None,
+ collection: None,
+ item_id: None,
+ device_id: None,
+ tampered: false,
+ };
+ for line in trailers.lines() {
+ let line = line.trim();
+ if let Some(rest) = line.strip_prefix("Relicario-Actor:") {
+ // Contract format: "<name> <member_id>" (member_id is the last token).
+ let rest = rest.trim();
+ if let Some((_name, id)) = rest.rsplit_once(' ') {
+ ev.trailer_actor_id = Some(id.trim().to_string());
+ } else if !rest.is_empty() {
+ ev.trailer_actor_id = Some(rest.to_string());
+ }
+ } else if let Some(v) = line.strip_prefix("Relicario-Action:") {
+ ev.action = Some(v.trim().to_string());
+ } else if let Some(v) = line.strip_prefix("Relicario-Collection:") {
+ ev.collection = Some(v.trim().to_string());
+ } else if let Some(v) = line.strip_prefix("Relicario-Item:") {
+ ev.item_id = Some(v.trim().to_string());
+ } else if let Some(v) = line.strip_prefix("Relicario-Device:") {
+ ev.device_id = Some(v.trim().to_string());
+ }
+ }
+ ev
+}
+
+/// Resolve a commit's SSH signature fingerprint to a current member, mirroring
+/// the pre-receive hook: build an allowed_signers from members.json, inject it
+/// via GIT_CONFIG_*, run `git verify-commit --raw`, parse the SHA256: key from
+/// stderr. Returns None if the commit is unsigned or the signer is not a member.
+fn resolve_signer<'m>(
+ root: &Path,
+ commit: &str,
+ members: &'m relicario_core::OrgMembers,
+) -> Option<&'m relicario_core::OrgMember> {
+ use std::io::Write;
+ let mut tmp = tempfile::NamedTempFile::new().ok()?;
+ for m in &members.members {
+ let _ = writeln!(tmp, "relicario {}", m.ed25519_pubkey.trim());
+ }
+ let allowed_path = tmp.path();
+
+ let output = std::process::Command::new("git")
+ .current_dir(root)
+ .args(["verify-commit", "--raw", commit])
+ .env("GIT_CONFIG_COUNT", "1")
+ .env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
+ .env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
+ .output()
+ .ok()?;
+ let stderr = String::from_utf8_lossy(&output.stderr);
+
+ let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").ok()?;
+ let fp = re.captures(&stderr)?.get(1)?.as_str().to_string();
+
+ members.members.iter().find(|m| {
+ relicario_core::fingerprint(&m.ed25519_pubkey).ok().as_deref() == Some(fp.as_str())
+ })
+}
+
+/// `org audit`: parse `git log`, resolve each commit's VERIFIED signer to a
+/// member and report THAT as the actor (trailers are advisory), flag
+/// trailer/signer mismatch as `TAMPERED`, and frame records with `%x1e`/`%x1f`
+/// (so multi-line trailer values cannot misalign records) using the committer
+/// date (`%cI`).
+pub fn run_org_audit(
+ dir: &Path,
+ since: Option<&str>,
+ member_filter: Option<&str>,
+ collection_filter: Option<&str>,
+ action_filter: Option<&str>,
+ format: &str,
+) -> Result<()> {
+ // Spec surface is `--format <table|json>` (default table). Accept only those.
+ let json = match format {
+ "json" => true,
+ "table" => false,
+ other => anyhow::bail!("unknown --format `{other}` — use table or json"),
+ };
+ let root = crate::org_session::org_dir(Some(dir))?;
+
+ // members.json — needed to resolve each commit's verified signer to a member.
+ let members: relicario_core::OrgMembers = {
+ let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
+ serde_json::from_str(&s).context("parse members.json")?
+ };
+
+ // git log framed with a record separator (%x1e, U+001E) PER COMMIT and a
+ // field separator (%x1f, U+001F) between fields, so multi-line trailer
+ // values cannot misalign record boundaries. Committer date (%cI), not
+ // author date: it is what revocation/audit is anchored to.
+ let fmt = "%x1e%H%x1f%cI%x1f%(trailers:only=true,unfold=true)";
+ let mut args: Vec<String> = vec!["log".into(), format!("--format={fmt}")];
+ if let Some(s) = since {
+ args.push(format!("--since={s}"));
+ }
+
+ let output = std::process::Command::new("git")
+ .current_dir(&root)
+ .args(&args)
+ .output()
+ .context("git log")?;
+ let log = String::from_utf8_lossy(&output.stdout);
+
+ let events = parse_audit_log(&root, &log, &members, member_filter, collection_filter, action_filter);
+
+ if json {
+ println!("{}", serde_json::to_string_pretty(&events)?);
+ } else {
+ println!(
+ "{:<44} {:<26} {:<20} {:<18} {}",
+ "COMMIT", "TIMESTAMP", "ACTION", "ACTOR", "FLAG"
+ );
+ for ev in &events {
+ println!(
+ "{:<44} {:<26} {:<20} {:<18} {}",
+ ev.commit,
+ ev.timestamp,
+ ev.action.as_deref().unwrap_or("-"),
+ ev.actor_name.as_deref().unwrap_or("<unverified>"),
+ if ev.tampered { "TAMPERED" } else { "" },
+ );
+ }
+ }
+ Ok(())
+}
+
+/// Frame a raw `git log` body (records split on `%x1e`, fields on `%x1f`) into
+/// attributed `AuditEvent`s. Each commit's VERIFIED signer is resolved via
+/// `resolve_signer` and reported as the authoritative actor; trailer/signer
+/// disagreement (or no matching member) sets the `tampered` flag. Filters apply
+/// to the VERIFIED actor id, not the spoofable trailer. Split out from
+/// `run_org_audit` so it can be unit-tested over a real signed repo.
+fn parse_audit_log(
+ root: &Path,
+ log: &str,
+ members: &relicario_core::OrgMembers,
+ member_filter: Option<&str>,
+ collection_filter: Option<&str>,
+ action_filter: Option<&str>,
+) -> Vec<AuditEvent> {
+ let mut events: Vec<AuditEvent> = Vec::new();
+ for record in log.split('\u{1e}') {
+ let record = record.trim_start_matches('\n');
+ if record.trim().is_empty() {
+ continue;
+ }
+ let mut fields = record.splitn(3, '\u{1f}');
+ let commit = fields.next().unwrap_or("").trim();
+ let ts = fields.next().unwrap_or("").trim();
+ let trailers = fields.next().unwrap_or("");
+ if commit.is_empty() {
+ continue;
+ }
+
+ let mut ev = parse_trailer_block(commit, ts, trailers);
+ if ev.action.is_none() {
+ continue; // not an org commit
+ }
+
+ // Resolve the VERIFIED signer and attribute it as the authoritative actor.
+ match resolve_signer(root, commit, members) {
+ Some(m) => {
+ ev.actor_name = Some(m.display_name.clone());
+ ev.actor_id = Some(m.member_id.as_str().to_string());
+ // Tampered if the trailer claims a different actor than the signer.
+ if let Some(claimed) = ev.trailer_actor_id.as_deref() {
+ if claimed != m.member_id.as_str() {
+ ev.tampered = true;
+ }
+ }
+ }
+ None => {
+ // No current member matched the signature -> cannot trust the
+ // trailer's claimed actor.
+ ev.tampered = true;
+ }
+ }
+
+ if let Some(mid) = member_filter {
+ // Filter on the VERIFIED actor id, not the spoofable trailer.
+ if ev.actor_id.as_deref() != Some(mid) {
+ continue;
+ }
+ }
+ if let Some(col) = collection_filter {
+ if ev.collection.as_deref() != Some(col) {
+ continue;
+ }
+ }
+ if let Some(act) = action_filter {
+ if ev.action.as_deref() != Some(act) {
+ continue;
+ }
+ }
+ events.push(ev);
+ }
+ events
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -385,4 +664,201 @@ mod tests {
assert!(!members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string()));
assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string()));
}
+
+ // ───── Status / Audit (B8) ─────
+
+ #[test]
+ fn parse_trailers_extracts_relicario_fields() {
+ // Contract trailer shape: "Relicario-Actor: <name> <member_id>".
+ let raw = "Relicario-Actor: alice a1b2c3d4e5f6a1b2\nRelicario-Action: item-create\nRelicario-Collection: prod\n";
+ let event = parse_trailer_block("abc123", "2026-06-06T12:00:00+00:00", raw);
+ assert_eq!(event.action.as_deref(), Some("item-create"));
+ assert_eq!(event.collection.as_deref(), Some("prod"));
+ // The verified actor_id is resolved later from the signature, not the trailer;
+ // the trailer only populates trailer_actor_id here.
+ assert_eq!(event.trailer_actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2"));
+ assert_eq!(event.actor_id, None);
+ assert!(!event.tampered);
+ }
+
+ #[test]
+ fn parse_trailers_captures_item_and_device() {
+ let raw = "Relicario-Actor: bob feedfacefeedface\nRelicario-Action: item-update\nRelicario-Item: 0123456789abcdef\nRelicario-Device: laptop\n";
+ let ev = parse_trailer_block("def456", "2026-06-06T13:00:00+00:00", raw);
+ assert_eq!(ev.action.as_deref(), Some("item-update"));
+ assert_eq!(ev.item_id.as_deref(), Some("0123456789abcdef"));
+ assert_eq!(ev.device_id.as_deref(), Some("laptop"));
+ assert_eq!(ev.trailer_actor_id.as_deref(), Some("feedfacefeedface"));
+ }
+
+ #[test]
+ fn parse_trailers_single_token_actor_falls_back_to_whole_value() {
+ // No space => the whole value is treated as the member id.
+ let raw = "Relicario-Actor: lonelytoken00000\nRelicario-Action: org-init\n";
+ let ev = parse_trailer_block("c0ffee", "2026-06-06T14:00:00+00:00", raw);
+ assert_eq!(ev.trailer_actor_id.as_deref(), Some("lonelytoken00000"));
+ assert_eq!(ev.action.as_deref(), Some("org-init"));
+ }
+
+ #[test]
+ fn parse_trailers_non_org_commit_has_no_action() {
+ // A commit with no Relicario-* trailers parses to an event with no action,
+ // which run_org_audit skips.
+ let ev = parse_trailer_block("beef", "2026-06-06T15:00:00+00:00", "");
+ assert!(ev.action.is_none());
+ }
+}
+
+#[cfg(test)]
+mod audit_log_tests {
+ //! Record-framing + filter tests for `parse_audit_log` against a synthetic
+ //! `git log` body (no real repo / signatures needed: members.json is empty so
+ //! `resolve_signer` always returns None and every org commit is flagged
+ //! TAMPERED — which is exactly the "signer is not a current member" path).
+ use super::*;
+ use relicario_core::OrgMembers;
+
+ /// Build one framed record: leading %x1e, then commit %x1f ts %x1f trailers.
+ fn record(commit: &str, ts: &str, trailers: &str) -> String {
+ format!("\u{1e}{commit}\u{1f}{ts}\u{1f}{trailers}")
+ }
+
+ #[test]
+ fn parse_audit_log_frames_records_and_flags_unverified() {
+ let members = OrgMembers::new(); // no members => no signer can resolve
+ let log = format!(
+ "{}{}",
+ record(
+ "1111111111111111111111111111111111111111",
+ "2026-06-06T12:00:00+00:00",
+ "Relicario-Actor: alice a1b2c3d4e5f6a1b2\nRelicario-Action: item-create\nRelicario-Collection: prod\n",
+ ),
+ record(
+ "2222222222222222222222222222222222222222",
+ "2026-06-06T13:00:00+00:00",
+ "Relicario-Actor: bob feedfacefeedface\nRelicario-Action: item-update\nRelicario-Collection: dev\n",
+ ),
+ );
+ // root path is unused once resolve_signer short-circuits on empty members,
+ // but verify-commit will run; point it at a tempdir to be safe.
+ let tmp = tempfile::tempdir().unwrap();
+ let events = parse_audit_log(tmp.path(), &log, &members, None, None, None);
+ assert_eq!(events.len(), 2);
+ // Leading %x1e produced an empty leading split element that was filtered.
+ assert_eq!(events[0].commit, "1111111111111111111111111111111111111111");
+ assert_eq!(events[0].action.as_deref(), Some("item-create"));
+ assert_eq!(events[0].collection.as_deref(), Some("prod"));
+ // No member matched the (absent) signature => TAMPERED, no verified actor.
+ assert!(events[0].tampered);
+ assert_eq!(events[0].actor_name, None);
+ assert_eq!(events[0].actor_id, None);
+ // Trailer claim is preserved for forensic comparison.
+ assert_eq!(events[0].trailer_actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2"));
+ }
+
+ #[test]
+ fn parse_audit_log_skips_non_org_commits() {
+ let members = OrgMembers::new();
+ let log = format!(
+ "{}{}",
+ // A non-org commit: no Relicario-Action trailer.
+ record("3333", "2026-06-06T10:00:00+00:00", "Some-Other: trailer\n"),
+ record(
+ "4444",
+ "2026-06-06T11:00:00+00:00",
+ "Relicario-Action: org-init\nRelicario-Actor: alice a1b2c3d4e5f6a1b2\n",
+ ),
+ );
+ let tmp = tempfile::tempdir().unwrap();
+ let events = parse_audit_log(tmp.path(), &log, &members, None, None, None);
+ assert_eq!(events.len(), 1);
+ assert_eq!(events[0].commit, "4444");
+ assert_eq!(events[0].action.as_deref(), Some("org-init"));
+ }
+
+ #[test]
+ fn parse_audit_log_multiline_trailer_value_does_not_misalign() {
+ // A multi-line trailer value must not break record framing: only %x1e
+ // ends a record, not a newline inside the trailer block.
+ let members = OrgMembers::new();
+ let log = format!(
+ "{}{}",
+ record(
+ "5555",
+ "2026-06-06T09:00:00+00:00",
+ "Relicario-Action: item-create\nRelicario-Actor: carol cafecafecafecafe\nRelicario-Collection: prod\n",
+ ),
+ record(
+ "6666",
+ "2026-06-06T09:30:00+00:00",
+ "Relicario-Action: item-delete\nRelicario-Actor: dave deaddeaddeaddead\nRelicario-Collection: dev\n",
+ ),
+ );
+ let tmp = tempfile::tempdir().unwrap();
+ let events = parse_audit_log(tmp.path(), &log, &members, None, None, None);
+ assert_eq!(events.len(), 2);
+ assert_eq!(events[0].commit, "5555");
+ assert_eq!(events[1].commit, "6666");
+ assert_eq!(events[1].action.as_deref(), Some("item-delete"));
+ }
+
+ #[test]
+ fn parse_audit_log_collection_and_action_filters_apply() {
+ let members = OrgMembers::new();
+ let log = format!(
+ "{}{}{}",
+ record(
+ "7777",
+ "2026-06-06T08:00:00+00:00",
+ "Relicario-Action: item-create\nRelicario-Collection: prod\nRelicario-Actor: a aaaa000000000000\n",
+ ),
+ record(
+ "8888",
+ "2026-06-06T08:10:00+00:00",
+ "Relicario-Action: item-update\nRelicario-Collection: prod\nRelicario-Actor: a aaaa000000000000\n",
+ ),
+ record(
+ "9999",
+ "2026-06-06T08:20:00+00:00",
+ "Relicario-Action: item-create\nRelicario-Collection: dev\nRelicario-Actor: a aaaa000000000000\n",
+ ),
+ );
+ let tmp = tempfile::tempdir().unwrap();
+
+ // Collection filter: only prod commits survive.
+ let prod = parse_audit_log(tmp.path(), &log, &members, None, Some("prod"), None);
+ assert_eq!(prod.len(), 2);
+ assert!(prod.iter().all(|e| e.collection.as_deref() == Some("prod")));
+
+ // Action filter: only item-create commits survive.
+ let creates = parse_audit_log(tmp.path(), &log, &members, None, None, Some("item-create"));
+ assert_eq!(creates.len(), 2);
+ assert!(creates.iter().all(|e| e.action.as_deref() == Some("item-create")));
+
+ // Combined: item-create AND prod => just commit 7777.
+ let combined =
+ parse_audit_log(tmp.path(), &log, &members, None, Some("prod"), Some("item-create"));
+ assert_eq!(combined.len(), 1);
+ assert_eq!(combined[0].commit, "7777");
+ }
+
+ #[test]
+ fn parse_audit_log_member_filter_uses_verified_actor_not_trailer() {
+ // With no resolvable signer, actor_id is None, so a member filter naming
+ // the TRAILER's claimed id must NOT match — the filter is on the verified
+ // actor, which is the whole point of TAMPERED attribution.
+ let members = OrgMembers::new();
+ let log = record(
+ "aaaa",
+ "2026-06-06T07:00:00+00:00",
+ "Relicario-Action: item-create\nRelicario-Actor: mallory deadbeefdeadbeef\n",
+ );
+ let tmp = tempfile::tempdir().unwrap();
+ let filtered =
+ parse_audit_log(tmp.path(), &log, &members, Some("deadbeefdeadbeef"), None, None);
+ assert!(
+ filtered.is_empty(),
+ "member filter must match the verified actor id, never the spoofable trailer"
+ );
+ }
}

View File

@@ -0,0 +1,156 @@
//! B8 `org audit` verified-signer attribution — integration coverage.
//!
//! The audit logic (`resolve_signer`, `parse_audit_log`, `run_org_audit`) lives
//! in the bin crate's private `commands::org` module and the CLI dispatch is not
//! wired until B14, so we cannot drive `org audit` through the binary yet. What
//! we CAN do is build a real signed org vault via `org init` and assert that the
//! exact verification mechanism `resolve_signer` uses — a temp `allowed_signers`
//! prefixed `relicario `, injected via `GIT_CONFIG_*`, then
//! `git verify-commit --raw`, then the `key (SHA256:...)` regex over stderr —
//! resolves the genesis commit's signature to the seeded member's fingerprint.
//!
//! This pins the security-critical half of B8 (attribute to the VERIFIED signer,
//! mirroring the pre-receive hook) against a genuine SSH signature rather than
//! the synthetic-log unit tests, which only cover the "no member matched ->
//! TAMPERED" fallback.
use std::fs;
use std::io::Write;
use std::path::Path;
use std::process::Command;
use tempfile::{NamedTempFile, TempDir};
fn relicario_with_git_identity(config_home: &Path, args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_relicario"))
.env("XDG_CONFIG_HOME", config_home)
.env("HOME", config_home)
.env("GIT_AUTHOR_NAME", "Test Device")
.env("GIT_AUTHOR_EMAIL", "test@relicario.test")
.env("GIT_COMMITTER_NAME", "Test Device")
.env("GIT_COMMITTER_EMAIL", "test@relicario.test")
.args(args)
.output()
.expect("run relicario")
}
/// Lay out a device keypair under `<config_home>/relicario/devices/<name>/` and
/// mark it current. Mirrors `org_init_signing::seed_device`. Returns the OpenSSH
/// public key string.
fn seed_device(config_home: &Path, name: &str) -> String {
let (priv_openssh, pub_openssh) =
relicario_core::device::generate_keypair().expect("generate_keypair");
let dev_dir = config_home.join("relicario").join("devices").join(name);
fs::create_dir_all(&dev_dir).expect("create device dir");
let signing_key_path = dev_dir.join("signing.key");
fs::write(&signing_key_path, priv_openssh.as_str()).expect("write signing.key");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&signing_key_path, fs::Permissions::from_mode(0o600))
.expect("chmod signing.key");
}
fs::write(dev_dir.join("signing.pub"), &pub_openssh).expect("write signing.pub");
fs::write(dev_dir.join("deploy.key"), "").expect("write stub deploy.key");
fs::write(dev_dir.join("deploy.pub"), "").expect("write stub deploy.pub");
let devices_dir = config_home.join("relicario").join("devices");
fs::write(devices_dir.join("current"), format!("{name}\n")).expect("write current");
pub_openssh
}
/// Replicate `commands::org::resolve_signer`'s verification: build an
/// allowed_signers file from the given pubkeys (prefixed `relicario `), inject it
/// via GIT_CONFIG_*, run `git verify-commit --raw`, and parse the SHA256 key
/// fingerprint from stderr.
fn resolve_signer_fp(org_root: &Path, commit: &str, pubkeys: &[&str]) -> Option<String> {
let mut tmp = NamedTempFile::new().ok()?;
for pk in pubkeys {
writeln!(tmp, "relicario {}", pk.trim()).ok()?;
}
let allowed_path = tmp.path();
let output = Command::new("git")
.current_dir(org_root)
.args(["verify-commit", "--raw", commit])
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
.output()
.ok()?;
// The clean exit IS the gate (matches the hook): a non-member signature fails.
if !output.status.success() {
return None;
}
let stderr = String::from_utf8_lossy(&output.stderr);
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").ok()?;
Some(re.captures(&stderr)?.get(1)?.as_str().to_string())
}
#[test]
fn audit_resolves_genesis_commit_to_the_signing_member() {
let cfg = TempDir::new().unwrap();
let org = TempDir::new().unwrap();
let pub_openssh = seed_device(cfg.path(), "test-dev");
let init = relicario_with_git_identity(
cfg.path(),
&["org", "init", "--dir", org.path().to_str().unwrap(), "--name", "Acme"],
);
assert!(
init.status.success(),
"org init failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&init.stdout),
String::from_utf8_lossy(&init.stderr)
);
// The signing member's pubkey is recorded in members.json. resolve_signer
// builds allowed_signers from exactly that set.
let members_json =
fs::read_to_string(org.path().join("members.json")).expect("read members.json");
let members: relicario_core::OrgMembers =
serde_json::from_str(&members_json).expect("parse members.json");
assert_eq!(members.members.len(), 1, "init seeds exactly one owner member");
let owner = &members.members[0];
// The genesis commit must resolve to the owner's fingerprint.
let signing_fp = resolve_signer_fp(org.path(), "HEAD", &[owner.ed25519_pubkey.as_str()])
.expect("genesis commit signature must verify against the member set");
let expected = relicario_core::fingerprint(&owner.ed25519_pubkey).expect("fingerprint owner");
assert_eq!(
signing_fp, expected,
"verified signer fingerprint must equal the owner member's fingerprint"
);
// The seeded pubkey and the members.json pubkey are the same key.
assert_eq!(owner.ed25519_pubkey.trim(), pub_openssh.trim());
}
#[test]
fn audit_rejects_signature_from_a_non_member_key() {
// A commit signed by the owner must NOT resolve when the allowed_signers set
// contains only some OTHER (non-member) key — this is the TAMPERED path:
// "signer is not a current member".
let cfg = TempDir::new().unwrap();
let org = TempDir::new().unwrap();
let _owner_pub = seed_device(cfg.path(), "test-dev");
let init = relicario_with_git_identity(
cfg.path(),
&["org", "init", "--dir", org.path().to_str().unwrap(), "--name", "Acme"],
);
assert!(init.status.success(), "org init failed");
// A stranger keypair that never signed anything in this repo.
let (_stranger_priv, stranger_pub) =
relicario_core::device::generate_keypair().expect("generate stranger keypair");
let resolved = resolve_signer_fp(org.path(), "HEAD", &[stranger_pub.as_str()]);
assert!(
resolved.is_none(),
"a commit signed by the owner must not verify against a stranger-only signer set"
);
}