Files
relicario/docs/superpowers/salvage/2026-06-20-org-vault-tail/f3e-1-org.rs.uncommitted.patch
adlee-was-taken 3774047298 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
2026-06-20 16:58:28 -04:00

900 lines
36 KiB
Diff

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:#}");
+ }
+}