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:
@@ -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()
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user