//! Attachment refs (carried on Item) and summaries (carried in Manifest). //! //! Encryption helpers (`encrypt_attachment`, `decrypt_attachment`) are added //! later in Task 22 once the crypto module is settled. use serde::{Deserialize, Serialize}; use crate::ids::AttachmentId; /// Reference to an attachment, carried on the Item record. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AttachmentRef { pub id: AttachmentId, pub filename: String, pub mime_type: String, /// Plaintext size in bytes. pub size: u64, /// Unix-seconds when this attachment was added. pub created: i64, } /// Compact summary of an attachment, carried in the Manifest so the popup /// can show attachment indicators without decrypting the item file. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AttachmentSummary { pub id: AttachmentId, pub filename: String, pub mime_type: String, pub size: u64, } impl From<&AttachmentRef> for AttachmentSummary { fn from(r: &AttachmentRef) -> Self { Self { id: r.id.clone(), filename: r.filename.clone(), mime_type: r.mime_type.clone(), size: r.size, } } } use zeroize::Zeroizing; use crate::crypto::{decrypt, encrypt}; use crate::error::{RelicarioError, Result}; /// Encrypted attachment with the AID derived from plaintext content. #[derive(Debug)] pub struct EncryptedAttachment { pub id: AttachmentId, pub bytes: Vec, } /// Encrypt raw attachment bytes, deriving the [`AttachmentId`] from `sha256(plaintext)`. /// /// Returns [`RelicarioError::AttachmentTooLarge`] immediately if `plaintext.len() > max_bytes`, /// before any crypto work is done. /// /// ## Call-site adaptation /// /// `crypto::encrypt` accepts `&[u8; 32]`; we coerce `&Zeroizing<[u8; 32]>` via /// `&**master_key` (double-deref: `Zeroizing<[u8;32]>` → `[u8;32]` → `&[u8;32]`). pub fn encrypt_attachment( plaintext: &[u8], master_key: &Zeroizing<[u8; 32]>, max_bytes: u64, ) -> Result { if plaintext.len() as u64 > max_bytes { return Err(RelicarioError::AttachmentTooLarge { size: plaintext.len() as u64, max: max_bytes, }); } let id = AttachmentId::from_plaintext(plaintext); let bytes = encrypt(master_key, plaintext)?; Ok(EncryptedAttachment { id, bytes }) } /// Decrypt a blob produced by [`encrypt_attachment`], returning the plaintext /// wrapped in [`Zeroizing`] so it is wiped on drop. /// /// ## Call-site adaptation /// /// `crypto::decrypt` accepts `&[u8; 32]`; we coerce via `&**master_key`. pub fn decrypt_attachment( encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>, ) -> Result>> { let plaintext = decrypt(master_key, encrypted)?; Ok(Zeroizing::new(plaintext)) } #[cfg(test)] mod crypto_tests { use super::*; fn key() -> Zeroizing<[u8; 32]> { Zeroizing::new([0x42u8; 32]) } #[test] fn attachment_round_trip() { let plaintext = b"the quick brown fox jumps over the lazy dog"; let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap(); let dec = decrypt_attachment(&enc.bytes, &key()).unwrap(); assert_eq!(dec.as_slice(), plaintext); } #[test] fn attachment_id_matches_sha256() { let plaintext = b"hello world"; let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap(); assert_eq!(enc.id, AttachmentId::from_plaintext(plaintext)); } #[test] fn oversize_attachment_rejected() { let plaintext = vec![0u8; 11_000_000]; let err = encrypt_attachment(&plaintext, &key(), 10 * 1024 * 1024); assert!(matches!(err, Err(RelicarioError::AttachmentTooLarge { .. }))); } #[test] fn wrong_key_fails_with_opaque_decrypt() { let plaintext = b"x"; let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap(); let wrong = Zeroizing::new([0u8; 32]); let err = decrypt_attachment(&enc.bytes, &wrong); assert!(matches!(err, Err(RelicarioError::Decrypt))); } } #[cfg(test)] mod tests { use super::*; #[test] fn attachment_ref_round_trip() { let r = AttachmentRef { id: AttachmentId("0123456789abcdef".into()), filename: "doc.pdf".into(), mime_type: "application/pdf".into(), size: 12345, created: 1_700_000_000, }; let json = serde_json::to_string(&r).unwrap(); let parsed: AttachmentRef = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.filename, "doc.pdf"); assert_eq!(parsed.size, 12345); } #[test] fn attachment_summary_from_ref() { let r = AttachmentRef { id: AttachmentId("aabb".into()), filename: "x.txt".into(), mime_type: "text/plain".into(), size: 5, created: 0, }; let s: AttachmentSummary = (&r).into(); assert_eq!(s.filename, "x.txt"); assert_eq!(s.id, r.id); } }