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

@@ -12,8 +12,8 @@ use anyhow::Context;
use quicproquo_core::{
AppMessage, DiskKeyStore, GroupMember, IdentityKeypair, ReceivedMessage,
compute_safety_number, hybrid_encrypt, parse as parse_app_msg, serialize_chat,
serialize_delete, serialize_edit, serialize_reaction, serialize_read_receipt,
serialize_typing,
serialize_delete, serialize_edit, serialize_file_ref, serialize_reaction,
serialize_read_receipt, serialize_typing,
};
use quicproquo_proto::node_capnp::node_service;
use tokio::sync::mpsc;
@@ -26,9 +26,9 @@ use super::conversation::{
};
use super::display;
use super::rpc::{
connect_node, create_channel, enqueue, fetch_hybrid_key, fetch_key_package,
fetch_wait, resolve_identity, resolve_user, try_hybrid_decrypt, upload_hybrid_key,
upload_key_package,
connect_node, create_channel, download_blob_chunk, enqueue, fetch_hybrid_key,
fetch_key_package, fetch_wait, resolve_identity, resolve_user, try_hybrid_decrypt,
upload_blob_chunk, upload_hybrid_key, upload_key_package,
};
use super::session::SessionState;
use super::state::{decode_identity_key, load_or_init_state};
@@ -73,6 +73,10 @@ enum SlashCommand {
Edit { index: usize, new_text: String },
/// Delete a previously sent message by index.
Delete { index: usize },
/// Send a file to the active conversation.
SendFile { path: String },
/// Download a file attachment by message index.
Download { index: usize },
}
fn parse_input(line: &str) -> Input {
@@ -206,6 +210,20 @@ fn parse_input(line: &str) -> Input {
Input::Empty
}
},
"/send-file" | "/sf" => match arg {
Some(path) => Input::Slash(SlashCommand::SendFile { path }),
None => {
display::print_error("usage: /send-file <path>");
Input::Empty
}
},
"/download" | "/dl" => match arg.and_then(|s| s.parse::<usize>().ok()) {
Some(index) => Input::Slash(SlashCommand::Download { index }),
None => {
display::print_error("usage: /download <msg-index>");
Input::Empty
}
},
_ => {
display::print_error(&format!("unknown command: {cmd}. Try /help"));
Input::Empty
@@ -692,6 +710,8 @@ async fn handle_slash(
SlashCommand::React { emoji, index } => cmd_react(session, client, &emoji, index).await,
SlashCommand::Edit { index, new_text } => cmd_edit(session, client, index, &new_text).await,
SlashCommand::Delete { index } => cmd_delete(session, client, index).await,
SlashCommand::SendFile { path } => cmd_send_file(session, client, &path).await,
SlashCommand::Download { index } => cmd_download(session, client, index).await,
};
if let Err(e) = result {
display::print_error(&format!("{e:#}"));
@@ -720,6 +740,8 @@ fn print_help() {
display::print_status(" /typing-notify on|off - Toggle typing notifications");
display::print_status(" /edit <index> <new text> - Edit a sent message");
display::print_status(" /delete <index> - Delete a sent message");
display::print_status(" /send-file <path> - Upload and send a file (max 50 MB)");
display::print_status(" /download <index> - Download a received file attachment");
display::print_status(" /quit - Exit");
}
@@ -1667,6 +1689,319 @@ async fn cmd_delete(
Ok(())
}
// ── File transfer ────────────────────────────────────────────────────────────
/// Maximum file size for upload (50 MB).
const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024;
/// Chunk size for upload/download (256 KB).
const BLOB_CHUNK_SIZE: usize = 256 * 1024;
/// Guess MIME type from file extension.
fn guess_mime(path: &Path) -> &'static str {
match path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.as_deref()
{
Some("pdf") => "application/pdf",
Some("jpg" | "jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("gif") => "image/gif",
Some("txt") => "text/plain",
Some("zip") => "application/zip",
Some("json") => "application/json",
Some("html" | "htm") => "text/html",
Some("mp4") => "video/mp4",
Some("mp3") => "audio/mpeg",
Some("webp") => "image/webp",
Some("svg") => "image/svg+xml",
_ => "application/octet-stream",
}
}
/// Format a byte size for human display (e.g. "1.2 MB").
fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
async fn cmd_send_file(
session: &mut SessionState,
client: &node_service::Client,
path_str: &str,
) -> anyhow::Result<()> {
let conv_id = session
.active_conversation
.as_ref()
.context("no active conversation; use /dm or /create-group first")?
.clone();
let file_path = PathBuf::from(path_str.trim_matches('"'));
anyhow::ensure!(file_path.exists(), "file not found: {}", file_path.display());
let metadata = std::fs::metadata(&file_path)
.with_context(|| format!("cannot read file: {}", file_path.display()))?;
let file_size = metadata.len();
anyhow::ensure!(
file_size <= MAX_FILE_SIZE,
"file too large ({}, max {})",
format_size(file_size),
format_size(MAX_FILE_SIZE)
);
let filename = file_path
.file_name()
.and_then(|n| n.to_str())
.context("cannot determine filename")?
.to_string();
let mime_type = guess_mime(&file_path);
// Read entire file and compute SHA-256 hash.
let file_bytes = std::fs::read(&file_path)
.with_context(|| format!("read file: {}", file_path.display()))?;
use sha2::{Sha256, Digest};
let hash = Sha256::digest(&file_bytes);
let blob_hash: [u8; 32] = hash.into();
// Upload in chunks with progress.
let total = file_bytes.len();
let mut offset = 0usize;
while offset < total {
let end = (offset + BLOB_CHUNK_SIZE).min(total);
let chunk = &file_bytes[offset..end];
upload_blob_chunk(
client,
&blob_hash,
chunk,
offset as u64,
total as u64,
mime_type,
)
.await?;
offset = end;
let pct = (offset as u64 * 100) / total as u64;
eprint!("\rUploading... {pct}%");
}
eprintln!();
// Build FileRef AppMessage.
let app_payload = serialize_file_ref(
&blob_hash,
filename.as_bytes(),
file_size,
mime_type.as_bytes(),
)
.context("serialize FileRef")?;
let my_key = session.identity_bytes();
let identity = std::sync::Arc::clone(&session.identity);
let member = session
.get_member_mut(&conv_id)
.context("no group member")?;
anyhow::ensure!(
member.group_ref().is_some(),
"cannot send files in a local-only conversation"
);
let sealed = quicproquo_core::sealed_sender::seal(&identity, &app_payload);
let padded = quicproquo_core::padding::pad(&sealed);
let ct = member
.send_message(&padded)
.context("MLS send_message failed")?;
let recipients: Vec<Vec<u8>> = member
.member_identities()
.into_iter()
.filter(|id| id.as_slice() != my_key.as_slice())
.collect();
for recipient_key in &recipients {
let peer_hybrid_pk = fetch_hybrid_key(client, recipient_key).await?;
let payload = if let Some(ref pk) = peer_hybrid_pk {
hybrid_encrypt(pk, &ct, b"", b"").context("hybrid encrypt")?
} else {
ct.clone()
};
enqueue(client, recipient_key, &payload).await?;
}
// Store outgoing message (include blob hash so /download can extract it).
let body = format!(
"\u{1f4ce} {} ({}) blob:{}",
filename,
format_size(file_size),
hex::encode(blob_hash)
);
let msg = StoredMessage {
conversation_id: conv_id.clone(),
message_id: None,
sender_key: my_key,
sender_name: Some("you".into()),
body,
msg_type: "file".into(),
ref_msg_id: None,
timestamp_ms: now_ms(),
is_outgoing: true,
};
session.conv_store.save_message(&msg)?;
session.conv_store.update_activity(&conv_id, now_ms())?;
session.save_member(&conv_id)?;
display::print_status(&format!(
"Sent: {} ({})",
filename,
format_size(file_size)
));
Ok(())
}
async fn cmd_download(
session: &mut SessionState,
client: &node_service::Client,
index: usize,
) -> anyhow::Result<()> {
let conv_id = session
.active_conversation
.as_ref()
.context("no active conversation")?
.clone();
let msgs = session.conv_store.load_all_messages(&conv_id)?;
anyhow::ensure!(!msgs.is_empty(), "no messages in this conversation");
anyhow::ensure!(
index < msgs.len(),
"message index {index} out of range (0..{})",
msgs.len() - 1
);
let target = &msgs[index];
anyhow::ensure!(
target.msg_type == "file",
"message at index {index} is not a file (type: {})",
target.msg_type
);
// Extract blob_id from the stored ref_msg_id field (32-byte blob hash stored as first 16 bytes
// in ref_msg_id is not enough). We store the blob_id hex in the body after the filename.
// Parse the body format: "\u{1f4ce} filename (size) blob:HEXHASH"
let blob_id = extract_blob_id_from_body(&target.body)
.context("cannot extract blob_id from file message; the message may be from an older version")?;
// Get filename from body: "\u{1f4ce} filename (size) ..."
let filename = extract_filename_from_body(&target.body)
.unwrap_or_else(|| "download".to_string());
// Download in chunks.
// First request to learn total_size.
let (first_chunk, total_size, _mime) =
download_blob_chunk(client, &blob_id, 0, BLOB_CHUNK_SIZE as u32).await?;
let mut data = Vec::with_capacity(total_size as usize);
data.extend_from_slice(&first_chunk);
if total_size > first_chunk.len() as u64 {
let pct = (data.len() as u64 * 100) / total_size;
eprint!("\rDownloading... {pct}%");
}
while (data.len() as u64) < total_size {
let (chunk, _, _) = download_blob_chunk(
client,
&blob_id,
data.len() as u64,
BLOB_CHUNK_SIZE as u32,
)
.await?;
if chunk.is_empty() {
break;
}
data.extend_from_slice(&chunk);
let pct = (data.len() as u64 * 100) / total_size;
eprint!("\rDownloading... {pct}%");
}
if total_size > BLOB_CHUNK_SIZE as u64 {
eprintln!();
}
// Verify SHA-256.
use sha2::{Sha256, Digest};
let computed_hash = Sha256::digest(&data);
anyhow::ensure!(
computed_hash.as_slice() == blob_id.as_slice(),
"SHA-256 mismatch: blob data is corrupt"
);
// Save to current directory, avoiding overwrites.
let mut save_path = PathBuf::from(&filename);
let mut counter = 1u32;
while save_path.exists() {
let stem = Path::new(&filename)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("download");
let ext = Path::new(&filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
if ext.is_empty() {
save_path = PathBuf::from(format!("{stem}.{counter}"));
} else {
save_path = PathBuf::from(format!("{stem}.{counter}.{ext}"));
}
counter += 1;
}
std::fs::write(&save_path, &data)
.with_context(|| format!("write file: {}", save_path.display()))?;
display::print_status(&format!(
"Downloaded: {} -> ./{}",
filename,
save_path.display()
));
Ok(())
}
/// Extract blob_id from the file message body format:
/// "\u{1f4ce} filename (size) blob:HEX64"
fn extract_blob_id_from_body(body: &str) -> Option<Vec<u8>> {
let marker = "blob:";
let idx = body.find(marker)?;
let hex_str = &body[idx + marker.len()..];
let hex_str = hex_str.split_whitespace().next()?;
if hex_str.len() != 64 {
return None;
}
hex::decode(hex_str).ok()
}
/// Extract filename from the file message body format:
/// "\u{1f4ce} filename (size) blob:HEX64"
fn extract_filename_from_body(body: &str) -> Option<String> {
// Skip the leading emoji + space.
let rest = body.strip_prefix("\u{1f4ce} ")?;
// Find the last " (" to separate filename from "(size) blob:..."
let paren_idx = rest.rfind(" (")?;
let filename = &rest[..paren_idx];
if filename.is_empty() {
None
} else {
Some(filename.to_string())
}
}
// ── Sending ──────────────────────────────────────────────────────────────────
async fn handle_send(
@@ -1937,7 +2272,7 @@ async fn poll_messages(
break;
}
// Storable message types: Chat, Reply, Reaction, legacy.
// Storable message types: Chat, Reply, Reaction, FileRef, legacy.
let (body, msg_id, msg_type, ref_msg_id) = match parsed {
Ok((_, AppMessage::Chat { message_id, body })) => (
String::from_utf8_lossy(&body).to_string(),
@@ -1957,6 +2292,16 @@ async fn poll_messages(
"reaction",
Some(ref_msg_id),
),
Ok((_, AppMessage::FileRef { blob_id, filename, file_size, .. })) => {
let fname = String::from_utf8_lossy(&filename).to_string();
let body = format!(
"\u{1f4ce} {} ({}) blob:{}",
fname,
format_size(file_size),
hex::encode(blob_id),
);
(body, None, "file", None)
}
_ => {
// Legacy raw plaintext or unknown type.
(
@@ -1997,6 +2342,12 @@ async fn poll_messages(
let conv_name = conv.map(|c| c.display_name).unwrap_or_default();
let display_body = if msg_type == "reaction" {
format!("reacted {body}")
} else if msg_type == "file" {
// Show the file info without the blob: suffix, plus download hint.
let visible = body.split(" blob:").next().unwrap_or(&body);
let all_msgs = session.conv_store.load_all_messages(conv_id)?;
let msg_idx = all_msgs.len().saturating_sub(1);
format!("{visible} -- use /download {msg_idx} to save")
} else {
body.clone()
};

View File

@@ -767,6 +767,70 @@ pub async fn create_channel(
Ok((channel_id, was_new))
}
/// Upload a single chunk of a blob to the server.
///
/// `blob_hash` is the expected SHA-256 hash (32 bytes) of the complete blob.
/// Returns the `blob_id` once the server has received and verified the final chunk.
pub async fn upload_blob_chunk(
client: &node_service::Client,
blob_hash: &[u8],
chunk: &[u8],
offset: u64,
total_size: u64,
mime_type: &str,
) -> anyhow::Result<Vec<u8>> {
let mut req = client.upload_blob_request();
{
let mut p = req.get();
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth)?;
p.set_blob_hash(blob_hash);
p.set_chunk(chunk);
p.set_offset(offset);
p.set_total_size(total_size);
p.set_mime_type(mime_type);
}
let resp = req.send().promise.await.context("upload_blob RPC failed")?;
let blob_id = resp
.get()
.context("upload_blob: bad response")?
.get_blob_id()
.context("upload_blob: missing blob_id")?
.to_vec();
Ok(blob_id)
}
/// Download a single chunk of a blob from the server.
///
/// Returns `(chunk_bytes, total_size, mime_type)`.
pub async fn download_blob_chunk(
client: &node_service::Client,
blob_id: &[u8],
offset: u64,
length: u32,
) -> anyhow::Result<(Vec<u8>, u64, String)> {
let mut req = client.download_blob_request();
{
let mut p = req.get();
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth)?;
p.set_blob_id(blob_id);
p.set_offset(offset);
p.set_length(length);
}
let resp = req.send().promise.await.context("download_blob RPC failed")?;
let reader = resp.get().context("download_blob: bad response")?;
let chunk = reader.get_chunk().context("download_blob: missing chunk")?.to_vec();
let total_size = reader.get_total_size();
let mime_type = reader
.get_mime_type()
.context("download_blob: missing mime_type")?
.to_str()
.unwrap_or("application/octet-stream")
.to_string();
Ok((chunk, total_size, mime_type))
}
/// Return the current Unix timestamp in milliseconds.
pub fn current_timestamp_ms() -> u64 {
std::time::SystemTime::now()