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
900 lines
36 KiB
Diff
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", ¬e.id).unwrap();
|
|
+ match ¬e_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:#}");
|
|
+ }
|
|
+}
|