feat: Sprint 5 — encrypted file transfer with chunked upload/download

- Add uploadBlob (@21) and downloadBlob (@22) RPCs to Cap'n Proto
  schema with SHA-256 content addressing and chunked transfer
- Server blob handler: 256KB chunks, SHA-256 verification on finalize,
  .meta JSON sidecar, 50MB size limit, content-addressable storage
- Add FileRef (0x08) AppMessage variant with blob_id, filename,
  file_size, mime_type
- /send-file command: read file, compute hash, upload in chunks with
  progress display, send FileRef via MLS, MIME auto-detection
- /download command: fetch blob in chunks with progress, verify hash,
  save to disk with collision avoidance
- 2 new E2E tests: upload/download round-trip with partial reads,
  hash mismatch rejection (14 E2E tests total)
- New error codes: E024-E027 for blob operations
This commit is contained in:
2026-03-04 00:27:18 +01:00
parent 81d5e2e590
commit 3350d765e5
12 changed files with 1086 additions and 8 deletions

View File

@@ -26,6 +26,7 @@ pub enum MessageType {
Typing = 0x05,
Edit = 0x06,
Delete = 0x07,
FileRef = 0x08,
}
impl MessageType {
@@ -38,6 +39,7 @@ impl MessageType {
0x05 => Some(MessageType::Typing),
0x06 => Some(MessageType::Edit),
0x07 => Some(MessageType::Delete),
0x08 => Some(MessageType::FileRef),
_ => None,
}
}
@@ -75,6 +77,13 @@ pub enum AppMessage {
Delete {
ref_msg_id: [u8; 16],
},
/// File reference: metadata pointing to a blob stored on the server.
FileRef {
blob_id: [u8; 32],
filename: Vec<u8>,
file_size: u64,
mime_type: Vec<u8>,
},
}
/// Generate a new 16-byte message ID (e.g. for Chat/Reply so recipients can reference it).
@@ -95,6 +104,7 @@ pub fn generate_message_id() -> [u8; 16] {
// Typing: [active: 1] 0 = stopped, 1 = typing
// Edit: [ref_msg_id: 16][body_len: 2 BE][body]
// Delete: [ref_msg_id: 16]
// FileRef: [blob_id: 32][filename_len: 2 BE][filename][file_size: 8 BE][mime_len: 2 BE][mime_type]
/// Serialize a rich message into the application payload format.
pub fn serialize(msg_type: MessageType, payload: &[u8]) -> Vec<u8> {
@@ -170,6 +180,29 @@ pub fn serialize_delete(ref_msg_id: &[u8; 16]) -> Vec<u8> {
serialize(MessageType::Delete, ref_msg_id)
}
/// Serialize a FileRef message (metadata pointing to a blob on the server).
pub fn serialize_file_ref(
blob_id: &[u8; 32],
filename: &[u8],
file_size: u64,
mime_type: &[u8],
) -> Result<Vec<u8>, CoreError> {
if filename.len() > u16::MAX as usize {
return Err(CoreError::AppMessage("filename exceeds maximum length".into()));
}
if mime_type.len() > u16::MAX as usize {
return Err(CoreError::AppMessage("mime_type exceeds maximum length".into()));
}
let mut payload = Vec::with_capacity(32 + 2 + filename.len() + 8 + 2 + mime_type.len());
payload.extend_from_slice(blob_id);
payload.extend_from_slice(&(filename.len() as u16).to_be_bytes());
payload.extend_from_slice(filename);
payload.extend_from_slice(&file_size.to_be_bytes());
payload.extend_from_slice(&(mime_type.len() as u16).to_be_bytes());
payload.extend_from_slice(mime_type);
Ok(serialize(MessageType::FileRef, &payload))
}
/// Parse bytes into (MessageType, AppMessage). Fails if version/type unknown or payload too short.
pub fn parse(bytes: &[u8]) -> Result<(MessageType, AppMessage), CoreError> {
if bytes.len() < 2 {
@@ -191,6 +224,7 @@ pub fn parse(bytes: &[u8]) -> Result<(MessageType, AppMessage), CoreError> {
MessageType::Typing => parse_typing(payload)?,
MessageType::Edit => parse_edit(payload)?,
MessageType::Delete => parse_delete(payload)?,
MessageType::FileRef => parse_file_ref(payload)?,
};
Ok((msg_type, app))
}
@@ -276,6 +310,34 @@ fn parse_delete(payload: &[u8]) -> Result<AppMessage, CoreError> {
Ok(AppMessage::Delete { ref_msg_id })
}
fn parse_file_ref(payload: &[u8]) -> Result<AppMessage, CoreError> {
// blob_id(32) + filename_len(2) minimum
if payload.len() < 34 {
return Err(CoreError::AppMessage("FileRef payload too short".into()));
}
let mut blob_id = [0u8; 32];
blob_id.copy_from_slice(&payload[..32]);
let filename_len = u16::from_be_bytes([payload[32], payload[33]]) as usize;
let pos = 34;
if payload.len() < pos + filename_len + 8 + 2 {
return Err(CoreError::AppMessage("FileRef payload truncated after filename_len".into()));
}
let filename = payload[pos..pos + filename_len].to_vec();
let pos = pos + filename_len;
let file_size = u64::from_be_bytes([
payload[pos], payload[pos + 1], payload[pos + 2], payload[pos + 3],
payload[pos + 4], payload[pos + 5], payload[pos + 6], payload[pos + 7],
]);
let pos = pos + 8;
let mime_len = u16::from_be_bytes([payload[pos], payload[pos + 1]]) as usize;
let pos = pos + 2;
if payload.len() < pos + mime_len {
return Err(CoreError::AppMessage("FileRef payload truncated after mime_len".into()));
}
let mime_type = payload[pos..pos + mime_len].to_vec();
Ok(AppMessage::FileRef { blob_id, filename, file_size, mime_type })
}
#[cfg(test)]
mod tests {
use super::*;
@@ -415,4 +477,29 @@ mod tests {
data.extend_from_slice(&[0u8; 10]);
assert!(parse(&data).is_err());
}
#[test]
fn roundtrip_file_ref() {
let blob_id = [7u8; 32];
let filename = b"report.pdf";
let file_size = 123456u64;
let mime_type = b"application/pdf";
let encoded = serialize_file_ref(&blob_id, filename, file_size, mime_type).unwrap();
let (t, msg) = parse(&encoded).unwrap();
assert_eq!(t, MessageType::FileRef);
match &msg {
AppMessage::FileRef {
blob_id: bid,
filename: fname,
file_size: fsize,
mime_type: mtype,
} => {
assert_eq!(bid, &blob_id);
assert_eq!(fname.as_slice(), filename);
assert_eq!(*fsize, file_size);
assert_eq!(mtype.as_slice(), mime_type);
}
_ => panic!("expected FileRef"),
}
}
}