feat(cli/org): collection-scoped attachment storage + default cap
This commit is contained in:
@@ -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<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
|
||||
/// 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]);
|
||||
|
||||
Reference in New Issue
Block a user