feat(cli/org): collection-scoped attachment storage + default cap

This commit is contained in:
adlee-was-taken
2026-06-20 17:24:44 -04:00
parent 6e73c5e6a1
commit bccd113f55

View File

@@ -9,9 +9,16 @@ use zeroize::Zeroizing;
use relicario_core::{ use relicario_core::{
decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest, 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 struct UnlockedOrgVault {
pub root: PathBuf, pub root: PathBuf,
pub org_key: Zeroizing<[u8; 32]>, 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<String> {
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<Zeroizing<Vec<u8>>> {
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 /// Bail unless `member` has `slug` in their collection grants. The slug
/// existence check is done separately by the caller against collections.json. /// existence check is done separately by the caller against collections.json.
pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> { pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> {
@@ -292,6 +331,22 @@ mod tests {
assert_eq!(loaded.entries.len(), 1); 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] #[test]
fn save_and_load_members() { fn save_and_load_members() {
let key = Zeroizing::new([0u8; 32]); let key = Zeroizing::new([0u8; 32]);