From bccd113f5571877850e861f348c13201c89873ef Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 17:24:44 -0400 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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"); +}