From 2acd57a4a5ae30a3dda31728be9921c8424fc3c2 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 14:08:22 -0400 Subject: [PATCH] feat(cli/org): org get + list with per-member grant filtering --- crates/relicario-cli/src/commands/org.rs | 125 +++++++++++++++++++++++ crates/relicario-cli/src/main.rs | 26 ++++- 2 files changed, 147 insertions(+), 4 deletions(-) diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index 8ee3294..6962e00 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -848,6 +848,131 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec Ok(()) } +pub fn run_list(dir: &Path, trashed: bool) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + 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(()) +} + +pub fn run_get(dir: &Path, query: &str, show: bool) -> Result<()> { + use relicario_core::ItemCore; + use zeroize::Zeroizing; + + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + let manifest = vault.load_manifest()?; + let visible = manifest.filter_for_member(&caller); + + let entry = resolve_org_query(&visible, query)?; + // Double-check the grant for the resolved collection (defense in depth). + crate::org_session::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> = 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(()) +} + +/// 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(", ")) + } + } +} + /// Insert-or-replace an `OrgManifestEntry` mirroring the personal-vault /// `Manifest::upsert`. Keyed by item id. fn upsert_org_entry( diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 25b848f..019bc84 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -512,8 +512,18 @@ pub(crate) enum OrgCommands { #[command(subcommand)] kind: OrgAddKind, }, - // Item subcommands (Get/List/Edit/Rm/Restore/Purge) are added by - // Tasks B11–B13, which extend this enum. + /// Print an org item (secrets masked unless --show). + Get { + /// Item id or case-insensitive title substring. + query: String, + #[arg(long)] show: bool, + }, + /// List org items visible to you (filtered by your collection grants). + List { + #[arg(long)] trashed: bool, + }, + // Item subcommands (Edit/Rm/Restore/Purge) are added by + // Tasks B12–B13, which extend this enum. } #[derive(clap::Subcommand)] @@ -654,8 +664,16 @@ fn main() -> Result<()> { }; commands::org::run_add(&d, &collection, add_kind, tags)?; } - // Item dispatch arms (Get/List/Edit/Rm/Restore/Purge) added by - // Tasks B11–B13. + OrgCommands::Get { query, show } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_get(&d, &query, show)?; + } + OrgCommands::List { trashed } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_list(&d, trashed)?; + } + // Item dispatch arms (Edit/Rm/Restore/Purge) added by + // Tasks B12–B13. } Ok(()) }