feat(hh): P5 — file & directory uploads ⛧

Wire-compatible with the Python client's _ft protocol (offer/accept/reject/
chunk/done, 64KB chunks, SHA-256 verified), over the encrypted channel:
- ft.rs: read_payload (file or tar'd directory), save/extract with a zip-slip
  guard (relative-only, no .. / absolute; unpack_in double-checks), SHA-256.
- /send <file> and /sendd <dir>; receiver sees an offer banner, /accept or
  /reject; chunks stream in the background and the result is verified + saved
  to ./downloads (directories extracted in place).
- Refactor: all outgoing ws frames now funnel through an mpsc channel drained
  (batched) by the run loop, so the background chunk-sender and the PTY relay
  transmit without owning the socket.
- ui.rs: pending-offer banner on the input bar.

Tests: 7 cargo tests (file + dir tar round-trip, traversal guard, + crypto/sbx).
Verified live: two TUIs, file and directory transfer, SHA-256 verified, saved.

Note: dropping accepted files into the active sandbox VM dir is a future add-on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-05-30 19:39:55 -07:00
parent f73c23bf57
commit 1dfc614cc5
10 changed files with 553 additions and 155 deletions
+198
View File
@@ -0,0 +1,198 @@
//! File & directory transfer over the encrypted channel — wire-compatible with
//! the Python client's `_ft` protocol (offer/accept/reject/chunk/done, 64 KB
//! chunks, SHA-256 verified). Directories are streamed as a tar with `dir:true`
//! and extracted on receipt (with a path-traversal guard); a Python receiver
//! just saves the `.tar`.
use anyhow::{Context, Result};
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::path::{Component, Path, PathBuf};
pub const MAX_SIZE: usize = 50 * 1024 * 1024;
pub const CHUNK: usize = 64 * 1024;
#[derive(Clone)]
pub struct Offer {
pub id: String,
pub name: String,
pub size: u64,
pub sha256: String,
pub dir: bool,
pub from: String,
}
pub enum Ft {
Offer(Offer),
Accept(String),
Reject(String),
Chunk { id: String, data: Vec<u8> },
Done(String),
}
pub fn sha256_hex(data: &[u8]) -> String {
let mut h = Sha256::new();
h.update(data);
hex::encode(h.finalize())
}
pub fn human(size: usize) -> String {
let (mut s, units) = (size as f64, ["B", "KB", "MB", "GB"]);
for u in units {
if s < 1024.0 {
return format!("{s:.1} {u}");
}
s /= 1024.0;
}
format!("{s:.1} TB")
}
/// Read the payload to offer for a path → (name, bytes, is_dir).
pub fn read_payload(path: &str) -> Result<(String, Vec<u8>, bool)> {
let p = Path::new(path);
let meta = std::fs::metadata(p).with_context(|| format!("not found: {path}"))?;
if meta.is_dir() {
let bytes = tar_dir(p)?;
anyhow::ensure!(bytes.len() <= MAX_SIZE, "directory too large ({})", human(bytes.len()));
let base = p.file_name().and_then(|s| s.to_str()).unwrap_or("dir");
Ok((format!("{base}.tar"), bytes, true))
} else {
anyhow::ensure!(meta.len() as usize <= MAX_SIZE, "file too large (max 50 MB)");
let bytes = std::fs::read(p)?;
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("file").to_string();
Ok((name, bytes, false))
}
}
fn tar_dir(dir: &Path) -> Result<Vec<u8>> {
let mut buf = Vec::new();
{
let mut tb = tar::Builder::new(&mut buf);
let base = dir.file_name().unwrap_or_default();
tb.append_dir_all(base, dir).context("tar directory")?;
tb.finish()?;
}
Ok(buf)
}
/// Persist received bytes under `downloads`. Directories (tar) are extracted
/// with a guard rejecting absolute paths and `..` escapes (zip-slip).
pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
std::fs::create_dir_all(downloads)?;
if offer.dir {
// Extract the tar's own top-level dir directly under downloads/.
let mut ar = tar::Archive::new(data);
let mut top: Option<std::ffi::OsString> = None;
for entry in ar.entries()? {
let mut e = entry?;
let path = e.path()?.into_owned();
// Explicit zip-slip guard (belt-and-suspenders; unpack_in also refuses).
anyhow::ensure!(safe_entry(&path), "unsafe tar entry rejected: {}", path.display());
if top.is_none() {
top = path.components().next().map(|c| c.as_os_str().to_owned());
}
e.unpack_in(downloads)
.with_context(|| format!("extract {}", path.display()))?;
}
Ok(top.map(|t| downloads.join(t)).unwrap_or_else(|| downloads.to_path_buf()))
} else {
let stem = Path::new(&offer.name).file_stem().and_then(|s| s.to_str()).unwrap_or("file");
let ext = Path::new(&offer.name).extension().and_then(|s| s.to_str()).unwrap_or("");
let dest = unique(downloads, stem, ext);
std::fs::write(&dest, data)?;
Ok(dest)
}
}
/// A tar entry path is safe to extract iff it's relative and has no `..` escape.
fn safe_entry(path: &Path) -> bool {
!path.is_absolute() && !path.components().any(|c| matches!(c, Component::ParentDir))
}
fn unique(dir: &Path, stem: &str, ext: &str) -> PathBuf {
let mk = |n: usize| {
let base = if n == 0 { stem.to_string() } else { format!("{stem}_{n}") };
if ext.is_empty() { dir.join(base) } else { dir.join(format!("{base}.{ext}")) }
};
(0..).map(mk).find(|p| !p.exists()).unwrap()
}
/// Parse a decrypted `{"_ft":...}` frame. `sender` is the server-authenticated
/// username of the offerer (so receivers can show who's sending).
pub fn parse(text: &str, sender: &str) -> Option<Ft> {
let v: Value = serde_json::from_str(text).ok()?;
match v["_ft"].as_str()? {
"offer" => Some(Ft::Offer(Offer {
id: v["id"].as_str()?.to_string(),
name: v["name"].as_str().unwrap_or("file").to_string(),
size: v["size"].as_u64().unwrap_or(0),
sha256: v["sha256"].as_str().unwrap_or("").to_string(),
dir: v["dir"].as_bool().unwrap_or(false),
from: sender.to_string(),
})),
"accept" => Some(Ft::Accept(v["id"].as_str()?.to_string())),
"reject" => Some(Ft::Reject(v["id"].as_str()?.to_string())),
"chunk" => Some(Ft::Chunk {
id: v["id"].as_str()?.to_string(),
data: STANDARD.decode(v["data"].as_str()?).ok()?,
}),
"done" => Some(Ft::Done(v["id"].as_str()?.to_string())),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_payload_roundtrip() {
let dir = std::env::temp_dir().join(format!("hh-ft-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let src = dir.join("note.txt");
std::fs::write(&src, b"offering to the coven").unwrap();
let (name, bytes, is_dir) = read_payload(src.to_str().unwrap()).unwrap();
assert_eq!(name, "note.txt");
assert!(!is_dir);
let offer = Offer { id: "1".into(), name, size: bytes.len() as u64,
sha256: sha256_hex(&bytes), dir: false, from: "x".into() };
let dl = dir.join("dl");
let out = save(&dl, &offer, &bytes).unwrap();
assert_eq!(std::fs::read(&out).unwrap(), b"offering to the coven");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn dir_tar_roundtrip() {
let dir = std::env::temp_dir().join(format!("hh-ftd-{}", std::process::id()));
let proj = dir.join("proj");
std::fs::create_dir_all(proj.join("sub")).unwrap();
std::fs::write(proj.join("a.txt"), b"AAA").unwrap();
std::fs::write(proj.join("sub/b.txt"), b"BBB").unwrap();
let (name, bytes, is_dir) = read_payload(proj.to_str().unwrap()).unwrap();
assert_eq!(name, "proj.tar");
assert!(is_dir);
let offer = Offer { id: "1".into(), name, size: bytes.len() as u64,
sha256: sha256_hex(&bytes), dir: true, from: "x".into() };
let dl = dir.join("dl");
let out = save(&dl, &offer, &bytes).unwrap(); // -> dl/proj
assert!(out.ends_with("proj"));
assert_eq!(std::fs::read(out.join("a.txt")).unwrap(), b"AAA");
assert_eq!(std::fs::read(out.join("sub/b.txt")).unwrap(), b"BBB");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn traversal_guard_blocks_escapes() {
// The zip-slip guard: relative-only, no `..` escape, no absolute paths.
assert!(!safe_entry(Path::new("../escape.txt")));
assert!(!safe_entry(Path::new("a/../../etc/passwd")));
assert!(!safe_entry(Path::new("/etc/passwd")));
assert!(safe_entry(Path::new("proj/sub/ok.txt")));
assert!(safe_entry(Path::new("file.txt")));
}
}