diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..853c236 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main, hack-house] + pull_request: + branches: [main] + +jobs: + rust: + name: rust client (hh) + runs-on: ubuntu-latest + defaults: + run: + working-directory: hh + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + with: + workspaces: hh + - run: cargo fmt --all --check + - run: cargo clippy --all-targets -- -D warnings + - run: cargo build --verbose + - run: cargo test --verbose + + python: + name: python server + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: pip install -r requirements.txt + - run: pytest -q diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml deleted file mode 100644 index 9766b45..0000000 --- a/.github/workflows/django.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Django CI - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - build: - - runs-on: ubuntu-latest - strategy: - max-parallel: 4 - matrix: - python-version: [3.7, 3.8, 3.9] - - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install Dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Run Tests - run: | - python manage.py test diff --git a/hh/src/app.rs b/hh/src/app.rs index d643d95..330fc5c 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -59,16 +59,37 @@ struct ActiveSend { /// Decoded events arriving from the websocket reader task. pub enum Net { - Init { lines: Vec, users: Vec }, + Init { + lines: Vec, + users: Vec, + }, Message(ChatLine), - Roster { users: Vec, capacity: usize }, + Roster { + users: Vec, + capacity: usize, + }, Joined(String), Left(String), - SbxStatus { backend: String, ready: bool, rows: u16, cols: u16 }, - SbxResize { rows: u16, cols: u16 }, + SbxStatus { + backend: String, + ready: bool, + rows: u16, + cols: u16, + }, + SbxResize { + rows: u16, + cols: u16, + }, SbxData(Vec), - SbxInput { from: String, bytes: Vec }, - Perm { owner: String, drivers: Vec, sudoers: Vec }, + SbxInput { + from: String, + bytes: Vec, + }, + Perm { + owner: String, + drivers: Vec, + sudoers: Vec, + }, Ft(ft::Ft), Err(String), Closed, @@ -195,7 +216,12 @@ impl App { self.sys(format!("{name} left")); } } - Net::SbxStatus { backend, ready, rows, cols } => { + Net::SbxStatus { + backend, + ready, + rows, + cols, + } => { if ready { self.sandbox = Some(SbxView { parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000), @@ -223,19 +249,29 @@ impl App { } } Net::SbxInput { .. } => {} // broker enforces + writes in the run loop - Net::Perm { owner, drivers, sudoers } => { + Net::Perm { + owner, + drivers, + sudoers, + } => { let new: std::collections::HashSet = drivers.into_iter().collect(); let sudo: std::collections::HashSet = sudoers.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() { + 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"); } - if sudo.contains(&self.me) && !self.sudoers.contains(&self.me) && self.owner.is_some() { + if sudo.contains(&self.me) + && !self.sudoers.contains(&self.me) + && self.owner.is_some() + { self.sys("⛧ you were granted sudo (superuser) in the VM"); } self.owner = Some(owner).filter(|o| !o.is_empty()); @@ -255,7 +291,10 @@ impl App { 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)) + ( + sbx_h.saturating_sub(2).max(1), + term_w.saturating_sub(2).max(1), + ) } /// One page of sandbox scrollback = the visible grid height (defaults to 10 if @@ -297,17 +336,29 @@ fn send_frame(out: &UnboundedSender, room: &fernet::Fernet, value: serde_ fn broadcast_acl(out: &UnboundedSender, room: &fernet::Fernet, app: &App) { let drivers: Vec<&String> = app.drivers.iter().collect(); let sudoers: Vec<&String> = app.sudoers.iter().collect(); - send_frame(out, room, json!({ - "_perm":"acl","owner": app.owner, "drivers": drivers, "sudoers": sudoers - })); + send_frame( + out, + room, + json!({ + "_perm":"acl","owner": app.owner, "drivers": drivers, "sudoers": sudoers + }), + ); } /// Stream a payload to the clergy as `_ft` chunks (background, paced). -fn spawn_send(id: String, payload: Arc>, out: UnboundedSender, room: Arc) { +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() { + if out + .send(WsMsg::Text(room.encrypt(frame.to_string().as_bytes()))) + .is_err() + { return; } tokio::time::sleep(Duration::from_millis(2)).await; @@ -331,10 +382,19 @@ fn handle_ft( } app.sys(format!( "⛧ {} offers {} ({}{}) — /accept or /reject", - o.from, o.name, ft::human(o.size as usize), + 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.transfers.insert( + o.id.clone(), + Transfer { + meta: o.clone(), + buf: Vec::new(), + accepted: false, + }, + ); app.pending_offer = Some(o); } ft::Ft::Accept(id) => { @@ -366,13 +426,22 @@ fn handle_ft( app.err(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()))), + Ok(p) => app.sys(format!( + "⛧ saved {} ({}) — verified ✓", + p.display(), + ft::human(t.buf.len()) + )), Err(e) => app.err(format!("save failed: {e}")), } } } } - if app.pending_offer.as_ref().map(|o| o.id == id).unwrap_or(false) { + if app + .pending_offer + .as_ref() + .map(|o| o.id == id) + .unwrap_or(false) + { app.pending_offer = None; } } @@ -410,7 +479,8 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme // 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 (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; @@ -472,7 +542,11 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme if let Some(sb) = &broker { let _ = sb.resize(dims.0, dims.1); } - send_frame(&out_tx, &session.room, json!({"_sbx":"resize","rows":dims.0,"cols":dims.1})); + send_frame( + &out_tx, + &session.room, + json!({"_sbx":"resize","rows":dims.0,"cols":dims.1}), + ); } } } @@ -702,22 +776,19 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme } } 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; - } + if let Some(first) = outgoing { + // 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 => {} } } _ = sigterm.recv() => { break Ok(()); } @@ -733,13 +804,23 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme } } disable_raw_mode()?; - execute!(term.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; + execute!( + term.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; term.show_cursor()?; result } enum BrokerMsg { - Ready { sb: sbx::Sandbox, backend: sbx::Backend, name: String, rows: u16, cols: u16 }, + Ready { + sb: sbx::Sandbox, + backend: sbx::Backend, + name: String, + rows: u16, + cols: u16, + }, Failed, } @@ -777,7 +858,10 @@ fn handle_command( // Live vestment switch: `/theme `, or bare `/theme` to list options. let name = rest.trim(); if name.is_empty() { - app.sys(format!("vestments: {} — /theme ", Theme::available().join(" · "))); + app.sys(format!( + "vestments: {} — /theme ", + Theme::available().join(" · ") + )); } else { match Theme::by_name(name) { Ok(t) => { @@ -800,18 +884,33 @@ fn handle_command( } else { app.sys("you don't have drive permission — the owner can /grant you"); } - } else if let Some(path) = line.strip_prefix("/sendd ").or_else(|| line.strip_prefix("/send ")) { + } else 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))); + *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.err(format!("send failed: {e}")), } @@ -843,21 +942,44 @@ fn handle_command( // `--start` (alias `--start-daemon` / `-y`) opts in to booting // a stopped Docker daemon; everything else is positional. let args: Vec<&str> = p.collect(); - let start_daemon = args.iter().any(|a| matches!(*a, "--start" | "--start-daemon" | "-y")); + let start_daemon = args + .iter() + .any(|a| matches!(*a, "--start" | "--start-daemon" | "-y")); let mut pos = args.iter().copied().filter(|a| !a.starts_with('-')); - let backend = pos.next().and_then(sbx::Backend::parse).unwrap_or(sbx::Backend::Local); - let image = pos.next().map(str::to_string).unwrap_or_else(|| backend.default_image().to_string()); + let backend = pos + .next() + .and_then(sbx::Backend::parse) + .unwrap_or(sbx::Backend::Local); + let image = pos + .next() + .map(str::to_string) + .unwrap_or_else(|| backend.default_image().to_string()); - if backend == sbx::Backend::Docker && !start_daemon && !sbx::docker_daemon_up() { + if backend == sbx::Backend::Docker && !start_daemon && !sbx::docker_daemon_up() + { app.err("docker daemon is not running — retry with `/sbx launch docker --start` to boot it (sudo), or run ./ensure-docker.sh in a terminal first"); } else { 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; - let members: Vec = app.users.iter().map(|u| u.username.clone()).collect(); - app.sys(format!("summoning {} sandbox… (provisioning unix users; multipass boot ~30s)", backend.label())); - spawn_launch(backend, image, app.me.clone(), members, rows, cols, start_daemon, - pty_tx.clone(), broker_tx.clone(), app_tx.clone()); + let members: Vec = + app.users.iter().map(|u| u.username.clone()).collect(); + app.sys(format!( + "summoning {} sandbox… (provisioning unix users; multipass boot ~30s)", + backend.label() + )); + spawn_launch( + backend, + image, + app.me.clone(), + members, + rows, + cols, + start_daemon, + pty_tx.clone(), + broker_tx.clone(), + app_tx.clone(), + ); } } } @@ -975,7 +1097,13 @@ fn spawn_launch( } } }); - let _ = broker_tx.send(BrokerMsg::Ready { sb, backend, name, rows, cols }); + let _ = broker_tx.send(BrokerMsg::Ready { + sb, + backend, + name, + rows, + cols, + }); } Err(e) => { let _ = app_tx.send(Net::Err(format!("sandbox launch failed: {e}"))); diff --git a/hh/src/crypto.rs b/hh/src/crypto.rs index be17b16..6450b66 100644 --- a/hh/src/crypto.rs +++ b/hh/src/crypto.rs @@ -133,11 +133,7 @@ impl SrpClient { /// Process the server challenge (salt, B). Returns (M, K, H_AMK_expected). /// `M` is sent to the server; `h_amk` is compared to the server's reply. - pub fn process_challenge( - &self, - salt: &[u8], - b_bytes: &[u8], - ) -> anyhow::Result { + pub fn process_challenge(&self, salt: &[u8], b_bytes: &[u8]) -> anyhow::Result { let n = &self.n; let width = long_to_bytes(n).len(); let big_b = bytes_to_long(b_bytes); @@ -218,7 +214,7 @@ mod tests { fn a_bytes() -> Vec { let mut v = vec![0x80u8]; - v.extend(std::iter::repeat(0x22u8).take(31)); + v.extend(std::iter::repeat_n(0x22u8, 31)); v } @@ -245,7 +241,9 @@ mod fernet_interop { #[test] fn rust_decrypts_python_fernet() { let f = fernet::Fernet::new(KEY).unwrap(); - let pt = f.decrypt(TOK).expect("rust must decrypt python fernet token"); + let pt = f + .decrypt(TOK) + .expect("rust must decrypt python fernet token"); assert_eq!(pt, b"hello from python fernet"); } } diff --git a/hh/src/ft.rs b/hh/src/ft.rs index 7178543..0e99358 100644 --- a/hh/src/ft.rs +++ b/hh/src/ft.rs @@ -55,13 +55,24 @@ pub fn read_payload(path: &str) -> Result<(String, Vec, bool)> { 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())); + 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)"); + 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(); + let name = p + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("file") + .to_string(); Ok((name, bytes, false)) } } @@ -89,17 +100,29 @@ pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result { 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()); + 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())) + 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 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) @@ -113,8 +136,16 @@ fn safe_entry(path: &Path) -> bool { 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}")) } + 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() } @@ -157,8 +188,14 @@ mod tests { 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 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 clergy"); @@ -176,8 +213,14 @@ mod tests { 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 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")); diff --git a/hh/src/main.rs b/hh/src/main.rs index cebd267..90d4e3f 100644 --- a/hh/src/main.rs +++ b/hh/src/main.rs @@ -45,7 +45,10 @@ enum Cmd { theme: Option, }, /// Debug: print the derived room Fernet key for a password + room_salt(hex). - Roomkey { password: String, room_salt_hex: String }, + Roomkey { + password: String, + room_salt_hex: String, + }, /// Run the offline SRP golden-vector self-test. Selftest, /// Debug: compute A and M from explicit a/salt/B hex (parity check vs python). @@ -98,7 +101,10 @@ fn main() -> Result<()> { }; tokio::runtime::Runtime::new()?.block_on(app::run(params, session, theme)) } - Cmd::Roomkey { password, room_salt_hex } => { + Cmd::Roomkey { + password, + room_salt_hex, + } => { let salt = hex::decode(room_salt_hex)?; let f = crypto::room_fernet(password.as_bytes(), &salt)?; // fernet crate doesn't expose the key; re-derive + print the b64 key. @@ -143,7 +149,7 @@ fn selftest() -> Result<()> { // Re-derive the golden vectors at runtime as a smoke check. let c = crypto::SrpClient::with_a(b"chat", b"labtest", &{ let mut v = vec![0x80u8]; - v.extend(std::iter::repeat(0x22u8).take(31)); + v.extend(std::iter::repeat_n(0x22u8, 31)); v }); let a = hex::encode(c.a_bytes()); @@ -212,7 +218,10 @@ fn handshake( let server_hamk = STD.decode(verify["H_AMK"].as_str().context("no H_AMK")?)?; anyhow::ensure!(server_hamk == ch.h_amk, "server H_AMK mismatch — MITM?"); - let ws_token = verify["ws_token"].as_str().context("no ws_token")?.to_string(); + let ws_token = verify["ws_token"] + .as_str() + .context("no ws_token")? + .to_string(); println!("/srp/verify ok — server identity proven (H_AMK ✓)"); // Room key + encrypt a message the Python clients can read. @@ -221,9 +230,7 @@ fn handshake( // Connect WS and send the ciphertext. let ws_scheme = if no_tls { "ws" } else { "wss" }; - let ws_url = format!( - "{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}" - ); + let ws_url = format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}"); let (mut sock, _resp) = tungstenite::connect(&ws_url).context("ws connect (insecure wss not yet wired)")?; println!("websocket attached to the house"); diff --git a/hh/src/net.rs b/hh/src/net.rs index 152e7bf..d96a00f 100644 --- a/hh/src/net.rs +++ b/hh/src/net.rs @@ -84,13 +84,15 @@ pub fn authenticate( .json()?; let server_hamk = STANDARD.decode(verify["H_AMK"].as_str().context("no H_AMK")?)?; - anyhow::ensure!(server_hamk == ch.h_amk, "server identity check failed (H_AMK) — MITM?"); + anyhow::ensure!( + server_hamk == ch.h_amk, + "server identity check failed (H_AMK) — MITM?" + ); let ws_token = verify["ws_token"].as_str().context("no ws_token")?; let fernet = crypto::room_fernet(password.as_bytes(), &room_salt)?; let ws_scheme = if no_tls { "ws" } else { "wss" }; - let ws_url = - format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}"); + let ws_url = format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}"); Ok(Session { username: user.to_string(), @@ -160,14 +162,18 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, live: bool) -> Decoded { // Control frames are live-only — never replayed from the stored snapshot. if t.starts_with("{\"_sbx\":") { return if live { - parse_sbx(&t, sender).map(Decoded::Sbx).unwrap_or(Decoded::Skip) + 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) + crate::ft::parse(&t, sender) + .map(|f| Decoded::Sbx(Net::Ft(f))) + .unwrap_or(Decoded::Skip) } else { Decoded::Skip }; @@ -177,7 +183,11 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, live: bool) -> Decoded { Err(_) => ("[unreadable — wrong room password?]".to_string(), true), }; let stamp = m["timestamp"].as_str().unwrap_or(""); - let ts = if stamp.len() >= 19 { stamp[11..19].to_string() } else { String::new() }; + let ts = if stamp.len() >= 19 { + stamp[11..19].to_string() + } else { + String::new() + }; Decoded::Chat(ChatLine { ts, username: m["username"].as_str().unwrap_or("?").to_string(), @@ -232,7 +242,11 @@ fn parse_perm(text: &str) -> Option { } /// Read websocket frames forever, forwarding decoded `Net` events to the UI. -pub async fn reader(mut read: impl StreamExt> + Unpin, room: Arc, tx: UnboundedSender) { +pub async fn reader( + mut read: impl StreamExt> + Unpin, + room: Arc, + tx: UnboundedSender, +) { while let Some(frame) = read.next().await { let txt = match frame { Ok(WsMsg::Text(t)) => t, @@ -254,7 +268,10 @@ pub async fn reader(mut read: impl StreamExt None, }) .collect(); - tx.send(Net::Init { lines, users: parse_users(&v["users"]) }) + tx.send(Net::Init { + lines, + users: parse_users(&v["users"]), + }) } "message" => match decode_msg(&room, &v["data"], true) { Decoded::Chat(l) => tx.send(Net::Message(l)), @@ -265,7 +282,9 @@ pub async fn reader(mut read: impl StreamExt tx.send(Net::Joined(v["username"].as_str().unwrap_or("?").to_string())), + "user_joined" => tx.send(Net::Joined( + v["username"].as_str().unwrap_or("?").to_string(), + )), "user_left" => tx.send(Net::Left(v["user_id"].as_str().unwrap_or("").to_string())), _ => Ok(()), }; diff --git a/hh/src/sbx.rs b/hh/src/sbx.rs index c31bd59..61758a3 100644 --- a/hh/src/sbx.rs +++ b/hh/src/sbx.rs @@ -36,7 +36,10 @@ fn start_docker_daemon() -> Result<()> { .context("running ensure-docker.sh")?; if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr); - let last = err.lines().last().unwrap_or("could not start the docker daemon"); + let last = err + .lines() + .last() + .unwrap_or("could not start the docker daemon"); anyhow::bail!("{last}"); } Ok(()) @@ -93,16 +96,25 @@ pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) -> // Capture output so it can't bleed onto the TUI surface; surface // the failure reason through the returned error instead. let out = Command::new("multipass") - .args(["launch", "--name", name, "--cpus", "1", "--memory", "1G", "--disk", "5G", image]) + .args([ + "launch", "--name", name, "--cpus", "1", "--memory", "1G", "--disk", "5G", + image, + ]) .output() .context("multipass launch (is multipass installed?)")?; if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr); - anyhow::bail!("multipass launch failed: {}", err.lines().last().unwrap_or("").trim()); + anyhow::bail!( + "multipass launch failed: {}", + err.lines().last().unwrap_or("").trim() + ); } } else { - let _ = Command::new("multipass").args(["start", name]) - .stdout(Stdio::null()).stderr(Stdio::null()).status(); + let _ = Command::new("multipass") + .args(["start", name]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); } Ok(()) } @@ -120,17 +132,35 @@ pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) -> } } // Persistent container so we can exec in to provision users + shells. - let _ = Command::new("docker").args(["rm", "-f", name]) - .stdout(Stdio::null()).stderr(Stdio::null()).status(); + let _ = Command::new("docker") + .args(["rm", "-f", name]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); // Capture output so a failure can't paint over the TUI; the reason is // surfaced through the returned error (shown in the error popup). let out = Command::new("docker") - .args(["run", "-d", "--name", name, "--hostname", name, "-w", "/root", image, "sleep", "infinity"]) + .args([ + "run", + "-d", + "--name", + name, + "--hostname", + name, + "-w", + "/root", + image, + "sleep", + "infinity", + ]) .output() .context("docker run (is docker installed?)")?; if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr); - anyhow::bail!("docker run failed: {}", err.lines().last().unwrap_or("").trim()); + anyhow::bail!( + "docker run failed: {}", + err.lines().last().unwrap_or("").trim() + ); } Ok(()) } @@ -142,12 +172,18 @@ pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) -> pub fn teardown(backend: Backend, name: &str) { match backend { Backend::Multipass => { - let _ = Command::new("multipass").args(["delete", name, "--purge"]) - .stdout(Stdio::null()).stderr(Stdio::null()).status(); + let _ = Command::new("multipass") + .args(["delete", name, "--purge"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); } Backend::Docker => { - let _ = Command::new("docker").args(["rm", "-f", name]) - .stdout(Stdio::null()).stderr(Stdio::null()).status(); + let _ = Command::new("docker") + .args(["rm", "-f", name]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); } Backend::Local => {} } @@ -163,7 +199,11 @@ fn command_for(backend: Backend, name: &str, run_user: &str) -> CommandBuilder { c } Backend::Docker => { - let user = if run_user.is_empty() { "root" } else { run_user }; + let user = if run_user.is_empty() { + "root" + } else { + run_user + }; let mut c = CommandBuilder::new("docker"); c.args(["exec", "-it", "-u", user, name, "bash", "-il"]); c @@ -196,14 +236,20 @@ fn mp(name: &str, args: &[&str]) { let mut a = vec!["exec", name, "--"]; a.extend_from_slice(args); // Null stdio so provisioning chatter never bleeds onto the TUI surface. - let _ = Command::new("multipass").args(a) - .stdout(Stdio::null()).stderr(Stdio::null()).status(); + let _ = Command::new("multipass") + .args(a) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); } fn dk(name: &str, args: &[&str]) { let mut a = vec!["exec", name]; a.extend_from_slice(args); - let _ = Command::new("docker").args(a) - .stdout(Stdio::null()).stderr(Stdio::null()).status(); + let _ = Command::new("docker") + .args(a) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); } /// Grant a Multipass user real *passwordless* sudo (group + sudoers.d drop-in) @@ -362,8 +408,8 @@ mod tests { #[test] fn local_shell_pty_roundtrip() { let (tx, rx) = mpsc::channel(); - let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx) - .expect("launch local shell"); + let mut sb = + Sandbox::launch(Backend::Local, "test", "", 24, 80, tx).expect("launch local shell"); sb.write_input(b"echo HELLO_PTY_42\n").unwrap(); let mut acc = String::new(); @@ -401,8 +447,7 @@ mod relay_tests { use base64::Engine; let (tx, rx) = mpsc::channel(); - let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx) - .expect("launch"); + let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx).expect("launch"); sb.write_input(b"echo RELAY_MARKER_7\n").unwrap(); // Remote client side: a vt100 parser fed from decoded data frames. diff --git a/hh/src/theme.rs b/hh/src/theme.rs index cc38245..f2ed4f8 100644 --- a/hh/src/theme.rs +++ b/hh/src/theme.rs @@ -41,18 +41,18 @@ impl Default for Theme { fn default() -> Self { Self { name: "crypt".into(), - border: Color::Rgb(0x6e, 0x6e, 0x7a), // gray chrome, defined against the surface - title: Color::Rgb(0xff, 0xff, 0xff), // white - accent: Color::Rgb(0xff, 0xff, 0xff), // white (sigil / prompt) - dim: Color::Rgb(0x9a, 0x9a, 0xa6), // timestamps — readable mid-gray - me: Color::Rgb(0xff, 0xff, 0xff), // your messages = white - other: Color::Rgb(0xc8, 0xc8, 0xd0), // others = bright gray - system: Color::Rgb(0xb0, 0xb0, 0xbc), // system / occult = legible muted gray + border: Color::Rgb(0x6e, 0x6e, 0x7a), // gray chrome, defined against the surface + title: Color::Rgb(0xff, 0xff, 0xff), // white + accent: Color::Rgb(0xff, 0xff, 0xff), // white (sigil / prompt) + dim: Color::Rgb(0x9a, 0x9a, 0xa6), // timestamps — readable mid-gray + me: Color::Rgb(0xff, 0xff, 0xff), // your messages = white + other: Color::Rgb(0xc8, 0xc8, 0xd0), // others = bright gray + system: Color::Rgb(0xb0, 0xb0, 0xbc), // system / occult = legible muted gray input: Color::Rgb(0xff, 0xff, 0xff), roster_me: Color::Rgb(0xff, 0xff, 0xff), // you / owner = white bg: Color::Rgb(0x1c, 0x1c, 0x22), // slate panel lifts text off pure-black roster_width: 22, - sigil: "✝".into(), // inverted cross / crypt + sigil: "✝".into(), // inverted cross / crypt } } } diff --git a/hh/src/ui.rs b/hh/src/ui.rs index 74fbabf..dcfd3d9 100644 --- a/hh/src/ui.rs +++ b/hh/src/ui.rs @@ -12,7 +12,10 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) { // Paint the whole frame in the theme background first so every panel (and the // gaps between them) sits on the same surface. With bg = Reset this is a no-op // and we ride the terminal's own colour. - f.render_widget(Block::default().style(Style::default().bg(theme.bg)), f.area()); + f.render_widget( + Block::default().style(Style::default().bg(theme.bg)), + f.area(), + ); let rows = Layout::vertical([ Constraint::Length(1), @@ -53,22 +56,33 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) { /// onto the input box. Cleared by the next keypress (see the run loop). fn draw_error(f: &mut Frame, area: Rect, theme: &Theme, msg: &str) { let text = format!("⚠ {msg}"); - let w = area.width.saturating_sub(2).min(48).max(16); + let w = area.width.saturating_sub(2).clamp(16, 48); let inner = w.saturating_sub(2).max(1); let rows = (text.chars().count() as u16).div_ceil(inner) + 2; let h = rows.min(area.height.saturating_sub(2)).max(3); let x = area.x + area.width.saturating_sub(w + 1); // hug the right edge let y = area.y + 1; // just under the top bar, over the clergy - let rect = Rect { x, y, width: w, height: h }; + let rect = Rect { + x, + y, + width: w, + height: h, + }; f.render_widget(Clear, rect); let popup = Paragraph::new(text) .style(Style::default().fg(theme.title).bg(theme.bg)) .block( Block::bordered() - .border_style(Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)) + .border_style( + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ) .title(Span::styled( format!(" {} error · any key ", theme.sigil), - Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), )), ) .wrap(Wrap { trim: false }); @@ -93,7 +107,9 @@ fn centered(percent_x: u16, percent_y: u16, area: Rect) -> Rect { } fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) { - let acc = Style::default().fg(theme.accent).add_modifier(Modifier::BOLD); + let acc = Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD); let key = Style::default().fg(theme.title); let dim = Style::default().fg(theme.system); let kv = |k: &str, v: &str| { @@ -106,17 +122,35 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) { let head = |s: &str| Line::from(Span::styled(format!("{sig} {s}"), acc)); let lines = vec![ head("COMMANDS (type in the input bar)"), - kv("/sbx launch [backend]", "summon a sandbox: local | docker | multipass"), + kv( + "/sbx launch [backend]", + "summon a sandbox: local | docker | multipass", + ), kv("/sbx stop", "tear down the sandbox (purges the VM)"), kv("/drive", "type into the shared shell (Esc releases)"), - kv("/grant ", "let a member drive the shell (owner)"), - kv("/revoke ", "take back drive permission (owner)"), - kv("/sudo ", "delegate VM superuser (real sudo) (owner)"), - kv("/unsudo ", "revoke VM superuser (owner)"), + kv( + "/grant ", + "let a member drive the shell (owner)", + ), + kv( + "/revoke ", + "take back drive permission (owner)", + ), + kv( + "/sudo ", + "delegate VM superuser (real sudo) (owner)", + ), + kv( + "/unsudo ", + "revoke VM superuser (owner)", + ), kv("/send ", "offer a file to the room"), kv("/sendd ", "offer a directory (sent as a tar)"), kv("/accept · /reject", "respond to an incoming file offer"), - kv("/theme [name]", "change vestments live: church | neon | crypt"), + kv( + "/theme [name]", + "change vestments live: church | neon | crypt", + ), kv("/pw", "show this room's password (local only)"), kv("/help", "show / hide this menu"), Line::from(""), @@ -126,17 +160,31 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) { kv("F2 · /drive", "take the shell · Esc releases it"), kv("Ctrl-C (while driving)", "interrupt the running command"), kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"), - kv("PgUp / PgDn (driving)", "scroll the sandbox terminal's scrollback"), - kv("Up / Down · wheel", "scroll the sandbox terminal (mouse works while driving)"), - kv("Ctrl-R (when closed)", "reconnect to the house after a drop / AFK"), + kv( + "PgUp / PgDn (driving)", + "scroll the sandbox terminal's scrollback", + ), + kv( + "Up / Down · wheel", + "scroll the sandbox terminal (mouse works while driving)", + ), + kv( + "Ctrl-R (when closed)", + "reconnect to the house after a drop / AFK", + ), kv("Ctrl-C · Ctrl-Q", "quit hack-house"), Line::from(""), head("ROSTER GLYPHS"), - kv(&format!("{} owner ⚡ sudoer", theme.sigil), "◆ may drive • member"), + kv( + &format!("{} owner ⚡ sudoer", theme.sigil), + "◆ may drive • member", + ), Line::from(""), Line::from(Span::styled( " malware bless · press any key to close", - Style::default().fg(theme.dim).add_modifier(Modifier::ITALIC), + Style::default() + .fg(theme.dim) + .add_modifier(Modifier::ITALIC), )), ]; let w = centered(78, 90, area); @@ -148,7 +196,9 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) { .border_style(Style::default().fg(theme.accent)) .title(Span::styled( format!(" {0} hack-house — help {0} ", theme.sigil), - Style::default().fg(theme.title).add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.title) + .add_modifier(Modifier::BOLD), )), ) .wrap(Wrap { trim: false }); @@ -173,7 +223,11 @@ fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &T " · /drive (or F2) · ↑/↓/wheel scroll".to_string() }; let title = format!(" sandbox · {}{} ", sv.backend, drive); - let border = if app.driving { theme.accent } else { theme.border }; + let border = if app.driving { + theme.accent + } else { + theme.border + }; let pane = Paragraph::new(lines).block( Block::bordered() .border_style(Style::default().fg(border)) @@ -183,7 +237,11 @@ fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &T } fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) { - let cap = if app.capacity > 0 { app.capacity } else { app.users.len() }; + let cap = if app.capacity > 0 { + app.capacity + } else { + app.users.len() + }; let status = if app.connected { "🔒 e2e" } else if app.reconnecting { @@ -194,7 +252,9 @@ fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme let bar = Line::from(vec![ Span::styled( format!(" {0} hack-house {0} ", theme.sigil), - Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), ), Span::styled(format!("· {status} "), Style::default().fg(theme.dim)), Span::styled( @@ -213,10 +273,16 @@ fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> { let text = l.text.replace('⛧', &theme.sigil); return Line::from(Span::styled( format!(" {} {}", theme.sigil, text), - Style::default().fg(theme.system).add_modifier(Modifier::ITALIC), + Style::default() + .fg(theme.system) + .add_modifier(Modifier::ITALIC), )); } - let name_color = if l.username == app.me { theme.me } else { theme.other }; + let name_color = if l.username == app.me { + theme.me + } else { + theme.other + }; Line::from(vec![ Span::styled(format!("{} ", l.ts), Style::default().fg(theme.dim)), Span::styled( @@ -305,8 +371,13 @@ fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &The })) .title(Span::styled( match &app.pending_offer { - Some(o) => format!(" {} incoming: {} — /accept or /reject ", theme.sigil, o.name), - None if app.driving => format!(" {} DRIVING the shell — Esc to release ", theme.sigil), + Some(o) => format!( + " {} incoming: {} — /accept or /reject ", + theme.sigil, o.name + ), + None if app.driving => { + format!(" {} DRIVING the shell — Esc to release ", theme.sigil) + } None => " message · enter send · /drive for shell · ctrl-q quit ".to_string(), }, Style::default().fg(theme.title),