From 6a16523ee075442d7c3126747d3f570b8d36b055 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 13:50:11 -0400 Subject: [PATCH 1/5] feat(cli/org): wire Commands::Org admin subcommands + parse_org_role + transfer-ownership/delete-org --- crates/relicario-cli/src/commands/org.rs | 67 ++++++++++++ crates/relicario-cli/src/main.rs | 132 ++++++++++++++++++++++- 2 files changed, 197 insertions(+), 2 deletions(-) diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index e9ee641..6805d21 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -441,6 +441,73 @@ pub fn run_rotate_key(dir: &Path) -> Result<()> { Ok(()) } +pub fn run_transfer_ownership(dir: &Path, member_id_prefix: &str, keep_owner: bool) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_owners() { + anyhow::bail!("only an owner can transfer ownership"); + } + let mut members = vault.load_members()?; + let target_id = resolve_member_id(&members, member_id_prefix)?; + if target_id == caller.member_id { + anyhow::bail!("you are already the owner"); + } + // Promote the target to Owner. + { + let target = members.find_by_id_mut(&target_id) + .ok_or_else(|| anyhow::anyhow!("member not found"))?; + target.role = OrgRole::Owner; + } + // Real transfer: also demote the CALLER to Admin, unless --keep-owner was + // passed (explicit co-ownership). The spec says "owner → another member", + // so demotion is the default. + if !keep_owner { + if let Some(me) = members.find_by_id_mut(&caller.member_id) { + me.role = OrgRole::Admin; + } + } + vault.save_members(&members)?; + + let mode = if keep_owner { "co-ownership (caller kept owner)" } else { "caller demoted to admin" }; + let commit_msg = format!( + "org: transfer ownership to {} ({mode})\n\nRelicario-Actor: {} {}\nRelicario-Action: ownership-transfer\nRelicario-Member: {}", + target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + if keep_owner { + println!("Ownership shared with {} (you remain an owner).", target_id.as_str()); + } else { + println!("Ownership transferred to {} (you are now an admin).", target_id.as_str()); + } + Ok(()) +} + +pub fn run_delete_org(dir: &Path, confirm: bool) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_owners() { + anyhow::bail!("only an owner can delete the org"); + } + if !confirm { + anyhow::bail!("refusing to delete org without --confirm"); + } + let commit_msg = format!( + "org: delete org\n\nRelicario-Actor: {} {}\nRelicario-Action: org-delete", + caller.display_name, caller.member_id.as_str() + ); + // Remove org files (the git history is retained as the audit record). + for f in ["org.json", "members.json", "collections.json", "manifest.enc"] { + let _ = fs::remove_file(vault.root.join(f)); + } + let _ = std::fs::remove_dir_all(vault.root.join("items")); + let _ = std::fs::remove_dir_all(vault.root.join("keys")); + crate::org_session::org_git_run(&vault.root, &["add", "-A"], "git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + println!("Org deleted (git history retained as audit record)."); + Ok(()) +} + pub fn run_status(dir: &Path) -> Result<()> { let root = crate::org_session::org_dir(Some(dir))?; diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 639c5be..3f95330 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -438,7 +438,77 @@ pub(crate) enum OrgCommands { #[arg(long)] name: String, }, - // Admin + item subcommands are added by later tasks (B10-B14). + /// Add a member to the org. + AddMember { + /// OpenSSH ed25519 public key of the new member. + #[arg(long)] + key: String, + /// Display name. + #[arg(long)] + name: String, + /// Role: owner, admin, or member. + #[arg(long, default_value = "member")] + role: String, + }, + /// Remove a member from the org. + RemoveMember { + /// Member ID prefix. + member_id: String, + }, + /// Change a member's role. + SetRole { + member_id: String, + role: String, + }, + /// Create a collection. + CreateCollection { + slug: String, + #[arg(long)] + name: String, + }, + /// Grant a member access to a collection. + Grant { + member_id: String, + collection: String, + }, + /// Revoke a member's access to a collection. + Revoke { + member_id: String, + collection: String, + }, + /// Rotate the org master key (run after removing a member). + RotateKey, + /// Transfer ownership to another member (owner only). By default the caller + /// is demoted to admin; pass --keep-owner for explicit co-ownership. + TransferOwnership { + member_id: String, + /// Keep the caller as an owner too (co-ownership) instead of demoting. + #[arg(long)] + keep_owner: bool, + }, + /// Delete the org (owner only; requires --confirm). + DeleteOrg { + #[arg(long)] + confirm: bool, + }, + /// Show org members and collections. + Status, + /// Query the org audit log. + Audit { + #[arg(long)] + since: Option, + #[arg(long)] + member: Option, + #[arg(long)] + collection: Option, + #[arg(long)] + action: Option, + /// Output format: `table` (default) or `json`. + #[arg(long, default_value = "table")] + format: String, + }, + // Item subcommands (Add/Get/List/Edit/Rm/Restore/Purge) are added by + // Tasks B10–B13, which extend this enum. } fn main() -> Result<()> { @@ -481,13 +551,71 @@ fn main() -> Result<()> { OrgCommands::Init { name } => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_init(&d, &name)?; - Ok(()) } + OrgCommands::AddMember { key, name, role } => { + let d = crate::org_session::org_dir(dir_path)?; + let role = parse_org_role(&role)?; + commands::org::run_add_member(&d, &key, &name, role)?; + } + OrgCommands::RemoveMember { member_id } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_remove_member(&d, &member_id)?; + } + OrgCommands::SetRole { member_id, role } => { + let d = crate::org_session::org_dir(dir_path)?; + let role = parse_org_role(&role)?; + commands::org::run_set_role(&d, &member_id, role)?; + } + OrgCommands::CreateCollection { slug, name } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_create_collection(&d, &slug, &name)?; + } + OrgCommands::Grant { member_id, collection } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_grant(&d, &member_id, &collection)?; + } + OrgCommands::Revoke { member_id, collection } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_revoke(&d, &member_id, &collection)?; + } + OrgCommands::RotateKey => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_rotate_key(&d)?; + } + OrgCommands::TransferOwnership { member_id, keep_owner } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_transfer_ownership(&d, &member_id, keep_owner)?; + } + OrgCommands::DeleteOrg { confirm } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_delete_org(&d, confirm)?; + } + OrgCommands::Status => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_status(&d)?; + } + OrgCommands::Audit { since, member, collection, action, format } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_audit(&d, since.as_deref(), member.as_deref(), + collection.as_deref(), action.as_deref(), &format)?; + } + // Item dispatch arms (Add/Get/List/Edit/Rm/Restore/Purge) added by + // Tasks B10–B13. } + Ok(()) } } } +fn parse_org_role(s: &str) -> anyhow::Result { + match s { + "owner" => Ok(relicario_core::OrgRole::Owner), + "admin" => Ok(relicario_core::OrgRole::Admin), + "member" => Ok(relicario_core::OrgRole::Member), + other => anyhow::bail!("unknown role `{other}` — use owner, admin, or member"), + } +} + /// Check for test passphrase override (debug builds only; stripped from release). #[cfg(debug_assertions)] pub(crate) fn test_passphrase_override() -> Option { From 87b1d166c219af9bb55e7993749585d181d2bc08 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 14:00:21 -0400 Subject: [PATCH 2/5] =?UTF-8?q?feat(cli/org):=20org=20add=20=E2=80=94=20co?= =?UTF-8?q?llection-scoped=20typed=20item=20create=20with=20grant=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/relicario-cli/src/commands/org.rs | 129 +++++++++++++++++++- crates/relicario-cli/src/main.rs | 63 +++++++++- crates/relicario-cli/tests/org_items.rs | 149 +++++++++++++++++++++++ 3 files changed, 336 insertions(+), 5 deletions(-) create mode 100644 crates/relicario-cli/tests/org_items.rs diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index 6805d21..8ee3294 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -6,7 +6,8 @@ use std::path::Path; use anyhow::{Context, Result}; use relicario_core::{ generate_org_key, wrap_org_key, - CollectionDef, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, OrgRole, OrgMember, + CollectionDef, Item, ItemCore, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, + OrgRole, OrgMember, encrypt_org_manifest, }; @@ -744,6 +745,132 @@ pub fn run_audit( Ok(()) } +/// Item kinds `org add` supports without interactive prompts. +pub enum OrgAddKind { + Login { + title: String, + username: Option, + url: Option, + password: Option, + }, + SecureNote { + title: String, + body: String, + }, + Identity { + title: String, + full_name: Option, + email: Option, + phone: Option, + }, +} + +fn build_org_item(kind: OrgAddKind, tags: Vec) -> Result { + 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) +} + +pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec) -> Result<()> { + use crate::org_session::UnlockedOrgVault; + + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + + // 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 (collection slug stored plaintext inside the + // encrypted 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(()) +} + +/// Insert-or-replace an `OrgManifestEntry` mirroring the personal-vault +/// `Manifest::upsert`. Keyed by item id. +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); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 3f95330..25b848f 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -507,8 +507,42 @@ pub(crate) enum OrgCommands { #[arg(long, default_value = "table")] format: String, }, - // Item subcommands (Add/Get/List/Edit/Rm/Restore/Purge) are added by - // Tasks B10–B13, which extend this enum. + /// Add an item to a collection in the org vault. + Add { + #[command(subcommand)] + kind: OrgAddKind, + }, + // Item subcommands (Get/List/Edit/Rm/Restore/Purge) are added by + // Tasks B11–B13, which extend this enum. +} + +#[derive(clap::Subcommand)] +pub(crate) enum OrgAddKind { + /// A login (username / url / password). + Login { + #[arg(long)] collection: String, + #[arg(long)] title: String, + #[arg(long)] username: Option, + #[arg(long)] url: Option, + #[arg(long)] password: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + /// A secure note. + SecureNote { + #[arg(long)] collection: String, + #[arg(long)] title: String, + #[arg(long)] body: String, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + /// An identity record. + Identity { + #[arg(long)] collection: String, + #[arg(long)] title: String, + #[arg(long)] full_name: Option, + #[arg(long)] email: Option, + #[arg(long)] phone: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, } fn main() -> Result<()> { @@ -599,8 +633,29 @@ fn main() -> Result<()> { commands::org::run_audit(&d, since.as_deref(), member.as_deref(), collection.as_deref(), action.as_deref(), &format)?; } - // Item dispatch arms (Add/Get/List/Edit/Rm/Restore/Purge) added by - // Tasks B10–B13. + OrgCommands::Add { kind } => { + let d = crate::org_session::org_dir(dir_path)?; + let (collection, add_kind, tags) = match kind { + OrgAddKind::Login { collection, title, username, url, password, tags } => ( + collection, + commands::org::OrgAddKind::Login { title, username, url, password }, + tags, + ), + OrgAddKind::SecureNote { collection, title, body, tags } => ( + collection, + commands::org::OrgAddKind::SecureNote { title, body }, + tags, + ), + OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => ( + collection, + commands::org::OrgAddKind::Identity { title, full_name, email, phone }, + tags, + ), + }; + commands::org::run_add(&d, &collection, add_kind, tags)?; + } + // Item dispatch arms (Get/List/Edit/Rm/Restore/Purge) added by + // Tasks B11–B13. } Ok(()) } diff --git a/crates/relicario-cli/tests/org_items.rs b/crates/relicario-cli/tests/org_items.rs new file mode 100644 index 0000000..e8528c0 --- /dev/null +++ b/crates/relicario-cli/tests/org_items.rs @@ -0,0 +1,149 @@ +use assert_cmd::cargo::CommandCargoExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use tempfile::TempDir; + +/// A throwaway org vault with a device signing key wired via XDG_CONFIG_HOME. +struct OrgFixture { + _config: TempDir, + vault: TempDir, + xdg: PathBuf, +} + +impl OrgFixture { + /// Generate an ed25519 signing key in OpenSSH format using ssh-keygen and + /// register it as the current device, then `org init`. + fn new() -> Self { + let config = TempDir::new().unwrap(); + let xdg = config.path().to_path_buf(); + let devices = xdg.join("relicario").join("devices").join("laptop"); + std::fs::create_dir_all(&devices).unwrap(); + + // Generate an OpenSSH ed25519 keypair without a passphrase. + let keyfile = devices.join("signing.key"); + let status = Command::new("ssh-keygen") + .args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"]) + .arg(&keyfile) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("ssh-keygen"); + assert!(status.success(), "ssh-keygen failed"); + // ssh-keygen writes signing.key + signing.key.pub; rename the .pub to signing.pub. + std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap(); + // Mark this device current. + std::fs::write( + xdg.join("relicario").join("devices").join("current"), + "laptop\n", + ) + .unwrap(); + + let vault = TempDir::new().unwrap(); + let f = OrgFixture { _config: config, vault, xdg }; + + let out = f.run(&["org", "init", "--dir", f.vault_str(), "--name", "Acme"]); + assert!(out.status.success(), "org init failed: {}", String::from_utf8_lossy(&out.stderr)); + f + } + + fn vault_path(&self) -> &Path { self.vault.path() } + fn vault_str(&self) -> &str { self.vault.path().to_str().unwrap() } + + fn run(&self, args: &[&str]) -> std::process::Output { + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.env("XDG_CONFIG_HOME", &self.xdg) + .env("RELICARIO_ORG_DIR", self.vault.path()) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + cmd.output().unwrap() + } + + /// Owner member id printed by `org init`/`org status`. We read it from + /// members.json directly to avoid parsing stdout. + fn owner_member_id(&self) -> String { + let s = std::fs::read_to_string(self.vault.path().join("members.json")).unwrap(); + let v: serde_json::Value = serde_json::from_str(&s).unwrap(); + v["members"][0]["member_id"].as_str().unwrap().to_string() + } +} + +#[test] +fn org_add_get_list_round_trip() { + let f = OrgFixture::new(); + let owner = f.owner_member_id(); + + // Create a collection and grant the owner access to it. + let out = f.run(&["org", "create-collection", "prod", "--name", "Production"]); + assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr)); + let out = f.run(&["org", "grant", &owner, "prod"]); + assert!(out.status.success(), "grant: {}", String::from_utf8_lossy(&out.stderr)); + + // Add a login into the prod collection. + let out = f.run(&[ + "org", "add", "login", "--collection", "prod", + "--title", "GitHub", "--username", "alice", + "--url", "https://github.com", "--password", "hunter2", + ]); + assert!(out.status.success(), "org add: {}", String::from_utf8_lossy(&out.stderr)); + + // The blob must live under items/prod/, NOT flat items/. + let prod_dir = f.vault_path().join("items").join("prod"); + let blobs: Vec<_> = std::fs::read_dir(&prod_dir).unwrap().collect(); + assert_eq!(blobs.len(), 1, "expected one blob under items/prod/"); + + // list shows it. + let out = f.run(&["org", "list"]); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}"); + + // get masks by default. + let out = f.run(&["org", "get", "GitHub"]); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(stdout.contains("********"), "expected masked secret: {stdout}"); + assert!(!stdout.contains("hunter2"), "leaked plaintext: {stdout}"); + + // get --show reveals. + let out = f.run(&["org", "get", "GitHub", "--show"]); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(stdout.contains("hunter2"), "expected plaintext with --show: {stdout}"); + + // The commit trailer records the action + collection + item. + let log = Command::new("git") + .args(["-C", f.vault_str(), "log", "-1", "--format=%B"]) + .output() + .unwrap(); + let body = String::from_utf8_lossy(&log.stdout).to_string(); + assert!(body.contains("Relicario-Action: item-create"), "missing action trailer: {body}"); + assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}"); + assert!(body.contains("Relicario-Item: "), "missing item trailer: {body}"); +} + +#[test] +fn org_add_rejects_ungranted_collection() { + let f = OrgFixture::new(); + // Create the collection but do NOT grant the owner. + let out = f.run(&["org", "create-collection", "secret", "--name", "Secret"]); + assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr)); + + let out = f.run(&[ + "org", "add", "login", "--collection", "secret", + "--title", "X", "--username", "u", "--password", "p", + ]); + assert!(!out.status.success(), "add into ungranted collection must fail"); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}"); +} + +#[test] +fn org_add_rejects_unknown_collection() { + let f = OrgFixture::new(); + let out = f.run(&[ + "org", "add", "login", "--collection", "ghost", + "--title", "X", "--username", "u", "--password", "p", + ]); + assert!(!out.status.success(), "add into nonexistent collection must fail"); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + assert!(stderr.contains("does not exist") || stderr.contains("ghost"), "unexpected error: {stderr}"); +} From 2acd57a4a5ae30a3dda31728be9921c8424fc3c2 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 14:08:22 -0400 Subject: [PATCH 3/5] 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(()) } From 057a7defe5c056de2a73d221bc91de1c8b1071ef Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 14:12:46 -0400 Subject: [PATCH 4/5] =?UTF-8?q?feat(cli/org):=20org=20edit=20=E2=80=94=20f?= =?UTF-8?q?lag-driven=20field=20update=20for=20login/note/identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/relicario-cli/src/commands/org.rs | 71 ++++++++++++++++++++++++ crates/relicario-cli/src/main.rs | 25 +++++++-- crates/relicario-cli/tests/org_items.rs | 30 ++++++++++ 3 files changed, 122 insertions(+), 4 deletions(-) diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index 6962e00..4ae1561 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -973,6 +973,77 @@ fn resolve_org_query<'a>( } } +pub fn run_edit( + dir: &Path, + query: &str, + title: Option, + username: Option, + url: Option, + password: Option, + body: Option, + email: Option, + phone: Option, + full_name: Option, +) -> Result<()> { + use relicario_core::time::now_unix; + 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)?; + let collection = entry.collection.clone(); + let id = entry.id.clone(); + crate::org_session::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(()) +} + /// 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 019bc84..9951ab9 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -522,8 +522,21 @@ pub(crate) enum OrgCommands { List { #[arg(long)] trashed: bool, }, - // Item subcommands (Edit/Rm/Restore/Purge) are added by - // Tasks B12–B13, which extend this enum. + /// Edit an org item's fields (flag-driven; blank flags keep current values). + Edit { + /// Item id or case-insensitive title substring. + query: String, + #[arg(long)] title: Option, + #[arg(long)] username: Option, + #[arg(long)] url: Option, + #[arg(long)] password: Option, + #[arg(long)] body: Option, + #[arg(long)] email: Option, + #[arg(long)] phone: Option, + #[arg(long)] full_name: Option, + }, + // Item subcommands (Rm/Restore/Purge) are added by + // Task B13, which extends this enum. } #[derive(clap::Subcommand)] @@ -672,8 +685,12 @@ fn main() -> Result<()> { 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. + OrgCommands::Edit { query, title, username, url, password, body, email, phone, full_name } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?; + } + // Item dispatch arms (Rm/Restore/Purge) added by + // Task B13. } Ok(()) } diff --git a/crates/relicario-cli/tests/org_items.rs b/crates/relicario-cli/tests/org_items.rs index e8528c0..4b53ea7 100644 --- a/crates/relicario-cli/tests/org_items.rs +++ b/crates/relicario-cli/tests/org_items.rs @@ -147,3 +147,33 @@ fn org_add_rejects_unknown_collection() { let stderr = String::from_utf8_lossy(&out.stderr).to_string(); assert!(stderr.contains("does not exist") || stderr.contains("ghost"), "unexpected error: {stderr}"); } + +#[test] +fn org_edit_updates_fields_and_commits_update_trailer() { + let f = OrgFixture::new(); + let owner = f.owner_member_id(); + assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success()); + assert!(f.run(&["org", "grant", &owner, "prod"]).status.success()); + assert!(f.run(&[ + "org", "add", "login", "--collection", "prod", + "--title", "Mail", "--username", "old", "--password", "pw", + ]).status.success()); + + // Edit the username. + let out = f.run(&[ + "org", "edit", "Mail", "--username", "new-user", + ]); + assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr)); + + // get --show reflects the new username. + let out = f.run(&["org", "get", "Mail", "--show"]); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(stdout.contains("new-user"), "edit did not take: {stdout}"); + + let log = Command::new("git") + .args(["-C", f.vault_str(), "log", "-1", "--format=%B"]) + .output().unwrap(); + let body = String::from_utf8_lossy(&log.stdout).to_string(); + assert!(body.contains("Relicario-Action: item-update"), "missing update trailer: {body}"); + assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}"); +} From 6123d8b033ae9fcb6c12100c1820c81a6805041b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 14:39:18 -0400 Subject: [PATCH 5/5] feat(cli/org): org rm/restore/purge trash lifecycle (collection-scoped) --- crates/relicario-cli/src/commands/org.rs | 88 +++++++++ crates/relicario-cli/src/main.rs | 22 ++- crates/relicario-cli/tests/org_items.rs | 38 ++++ crates/relicario-cli/tests/org_lifecycle.rs | 206 ++++++++++++++++++++ 4 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 crates/relicario-cli/tests/org_lifecycle.rs diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index 4ae1561..dbb8fa0 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -1044,6 +1044,94 @@ pub fn run_edit( Ok(()) } +/// Resolve a query to (collection, item) with grant enforcement. Used by the +/// trash-lifecycle commands. +fn open_org_item( + vault: &crate::org_session::UnlockedOrgVault, + caller: &relicario_core::OrgMember, + query: &str, +) -> Result<(String, relicario_core::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(); + crate::org_session::UnlockedOrgVault::ensure_grant(caller, &collection)?; + let item = vault.load_item(&collection, &id)?; + Ok((collection, item)) +} + +pub fn run_rm(dir: &Path, query: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + 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(()) +} + +pub fn run_restore(dir: &Path, query: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + 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(()) +} + +pub fn run_purge(dir: &Path, query: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + 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(()) +} + /// 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 9951ab9..e9eb826 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -535,8 +535,12 @@ pub(crate) enum OrgCommands { #[arg(long)] phone: Option, #[arg(long)] full_name: Option, }, - // Item subcommands (Rm/Restore/Purge) are added by - // Task B13, which extends this enum. + /// Soft-delete an org item (reversible via `org restore`). + Rm { query: String }, + /// Restore a soft-deleted org item. + Restore { query: String }, + /// Permanently purge an org item (deletes the encrypted blob). + Purge { query: String }, } #[derive(clap::Subcommand)] @@ -689,8 +693,18 @@ fn main() -> Result<()> { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?; } - // Item dispatch arms (Rm/Restore/Purge) added by - // Task B13. + OrgCommands::Rm { query } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_rm(&d, &query)?; + } + OrgCommands::Restore { query } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_restore(&d, &query)?; + } + OrgCommands::Purge { query } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_purge(&d, &query)?; + } } Ok(()) } diff --git a/crates/relicario-cli/tests/org_items.rs b/crates/relicario-cli/tests/org_items.rs index 4b53ea7..3d0105b 100644 --- a/crates/relicario-cli/tests/org_items.rs +++ b/crates/relicario-cli/tests/org_items.rs @@ -177,3 +177,41 @@ fn org_edit_updates_fields_and_commits_update_trailer() { assert!(body.contains("Relicario-Action: item-update"), "missing update trailer: {body}"); assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}"); } + +#[test] +fn org_rm_restore_purge_cycle() { + let f = OrgFixture::new(); + let owner = f.owner_member_id(); + assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success()); + assert!(f.run(&["org", "grant", &owner, "prod"]).status.success()); + assert!(f.run(&[ + "org", "add", "secure-note", "--collection", "prod", + "--title", "Recovery", "--body", "codes-here", + ]).status.success()); + + // rm → appears only with --trashed. + assert!(f.run(&["org", "rm", "Recovery"]).status.success()); + let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string(); + assert!(!listed.contains("Recovery"), "trashed item still in default list: {listed}"); + let trashed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string(); + assert!(trashed.contains("Recovery"), "trashed item not in --trashed list: {trashed}"); + + // restore → back in default list. + assert!(f.run(&["org", "restore", "Recovery"]).status.success()); + let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string(); + assert!(listed.contains("Recovery"), "restore did not bring it back: {listed}"); + + // purge → blob gone, entry gone, item-purge trailer. + assert!(f.run(&["org", "purge", "Recovery"]).status.success()); + let prod_dir = f.vault_path().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 listed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string(); + assert!(!listed.contains("Recovery"), "purged item still listed: {listed}"); + + let log = Command::new("git") + .args(["-C", f.vault_str(), "log", "-1", "--format=%B"]) + .output().unwrap(); + let body = String::from_utf8_lossy(&log.stdout).to_string(); + assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}"); +} diff --git a/crates/relicario-cli/tests/org_lifecycle.rs b/crates/relicario-cli/tests/org_lifecycle.rs new file mode 100644 index 0000000..06e0ac9 --- /dev/null +++ b/crates/relicario-cli/tests/org_lifecycle.rs @@ -0,0 +1,206 @@ +use assert_cmd::cargo::CommandCargoExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use tempfile::TempDir; + +/// A device home + an org vault. A second device can be wired for multi-member. +struct Dev { + xdg: PathBuf, + _config: TempDir, +} + +impl Dev { + fn new(name: &str) -> Self { + let config = TempDir::new().unwrap(); + let xdg = config.path().to_path_buf(); + let devices = xdg.join("relicario").join("devices").join(name); + std::fs::create_dir_all(&devices).unwrap(); + let keyfile = devices.join("signing.key"); + let st = Command::new("ssh-keygen") + .args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"]) + .arg(&keyfile) + .stdout(Stdio::null()).stderr(Stdio::null()) + .status().expect("ssh-keygen"); + assert!(st.success()); + std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap(); + std::fs::write(xdg.join("relicario").join("devices").join("current"), format!("{name}\n")).unwrap(); + Dev { xdg, _config: config } + } + + fn pubkey(&self, name: &str) -> String { + std::fs::read_to_string( + self.xdg.join("relicario").join("devices").join(name).join("signing.pub"), + ).unwrap().trim().to_string() + } + + fn run(&self, vault: &Path, args: &[&str]) -> std::process::Output { + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.env("XDG_CONFIG_HOME", &self.xdg) + .env("RELICARIO_ORG_DIR", vault) + .args(args) + .stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + cmd.output().unwrap() + } +} + +fn owner_member_id(vault: &Path) -> String { + let s = std::fs::read_to_string(vault.join("members.json")).unwrap(); + let v: serde_json::Value = serde_json::from_str(&s).unwrap(); + v["members"][0]["member_id"].as_str().unwrap().to_string() +} + +/// Set up an org with the owner granted `prod` and one login item in it. +fn setup_with_item() -> (Dev, TempDir, String) { + let dev = Dev::new("laptop"); + let vault = TempDir::new().unwrap(); + let v = vault.path(); + assert!(dev.run(v, &["org", "init", "--dir", v.to_str().unwrap(), "--name", "Acme"]).status.success()); + let owner = owner_member_id(v); + assert!(dev.run(v, &["org", "create-collection", "prod", "--name", "Prod"]).status.success()); + assert!(dev.run(v, &["org", "grant", &owner, "prod"]).status.success()); + assert!(dev.run(v, &[ + "org", "add", "login", "--collection", "prod", + "--title", "GitHub", "--username", "alice", "--password", "hunter2", + ]).status.success()); + (dev, vault, owner) +} + +// (b) audit --format json parses + has expected actions. +#[test] +fn audit_format_json_is_valid_and_has_actions() { + let (dev, vault, _owner) = setup_with_item(); + let out = dev.run(vault.path(), &["org", "audit", "--format", "json"]); + assert!(out.status.success(), "audit json: {}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + let events: serde_json::Value = serde_json::from_str(&stdout).expect("audit json must parse"); + let arr = events.as_array().expect("array"); + let actions: Vec<&str> = arr.iter() + .filter_map(|e| e["action"].as_str()) + .collect(); + assert!(actions.contains(&"org-init"), "actions: {actions:?}"); + assert!(actions.contains(&"collection-create"), "actions: {actions:?}"); + assert!(actions.contains(&"item-create"), "actions: {actions:?}"); + // Honest signer attribution: none of these should be TAMPERED (signer == trailer). + assert!(arr.iter().all(|e| e["tampered"] == serde_json::Value::Bool(false))); +} + +// (a) a forged-trailer commit is flagged TAMPERED. +#[test] +fn forged_trailer_commit_is_flagged_tampered() { + let (dev, vault, owner) = setup_with_item(); + let v = vault.path(); + + // Hand-craft a SIGNED commit whose trailer CLAIMS a different actor id than + // the real signer. We reuse the org repo's own signing config (set by + // `org init`), so the commit verifies — but the trailer lies. + std::fs::write(v.join("decoy.txt"), "x").unwrap(); + let git = |args: &[&str]| { + Command::new("git").current_dir(v).args(args) + .env("XDG_CONFIG_HOME", &dev.xdg) + .output().unwrap() + }; + assert!(git(&["add", "decoy.txt"]).status.success()); + let forged_msg = format!( + "forged\n\nRelicario-Actor: impostor ffffffffffffffff\nRelicario-Action: item-update\nRelicario-Member: {owner}" + ); + // commit -S uses the repo's configured signing key (the real owner key). + let c = git(&["commit", "-S", "-m", &forged_msg]); + assert!(c.status.success(), "forged commit: {}", String::from_utf8_lossy(&c.stderr)); + + let out = dev.run(v, &["org", "audit", "--format", "json"]); + let events: serde_json::Value = + serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap(); + let forged = events.as_array().unwrap().iter() + .find(|e| e["action"] == "item-update") + .expect("forged item-update event present"); + // Trailer claims ffff... but the verified signer is the owner → TAMPERED. + assert_eq!(forged["tampered"], serde_json::Value::Bool(true)); + assert_eq!(forged["actor_id"].as_str(), Some(owner.as_str())); +} + +// (c) concurrent rotate-key aborts with the exact spec error string. +#[test] +fn concurrent_rotate_key_aborts_with_spec_string() { + let (dev, vault, _owner) = setup_with_item(); + let origin = TempDir::new().unwrap(); + let v = vault.path(); + let git = |args: &[&str]| Command::new("git").current_dir(v).args(args) + .env("XDG_CONFIG_HOME", &dev.xdg).output().unwrap(); + + // Make a bare origin and push, so a divergent upstream can be simulated. + assert!(Command::new("git").args(["init", "--bare", origin.path().to_str().unwrap()]) + .output().unwrap().status.success()); + assert!(git(&["remote", "add", "origin", origin.path().to_str().unwrap()]).status.success()); + assert!(git(&["push", "-u", "origin", "HEAD"]).status.success()); + + // Diverge upstream: a second clone commits + pushes, writing to a SHARED file + // so that `git pull --rebase` will hit a merge conflict (add/add or edit/edit) + // and exit non-zero — which is how run_rotate_key detects a concurrent rotation. + let clone2 = TempDir::new().unwrap(); + assert!(Command::new("git") + .args(["clone", origin.path().to_str().unwrap(), clone2.path().to_str().unwrap()]) + .output().unwrap().status.success()); + std::fs::write(clone2.path().join("conflict.txt"), "upstream-version").unwrap(); + for a in [&["add", "conflict.txt"][..], &["-c", "user.email=u@u", "-c", "user.name=u", "commit", "-m", "upstream"][..], &["push", "origin", "HEAD:master"][..], &["push", "origin", "HEAD:main"][..]] { + let _ = Command::new("git").current_dir(clone2.path()).args(a).output(); + } + // Local also writes conflict.txt with different content → add/add conflict on pull. + std::fs::write(v.join("conflict.txt"), "local-version").unwrap(); + assert!(git(&["add", "conflict.txt"]).status.success()); + assert!(git(&["-c", "commit.gpgsign=false", "commit", "-m", "local"]).status.success()); + + let out = dev.run(v, &["org", "rotate-key"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "rotate-key should abort on a concurrent rotation"); + assert!( + stderr.contains("Concurrent key rotation detected — pull and re-run org rotate-key."), + "missing spec error string: {stderr}" + ); +} + +// (d) remove-member → rotate-key → old clone cannot decrypt; remaining member can. +#[test] +fn removed_member_clone_cannot_decrypt_after_rotation() { + // Owner laptop sets up the org + a second member "bob". + let (owner_dev, vault, _owner) = setup_with_item(); + let v = vault.path(); + let bob = Dev::new("bob-laptop"); + let bob_pub = bob.pubkey("bob-laptop"); + + // Owner adds Bob and grants him prod. + assert!(owner_dev.run(v, &["org", "add-member", "--key", &bob_pub, "--name", "Bob", "--role", "member"]).status.success()); + let members = std::fs::read_to_string(v.join("members.json")).unwrap(); + let mv: serde_json::Value = serde_json::from_str(&members).unwrap(); + let bob_id = mv["members"].as_array().unwrap().iter() + .find(|m| m["display_name"] == "Bob").unwrap()["member_id"].as_str().unwrap().to_string(); + assert!(owner_dev.run(v, &["org", "grant", &bob_id, "prod"]).status.success()); + + // Bob clones the vault dir (his device, his key blob is present). + // `cp -r /vault /dst/` places contents at `/dst//` — use that + // sub-path, not the TempDir root, as the vault for Bob's commands. + let bob_clone = TempDir::new().unwrap(); + let vault_basename = v.file_name().unwrap(); + let cp = Command::new("cp").args(["-r", v.to_str().unwrap(), bob_clone.path().to_str().unwrap()]).output().unwrap(); + assert!(cp.status.success()); + let bob_vault = bob_clone.path().join(vault_basename); + // Bob can read the item BEFORE removal. + let pre = bob.run(&bob_vault, &["org", "get", "GitHub", "--show"]); + assert!(String::from_utf8_lossy(&pre.stdout).contains("hunter2"), "bob should read pre-removal"); + + // Owner removes Bob and rotates the key in the live vault. + assert!(owner_dev.run(v, &["org", "remove-member", &bob_id]).status.success()); + assert!(owner_dev.run(v, &["org", "rotate-key"]).status.success()); + + // Owner (remaining member) can still decrypt in the live vault. + let owner_get = owner_dev.run(v, &["org", "get", "GitHub", "--show"]); + assert!(String::from_utf8_lossy(&owner_get.stdout).contains("hunter2"), "owner must still read"); + + // Copy the rotated item + manifest into Bob's stale clone (simulating a + // pull) — his OLD key blob can no longer unwrap the rotated org key. + let _ = Command::new("cp").args(["-r", + v.join("items").to_str().unwrap(), bob_vault.to_str().unwrap()]).output(); + let _ = std::fs::copy(v.join("manifest.enc"), bob_vault.join("manifest.enc")); + let post = bob.run(&bob_vault, &["org", "get", "GitHub", "--show"]); + assert!(!post.status.success() || !String::from_utf8_lossy(&post.stdout).contains("hunter2"), + "removed member must NOT decrypt post-rotation: {}", String::from_utf8_lossy(&post.stdout)); +}