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:
@@ -15,6 +15,8 @@ fn ensure_rustls_provider() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
}
|
||||
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
use quicproquo_client::{
|
||||
cmd_create_group, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_register_state,
|
||||
cmd_register_user, cmd_send, connect_node, create_channel, enqueue, fetch_wait, init_auth,
|
||||
@@ -1342,3 +1344,199 @@ async fn e2e_multi_party_group() -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── blob upload / download tests ────────────────────────────────────────────
|
||||
|
||||
/// Upload a 2 KB blob, download it in full, then download a partial slice.
|
||||
/// Verifies SHA-256 integrity, blobId, and partial-range semantics.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn e2e_file_upload_download() -> anyhow::Result<()> {
|
||||
ensure_rustls_provider();
|
||||
|
||||
let temp = TempDir::new()?;
|
||||
let base = temp.path();
|
||||
|
||||
let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]);
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
init_auth(ClientAuth::from_parts("devtoken".to_string(), None));
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
|
||||
// Register Alice (needed so the auth context is valid).
|
||||
let alice_state = base.join("alice.bin");
|
||||
local
|
||||
.run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
|
||||
let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?;
|
||||
|
||||
// Build 2 KB of known data.
|
||||
let pattern = b"hello-world-file-test\n";
|
||||
let repeat_count = (2048 + pattern.len() - 1) / pattern.len();
|
||||
let file_data: Vec<u8> = pattern.iter().copied().cycle().take(repeat_count * pattern.len()).collect();
|
||||
let file_data = &file_data[..2048]; // exactly 2 KB
|
||||
|
||||
// Compute SHA-256.
|
||||
let hash: [u8; 32] = Sha256::digest(file_data).into();
|
||||
|
||||
// ── Upload ──
|
||||
let blob_id = local
|
||||
.run_until(async {
|
||||
let mut req = client.upload_blob_request();
|
||||
{
|
||||
let mut p = req.get();
|
||||
let mut auth = p.reborrow().init_auth();
|
||||
quicproquo_client::client::rpc::set_auth(&mut auth)?;
|
||||
p.set_blob_hash(&hash);
|
||||
p.set_chunk(file_data);
|
||||
p.set_offset(0);
|
||||
p.set_total_size(file_data.len() as u64);
|
||||
p.set_mime_type("application/octet-stream");
|
||||
}
|
||||
let resp = req.send().promise.await
|
||||
.map_err(|e| anyhow::anyhow!("uploadBlob RPC failed: {e}"))?;
|
||||
let blob_id = resp.get()
|
||||
.map_err(|e| anyhow::anyhow!("uploadBlob bad response: {e}"))?
|
||||
.get_blob_id()
|
||||
.map_err(|e| anyhow::anyhow!("uploadBlob missing blobId: {e}"))?
|
||||
.to_vec();
|
||||
Ok::<Vec<u8>, anyhow::Error>(blob_id)
|
||||
})
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
blob_id == hash,
|
||||
"blobId must equal SHA-256 hash; got {} vs {}",
|
||||
hex_encode(&blob_id),
|
||||
hex_encode(&hash)
|
||||
);
|
||||
|
||||
// ── Full download ──
|
||||
let (chunk, total_size) = local
|
||||
.run_until(async {
|
||||
let mut req = client.download_blob_request();
|
||||
{
|
||||
let mut p = req.get();
|
||||
let mut auth = p.reborrow().init_auth();
|
||||
quicproquo_client::client::rpc::set_auth(&mut auth)?;
|
||||
p.set_blob_id(&blob_id);
|
||||
p.set_offset(0);
|
||||
p.set_length(file_data.len() as u32);
|
||||
}
|
||||
let resp = req.send().promise.await
|
||||
.map_err(|e| anyhow::anyhow!("downloadBlob RPC failed: {e}"))?;
|
||||
let r = resp.get()
|
||||
.map_err(|e| anyhow::anyhow!("downloadBlob bad response: {e}"))?;
|
||||
let chunk = r.get_chunk()
|
||||
.map_err(|e| anyhow::anyhow!("downloadBlob missing chunk: {e}"))?
|
||||
.to_vec();
|
||||
let total = r.get_total_size();
|
||||
Ok::<(Vec<u8>, u64), anyhow::Error>((chunk, total))
|
||||
})
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
total_size == file_data.len() as u64,
|
||||
"totalSize mismatch: {} vs {}",
|
||||
total_size,
|
||||
file_data.len()
|
||||
);
|
||||
anyhow::ensure!(
|
||||
chunk == file_data,
|
||||
"downloaded data does not match uploaded data (len {} vs {})",
|
||||
chunk.len(),
|
||||
file_data.len()
|
||||
);
|
||||
|
||||
// ── Partial download: offset=100, length=200 ──
|
||||
let partial = local
|
||||
.run_until(async {
|
||||
let mut req = client.download_blob_request();
|
||||
{
|
||||
let mut p = req.get();
|
||||
let mut auth = p.reborrow().init_auth();
|
||||
quicproquo_client::client::rpc::set_auth(&mut auth)?;
|
||||
p.set_blob_id(&blob_id);
|
||||
p.set_offset(100);
|
||||
p.set_length(200);
|
||||
}
|
||||
let resp = req.send().promise.await
|
||||
.map_err(|e| anyhow::anyhow!("downloadBlob partial RPC failed: {e}"))?;
|
||||
let r = resp.get()
|
||||
.map_err(|e| anyhow::anyhow!("downloadBlob partial bad response: {e}"))?;
|
||||
let chunk = r.get_chunk()
|
||||
.map_err(|e| anyhow::anyhow!("downloadBlob partial missing chunk: {e}"))?
|
||||
.to_vec();
|
||||
Ok::<Vec<u8>, anyhow::Error>(chunk)
|
||||
})
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
partial == &file_data[100..300],
|
||||
"partial download [100..300] does not match expected slice"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uploading with a blobHash that does not match the chunk data must return E026.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn e2e_blob_hash_mismatch() -> anyhow::Result<()> {
|
||||
ensure_rustls_provider();
|
||||
|
||||
let temp = TempDir::new()?;
|
||||
let base = temp.path();
|
||||
|
||||
let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]);
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
init_auth(ClientAuth::from_parts("devtoken".to_string(), None));
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
|
||||
let alice_state = base.join("alice.bin");
|
||||
local
|
||||
.run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
|
||||
let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?;
|
||||
|
||||
// Chunk data.
|
||||
let chunk_data = b"some file content for mismatch test";
|
||||
|
||||
// Wrong hash (all zeros — will not match any real data).
|
||||
let wrong_hash = [0u8; 32];
|
||||
|
||||
let result = local
|
||||
.run_until(async {
|
||||
let mut req = client.upload_blob_request();
|
||||
{
|
||||
let mut p = req.get();
|
||||
let mut auth = p.reborrow().init_auth();
|
||||
quicproquo_client::client::rpc::set_auth(&mut auth)?;
|
||||
p.set_blob_hash(&wrong_hash);
|
||||
p.set_chunk(&chunk_data[..]);
|
||||
p.set_offset(0);
|
||||
p.set_total_size(chunk_data.len() as u64);
|
||||
p.set_mime_type("text/plain");
|
||||
}
|
||||
let resp = req.send().promise.await
|
||||
.map_err(|e| anyhow::anyhow!("uploadBlob RPC: {e}"))?;
|
||||
resp.get()
|
||||
.map_err(|e| anyhow::anyhow!("uploadBlob response: {e}"))?;
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => anyhow::bail!("uploadBlob with wrong hash should have been rejected"),
|
||||
Err(e) => {
|
||||
let msg = format!("{e:#}");
|
||||
anyhow::ensure!(
|
||||
msg.contains("E026") || msg.contains("hash") || msg.contains("mismatch"),
|
||||
"expected E026 / hash mismatch error, got: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user