From 07862b8d4436465c8ef81e35db8d53ee1cede426 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 17:26:48 -0400 Subject: [PATCH 01/12] test(cli/org): failing Card/Key/Totp org add round-trips (B4, pre-A-integration) Adds run_stdin + create_collection_and_grant fixture helpers and three acceptance tests for org add card/key/totp. Red until B1/B2 wire the subcommands (currently: unrecognized subcommand). Asserts org get masks card number + key material without --show. Edit round-trips land with B3. --- crates/relicario-cli/tests/org_items.rs | 106 ++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/crates/relicario-cli/tests/org_items.rs b/crates/relicario-cli/tests/org_items.rs index 3d0105b..bd7197c 100644 --- a/crates/relicario-cli/tests/org_items.rs +++ b/crates/relicario-cli/tests/org_items.rs @@ -67,6 +67,39 @@ impl OrgFixture { let v: serde_json::Value = serde_json::from_str(&s).unwrap(); v["members"][0]["member_id"].as_str().unwrap().to_string() } + + /// Like `run`, but pipes `stdin_data` into the child's stdin — used to drive + /// `--*-stdin` secret flags and the interactive edit prompts. `wait_with_output` + /// closes stdin for us, so multiline secrets (read-to-EOF) terminate cleanly. + fn run_stdin(&self, args: &[&str], stdin_data: &str) -> std::process::Output { + use std::io::Write as _; + let mut child = Command::cargo_bin("relicario") + .unwrap() + .env("XDG_CONFIG_HOME", &self.xdg) + .env("RELICARIO_ORG_DIR", self.vault.path()) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.as_mut().unwrap().write_all(stdin_data.as_bytes()).unwrap(); + child.wait_with_output().unwrap() + } + + /// Create collection `slug` and grant the owner access to it — the common + /// setup the item-type round-trip tests share. + fn create_collection_and_grant(&self, slug: &str) { + let owner = self.owner_member_id(); + assert!( + self.run(&["org", "create-collection", slug, "--name", slug]).status.success(), + "create-collection {slug} failed", + ); + assert!( + self.run(&["org", "grant", &owner, slug]).status.success(), + "grant {slug} failed", + ); + } } #[test] @@ -215,3 +248,76 @@ fn org_rm_restore_purge_cycle() { let body = String::from_utf8_lossy(&log.stdout).to_string(); assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}"); } + +// --- v0.8.1 org item-type parity: Card / Key / Totp ------------------------- +// These drive the new `org add ` subcommands. Secrets enter via +// `--*-stdin` (read from piped stdin) or, for Totp, the `--secret` flag. `org get` +// must mask every secret unless `--show` is passed — asserted below. + +#[test] +fn org_add_card_via_stdin_then_get_masks_secret() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + + // build_card reads number, then cvv, then pin — one line each, in that order. + let out = f.run_stdin( + &[ + "org", "add", "card", "--collection", "eng", "--title", "Corp Visa", + "--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin", + ], + "4111111111111111\n123\n4321\n", + ); + assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr)); + + // get masks the card number by default. + let got = f.run(&["org", "get", "Corp Visa"]); + let stdout = String::from_utf8_lossy(&got.stdout).to_string(); + assert!(stdout.contains("Corp Visa"), "title missing: {stdout}"); + assert!(stdout.contains("********"), "card number must be masked without --show: {stdout}"); + assert!(!stdout.contains("4111111111111111"), "secret leaked without --show: {stdout}"); + + // --show reveals it. + let shown = f.run(&["org", "get", "Corp Visa", "--show"]); + let shown = String::from_utf8_lossy(&shown.stdout).to_string(); + assert!(shown.contains("4111111111111111"), "number not revealed with --show: {shown}"); +} + +#[test] +fn org_add_key_via_stdin_then_get_masks_material() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + + // build_key reads key material from stdin to EOF (multiline secret). + let out = f.run_stdin( + &[ + "org", "add", "key", "--collection", "eng", "--title", "Deploy Key", + "--label", "ci", "--algorithm", "ed25519", "--material-stdin", + ], + "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAsecretmaterial\n-----END OPENSSH PRIVATE KEY-----\n", + ); + assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr)); + + let got = f.run(&["org", "get", "Deploy Key"]); + let stdout = String::from_utf8_lossy(&got.stdout).to_string(); + assert!(stdout.contains("Label: ci"), "label missing: {stdout}"); + assert!(stdout.contains("********"), "key material must be masked without --show: {stdout}"); + assert!(!stdout.contains("secretmaterial"), "key material leaked without --show: {stdout}"); +} + +#[test] +fn org_add_totp_with_secret_flag_round_trips() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + + // Totp accepts the base32 secret via --secret (no stdin needed). + let out = f.run(&[ + "org", "add", "totp", "--collection", "eng", "--title", "AWS root", + "--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP", + ]); + assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr)); + + let got = f.run(&["org", "get", "AWS root"]); + let stdout = String::from_utf8_lossy(&got.stdout).to_string(); + assert!(stdout.contains("AWS root"), "title missing: {stdout}"); + assert!(stdout.contains("Issuer: AWS"), "issuer missing: {stdout}"); +} From 82feb49ab4e66926f8050457917be1b55e9cff73 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 18:31:29 -0400 Subject: [PATCH 02/12] feat(cli/org): org add parity for Card/Key/Totp via shared builders --- crates/relicario-cli/src/commands/org.rs | 88 ++++++++++++++---------- crates/relicario-cli/src/main.rs | 62 +++++++++++++++-- 2 files changed, 110 insertions(+), 40 deletions(-) diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index dbb8fa0..33dd3ae 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -6,12 +6,13 @@ use std::path::Path; use anyhow::{Context, Result}; use relicario_core::{ generate_org_key, wrap_org_key, - CollectionDef, Item, ItemCore, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, + CollectionDef, Item, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, OrgRole, OrgMember, encrypt_org_manifest, }; use crate::org_session::atomic_write; +use crate::commands::item_build as ib; pub fn run_init(dir: &Path, name: &str) -> Result<()> { // Create directory structure @@ -745,17 +746,20 @@ pub fn run_audit( Ok(()) } -/// Item kinds `org add` supports without interactive prompts. +/// Item kinds `org add` supports. Secrets resolve via `--*-stdin` flags or an +/// interactive prompt inside the shared `item_build` builders. pub enum OrgAddKind { Login { title: String, username: Option, url: Option, password: Option, + password_stdin: bool, }, SecureNote { title: String, - body: String, + body: Option, + body_stdin: bool, }, Identity { title: String, @@ -763,43 +767,56 @@ pub enum OrgAddKind { email: Option, phone: Option, }, + Card { + title: String, + holder: Option, + expiry: Option, + kind: String, + number_stdin: bool, + cvv_stdin: bool, + pin_stdin: bool, + }, + Key { + title: String, + label: Option, + algorithm: Option, + public_key: Option, + material_stdin: bool, + }, + Totp { + title: String, + issuer: Option, + label: Option, + secret: Option, + secret_stdin: bool, + period: u32, + digits: u8, + algorithm: String, + }, + // Document is added later by Dev-C. } -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, - })) +fn build_org_item(kind: OrgAddKind) -> Result { + match kind { + OrgAddKind::Login { title, username, url, password, password_stdin } => { + ib::build_login(title, username, url, password, password_stdin, false, None) } - OrgAddKind::SecureNote { title, body } => { - Item::new(title, ItemCore::SecureNote(SecureNoteCore { - body: Zeroizing::new(body), - })) + OrgAddKind::SecureNote { title, body, body_stdin } => { + ib::build_secure_note(title, body, body_stdin) } OrgAddKind::Identity { title, full_name, email, phone } => { - Item::new(title, ItemCore::Identity(IdentityCore { - full_name, - address: None, - phone, - email, - date_of_birth: None, - })) + ib::build_identity(title, full_name, email, phone, None) } - }; - item.tags = tags; - Ok(item) + OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin } => { + ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin) + } + OrgAddKind::Key { title, label, algorithm, public_key, material_stdin } => { + ib::build_key(title, label, algorithm, public_key, material_stdin) + } + OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm } => { + ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm) + } + } } pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec) -> Result<()> { @@ -816,7 +833,8 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec // …and the caller must hold a grant for it. UnlockedOrgVault::ensure_grant(&caller, collection)?; - let item = build_org_item(kind, tags)?; + let mut item = build_org_item(kind)?; + item.tags = tags; let item_rel = vault.save_item(collection, &item)?; // Upsert the manifest entry (collection slug stored plaintext inside the diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index e3f586a..29e0b87 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -566,13 +566,15 @@ pub(crate) enum OrgAddKind { #[arg(long)] url: Option, #[arg(long)] password: Option, #[arg(long, value_delimiter = ',')] tags: Vec, + #[arg(long)] password_stdin: bool, }, /// A secure note. SecureNote { #[arg(long)] collection: String, #[arg(long)] title: String, - #[arg(long)] body: String, + #[arg(long)] body: Option, #[arg(long, value_delimiter = ',')] tags: Vec, + #[arg(long)] body_stdin: bool, }, /// An identity record. Identity { @@ -583,6 +585,41 @@ pub(crate) enum OrgAddKind { #[arg(long)] phone: Option, #[arg(long, value_delimiter = ',')] tags: Vec, }, + /// A payment card (number / cvv / pin entered via --*-stdin or prompt). + Card { + #[arg(long)] collection: String, + #[arg(long)] title: String, + #[arg(long)] holder: Option, + #[arg(long)] expiry: Option, + #[arg(long, default_value = "credit")] kind: String, + #[arg(long, value_delimiter = ',')] tags: Vec, + #[arg(long)] number_stdin: bool, + #[arg(long)] cvv_stdin: bool, + #[arg(long)] pin_stdin: bool, + }, + /// A key / credential blob (material entered via --material-stdin or prompt). + Key { + #[arg(long)] collection: String, + #[arg(long)] title: String, + #[arg(long)] label: Option, + #[arg(long)] algorithm: Option, + #[arg(long)] public_key: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + #[arg(long)] material_stdin: bool, + }, + /// A TOTP authenticator (base32 secret via --secret or --secret-stdin). + Totp { + #[arg(long)] collection: String, + #[arg(long)] title: String, + #[arg(long)] issuer: Option, + #[arg(long)] label: Option, + #[arg(long)] secret: Option, + #[arg(long, default_value_t = 30)] period: u32, + #[arg(long, default_value_t = 6)] digits: u8, + #[arg(long, default_value = "sha1")] algorithm: String, + #[arg(long, value_delimiter = ',')] tags: Vec, + #[arg(long)] secret_stdin: bool, + }, } fn main() -> Result<()> { @@ -676,14 +713,14 @@ fn main() -> Result<()> { 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 } => ( + OrgAddKind::Login { collection, title, username, url, password, tags, password_stdin } => ( collection, - commands::org::OrgAddKind::Login { title, username, url, password }, + commands::org::OrgAddKind::Login { title, username, url, password, password_stdin }, tags, ), - OrgAddKind::SecureNote { collection, title, body, tags } => ( + OrgAddKind::SecureNote { collection, title, body, tags, body_stdin } => ( collection, - commands::org::OrgAddKind::SecureNote { title, body }, + commands::org::OrgAddKind::SecureNote { title, body, body_stdin }, tags, ), OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => ( @@ -691,6 +728,21 @@ fn main() -> Result<()> { commands::org::OrgAddKind::Identity { title, full_name, email, phone }, tags, ), + OrgAddKind::Card { collection, title, holder, expiry, kind, tags, number_stdin, cvv_stdin, pin_stdin } => ( + collection, + commands::org::OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin }, + tags, + ), + OrgAddKind::Key { collection, title, label, algorithm, public_key, tags, material_stdin } => ( + collection, + commands::org::OrgAddKind::Key { title, label, algorithm, public_key, material_stdin }, + tags, + ), + OrgAddKind::Totp { collection, title, issuer, label, secret, period, digits, algorithm, tags, secret_stdin } => ( + collection, + commands::org::OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm }, + tags, + ), }; commands::org::run_add(&d, &collection, add_kind, tags)?; } From 290bc4e2d04488201b1c70c5f1b61de181de84a6 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 20:43:03 -0400 Subject: [PATCH 03/12] feat(cli/org): interactive per-type org edit via shared edit helpers --- crates/relicario-cli/src/commands/org.rs | 57 +++++++++--------------- crates/relicario-cli/src/main.rs | 16 +++---- crates/relicario-cli/tests/org_items.rs | 53 ++++++++++++++++++---- 3 files changed, 72 insertions(+), 54 deletions(-) diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index 33dd3ae..675b7b5 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -991,21 +991,9 @@ 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<()> { +pub fn run_edit(dir: &Path, query: &str, totp_qr: 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()?; @@ -1017,31 +1005,28 @@ pub fn run_edit( crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?; let mut item = vault.load_item(&collection, &id)?; + eprintln!( + "Editing: {} ({}) — leave a prompt blank to keep the current value.", + item.title, + item.id.as_str() + ); + if let Some(v) = crate::prompt::prompt_keep("Title", &item.title)? { + item.title = v; + } - if let Some(t) = title { item.title = t; } - + let history = &mut item.field_history; 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"), + ItemCore::Login(l) => ib::edit_login(l, history, totp_qr)?, + ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?, + ItemCore::Identity(i) => ib::edit_identity(i)?, + ItemCore::Card(c) => ib::edit_card(c, history)?, + ItemCore::Key(k) => ib::edit_key(k, history)?, + ItemCore::Document(_) => ib::edit_document_message(), + ItemCore::Totp(t) => ib::edit_totp(t, history)?, } 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)?; @@ -1053,12 +1038,14 @@ pub fn run_edit( ); 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() + 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()); + println!("Updated {} ({}) in `{}`", item.title, item.id.as_str(), collection); Ok(()) } diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 29e0b87..a52c153 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -535,18 +535,12 @@ pub(crate) enum OrgCommands { List { #[arg(long)] trashed: bool, }, - /// Edit an org item's fields (flag-driven; blank flags keep current values). + /// Edit an org item interactively (per-type prompts; blank keeps current). 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, + /// Replace the login TOTP secret from a QR image. + #[arg(long)] totp_qr: Option, }, /// Soft-delete an org item (reversible via `org restore`). Rm { query: String }, @@ -754,9 +748,9 @@ fn main() -> Result<()> { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_list(&d, trashed)?; } - OrgCommands::Edit { query, title, username, url, password, body, email, phone, full_name } => { + OrgCommands::Edit { query, totp_qr } => { let d = crate::org_session::org_dir(dir_path)?; - commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?; + commands::org::run_edit(&d, &query, totp_qr)?; } OrgCommands::Rm { query } => { let d = crate::org_session::org_dir(dir_path)?; diff --git a/crates/relicario-cli/tests/org_items.rs b/crates/relicario-cli/tests/org_items.rs index bd7197c..f5f6a64 100644 --- a/crates/relicario-cli/tests/org_items.rs +++ b/crates/relicario-cli/tests/org_items.rs @@ -184,21 +184,17 @@ fn org_add_rejects_unknown_collection() { #[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()); + f.create_collection_and_grant("prod"); 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", - ]); + // org edit is now interactive per-type: keep title, set username=new-user, + // keep URL, decline password change. + let out = f.run_stdin(&["org", "edit", "Mail"], "\nnew-user\n\nn\n"); 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}"); @@ -321,3 +317,44 @@ fn org_add_totp_with_secret_flag_round_trips() { assert!(stdout.contains("AWS root"), "title missing: {stdout}"); assert!(stdout.contains("Issuer: AWS"), "issuer missing: {stdout}"); } + +#[test] +fn org_edit_card_interactive_changes_holder() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + let out = f.run_stdin( + &[ + "org", "add", "card", "--collection", "eng", "--title", "Corp Visa", + "--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin", + ], + "4111111111111111\n123\n4321\n", + ); + assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr)); + + // Interactive edit: keep title, set holder, decline number change. + let out = f.run_stdin(&["org", "edit", "Corp Visa"], "\nJane Q. Public\nn\n"); + assert!(out.status.success(), "org edit card: {}", String::from_utf8_lossy(&out.stderr)); + + let got = f.run(&["org", "get", "Corp Visa"]); + let stdout = String::from_utf8_lossy(&got.stdout).to_string(); + assert!(stdout.contains("Holder: Jane Q. Public"), "holder edit did not take: {stdout}"); + assert!(stdout.contains("********"), "number must stay masked after declining change: {stdout}"); + assert!(!stdout.contains("4111111111111111"), "number leaked without --show: {stdout}"); +} + +#[test] +fn org_edit_totp_interactive_changes_issuer() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + assert!(f.run(&[ + "org", "add", "totp", "--collection", "eng", "--title", "AWS root", + "--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP", + ]).status.success()); + + // Interactive edit: keep title, set issuer=GitHub, keep label, decline secret change. + let out = f.run_stdin(&["org", "edit", "AWS root"], "\nGitHub\n\nn\n"); + assert!(out.status.success(), "org edit totp: {}", String::from_utf8_lossy(&out.stderr)); + + let got = f.run(&["org", "get", "AWS root"]); + assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: GitHub"), "issuer edit did not take"); +} From 04ad98973ac1ab33e88428d7377980c28a6f23f3 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 20:49:12 -0400 Subject: [PATCH 04/12] test(cli/org): adapt grant-denial edit case to interactive org edit B3 dropped the flat --username/--url/... flags from `org edit`, so the ungranted-member denial test must drive the bare interactive form. The ungranted member is now rejected at manifest lookup (filter_for_member + resolve_org_query) before any prompt is read. --- crates/relicario-cli/tests/org_authz.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/relicario-cli/tests/org_authz.rs b/crates/relicario-cli/tests/org_authz.rs index 0017a29..d8b4e06 100644 --- a/crates/relicario-cli/tests/org_authz.rs +++ b/crates/relicario-cli/tests/org_authz.rs @@ -152,7 +152,9 @@ fn org_get_edit_rm_restore_purge_reject_ungranted_member() { ); for (label, args) in [ - ("edit", vec!["org", "edit", "GitHub", "--username", "evil"]), + // `org edit` is now interactive (no flat flags); the ungranted member is + // rejected at manifest lookup, before any prompt is read. + ("edit", vec!["org", "edit", "GitHub"]), ("rm", vec!["org", "rm", "GitHub"]), ("restore", vec!["org", "restore", "GitHub"]), ("purge", vec!["org", "purge", "GitHub"]), @@ -170,13 +172,12 @@ fn org_get_edit_rm_restore_purge_reject_ungranted_member() { } // The item is untouched: the owner can still read the original password and - // the username was NOT changed to the ungranted member's "evil" attempt. + // username — the ungranted member's get/edit/rm/restore/purge were all denied. let owner_get = owner_dev.run(vault, &["org", "get", "GitHub", "--show"]); let owner_out = String::from_utf8_lossy(&owner_get.stdout).to_string(); assert!(owner_get.status.success(), "owner should still read the item"); assert!(owner_out.contains("hunter2"), "owner read must still show original password: {owner_out}"); - assert!(owner_out.contains("alice"), "edit by ungranted member must not have changed username: {owner_out}"); - assert!(!owner_out.contains("evil"), "ungranted edit leaked through: {owner_out}"); + assert!(owner_out.contains("alice"), "ungranted member must not have modified the item: {owner_out}"); } #[test] From e76d7167d63a002393e69c00ac2ada858ab53f35 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 20:58:26 -0400 Subject: [PATCH 05/12] test(cli/org): grant enforcement + body/secret-stdin + key-edit coverage Closes the minor coverage gaps from the final adversarial review: - org add card/key/totp reject ungranted + unknown collections (pins the grant gate on the new write paths, which runs before any secret prompt) - secure-note --body-stdin masks body; totp --secret-stdin round-trips (completes the --*-stdin matrix for the org surface) - key-material edit accept-branch round-trip, verified via get --show --- crates/relicario-cli/tests/org_items.rs | 98 +++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/crates/relicario-cli/tests/org_items.rs b/crates/relicario-cli/tests/org_items.rs index f5f6a64..d98883b 100644 --- a/crates/relicario-cli/tests/org_items.rs +++ b/crates/relicario-cli/tests/org_items.rs @@ -358,3 +358,101 @@ fn org_edit_totp_interactive_changes_issuer() { let got = f.run(&["org", "get", "AWS root"]); assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: GitHub"), "issuer edit did not take"); } + +// --- grant enforcement + remaining --*-stdin paths for the new types --------- + +#[test] +fn org_add_card_key_totp_reject_ungranted_and_unknown_collection() { + let f = OrgFixture::new(); + // `secret` exists but is NOT granted to the owner. + assert!(f.run(&["org", "create-collection", "secret", "--name", "secret"]).status.success()); + + // ensure_grant runs before any secret prompt in run_add, so these need no + // stdin — each new type must be rejected for a collection it lacks a grant for. + for args in [ + vec!["org", "add", "card", "--collection", "secret", "--title", "X", "--kind", "credit"], + vec!["org", "add", "key", "--collection", "secret", "--title", "X"], + vec!["org", "add", "totp", "--collection", "secret", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"], + ] { + let out = f.run(&args); + assert!(!out.status.success(), "ungranted add must fail: {args:?}"); + let err = String::from_utf8_lossy(&out.stderr).to_string(); + assert!(err.contains("access denied") || err.contains("grant"), + "expected grant denial for {args:?}: {err}"); + } + + // …and rejected for a nonexistent collection. + for args in [ + vec!["org", "add", "card", "--collection", "ghost", "--title", "X", "--kind", "credit"], + vec!["org", "add", "key", "--collection", "ghost", "--title", "X"], + vec!["org", "add", "totp", "--collection", "ghost", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"], + ] { + let out = f.run(&args); + assert!(!out.status.success(), "unknown-collection add must fail: {args:?}"); + let err = String::from_utf8_lossy(&out.stderr).to_string(); + assert!(err.contains("does not exist") || err.contains("ghost"), + "expected unknown-collection error for {args:?}: {err}"); + } +} + +#[test] +fn org_add_secure_note_via_body_stdin_masks_body() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + // build_secure_note(body_stdin=true) reads the body from stdin to EOF. + let out = f.run_stdin( + &["org", "add", "secure-note", "--collection", "eng", "--title", "Runbook", "--body-stdin"], + "line one\nsuper-secret-line\n", + ); + assert!(out.status.success(), "add note: {}", String::from_utf8_lossy(&out.stderr)); + + let got = f.run(&["org", "get", "Runbook"]); + let stdout = String::from_utf8_lossy(&got.stdout).to_string(); + assert!(stdout.contains("********"), "note body must be masked without --show: {stdout}"); + assert!(!stdout.contains("super-secret-line"), "note body leaked without --show: {stdout}"); + + let shown = f.run(&["org", "get", "Runbook", "--show"]); + assert!(String::from_utf8_lossy(&shown.stdout).contains("super-secret-line"), "body not revealed with --show"); +} + +#[test] +fn org_add_totp_via_secret_stdin_round_trips() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + // build_totp(secret_stdin=true) reads one base32 line from stdin. + let out = f.run_stdin( + &["org", "add", "totp", "--collection", "eng", "--title", "VPN", "--issuer", "Corp", "--secret-stdin"], + "JBSWY3DPEHPK3PXP\n", + ); + assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr)); + + let got = f.run(&["org", "get", "VPN"]); + assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: Corp"), "issuer missing"); +} + +#[test] +fn org_edit_key_replaces_material_and_reveals_with_show() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + let out = f.run_stdin( + &["org", "add", "key", "--collection", "eng", "--title", "Signing Key", + "--label", "ci", "--material-stdin"], + "OLD-MATERIAL-aaaa\n", + ); + assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr)); + + // Interactive edit: keep title, ACCEPT "Replace key material?" -> new material + // read from stdin to EOF (edit_key). Exercises the accept branch + history push. + let out = f.run_stdin(&["org", "edit", "Signing Key"], "\ny\nNEW-MATERIAL-bbbb\n"); + assert!(out.status.success(), "org edit key: {}", String::from_utf8_lossy(&out.stderr)); + + let masked = f.run(&["org", "get", "Signing Key"]); + let masked = String::from_utf8_lossy(&masked.stdout).to_string(); + assert!(masked.contains("********"), "material must be masked without --show: {masked}"); + assert!(!masked.contains("NEW-MATERIAL"), "material leaked without --show: {masked}"); + + let shown = f.run(&["org", "get", "Signing Key", "--show"]); + let shown = String::from_utf8_lossy(&shown.stdout).to_string(); + assert!(shown.contains("NEW-MATERIAL-bbbb"), "replaced material not revealed with --show: {shown}"); + assert!(!shown.contains("OLD-MATERIAL"), "old material still present after replace: {shown}"); +} From bccd113f5571877850e861f348c13201c89873ef Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 17:24:44 -0400 Subject: [PATCH 06/12] feat(cli/org): collection-scoped attachment storage + default cap --- crates/relicario-cli/src/org_session.rs | 57 ++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/crates/relicario-cli/src/org_session.rs b/crates/relicario-cli/src/org_session.rs index f0c0176..b7a09af 100644 --- a/crates/relicario-cli/src/org_session.rs +++ b/crates/relicario-cli/src/org_session.rs @@ -9,9 +9,16 @@ use zeroize::Zeroizing; use relicario_core::{ decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest, - Item, ItemId, MemberId, OrgCollections, OrgManifest, OrgMember, OrgMembers, OrgMeta, + AttachmentId, EncryptedAttachment, Item, ItemId, MemberId, OrgCollections, OrgManifest, + OrgMember, OrgMembers, OrgMeta, }; +/// Default per-attachment cap for org vaults. Org vaults have no settings.enc, +/// so this mirrors the personal-vault default +/// `AttachmentCaps::per_attachment_max_bytes` at +/// crates/relicario-core/src/settings.rs:116. +pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 10 * 1024 * 1024; + pub struct UnlockedOrgVault { pub root: PathBuf, pub org_key: Zeroizing<[u8; 32]>, @@ -115,6 +122,38 @@ impl UnlockedOrgVault { } } + pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf { + self.root.join("attachments").join(collection_slug) + .join(item_id.as_str()).join(format!("{}.enc", att_id.as_str())) + } + + /// Encrypt-already-done blob: persist it and return the repo-relative path for git staging. + pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result { + let path = self.attachment_path(collection_slug, item_id, &enc.id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + atomic_write(&path, &enc.bytes)?; + Ok(format!("attachments/{}/{}/{}.enc", collection_slug, item_id.as_str(), enc.id.as_str())) + } + + pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result>> { + let path = self.attachment_path(collection_slug, item_id, att_id); + let bytes = fs::read(&path).with_context(|| format!("read attachment {}", path.display()))?; + Ok(relicario_core::decrypt_attachment(&bytes, &self.org_key)?) + } + + /// Remove an item's whole attachment directory. Missing dir is NOT an error + /// (mirrors `remove_item`'s NotFound-tolerant behavior, for partial-write recovery). + pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()> { + let dir = self.root.join("attachments").join(collection_slug).join(item_id.as_str()); + match fs::remove_dir_all(&dir) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(anyhow::Error::from(e).context(format!("remove {}", dir.display()))), + } + } + /// Bail unless `member` has `slug` in their collection grants. The slug /// existence check is done separately by the caller against collections.json. pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> { @@ -292,6 +331,22 @@ mod tests { assert_eq!(loaded.entries.len(), 1); } + #[test] + fn attachment_round_trip_collection_scoped() { + use relicario_core::encrypt_attachment; + let key = Zeroizing::new([7u8; 32]); + let (dir, vault) = make_vault(key); + let _ = dir; // keep tempdir alive + let item_id = ItemId::new(); + let enc = encrypt_attachment(b"hello world", &vault.org_key, DEFAULT_ORG_ATTACHMENT_MAX_BYTES).unwrap(); + let rel = vault.save_attachment("eng", &item_id, &enc).unwrap(); + assert_eq!(rel, format!("attachments/eng/{}/{}.enc", item_id.as_str(), enc.id.as_str())); + let got = vault.load_attachment("eng", &item_id, &enc.id).unwrap(); + assert_eq!(got.as_slice(), b"hello world"); + vault.remove_item_attachments("eng", &item_id).unwrap(); + assert!(vault.load_attachment("eng", &item_id, &enc.id).is_err()); + } + #[test] fn save_and_load_members() { let key = Zeroizing::new([0u8; 32]); From 68c6da4d67ea57bd436c83c7e1bdfd5dc6342136 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 17:33:15 -0400 Subject: [PATCH 07/12] chore(cli/org): silence dead_code on not-yet-consumed attachment API --- crates/relicario-cli/src/org_session.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/relicario-cli/src/org_session.rs b/crates/relicario-cli/src/org_session.rs index b7a09af..d0a5b33 100644 --- a/crates/relicario-cli/src/org_session.rs +++ b/crates/relicario-cli/src/org_session.rs @@ -17,6 +17,10 @@ use relicario_core::{ /// so this mirrors the personal-vault default /// `AttachmentCaps::per_attachment_max_bytes` at /// crates/relicario-core/src/settings.rs:116. +// Attachment API — consumed by `org add document`, Document edit, and purge +// landing in Tasks C2/C3; `load_attachment` additionally backs a future +// org document read/extract. Allow dead_code until those consumers land. +#[allow(dead_code)] pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 10 * 1024 * 1024; pub struct UnlockedOrgVault { @@ -122,12 +126,14 @@ impl UnlockedOrgVault { } } + #[allow(dead_code)] pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf { self.root.join("attachments").join(collection_slug) .join(item_id.as_str()).join(format!("{}.enc", att_id.as_str())) } /// Encrypt-already-done blob: persist it and return the repo-relative path for git staging. + #[allow(dead_code)] pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result { let path = self.attachment_path(collection_slug, item_id, &enc.id); if let Some(parent) = path.parent() { @@ -137,6 +143,7 @@ impl UnlockedOrgVault { Ok(format!("attachments/{}/{}/{}.enc", collection_slug, item_id.as_str(), enc.id.as_str())) } + #[allow(dead_code)] pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result>> { let path = self.attachment_path(collection_slug, item_id, att_id); let bytes = fs::read(&path).with_context(|| format!("read attachment {}", path.display()))?; @@ -145,6 +152,7 @@ impl UnlockedOrgVault { /// Remove an item's whole attachment directory. Missing dir is NOT an error /// (mirrors `remove_item`'s NotFound-tolerant behavior, for partial-write recovery). + #[allow(dead_code)] pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()> { let dir = self.root.join("attachments").join(collection_slug).join(item_id.as_str()); match fs::remove_dir_all(&dir) { From db0ab1d82e545360d23348192d0794bfeec29b1b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 18:10:15 -0400 Subject: [PATCH 08/12] docs(formats): org collection-scoped attachment layout + default cap Document the attachments///.enc layout (exactly 3 segments, slug-authorized by the pre-receive hook, never decrypted server-side) and DEFAULT_ORG_ATTACHMENT_MAX_BYTES = 10 MiB, citing org_session.rs:24 and the mirrored personal default settings.rs:116. --- docs/FORMATS.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/FORMATS.md b/docs/FORMATS.md index a8387a0..2890ec2 100644 --- a/docs/FORMATS.md +++ b/docs/FORMATS.md @@ -82,6 +82,7 @@ collections.json # collection definitions keys/.enc # org master key wrapped to that member's device key manifest.enc # OrgManifest (schema_version 1, per-member-filtered) items//.enc # collection-scoped item blobs +attachments///.enc # Document attachment blobs (collection-scoped) ``` ### `org.json` — OrgMeta @@ -123,7 +124,13 @@ Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org mas These blobs are written and read by the `relicario org` item commands (`org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge`), all collection-scoped and grant-enforced. `org add` currently creates Login / SecureNote / Identity items; `get` / `list` display any item type present. -**TODO (extension follow-up):** extension UI for browsing and editing org vault items. **Deferred:** `org add` / `edit` parity for Card / Key / Document / Totp item types. +### `attachments///.enc` + +Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org master key — the encrypted file payload of a Document item. As with item blobs, the blob does **not** name its collection; the leading `` path segment carries it, so the pre-receive hook (`relicario-server`, `classify_path`) authorizes the write by slug without decrypting — reusing the same grant + slug-existence check as the `items/` branch. The path is **exactly three segments** after `attachments/` (`//.enc`); the hook rejects any other shape (segment-count and `.`-free slug guards). `` is the content-addressed `AttachmentId` (see **Item IDs and Field IDs** below). + +Per-attachment size is capped at `DEFAULT_ORG_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024` (10 MiB) (`org_session.rs:24`), mirroring the personal-vault default `AttachmentCaps::per_attachment_max_bytes` (`settings.rs:116`). Org vaults have no `settings.enc`, so this cap is a fixed default rather than per-org configurable. Blobs are persisted / read / removed by `UnlockedOrgVault::save_attachment` / `load_attachment` / `remove_item_attachments` (`org_session.rs:137`, `:147`, `:156`). The storage primitives back the org **Document** item type; the `org add document` / Document-edit commands that produce these blobs land in v0.8.1 (see the item-type-parity note below). + +**TODO (extension follow-up):** extension UI for browsing and editing org vault items. **Deferred:** `org add` / `edit` parity for Card / Key / Document / Totp item types (landing in v0.8.1; Document file payloads use the attachment layout above). ## Item IDs and Field IDs From bd323d8b1b0c80439c5a6d84806794da0986303d Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 21:13:26 -0400 Subject: [PATCH 09/12] feat(cli/org): org add document with collection-scoped attachment --- crates/relicario-cli/src/commands/org.rs | 24 +++++++++++++++++------- crates/relicario-cli/src/main.rs | 12 ++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index 675b7b5..7b85aa7 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -793,7 +793,7 @@ pub enum OrgAddKind { digits: u8, algorithm: String, }, - // Document is added later by Dev-C. + Document { title: String, file: std::path::PathBuf }, } fn build_org_item(kind: OrgAddKind) -> Result { @@ -816,6 +816,7 @@ fn build_org_item(kind: OrgAddKind) -> Result { OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm } => { ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm) } + OrgAddKind::Document { .. } => unreachable!("Document handled in run_add before build_org_item"), } } @@ -833,7 +834,16 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec // …and the caller must hold a grant for it. UnlockedOrgVault::ensure_grant(&caller, collection)?; - let mut item = build_org_item(kind)?; + // Build the item; Document additionally yields an encrypted attachment to persist. + let (mut item, attachment_rel): (relicario_core::Item, Option) = match kind { + OrgAddKind::Document { title, file } => { + let (item, enc) = ib::build_document( + title, file, vault.key(), crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?; + let rel = vault.save_attachment(collection, &item.id, &enc)?; + (item, Some(rel)) + } + other => (build_org_item(other)?, None), + }; item.tags = tags; let item_rel = vault.save_item(collection, &item)?; @@ -855,11 +865,11 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec collection, item.id.as_str() ); - crate::org_session::org_git_run( - &vault.root, - &["add", &item_rel, "manifest.enc"], - "org add: git add", - )?; + let mut add_args: Vec<&str> = vec!["add", &item_rel, "manifest.enc"]; + if let Some(ref rel) = attachment_rel { + add_args.insert(1, rel); + } + crate::org_session::org_git_run(&vault.root, &add_args, "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); diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index a52c153..e7069ee 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -614,6 +614,13 @@ pub(crate) enum OrgAddKind { #[arg(long, value_delimiter = ',')] tags: Vec, #[arg(long)] secret_stdin: bool, }, + /// A document (file payload encrypted into a collection-scoped attachment). + Document { + #[arg(long)] collection: String, + #[arg(long)] title: String, + #[arg(long)] file: std::path::PathBuf, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, } fn main() -> Result<()> { @@ -737,6 +744,11 @@ fn main() -> Result<()> { commands::org::OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm }, tags, ), + OrgAddKind::Document { collection, title, file, tags } => ( + collection, + commands::org::OrgAddKind::Document { title, file }, + tags, + ), }; commands::org::run_add(&d, &collection, add_kind, tags)?; } From 8ec616be5d18ed4539719c8be9d97508b27a2db0 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 21:23:46 -0400 Subject: [PATCH 10/12] feat(cli/org): org document edit via --file + purge removes attachments --- crates/relicario-cli/src/commands/org.rs | 48 ++++++++++++++++++++++-- crates/relicario-cli/src/main.rs | 6 ++- crates/relicario-cli/src/org_session.rs | 8 +--- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index 7b85aa7..93fad2e 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -1001,7 +1001,7 @@ fn resolve_org_query<'a>( } } -pub fn run_edit(dir: &Path, query: &str, totp_qr: Option) -> Result<()> { +pub fn run_edit(dir: &Path, query: &str, totp_qr: Option, file: Option) -> Result<()> { use relicario_core::time::now_unix; use relicario_core::ItemCore; @@ -1025,15 +1025,47 @@ pub fn run_edit(dir: &Path, query: &str, totp_qr: Option) -> } let history = &mut item.field_history; + let mut doc_attachment_rel: Option = None; + let mut new_doc_attachments: Option> = None; match &mut item.core { ItemCore::Login(l) => ib::edit_login(l, history, totp_qr)?, ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?, ItemCore::Identity(i) => ib::edit_identity(i)?, ItemCore::Card(c) => ib::edit_card(c, history)?, ItemCore::Key(k) => ib::edit_key(k, history)?, - ItemCore::Document(_) => ib::edit_document_message(), + ItemCore::Document(d) => { + if let Some(path) = &file { + let bytes = std::fs::read(path) + .with_context(|| format!("read {}", path.display()))?; + let enc = relicario_core::encrypt_attachment( + &bytes, vault.key(), crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?; + vault.remove_item_attachments(&collection, &id)?; + let rel = vault.save_attachment(&collection, &id, &enc)?; + let filename = path + .file_name() + .ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", path.display()))? + .to_string_lossy() + .into_owned(); + d.mime_type = crate::parse::guess_mime(&filename); + d.primary_attachment = enc.id.clone(); + d.filename = filename.clone(); + new_doc_attachments = Some(vec![relicario_core::AttachmentRef { + id: enc.id, + filename, + mime_type: d.mime_type.clone(), + size: bytes.len() as u64, + created: now_unix(), + }]); + doc_attachment_rel = Some(rel); + } else { + ib::edit_document_message(); + } + } ItemCore::Totp(t) => ib::edit_totp(t, history)?, } + if let Some(atts) = new_doc_attachments { + item.attachments = atts; + } item.modified = now_unix(); let item_rel = vault.save_item(&collection, &item)?; @@ -1053,7 +1085,13 @@ pub fn run_edit(dir: &Path, query: &str, totp_qr: Option) -> collection, item.id.as_str() ); - crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?; + let mut add_args: Vec<&str> = vec!["add", &item_rel, "manifest.enc"]; + let att_dir_rel; + if doc_attachment_rel.is_some() { + att_dir_rel = format!("attachments/{}/{}", collection, id.as_str()); + add_args.push(&att_dir_rel); + } + crate::org_session::org_git_run(&vault.root, &add_args, "org edit: git add")?; crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?; println!("Updated {} ({}) in `{}`", item.title, item.id.as_str(), collection); Ok(()) @@ -1129,12 +1167,14 @@ pub fn run_purge(dir: &Path, query: &str) -> Result<()> { // Remove the blob from disk, drop the manifest entry, stage with git rm. vault.remove_item(&collection, &id)?; + vault.remove_item_attachments(&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")?; + let att_dir_rel = format!("attachments/{}/{}", collection, id.as_str()); + crate::helpers::git_rm(&vault.root, &[item_rel, att_dir_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!( diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index e7069ee..3e38e43 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -541,6 +541,8 @@ pub(crate) enum OrgCommands { query: String, /// Replace the login TOTP secret from a QR image. #[arg(long)] totp_qr: Option, + /// Replace a Document item's attachment file. + #[arg(long)] file: Option, }, /// Soft-delete an org item (reversible via `org restore`). Rm { query: String }, @@ -760,9 +762,9 @@ fn main() -> Result<()> { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_list(&d, trashed)?; } - OrgCommands::Edit { query, totp_qr } => { + OrgCommands::Edit { query, totp_qr, file } => { let d = crate::org_session::org_dir(dir_path)?; - commands::org::run_edit(&d, &query, totp_qr)?; + commands::org::run_edit(&d, &query, totp_qr, file)?; } OrgCommands::Rm { query } => { let d = crate::org_session::org_dir(dir_path)?; diff --git a/crates/relicario-cli/src/org_session.rs b/crates/relicario-cli/src/org_session.rs index d0a5b33..f36db6b 100644 --- a/crates/relicario-cli/src/org_session.rs +++ b/crates/relicario-cli/src/org_session.rs @@ -17,10 +17,6 @@ use relicario_core::{ /// so this mirrors the personal-vault default /// `AttachmentCaps::per_attachment_max_bytes` at /// crates/relicario-core/src/settings.rs:116. -// Attachment API — consumed by `org add document`, Document edit, and purge -// landing in Tasks C2/C3; `load_attachment` additionally backs a future -// org document read/extract. Allow dead_code until those consumers land. -#[allow(dead_code)] pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 10 * 1024 * 1024; pub struct UnlockedOrgVault { @@ -126,14 +122,12 @@ impl UnlockedOrgVault { } } - #[allow(dead_code)] pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf { self.root.join("attachments").join(collection_slug) .join(item_id.as_str()).join(format!("{}.enc", att_id.as_str())) } /// Encrypt-already-done blob: persist it and return the repo-relative path for git staging. - #[allow(dead_code)] pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result { let path = self.attachment_path(collection_slug, item_id, &enc.id); if let Some(parent) = path.parent() { @@ -143,6 +137,7 @@ impl UnlockedOrgVault { Ok(format!("attachments/{}/{}/{}.enc", collection_slug, item_id.as_str(), enc.id.as_str())) } + // Retained for a future `org document read/extract` command (mirrors `org_meta_path` convention). #[allow(dead_code)] pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result>> { let path = self.attachment_path(collection_slug, item_id, att_id); @@ -152,7 +147,6 @@ impl UnlockedOrgVault { /// Remove an item's whole attachment directory. Missing dir is NOT an error /// (mirrors `remove_item`'s NotFound-tolerant behavior, for partial-write recovery). - #[allow(dead_code)] pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()> { let dir = self.root.join("attachments").join(collection_slug).join(item_id.as_str()); match fs::remove_dir_all(&dir) { From fe8eeb97c9bfe2462b170dfdcb9455eb230040c2 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 21:28:52 -0400 Subject: [PATCH 11/12] fix(cli/org): reject --file on non-Document org edit (fail fast) --- crates/relicario-cli/src/commands/org.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index 93fad2e..0e08220 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -1015,6 +1015,9 @@ pub fn run_edit(dir: &Path, query: &str, totp_qr: Option, fi crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?; let mut item = vault.load_item(&collection, &id)?; + if file.is_some() && !matches!(item.core, ItemCore::Document(_)) { + anyhow::bail!("--file is only valid when editing a Document item"); + } eprintln!( "Editing: {} ({}) — leave a prompt blank to keep the current value.", item.title, From 03559f81eacab84bb74a60b7266f02c6b4cfd830 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 21:35:24 -0400 Subject: [PATCH 12/12] test(cli/org): org document add/edit/purge round-trips + attachment staging + grant denial --- crates/relicario-cli/tests/org_items.rs | 129 ++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/crates/relicario-cli/tests/org_items.rs b/crates/relicario-cli/tests/org_items.rs index d98883b..5c7307c 100644 --- a/crates/relicario-cli/tests/org_items.rs +++ b/crates/relicario-cli/tests/org_items.rs @@ -456,3 +456,132 @@ fn org_edit_key_replaces_material_and_reveals_with_show() { assert!(shown.contains("NEW-MATERIAL-bbbb"), "replaced material not revealed with --show: {shown}"); assert!(!shown.contains("OLD-MATERIAL"), "old material still present after replace: {shown}"); } + +// --- v0.8.1 org Document tests ----------------------------------------------- + +/// `git status --porcelain` output for the org repo (trimmed). Empty-of-`attachments/` +/// proves every attachment add/remove was staged into the signed commit. +fn git_porcelain(repo: &str) -> String { + let out = std::process::Command::new("git") + .args(["-C", repo, "status", "--porcelain"]) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +#[test] +fn org_add_document_stores_collection_scoped_attachment() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + let srcdir = TempDir::new().unwrap(); + let src = srcdir.path().join("note.txt"); + std::fs::write(&src, b"secret memo").unwrap(); + + let out = f.run(&["org", "add", "document", "--collection", "eng", + "--title", "Q3 Memo", "--file", src.to_str().unwrap()]); + assert!(out.status.success(), "add doc: {}", String::from_utf8_lossy(&out.stderr)); + + // Encrypted blob at attachments/eng//.enc (3 segments). + let att_eng = f.vault_path().join("attachments").join("eng"); + assert!(att_eng.exists(), "attachment dir missing"); + let item_dirs: Vec<_> = std::fs::read_dir(&att_eng).unwrap().map(|e| e.unwrap().path()).collect(); + assert_eq!(item_dirs.len(), 1, "expected exactly one item attachment dir"); + let blobs: Vec<_> = std::fs::read_dir(&item_dirs[0]).unwrap().map(|e| e.unwrap().path()).collect(); + assert_eq!(blobs.len(), 1, "expected exactly one attachment blob"); + assert_eq!(blobs[0].extension().and_then(|e| e.to_str()), Some("enc"), "blob must be .enc"); + + let got = f.run(&["org", "get", "Q3 Memo"]); + let stdout = String::from_utf8_lossy(&got.stdout); + assert!(stdout.contains("Filename: note.txt"), "get missing filename: {stdout}"); + // Staging proof: nothing attachment-related left uncommitted. + assert!(!git_porcelain(f.vault_str()).contains("attachments/"), "unstaged attachment after add"); +} + +#[test] +fn org_purge_document_removes_attachment_dir() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + let srcdir = TempDir::new().unwrap(); + let src = srcdir.path().join("d.bin"); + std::fs::write(&src, b"bytes").unwrap(); + assert!(f.run(&["org", "add", "document", "--collection", "eng", + "--title", "Doc", "--file", src.to_str().unwrap()]).status.success()); + + let att_eng = f.vault_path().join("attachments").join("eng"); + assert!(std::fs::read_dir(&att_eng).unwrap().next().is_some(), "attachment must exist after add"); + + assert!(f.run(&["org", "rm", "Doc"]).status.success(), "rm"); + let out = f.run(&["org", "purge", "Doc"]); + assert!(out.status.success(), "purge: {}", String::from_utf8_lossy(&out.stderr)); + + let empty = !att_eng.exists() || std::fs::read_dir(&att_eng).unwrap().next().is_none(); + assert!(empty, "attachment dir should be gone after purge"); + assert!(!git_porcelain(f.vault_str()).contains("attachments/"), "unstaged attachment removal after purge"); +} + +#[test] +fn org_edit_document_replaces_attachment_and_stages_cleanly() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + let srcdir = TempDir::new().unwrap(); + let a = srcdir.path().join("a.txt"); + std::fs::write(&a, b"version A").unwrap(); + assert!(f.run(&["org", "add", "document", "--collection", "eng", + "--title", "Spec", "--file", a.to_str().unwrap()]).status.success()); + + let b = srcdir.path().join("b.md"); + std::fs::write(&b, b"version B has different content").unwrap(); + let out = f.run(&["org", "edit", "Spec", "--file", b.to_str().unwrap()]); + assert!(out.status.success(), "edit --file: {}", String::from_utf8_lossy(&out.stderr)); + + let got = String::from_utf8_lossy(&f.run(&["org", "get", "Spec"]).stdout).to_string(); + assert!(got.contains("Filename: b.md"), "get should show new filename: {got}"); + assert!(!got.contains("a.txt"), "old filename should be gone: {got}"); + + // Old blob replaced, not accumulated: exactly one blob remains. + let att_eng = f.vault_path().join("attachments").join("eng"); + let item_dirs: Vec<_> = std::fs::read_dir(&att_eng).unwrap().map(|e| e.unwrap().path()).collect(); + assert_eq!(item_dirs.len(), 1, "one item attachment dir"); + let blobs = std::fs::read_dir(&item_dirs[0]).unwrap().count(); + assert_eq!(blobs, 1, "old blob must be replaced, not accumulated"); + + // The key staging proof: no orphaned old blob / unstaged new blob. + assert!(!git_porcelain(f.vault_str()).contains("attachments/"), + "edit-replace left attachment changes unstaged (incomplete git add)"); +} + +#[test] +fn org_edit_file_on_non_document_is_rejected() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + assert!(f.run(&["org", "add", "login", "--collection", "eng", + "--title", "Site", "--password", "p"]).status.success()); + let srcdir = TempDir::new().unwrap(); + let x = srcdir.path().join("x.txt"); + std::fs::write(&x, b"nope").unwrap(); + + let out = f.run(&["org", "edit", "Site", "--file", x.to_str().unwrap()]); + assert!(!out.status.success(), "--file on a Login must be rejected"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("--file is only valid"), "unexpected error: {stderr}"); +} + +#[test] +fn org_add_document_into_ungranted_collection_is_denied() { + let f = OrgFixture::new(); + // Collection exists but the owner is NOT granted. + assert!(f.run(&["org", "create-collection", "secret", "--name", "Secret"]).status.success(), + "create-collection"); + let srcdir = TempDir::new().unwrap(); + let src = srcdir.path().join("f.txt"); + std::fs::write(&src, b"data").unwrap(); + + let out = f.run(&["org", "add", "document", "--collection", "secret", + "--title", "X", "--file", src.to_str().unwrap()]); + assert!(!out.status.success(), "ungranted document add must fail"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}"); + // Grant is enforced before any attachment is written. + assert!(!f.vault_path().join("attachments").join("secret").exists(), + "no attachment dir should exist on a denied add"); +}