From bccd113f5571877850e861f348c13201c89873ef Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 17:24:44 -0400 Subject: [PATCH] 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]);