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