diff --git a/hh/Cargo.lock b/hh/Cargo.lock index 8e4d940..793889d 100644 --- a/hh/Cargo.lock +++ b/hh/Cargo.lock @@ -281,7 +281,7 @@ dependencies = [ "futures-core", "mio", "parking_lot", - "rustix", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -426,6 +426,16 @@ dependencies = [ "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]] name = "find-msvc-tools" version = "0.1.9" @@ -588,6 +598,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tar", "tokio", "tokio-tungstenite", "toml", @@ -958,6 +969,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1442,10 +1459,23 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "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]] name = "rustls" version = "0.23.40" @@ -1782,6 +1812,17 @@ dependencies = [ "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]] name = "termios" version = "0.2.2" @@ -2493,6 +2534,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "yoke" version = "0.8.2" diff --git a/hh/Cargo.toml b/hh/Cargo.toml index 2bddab2..0ff169a 100644 --- a/hh/Cargo.toml +++ b/hh/Cargo.toml @@ -30,6 +30,9 @@ url = "2" portable-pty = "0.8" vt100 = "0.15" +# file/dir transfer (P5) +tar = "0.4" + # async + tui tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } diff --git a/hh/downloads/hh-payload.txt b/hh/downloads/hh-payload.txt new file mode 100644 index 0000000..35cffdb --- /dev/null +++ b/hh/downloads/hh-payload.txt @@ -0,0 +1 @@ +secret offering to the coven — marker XYZ123 diff --git a/hh/downloads/hh-proj/a.txt b/hh/downloads/hh-proj/a.txt new file mode 100644 index 0000000..43d5a8e --- /dev/null +++ b/hh/downloads/hh-proj/a.txt @@ -0,0 +1 @@ +AAA diff --git a/hh/downloads/hh-proj/sub/b.txt b/hh/downloads/hh-proj/sub/b.txt new file mode 100644 index 0000000..ba62923 --- /dev/null +++ b/hh/downloads/hh-proj/sub/b.txt @@ -0,0 +1 @@ +BBB diff --git a/hh/src/app.rs b/hh/src/app.rs index a20681d..b1ed4bb 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -1,5 +1,6 @@ //! TUI application state, network event model, and the async run loop. +use crate::ft; use crate::net::{self, Session}; use crate::sbx; use crate::theme::Theme; @@ -16,13 +17,15 @@ use futures_util::{SinkExt, StreamExt}; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use serde_json::json; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio_tungstenite::tungstenite::Message as WsMsg; const SBX_NAME: &str = "hack-house"; -/// One rendered chat row. #[derive(Clone)] pub struct ChatLine { pub ts: String, @@ -37,6 +40,20 @@ pub struct User { pub username: String, } +/// An in-progress incoming transfer we accepted. +struct Transfer { + meta: ft::Offer, + buf: Vec, + accepted: bool, +} + +/// An outgoing transfer awaiting / serving an accept. +struct ActiveSend { + id: String, + payload: Arc>, + sending: bool, +} + /// Decoded events arriving from the websocket reader task. pub enum Net { Init { lines: Vec, users: Vec }, @@ -49,23 +66,11 @@ pub enum Net { SbxData(Vec), SbxInput { from: String, bytes: Vec }, Perm { owner: String, drivers: Vec }, + Ft(ft::Ft), Sys(String), 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 parser: vt100::Parser, pub backend: String, @@ -80,10 +85,10 @@ pub struct App { pub connected: bool, pub sandbox: Option, pub driving: bool, - /// Sandbox owner (the initiator / superuser). Empty until a sandbox launches. pub owner: Option, - /// Members allowed to drive the shared shell (always includes the owner). pub drivers: std::collections::HashSet, + pub pending_offer: Option, + transfers: HashMap, } impl App { @@ -99,13 +104,14 @@ impl App { driving: false, owner: None, drivers: std::collections::HashSet::new(), + pending_offer: None, + transfers: HashMap::new(), } } pub fn is_owner(&self) -> bool { self.owner.as_deref() == Some(self.me.as_str()) } - pub fn can_drive(&self) -> bool { self.drivers.contains(&self.me) } @@ -126,7 +132,7 @@ impl App { self.users = users; self.connected = true; self.sys(format!("joined as {} ⛧", self.me)); - self.sys("/sbx launch [local|docker|multipass] · /sbx stop · F2 to drive"); + self.sys("/send · /sendd · /sbx launch · F2 drive · ctrl-q quit"); } Net::Message(l) => self.lines.push(l), Net::Roster { users, capacity } => { @@ -155,21 +161,6 @@ impl App { self.sys("⛧ sandbox dismissed"); } } - Net::Perm { owner, drivers } => { - let new: std::collections::HashSet = 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 } => { if let Some(v) = &mut self.sandbox { v.parser.set_size(rows.max(1), cols.max(1)); @@ -180,7 +171,22 @@ impl App { 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 = 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::Closed => { 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) { let body_h = term_h.saturating_sub(4); 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)) } -/// Translate a key event into the bytes a PTY expects (drive mode). fn key_to_pty(code: KeyCode, mods: KeyModifiers) -> Option> { match code { KeyCode::Char(c) => { @@ -220,26 +223,91 @@ fn key_to_pty(code: KeyCode, mods: KeyModifiers) -> Option> { } } -async fn send_frame(write: &mut S, room: &fernet::Fernet, value: serde_json::Value) -where - S: SinkExt + Unpin, -{ - let ct = room.encrypt(value.to_string().as_bytes()); - let _ = write.send(WsMsg::Text(ct)).await; +/// Queue an encrypted JSON frame for transmission (drained by the run loop). +fn send_frame(out: &UnboundedSender, room: &fernet::Fernet, value: serde_json::Value) { + let _ = out.send(WsMsg::Text(room.encrypt(value.to_string().as_bytes()))); } -/// Broadcast the current access-control list (owner + permitted drivers). -async fn broadcast_acl(write: &mut S, room: &fernet::Fernet, app: &App) -where - S: SinkExt + Unpin, -{ +fn broadcast_acl(out: &UnboundedSender, room: &fernet::Fernet, app: &App) { let drivers: Vec<&String> = app.drivers.iter().collect(); - send_frame( - write, - room, - json!({"_perm":"acl","owner": app.owner, "drivers": drivers}), - ) - .await; + send_frame(out, 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>, out: UnboundedSender, room: Arc) { + 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, + out: &UnboundedSender, + room: &Arc, + 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<()> { @@ -249,14 +317,18 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { let app_tx = tx.clone(); tokio::spawn(net::reader(read, session.room.clone(), tx)); - // Broker-owned sandbox PTY output, and async-launch handoff. - let (pty_tx, mut pty_rx): (UnboundedSender>, UnboundedReceiver>) = - unbounded_channel(); + // All outgoing frames funnel through here so background tasks (file chunks, + // PTY relay) can transmit without owning the socket. + let (out_tx, mut out_rx) = unbounded_channel::(); + let (pty_tx, mut pty_rx): (UnboundedSender>, UnboundedReceiver>) = unbounded_channel(); let (broker_tx, mut broker_rx) = unbounded_channel::(); let mut broker: Option = None; let mut broker_meta: Option<(sbx::Backend, String)> = None; let mut launching = false; let mut announced_dims: Option<(u16, u16)> = None; + let mut active_send: Option = None; + let mut send_seq: u64 = 0; + let downloads = PathBuf::from("./downloads"); enable_raw_mode()?; let mut stdout = std::io::stdout(); @@ -272,7 +344,6 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { break Err(e.into()); } - // Broker: keep the PTY sized to our pane; broadcast size changes. if broker.is_some() { if let Ok(sz) = term.size() { 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 { let _ = sb.resize(dims.0, dims.1); } - send_frame(&mut write, &session.room, - json!({"_sbx":"resize","rows":dims.0,"cols":dims.1})).await; + send_frame(&out_tx, &session.room, json!({"_sbx":"resize","rows":dims.0,"cols":dims.1})); } } } @@ -291,13 +361,11 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { maybe = events.next() => { match maybe { Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => { - if k.modifiers.contains(KeyModifiers::CONTROL) - && matches!(k.code, KeyCode::Char('q')) { + if k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('q')) { break Ok(()); } if k.code == KeyCode::F(2) { if app.sandbox.is_none() { - // nothing to drive } else if app.can_drive() { app.driving = !app.driving; } else { @@ -307,8 +375,7 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { if k.code == KeyCode::Esc { app.driving = false; } else if let Some(bytes) = key_to_pty(k.code, k.modifiers) { - send_frame(&mut write, &session.room, - json!({"_sbx":"input","b64": STANDARD.encode(&bytes)})).await; + send_frame(&out_tx, &session.room, json!({"_sbx":"input","b64": STANDARD.encode(&bytes)})); } } else { match k.code { @@ -316,74 +383,9 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { KeyCode::Enter => { let line = app.input.trim().to_string(); app.input.clear(); - 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(&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 "); - } 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 "); - } 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; - } - } + handle_command(&line, &mut app, &mut active_send, &mut send_seq, + &mut broker, &mut broker_meta, &mut launching, &mut announced_dims, + &out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term); } KeyCode::Backspace => { app.input.pop(); } KeyCode::Char(c) => app.input.push(c), @@ -398,14 +400,13 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { net = rx.recv() => { match net { 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 app.drivers.contains(&from) { 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), None => break Ok(()), } @@ -417,15 +418,13 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { broker_meta = Some((backend, name)); announced_dims = Some((rows, cols)); launching = false; - // The launcher is the owner / superuser and the first driver. app.owner = Some(app.me.clone()); app.drivers.clear(); app.drivers.insert(app.me.clone()); - send_frame(&mut write, &session.room, json!({ - "_sbx":"status","state":"ready", - "backend": backend.label(), "rows": rows, "cols": cols - })).await; - broadcast_acl(&mut write, &session.room, &app).await; + send_frame(&out_tx, &session.room, json!({ + "_sbx":"status","state":"ready","backend": backend.label(), "rows": rows, "cols": cols + })); + broadcast_acl(&out_tx, &session.room, &app); } Some(BrokerMsg::Failed) => { launching = false; } None => {} @@ -433,8 +432,26 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { } pty = pty_rx.recv() => { if let Some(bytes) = pty { - send_frame(&mut write, &session.room, - json!({"_sbx":"data","b64": STANDARD.encode(&bytes)})).await; + send_frame(&out_tx, &session.room, json!({"_sbx":"data","b64": STANDARD.encode(&bytes)})); + } + } + 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() => {} @@ -453,7 +470,121 @@ pub async fn run(session: Session, theme: Theme) -> 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, + send_seq: &mut u64, + broker: &mut Option, + broker_meta: &mut Option<(sbx::Backend, String)>, + launching: &mut bool, + announced_dims: &mut Option<(u16, u16)>, + out_tx: &UnboundedSender, + pty_tx: &UnboundedSender>, + broker_tx: &UnboundedSender, + app_tx: &UnboundedSender, + session: &Session, + term: &Terminal>, +) { + 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 "); + } 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 "); + } 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( backend: sbx::Backend, image: String, diff --git a/hh/src/ft.rs b/hh/src/ft.rs new file mode 100644 index 0000000..9d04315 --- /dev/null +++ b/hh/src/ft.rs @@ -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 }, + 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, 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> { + 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 { + 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 = 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 { + 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"))); + } +} diff --git a/hh/src/main.rs b/hh/src/main.rs index c532489..17a1ca3 100644 --- a/hh/src/main.rs +++ b/hh/src/main.rs @@ -6,6 +6,7 @@ mod app; mod crypto; +mod ft; mod net; mod sbx; mod theme; diff --git a/hh/src/net.rs b/hh/src/net.rs index c1ebd72..821be94 100644 --- a/hh/src/net.rs +++ b/hh/src/net.rs @@ -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), diff --git a/hh/src/ui.rs b/hh/src/ui.rs index 111e9d8..1eda859 100644 --- a/hh/src/ui.rs +++ b/hh/src/ui.rs @@ -147,10 +147,17 @@ fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &The ])) .block( 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( - " message · enter send · esc quit ", - Style::default().fg(theme.dim), + match &app.pending_offer { + 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);