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