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

55
hh/Cargo.lock generated
View File

@ -281,7 +281,7 @@ dependencies = [
"futures-core", "futures-core",
"mio", "mio",
"parking_lot", "parking_lot",
"rustix", "rustix 0.38.44",
"signal-hook", "signal-hook",
"signal-hook-mio", "signal-hook-mio",
"winapi", "winapi",
@ -426,6 +426,16 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@ -588,6 +598,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"tar",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",
"toml", "toml",
@ -958,6 +969,12 @@ version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@ -1442,10 +1459,23 @@ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys 0.4.15",
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.40" version = "0.23.40"
@ -1782,6 +1812,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "termios" name = "termios"
version = "0.2.2" version = "0.2.2"
@ -2493,6 +2534,16 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.4",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.2"

View File

@ -30,6 +30,9 @@ url = "2"
portable-pty = "0.8" portable-pty = "0.8"
vt100 = "0.15" vt100 = "0.15"
# file/dir transfer (P5)
tar = "0.4"
# async + tui # async + tui
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }

View File

@ -0,0 +1 @@
secret offering to the coven — marker XYZ123

View File

@ -0,0 +1 @@
AAA

View File

@ -0,0 +1 @@
BBB

View File

@ -1,5 +1,6 @@
//! TUI application state, network event model, and the async run loop. //! TUI application state, network event model, and the async run loop.
use crate::ft;
use crate::net::{self, Session}; use crate::net::{self, Session};
use crate::sbx; use crate::sbx;
use crate::theme::Theme; use crate::theme::Theme;
@ -16,13 +17,15 @@ use futures_util::{SinkExt, StreamExt};
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::Terminal; use ratatui::Terminal;
use serde_json::json; use serde_json::json;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use tokio_tungstenite::tungstenite::Message as WsMsg; use tokio_tungstenite::tungstenite::Message as WsMsg;
const SBX_NAME: &str = "hack-house"; const SBX_NAME: &str = "hack-house";
/// One rendered chat row.
#[derive(Clone)] #[derive(Clone)]
pub struct ChatLine { pub struct ChatLine {
pub ts: String, pub ts: String,
@ -37,6 +40,20 @@ pub struct User {
pub username: String, pub username: String,
} }
/// An in-progress incoming transfer we accepted.
struct Transfer {
meta: ft::Offer,
buf: Vec<u8>,
accepted: bool,
}
/// An outgoing transfer awaiting / serving an accept.
struct ActiveSend {
id: String,
payload: Arc<Vec<u8>>,
sending: bool,
}
/// Decoded events arriving from the websocket reader task. /// Decoded events arriving from the websocket reader task.
pub enum Net { pub enum Net {
Init { lines: Vec<ChatLine>, users: Vec<User> }, Init { lines: Vec<ChatLine>, users: Vec<User> },
@ -49,23 +66,11 @@ pub enum Net {
SbxData(Vec<u8>), SbxData(Vec<u8>),
SbxInput { from: String, bytes: Vec<u8> }, SbxInput { from: String, bytes: Vec<u8> },
Perm { owner: String, drivers: Vec<String> }, Perm { owner: String, drivers: Vec<String> },
Ft(ft::Ft),
Sys(String), Sys(String),
Closed, Closed,
} }
/// Sandbox handoff from the async launch task to the run loop.
enum BrokerMsg {
Ready {
sb: sbx::Sandbox,
backend: sbx::Backend,
name: String,
rows: u16,
cols: u16,
},
Failed,
}
/// Local view of the shared sandbox terminal (everyone renders from `data`).
pub struct SbxView { pub struct SbxView {
pub parser: vt100::Parser, pub parser: vt100::Parser,
pub backend: String, pub backend: String,
@ -80,10 +85,10 @@ pub struct App {
pub connected: bool, pub connected: bool,
pub sandbox: Option<SbxView>, pub sandbox: Option<SbxView>,
pub driving: bool, pub driving: bool,
/// Sandbox owner (the initiator / superuser). Empty until a sandbox launches.
pub owner: Option<String>, pub owner: Option<String>,
/// Members allowed to drive the shared shell (always includes the owner).
pub drivers: std::collections::HashSet<String>, pub drivers: std::collections::HashSet<String>,
pub pending_offer: Option<ft::Offer>,
transfers: HashMap<String, Transfer>,
} }
impl App { impl App {
@ -99,13 +104,14 @@ impl App {
driving: false, driving: false,
owner: None, owner: None,
drivers: std::collections::HashSet::new(), drivers: std::collections::HashSet::new(),
pending_offer: None,
transfers: HashMap::new(),
} }
} }
pub fn is_owner(&self) -> bool { pub fn is_owner(&self) -> bool {
self.owner.as_deref() == Some(self.me.as_str()) self.owner.as_deref() == Some(self.me.as_str())
} }
pub fn can_drive(&self) -> bool { pub fn can_drive(&self) -> bool {
self.drivers.contains(&self.me) self.drivers.contains(&self.me)
} }
@ -126,7 +132,7 @@ impl App {
self.users = users; self.users = users;
self.connected = true; self.connected = true;
self.sys(format!("joined as {}", self.me)); self.sys(format!("joined as {}", self.me));
self.sys("/sbx launch [local|docker|multipass] · /sbx stop · F2 to drive"); self.sys("/send <file> · /sendd <dir> · /sbx launch · F2 drive · ctrl-q quit");
} }
Net::Message(l) => self.lines.push(l), Net::Message(l) => self.lines.push(l),
Net::Roster { users, capacity } => { Net::Roster { users, capacity } => {
@ -155,21 +161,6 @@ impl App {
self.sys("⛧ sandbox dismissed"); self.sys("⛧ sandbox dismissed");
} }
} }
Net::Perm { owner, drivers } => {
let new: std::collections::HashSet<String> = drivers.into_iter().collect();
// Surface changes that affect me.
if !owner.is_empty() && self.owner.as_deref() != Some(owner.as_str()) {
self.sys(format!("{owner} is the superuser (sandbox owner)"));
}
if new.contains(&self.me) && !self.drivers.contains(&self.me) && self.owner.is_some() {
self.sys("⛧ you were granted drive (you can drive — F2)");
} else if !new.contains(&self.me) && self.drivers.contains(&self.me) {
self.driving = false;
self.sys("⛧ your drive permission was revoked");
}
self.owner = Some(owner).filter(|o| !o.is_empty());
self.drivers = new;
}
Net::SbxResize { rows, cols } => { Net::SbxResize { rows, cols } => {
if let Some(v) = &mut self.sandbox { if let Some(v) = &mut self.sandbox {
v.parser.set_size(rows.max(1), cols.max(1)); v.parser.set_size(rows.max(1), cols.max(1));
@ -180,7 +171,22 @@ impl App {
v.parser.process(&bytes); v.parser.process(&bytes);
} }
} }
Net::SbxInput { .. } => {} // broker enforces + writes to PTY in the run loop Net::SbxInput { .. } => {} // broker enforces + writes in the run loop
Net::Perm { owner, drivers } => {
let new: std::collections::HashSet<String> = drivers.into_iter().collect();
if !owner.is_empty() && self.owner.as_deref() != Some(owner.as_str()) {
self.sys(format!("{owner} is the superuser (sandbox owner)"));
}
if new.contains(&self.me) && !self.drivers.contains(&self.me) && self.owner.is_some() {
self.sys("⛧ you were granted drive (F2 to take the shell)");
} else if !new.contains(&self.me) && self.drivers.contains(&self.me) {
self.driving = false;
self.sys("⛧ your drive permission was revoked");
}
self.owner = Some(owner).filter(|o| !o.is_empty());
self.drivers = new;
}
Net::Ft(_) => {} // handled in the run loop (needs out channel + disk)
Net::Sys(t) => self.sys(t), Net::Sys(t) => self.sys(t),
Net::Closed => { Net::Closed => {
self.connected = false; self.connected = false;
@ -190,15 +196,12 @@ impl App {
} }
} }
/// Approximate inner dimensions of the sandbox pane for a terminal of this size
/// (mirrors ui.rs layout: top bar 1, input 3, sandbox = 55% of the body).
fn sbx_dims(term_w: u16, term_h: u16) -> (u16, u16) { fn sbx_dims(term_w: u16, term_h: u16) -> (u16, u16) {
let body_h = term_h.saturating_sub(4); let body_h = term_h.saturating_sub(4);
let sbx_h = (body_h as u32 * 55 / 100) as u16; let sbx_h = (body_h as u32 * 55 / 100) as u16;
(sbx_h.saturating_sub(2).max(1), term_w.saturating_sub(2).max(1)) (sbx_h.saturating_sub(2).max(1), term_w.saturating_sub(2).max(1))
} }
/// Translate a key event into the bytes a PTY expects (drive mode).
fn key_to_pty(code: KeyCode, mods: KeyModifiers) -> Option<Vec<u8>> { fn key_to_pty(code: KeyCode, mods: KeyModifiers) -> Option<Vec<u8>> {
match code { match code {
KeyCode::Char(c) => { KeyCode::Char(c) => {
@ -220,26 +223,91 @@ fn key_to_pty(code: KeyCode, mods: KeyModifiers) -> Option<Vec<u8>> {
} }
} }
async fn send_frame<S>(write: &mut S, room: &fernet::Fernet, value: serde_json::Value) /// Queue an encrypted JSON frame for transmission (drained by the run loop).
where fn send_frame(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, value: serde_json::Value) {
S: SinkExt<WsMsg> + Unpin, let _ = out.send(WsMsg::Text(room.encrypt(value.to_string().as_bytes())));
{
let ct = room.encrypt(value.to_string().as_bytes());
let _ = write.send(WsMsg::Text(ct)).await;
} }
/// Broadcast the current access-control list (owner + permitted drivers). fn broadcast_acl(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, app: &App) {
async fn broadcast_acl<S>(write: &mut S, room: &fernet::Fernet, app: &App)
where
S: SinkExt<WsMsg> + Unpin,
{
let drivers: Vec<&String> = app.drivers.iter().collect(); let drivers: Vec<&String> = app.drivers.iter().collect();
send_frame( send_frame(out, room, json!({"_perm":"acl","owner": app.owner, "drivers": drivers}));
write, }
room,
json!({"_perm":"acl","owner": app.owner, "drivers": drivers}), /// Stream a payload to the coven as `_ft` chunks (background, paced).
) fn spawn_send(id: String, payload: Arc<Vec<u8>>, out: UnboundedSender<WsMsg>, room: Arc<fernet::Fernet>) {
.await; tokio::spawn(async move {
for (seq, chunk) in payload.chunks(ft::CHUNK).enumerate() {
let frame = json!({"_ft":"chunk","id": id,"seq": seq,"data": STANDARD.encode(chunk)});
if out.send(WsMsg::Text(room.encrypt(frame.to_string().as_bytes()))).is_err() {
return;
}
tokio::time::sleep(Duration::from_millis(2)).await;
}
send_frame(&out, &room, json!({"_ft":"done","id": id}));
});
}
fn handle_ft(
f: ft::Ft,
app: &mut App,
active: &mut Option<ActiveSend>,
out: &UnboundedSender<WsMsg>,
room: &Arc<fernet::Fernet>,
downloads: &std::path::Path,
) {
match f {
ft::Ft::Offer(o) => {
if o.from == app.me {
return; // our own offer echo
}
app.sys(format!(
"⛧ {} offers {} ({}{}) — /accept or /reject",
o.from, o.name, ft::human(o.size as usize),
if o.dir { ", directory" } else { "" }
));
app.transfers.insert(o.id.clone(), Transfer { meta: o.clone(), buf: Vec::new(), accepted: false });
app.pending_offer = Some(o);
}
ft::Ft::Accept(id) => {
if let Some(a) = active.as_mut() {
if a.id == id && !a.sending {
a.sending = true;
spawn_send(id, a.payload.clone(), out.clone(), room.clone());
app.sys("transfer accepted — sending…");
}
}
}
ft::Ft::Reject(id) => {
if active.as_ref().map(|a| a.id == id).unwrap_or(false) {
app.sys("transfer rejected");
*active = None;
}
}
ft::Ft::Chunk { id, data } => {
if let Some(t) = app.transfers.get_mut(&id) {
if t.accepted {
t.buf.extend_from_slice(&data);
}
}
}
ft::Ft::Done(id) => {
if let Some(t) = app.transfers.remove(&id) {
if t.accepted {
if ft::sha256_hex(&t.buf) != t.meta.sha256 {
app.sys(format!("{} — SHA-256 mismatch, discarded", t.meta.name));
} else {
match ft::save(downloads, &t.meta, &t.buf) {
Ok(p) => app.sys(format!("⛧ saved {} ({}) — verified ✓", p.display(), ft::human(t.buf.len()))),
Err(e) => app.sys(format!("save failed: {e}")),
}
}
}
}
if app.pending_offer.as_ref().map(|o| o.id == id).unwrap_or(false) {
app.pending_offer = None;
}
}
}
} }
pub async fn run(session: Session, theme: Theme) -> Result<()> { pub async fn run(session: Session, theme: Theme) -> Result<()> {
@ -249,14 +317,18 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
let app_tx = tx.clone(); let app_tx = tx.clone();
tokio::spawn(net::reader(read, session.room.clone(), tx)); tokio::spawn(net::reader(read, session.room.clone(), tx));
// Broker-owned sandbox PTY output, and async-launch handoff. // All outgoing frames funnel through here so background tasks (file chunks,
let (pty_tx, mut pty_rx): (UnboundedSender<Vec<u8>>, UnboundedReceiver<Vec<u8>>) = // PTY relay) can transmit without owning the socket.
unbounded_channel(); let (out_tx, mut out_rx) = unbounded_channel::<WsMsg>();
let (pty_tx, mut pty_rx): (UnboundedSender<Vec<u8>>, UnboundedReceiver<Vec<u8>>) = unbounded_channel();
let (broker_tx, mut broker_rx) = unbounded_channel::<BrokerMsg>(); let (broker_tx, mut broker_rx) = unbounded_channel::<BrokerMsg>();
let mut broker: Option<sbx::Sandbox> = None; let mut broker: Option<sbx::Sandbox> = None;
let mut broker_meta: Option<(sbx::Backend, String)> = None; let mut broker_meta: Option<(sbx::Backend, String)> = None;
let mut launching = false; let mut launching = false;
let mut announced_dims: Option<(u16, u16)> = None; let mut announced_dims: Option<(u16, u16)> = None;
let mut active_send: Option<ActiveSend> = None;
let mut send_seq: u64 = 0;
let downloads = PathBuf::from("./downloads");
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = std::io::stdout(); let mut stdout = std::io::stdout();
@ -272,7 +344,6 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
break Err(e.into()); break Err(e.into());
} }
// Broker: keep the PTY sized to our pane; broadcast size changes.
if broker.is_some() { if broker.is_some() {
if let Ok(sz) = term.size() { if let Ok(sz) = term.size() {
let dims = sbx_dims(sz.width, sz.height); let dims = sbx_dims(sz.width, sz.height);
@ -281,8 +352,7 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
if let Some(sb) = &broker { if let Some(sb) = &broker {
let _ = sb.resize(dims.0, dims.1); let _ = sb.resize(dims.0, dims.1);
} }
send_frame(&mut write, &session.room, send_frame(&out_tx, &session.room, json!({"_sbx":"resize","rows":dims.0,"cols":dims.1}));
json!({"_sbx":"resize","rows":dims.0,"cols":dims.1})).await;
} }
} }
} }
@ -291,13 +361,11 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
maybe = events.next() => { maybe = events.next() => {
match maybe { match maybe {
Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => { Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => {
if k.modifiers.contains(KeyModifiers::CONTROL) if k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('q')) {
&& matches!(k.code, KeyCode::Char('q')) {
break Ok(()); break Ok(());
} }
if k.code == KeyCode::F(2) { if k.code == KeyCode::F(2) {
if app.sandbox.is_none() { if app.sandbox.is_none() {
// nothing to drive
} else if app.can_drive() { } else if app.can_drive() {
app.driving = !app.driving; app.driving = !app.driving;
} else { } else {
@ -307,8 +375,7 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
if k.code == KeyCode::Esc { if k.code == KeyCode::Esc {
app.driving = false; app.driving = false;
} else if let Some(bytes) = key_to_pty(k.code, k.modifiers) { } else if let Some(bytes) = key_to_pty(k.code, k.modifiers) {
send_frame(&mut write, &session.room, send_frame(&out_tx, &session.room, json!({"_sbx":"input","b64": STANDARD.encode(&bytes)}));
json!({"_sbx":"input","b64": STANDARD.encode(&bytes)})).await;
} }
} else { } else {
match k.code { match k.code {
@ -316,74 +383,9 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
KeyCode::Enter => { KeyCode::Enter => {
let line = app.input.trim().to_string(); let line = app.input.trim().to_string();
app.input.clear(); app.input.clear();
if let Some(rest) = line.strip_prefix("/sbx") { handle_command(&line, &mut app, &mut active_send, &mut send_seq,
let mut p = rest.split_whitespace(); &mut broker, &mut broker_meta, &mut launching, &mut announced_dims,
match p.next() { &out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term);
Some("launch") => {
if app.sandbox.is_some() || broker.is_some() || launching {
app.sys("a sandbox is already running");
} else {
let backend = p.next()
.and_then(sbx::Backend::parse)
.unwrap_or(sbx::Backend::Local);
let image = p.next()
.map(str::to_string)
.unwrap_or_else(|| backend.default_image().to_string());
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_dims(sz.0, sz.1);
launching = true;
app.sys(format!(
"summoning {} sandbox… (multipass boot can take ~30s)",
backend.label()));
spawn_launch(backend, image, rows, cols,
pty_tx.clone(), broker_tx.clone(), app_tx.clone());
}
}
Some("stop") => {
if let Some(mut sb) = broker.take() {
sb.stop();
if let Some((be, name)) = broker_meta.take() {
tokio::task::spawn_blocking(move || sbx::teardown(be, &name));
}
announced_dims = None;
send_frame(&mut write, &session.room,
json!({"_sbx":"status","state":"stopped"})).await;
} else {
app.sys("you are not hosting a sandbox");
}
}
_ => app.sys("usage: /sbx launch [local|docker|multipass] [image] | /sbx stop"),
}
} else if let Some(rest) = line.strip_prefix("/grant") {
let target = rest.trim();
if !app.is_owner() {
app.sys("only the sandbox owner can /grant");
} else if target.is_empty() {
app.sys("usage: /grant <user>");
} else {
app.drivers.insert(target.to_string());
broadcast_acl(&mut write, &session.room, &app).await;
app.sys(format!("granted drive to {target}"));
}
} else if let Some(rest) = line.strip_prefix("/revoke") {
let target = rest.trim();
if !app.is_owner() {
app.sys("only the sandbox owner can /revoke");
} else if target == app.me {
app.sys("the owner cannot revoke themselves");
} else if target.is_empty() {
app.sys("usage: /revoke <user>");
} else {
app.drivers.remove(target);
broadcast_acl(&mut write, &session.room, &app).await;
app.sys(format!("revoked drive from {target}"));
}
} else if !line.is_empty() && app.connected {
let ct = session.room.encrypt(line.as_bytes());
if write.send(WsMsg::Text(ct)).await.is_err() {
app.connected = false;
}
}
} }
KeyCode::Backspace => { app.input.pop(); } KeyCode::Backspace => { app.input.pop(); }
KeyCode::Char(c) => app.input.push(c), KeyCode::Char(c) => app.input.push(c),
@ -398,14 +400,13 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
net = rx.recv() => { net = rx.recv() => {
match net { match net {
Some(Net::SbxInput { from, bytes }) => { Some(Net::SbxInput { from, bytes }) => {
// Broker authority: only honor input from a permitted driver
// (sender is server-authenticated via the message username).
if let Some(sb) = &mut broker { if let Some(sb) = &mut broker {
if app.drivers.contains(&from) { if app.drivers.contains(&from) {
let _ = sb.write_input(&bytes); let _ = sb.write_input(&bytes);
} }
} }
} }
Some(Net::Ft(f)) => handle_ft(f, &mut app, &mut active_send, &out_tx, &session.room, &downloads),
Some(n) => app.apply(n), Some(n) => app.apply(n),
None => break Ok(()), None => break Ok(()),
} }
@ -417,15 +418,13 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
broker_meta = Some((backend, name)); broker_meta = Some((backend, name));
announced_dims = Some((rows, cols)); announced_dims = Some((rows, cols));
launching = false; launching = false;
// The launcher is the owner / superuser and the first driver.
app.owner = Some(app.me.clone()); app.owner = Some(app.me.clone());
app.drivers.clear(); app.drivers.clear();
app.drivers.insert(app.me.clone()); app.drivers.insert(app.me.clone());
send_frame(&mut write, &session.room, json!({ send_frame(&out_tx, &session.room, json!({
"_sbx":"status","state":"ready", "_sbx":"status","state":"ready","backend": backend.label(), "rows": rows, "cols": cols
"backend": backend.label(), "rows": rows, "cols": cols }));
})).await; broadcast_acl(&out_tx, &session.room, &app);
broadcast_acl(&mut write, &session.room, &app).await;
} }
Some(BrokerMsg::Failed) => { launching = false; } Some(BrokerMsg::Failed) => { launching = false; }
None => {} None => {}
@ -433,8 +432,26 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
} }
pty = pty_rx.recv() => { pty = pty_rx.recv() => {
if let Some(bytes) = pty { if let Some(bytes) = pty {
send_frame(&mut write, &session.room, send_frame(&out_tx, &session.room, json!({"_sbx":"data","b64": STANDARD.encode(&bytes)}));
json!({"_sbx":"data","b64": STANDARD.encode(&bytes)})).await; }
}
outgoing = out_rx.recv() => {
match outgoing {
Some(first) => {
// Flush a batch to keep file-chunk bursts from redrawing per frame.
let mut batch = vec![first];
while let Ok(m) = out_rx.try_recv() {
batch.push(m);
if batch.len() >= 64 { break; }
}
for m in batch {
if write.send(m).await.is_err() {
app.connected = false;
break;
}
}
}
None => {}
} }
} }
_ = tick.tick() => {} _ = tick.tick() => {}
@ -453,7 +470,121 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
result result
} }
/// Boot a sandbox off the UI thread (prepare → spawn PTY → hand back the handle). enum BrokerMsg {
Ready { sb: sbx::Sandbox, backend: sbx::Backend, name: String, rows: u16, cols: u16 },
Failed,
}
#[allow(clippy::too_many_arguments)]
fn handle_command(
line: &str,
app: &mut App,
active_send: &mut Option<ActiveSend>,
send_seq: &mut u64,
broker: &mut Option<sbx::Sandbox>,
broker_meta: &mut Option<(sbx::Backend, String)>,
launching: &mut bool,
announced_dims: &mut Option<(u16, u16)>,
out_tx: &UnboundedSender<WsMsg>,
pty_tx: &UnboundedSender<Vec<u8>>,
broker_tx: &UnboundedSender<BrokerMsg>,
app_tx: &UnboundedSender<Net>,
session: &Session,
term: &Terminal<CrosstermBackend<std::io::Stdout>>,
) {
let room = &session.room;
if let Some(path) = line.strip_prefix("/sendd ").or_else(|| line.strip_prefix("/send ")) {
let path = path.trim();
match ft::read_payload(path) {
Ok((name, bytes, dir)) => {
*send_seq += 1;
let id = format!("{}-{}", app.me, send_seq);
let (size, sha) = (bytes.len(), ft::sha256_hex(&bytes));
*active_send = Some(ActiveSend { id: id.clone(), payload: Arc::new(bytes), sending: false });
send_frame(out_tx, room, json!({
"_ft":"offer","id": id,"name": name,"size": size,"sha256": sha,"dir": dir
}));
app.sys(format!("offered {} ({}) — waiting for an /accept", name, ft::human(size)));
}
Err(e) => app.sys(format!("send failed: {e}")),
}
} else if line == "/accept" {
if let Some(o) = app.pending_offer.take() {
send_frame(out_tx, room, json!({"_ft":"accept","id": o.id}));
if let Some(t) = app.transfers.get_mut(&o.id) {
t.accepted = true;
}
app.sys(format!("accepting {}", o.name));
} else {
app.sys("no pending offer");
}
} else if line == "/reject" {
if let Some(o) = app.pending_offer.take() {
send_frame(out_tx, room, json!({"_ft":"reject","id": o.id}));
app.transfers.remove(&o.id);
app.sys("rejected the offer");
} else {
app.sys("no pending offer");
}
} else if let Some(rest) = line.strip_prefix("/sbx") {
let mut p = rest.split_whitespace();
match p.next() {
Some("launch") => {
if app.sandbox.is_some() || broker.is_some() || *launching {
app.sys("a sandbox is already running");
} else {
let backend = p.next().and_then(sbx::Backend::parse).unwrap_or(sbx::Backend::Local);
let image = p.next().map(str::to_string).unwrap_or_else(|| backend.default_image().to_string());
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_dims(sz.0, sz.1);
*launching = true;
app.sys(format!("summoning {} sandbox… (multipass boot can take ~30s)", backend.label()));
spawn_launch(backend, image, rows, cols, pty_tx.clone(), broker_tx.clone(), app_tx.clone());
}
}
Some("stop") => {
if let Some(mut sb) = broker.take() {
sb.stop();
if let Some((be, name)) = broker_meta.take() {
tokio::task::spawn_blocking(move || sbx::teardown(be, &name));
}
*announced_dims = None;
send_frame(out_tx, room, json!({"_sbx":"status","state":"stopped"}));
} else {
app.sys("you are not hosting a sandbox");
}
}
_ => app.sys("usage: /sbx launch [local|docker|multipass] [image] | /sbx stop"),
}
} else if let Some(rest) = line.strip_prefix("/grant") {
let target = rest.trim();
if !app.is_owner() {
app.sys("only the sandbox owner can /grant");
} else if target.is_empty() {
app.sys("usage: /grant <user>");
} else {
app.drivers.insert(target.to_string());
broadcast_acl(out_tx, room, app);
app.sys(format!("granted drive to {target}"));
}
} else if let Some(rest) = line.strip_prefix("/revoke") {
let target = rest.trim();
if !app.is_owner() {
app.sys("only the sandbox owner can /revoke");
} else if target == app.me {
app.sys("the owner cannot revoke themselves");
} else if target.is_empty() {
app.sys("usage: /revoke <user>");
} else {
app.drivers.remove(target);
broadcast_acl(out_tx, room, app);
app.sys(format!("revoked drive from {target}"));
}
} else if !line.is_empty() && app.connected {
let _ = out_tx.send(WsMsg::Text(room.encrypt(line.as_bytes())));
}
}
fn spawn_launch( fn spawn_launch(
backend: sbx::Backend, backend: sbx::Backend,
image: String, image: String,

198
hh/src/ft.rs Normal file
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")));
}
}

View File

@ -6,6 +6,7 @@
mod app; mod app;
mod crypto; mod crypto;
mod ft;
mod net; mod net;
mod sbx; mod sbx;
mod theme; mod theme;

View File

@ -120,7 +120,7 @@ enum Decoded {
} }
/// Decrypt + classify one stored/broadcast message object. /// 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() { let ct = match m["text"].as_str() {
Some(c) if !c.is_empty() => c, Some(c) if !c.is_empty() => c,
_ => return Decoded::Skip, _ => 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) { let (text, system) = match room.decrypt(ct) {
Ok(pt) => { Ok(pt) => {
let t = String::from_utf8_lossy(&pt).to_string(); 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. // Server-stamped (authenticated) sender of this message.
let sender = m["username"].as_str().unwrap_or("?"); let sender = m["username"].as_str().unwrap_or("?");
if t.starts_with("{\"_perm\":") { if t.starts_with("{\"_perm\":") {
return parse_perm(&t).map(Decoded::Sbx).unwrap_or(Decoded::Skip); 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\":") { if t.starts_with("{\"_sbx\":") {
// Don't replay terminal history from the stored snapshot. return if live {
return if allow_sbx {
parse_sbx(&t, sender).map(Decoded::Sbx).unwrap_or(Decoded::Skip) parse_sbx(&t, sender).map(Decoded::Sbx).unwrap_or(Decoded::Skip)
} else { } else {
Decoded::Skip 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) (t, false)
} }
Err(_) => ("[unreadable — wrong room password?]".to_string(), true), Err(_) => ("[unreadable — wrong room password?]".to_string(), true),

View File

@ -147,10 +147,17 @@ fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &The
])) ]))
.block( .block(
Block::bordered() Block::bordered()
.border_style(Style::default().fg(theme.border)) .border_style(Style::default().fg(if app.pending_offer.is_some() {
theme.accent
} else {
theme.border
}))
.title(Span::styled( .title(Span::styled(
" message · enter send · esc quit ", match &app.pending_offer {
Style::default().fg(theme.dim), Some(o) => format!(" ⛧ incoming: {} — /accept or /reject ", o.name),
None => " message · enter send · esc quit ".to_string(),
},
Style::default().fg(theme.title),
)), )),
); );
f.render_widget(input, area); f.render_widget(input, area);