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
+10 -6
View File
@@ -120,7 +120,7 @@ enum Decoded {
}
/// Decrypt + classify one stored/broadcast message object.
fn decode_msg(room: &fernet::Fernet, m: &Value, allow_sbx: bool) -> Decoded {
fn decode_msg(room: &fernet::Fernet, m: &Value, live: bool) -> Decoded {
let ct = match m["text"].as_str() {
Some(c) if !c.is_empty() => c,
_ => return Decoded::Skip,
@@ -128,22 +128,26 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, allow_sbx: bool) -> Decoded {
let (text, system) = match room.decrypt(ct) {
Ok(pt) => {
let t = String::from_utf8_lossy(&pt).to_string();
if t.starts_with("{\"_ft\":") {
return Decoded::Skip; // file-transfer control frame (P5)
}
// Server-stamped (authenticated) sender of this message.
let sender = m["username"].as_str().unwrap_or("?");
if t.starts_with("{\"_perm\":") {
return parse_perm(&t).map(Decoded::Sbx).unwrap_or(Decoded::Skip);
}
// Control frames are live-only — never replayed from the stored snapshot.
if t.starts_with("{\"_sbx\":") {
// Don't replay terminal history from the stored snapshot.
return if allow_sbx {
return if live {
parse_sbx(&t, sender).map(Decoded::Sbx).unwrap_or(Decoded::Skip)
} else {
Decoded::Skip
};
}
if t.starts_with("{\"_ft\":") {
return if live {
crate::ft::parse(&t, sender).map(|f| Decoded::Sbx(Net::Ft(f))).unwrap_or(Decoded::Skip)
} else {
Decoded::Skip
};
}
(t, false)
}
Err(_) => ("[unreadable — wrong room password?]".to_string(), true),