ci: proper Rust+Python CI workflow; cargo fmt + clippy clean

Replace the stale Django CI template with a CI workflow that builds and
tests both codebases: cargo fmt/clippy/build/test for the hh client and
pytest across Python 3.10-3.12 for the server. Apply cargo fmt and fix
all clippy lints so the gates pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt 2026-06-01 00:52:20 -07:00
parent cf92b358c4
commit 8eacf4d27b
10 changed files with 498 additions and 175 deletions

42
.github/workflows/ci.yml vendored Normal file
View File

@ -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

View File

@ -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

View File

@ -59,16 +59,37 @@ struct ActiveSend {
/// 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>,
},
Message(ChatLine), Message(ChatLine),
Roster { users: Vec<User>, capacity: usize }, Roster {
users: Vec<User>,
capacity: usize,
},
Joined(String), Joined(String),
Left(String), Left(String),
SbxStatus { backend: String, ready: bool, rows: u16, cols: u16 }, SbxStatus {
SbxResize { rows: u16, cols: u16 }, backend: String,
ready: bool,
rows: u16,
cols: u16,
},
SbxResize {
rows: u16,
cols: u16,
},
SbxData(Vec<u8>), SbxData(Vec<u8>),
SbxInput { from: String, bytes: Vec<u8> }, SbxInput {
Perm { owner: String, drivers: Vec<String>, sudoers: Vec<String> }, from: String,
bytes: Vec<u8>,
},
Perm {
owner: String,
drivers: Vec<String>,
sudoers: Vec<String>,
},
Ft(ft::Ft), Ft(ft::Ft),
Err(String), Err(String),
Closed, Closed,
@ -195,7 +216,12 @@ impl App {
self.sys(format!("{name} left")); self.sys(format!("{name} left"));
} }
} }
Net::SbxStatus { backend, ready, rows, cols } => { Net::SbxStatus {
backend,
ready,
rows,
cols,
} => {
if ready { if ready {
self.sandbox = Some(SbxView { self.sandbox = Some(SbxView {
parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000), 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::SbxInput { .. } => {} // broker enforces + writes in the run loop
Net::Perm { owner, drivers, sudoers } => { Net::Perm {
owner,
drivers,
sudoers,
} => {
let new: std::collections::HashSet<String> = drivers.into_iter().collect(); let new: std::collections::HashSet<String> = drivers.into_iter().collect();
let sudo: std::collections::HashSet<String> = sudoers.into_iter().collect(); let sudo: std::collections::HashSet<String> = sudoers.into_iter().collect();
if !owner.is_empty() && self.owner.as_deref() != Some(owner.as_str()) { if !owner.is_empty() && self.owner.as_deref() != Some(owner.as_str()) {
self.sys(format!("{owner} is the superuser (sandbox owner)")); 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)"); self.sys("⛧ you were granted drive (F2 to take the shell)");
} else if !new.contains(&self.me) && self.drivers.contains(&self.me) { } else if !new.contains(&self.me) && self.drivers.contains(&self.me) {
self.driving = false; self.driving = false;
self.sys("⛧ your drive permission was revoked"); 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.sys("⛧ you were granted sudo (superuser) in the VM");
} }
self.owner = Some(owner).filter(|o| !o.is_empty()); 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) { 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),
)
} }
/// One page of sandbox scrollback = the visible grid height (defaults to 10 if /// One page of sandbox scrollback = the visible grid height (defaults to 10 if
@ -297,17 +336,29 @@ fn send_frame(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, value: serde_
fn broadcast_acl(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, app: &App) { fn broadcast_acl(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, app: &App) {
let drivers: Vec<&String> = app.drivers.iter().collect(); let drivers: Vec<&String> = app.drivers.iter().collect();
let sudoers: Vec<&String> = app.sudoers.iter().collect(); let sudoers: Vec<&String> = app.sudoers.iter().collect();
send_frame(out, room, json!({ send_frame(
"_perm":"acl","owner": app.owner, "drivers": drivers, "sudoers": sudoers out,
})); room,
json!({
"_perm":"acl","owner": app.owner, "drivers": drivers, "sudoers": sudoers
}),
);
} }
/// Stream a payload to the clergy as `_ft` chunks (background, paced). /// Stream a payload to the clergy as `_ft` chunks (background, paced).
fn spawn_send(id: String, payload: Arc<Vec<u8>>, out: UnboundedSender<WsMsg>, room: Arc<fernet::Fernet>) { fn spawn_send(
id: String,
payload: Arc<Vec<u8>>,
out: UnboundedSender<WsMsg>,
room: Arc<fernet::Fernet>,
) {
tokio::spawn(async move { tokio::spawn(async move {
for (seq, chunk) in payload.chunks(ft::CHUNK).enumerate() { for (seq, chunk) in payload.chunks(ft::CHUNK).enumerate() {
let frame = json!({"_ft":"chunk","id": id,"seq": seq,"data": STANDARD.encode(chunk)}); 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; return;
} }
tokio::time::sleep(Duration::from_millis(2)).await; tokio::time::sleep(Duration::from_millis(2)).await;
@ -331,10 +382,19 @@ fn handle_ft(
} }
app.sys(format!( app.sys(format!(
"⛧ {} offers {} ({}{}) — /accept or /reject", "⛧ {} 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 { "" } 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); app.pending_offer = Some(o);
} }
ft::Ft::Accept(id) => { ft::Ft::Accept(id) => {
@ -366,13 +426,22 @@ fn handle_ft(
app.err(format!("{} — SHA-256 mismatch, discarded", t.meta.name)); app.err(format!("{} — SHA-256 mismatch, discarded", t.meta.name));
} else { } else {
match ft::save(downloads, &t.meta, &t.buf) { 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}")), 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; 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, // All outgoing frames funnel through here so background tasks (file chunks,
// PTY relay) can transmit without owning the socket. // PTY relay) can transmit without owning the socket.
let (out_tx, mut out_rx) = unbounded_channel::<WsMsg>(); let (out_tx, mut out_rx) = unbounded_channel::<WsMsg>();
let (pty_tx, mut pty_rx): (UnboundedSender<Vec<u8>>, UnboundedReceiver<Vec<u8>>) = unbounded_channel(); 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;
@ -472,7 +542,11 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
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(&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() => { outgoing = out_rx.recv() => {
match outgoing { if let Some(first) = outgoing {
Some(first) => { // Flush a batch to keep file-chunk bursts from redrawing per frame.
// Flush a batch to keep file-chunk bursts from redrawing per frame. let mut batch = vec![first];
let mut batch = vec![first]; while let Ok(m) = out_rx.try_recv() {
while let Ok(m) = out_rx.try_recv() { batch.push(m);
batch.push(m); if batch.len() >= 64 { break; }
if batch.len() >= 64 { break; } }
} for m in batch {
for m in batch { if write.send(m).await.is_err() {
if write.send(m).await.is_err() { app.connected = false;
app.connected = false; break;
break;
}
} }
} }
None => {}
} }
} }
_ = sigterm.recv() => { break Ok(()); } _ = sigterm.recv() => { break Ok(()); }
@ -733,13 +804,23 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
} }
} }
disable_raw_mode()?; disable_raw_mode()?;
execute!(term.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; execute!(
term.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
term.show_cursor()?; term.show_cursor()?;
result result
} }
enum BrokerMsg { 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, Failed,
} }
@ -777,7 +858,10 @@ fn handle_command(
// Live vestment switch: `/theme <name>`, or bare `/theme` to list options. // Live vestment switch: `/theme <name>`, or bare `/theme` to list options.
let name = rest.trim(); let name = rest.trim();
if name.is_empty() { if name.is_empty() {
app.sys(format!("vestments: {} — /theme <name>", Theme::available().join(" · "))); app.sys(format!(
"vestments: {} — /theme <name>",
Theme::available().join(" · ")
));
} else { } else {
match Theme::by_name(name) { match Theme::by_name(name) {
Ok(t) => { Ok(t) => {
@ -800,18 +884,33 @@ fn handle_command(
} else { } else {
app.sys("you don't have drive permission — the owner can /grant you"); 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(); let path = path.trim();
match ft::read_payload(path) { match ft::read_payload(path) {
Ok((name, bytes, dir)) => { Ok((name, bytes, dir)) => {
*send_seq += 1; *send_seq += 1;
let id = format!("{}-{}", app.me, send_seq); let id = format!("{}-{}", app.me, send_seq);
let (size, sha) = (bytes.len(), ft::sha256_hex(&bytes)); let (size, sha) = (bytes.len(), ft::sha256_hex(&bytes));
*active_send = Some(ActiveSend { id: id.clone(), payload: Arc::new(bytes), sending: false }); *active_send = Some(ActiveSend {
send_frame(out_tx, room, json!({ id: id.clone(),
"_ft":"offer","id": id,"name": name,"size": size,"sha256": sha,"dir": dir payload: Arc::new(bytes),
})); sending: false,
app.sys(format!("offered {} ({}) — waiting for an /accept", name, ft::human(size))); });
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}")), Err(e) => app.err(format!("send failed: {e}")),
} }
@ -843,21 +942,44 @@ fn handle_command(
// `--start` (alias `--start-daemon` / `-y`) opts in to booting // `--start` (alias `--start-daemon` / `-y`) opts in to booting
// a stopped Docker daemon; everything else is positional. // a stopped Docker daemon; everything else is positional.
let args: Vec<&str> = p.collect(); 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 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 backend = pos
let image = pos.next().map(str::to_string).unwrap_or_else(|| backend.default_image().to_string()); .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"); 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 { } else {
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24)); let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_dims(sz.0, sz.1); let (rows, cols) = sbx_dims(sz.0, sz.1);
*launching = true; *launching = true;
let members: Vec<String> = app.users.iter().map(|u| u.username.clone()).collect(); let members: Vec<String> =
app.sys(format!("summoning {} sandbox… (provisioning unix users; multipass boot ~30s)", backend.label())); app.users.iter().map(|u| u.username.clone()).collect();
spawn_launch(backend, image, app.me.clone(), members, rows, cols, start_daemon, app.sys(format!(
pty_tx.clone(), broker_tx.clone(), app_tx.clone()); "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) => { Err(e) => {
let _ = app_tx.send(Net::Err(format!("sandbox launch failed: {e}"))); let _ = app_tx.send(Net::Err(format!("sandbox launch failed: {e}")));

View File

@ -133,11 +133,7 @@ impl SrpClient {
/// Process the server challenge (salt, B). Returns (M, K, H_AMK_expected). /// 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. /// `M` is sent to the server; `h_amk` is compared to the server's reply.
pub fn process_challenge( pub fn process_challenge(&self, salt: &[u8], b_bytes: &[u8]) -> anyhow::Result<Challenge> {
&self,
salt: &[u8],
b_bytes: &[u8],
) -> anyhow::Result<Challenge> {
let n = &self.n; let n = &self.n;
let width = long_to_bytes(n).len(); let width = long_to_bytes(n).len();
let big_b = bytes_to_long(b_bytes); let big_b = bytes_to_long(b_bytes);
@ -218,7 +214,7 @@ mod tests {
fn a_bytes() -> Vec<u8> { fn a_bytes() -> Vec<u8> {
let mut v = vec![0x80u8]; let mut v = vec![0x80u8];
v.extend(std::iter::repeat(0x22u8).take(31)); v.extend(std::iter::repeat_n(0x22u8, 31));
v v
} }
@ -245,7 +241,9 @@ mod fernet_interop {
#[test] #[test]
fn rust_decrypts_python_fernet() { fn rust_decrypts_python_fernet() {
let f = fernet::Fernet::new(KEY).unwrap(); 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"); assert_eq!(pt, b"hello from python fernet");
} }
} }

View File

@ -55,13 +55,24 @@ pub fn read_payload(path: &str) -> Result<(String, Vec<u8>, bool)> {
let meta = std::fs::metadata(p).with_context(|| format!("not found: {path}"))?; let meta = std::fs::metadata(p).with_context(|| format!("not found: {path}"))?;
if meta.is_dir() { if meta.is_dir() {
let bytes = tar_dir(p)?; 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"); let base = p.file_name().and_then(|s| s.to_str()).unwrap_or("dir");
Ok((format!("{base}.tar"), bytes, true)) Ok((format!("{base}.tar"), bytes, true))
} else { } 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 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)) Ok((name, bytes, false))
} }
} }
@ -89,17 +100,29 @@ pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
let mut e = entry?; let mut e = entry?;
let path = e.path()?.into_owned(); let path = e.path()?.into_owned();
// Explicit zip-slip guard (belt-and-suspenders; unpack_in also refuses). // 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() { if top.is_none() {
top = path.components().next().map(|c| c.as_os_str().to_owned()); top = path.components().next().map(|c| c.as_os_str().to_owned());
} }
e.unpack_in(downloads) e.unpack_in(downloads)
.with_context(|| format!("extract {}", path.display()))?; .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 { } else {
let stem = Path::new(&offer.name).file_stem().and_then(|s| s.to_str()).unwrap_or("file"); let stem = Path::new(&offer.name)
let ext = Path::new(&offer.name).extension().and_then(|s| s.to_str()).unwrap_or(""); .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); let dest = unique(downloads, stem, ext);
std::fs::write(&dest, data)?; std::fs::write(&dest, data)?;
Ok(dest) Ok(dest)
@ -113,8 +136,16 @@ fn safe_entry(path: &Path) -> bool {
fn unique(dir: &Path, stem: &str, ext: &str) -> PathBuf { fn unique(dir: &Path, stem: &str, ext: &str) -> PathBuf {
let mk = |n: usize| { let mk = |n: usize| {
let base = if n == 0 { stem.to_string() } else { format!("{stem}_{n}") }; let base = if n == 0 {
if ext.is_empty() { dir.join(base) } else { dir.join(format!("{base}.{ext}")) } 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() (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(); let (name, bytes, is_dir) = read_payload(src.to_str().unwrap()).unwrap();
assert_eq!(name, "note.txt"); assert_eq!(name, "note.txt");
assert!(!is_dir); assert!(!is_dir);
let offer = Offer { id: "1".into(), name, size: bytes.len() as u64, let offer = Offer {
sha256: sha256_hex(&bytes), dir: false, from: "x".into() }; id: "1".into(),
name,
size: bytes.len() as u64,
sha256: sha256_hex(&bytes),
dir: false,
from: "x".into(),
};
let dl = dir.join("dl"); let dl = dir.join("dl");
let out = save(&dl, &offer, &bytes).unwrap(); let out = save(&dl, &offer, &bytes).unwrap();
assert_eq!(std::fs::read(&out).unwrap(), b"offering to the clergy"); 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(); let (name, bytes, is_dir) = read_payload(proj.to_str().unwrap()).unwrap();
assert_eq!(name, "proj.tar"); assert_eq!(name, "proj.tar");
assert!(is_dir); assert!(is_dir);
let offer = Offer { id: "1".into(), name, size: bytes.len() as u64, let offer = Offer {
sha256: sha256_hex(&bytes), dir: true, from: "x".into() }; id: "1".into(),
name,
size: bytes.len() as u64,
sha256: sha256_hex(&bytes),
dir: true,
from: "x".into(),
};
let dl = dir.join("dl"); let dl = dir.join("dl");
let out = save(&dl, &offer, &bytes).unwrap(); // -> dl/proj let out = save(&dl, &offer, &bytes).unwrap(); // -> dl/proj
assert!(out.ends_with("proj")); assert!(out.ends_with("proj"));

View File

@ -45,7 +45,10 @@ enum Cmd {
theme: Option<String>, theme: Option<String>,
}, },
/// Debug: print the derived room Fernet key for a password + room_salt(hex). /// 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. /// Run the offline SRP golden-vector self-test.
Selftest, Selftest,
/// Debug: compute A and M from explicit a/salt/B hex (parity check vs python). /// 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)) 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 salt = hex::decode(room_salt_hex)?;
let f = crypto::room_fernet(password.as_bytes(), &salt)?; let f = crypto::room_fernet(password.as_bytes(), &salt)?;
// fernet crate doesn't expose the key; re-derive + print the b64 key. // 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. // Re-derive the golden vectors at runtime as a smoke check.
let c = crypto::SrpClient::with_a(b"chat", b"labtest", &{ let c = crypto::SrpClient::with_a(b"chat", b"labtest", &{
let mut v = vec![0x80u8]; let mut v = vec![0x80u8];
v.extend(std::iter::repeat(0x22u8).take(31)); v.extend(std::iter::repeat_n(0x22u8, 31));
v v
}); });
let a = hex::encode(c.a_bytes()); 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")?)?; 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?"); 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 ✓)"); println!("/srp/verify ok — server identity proven (H_AMK ✓)");
// Room key + encrypt a message the Python clients can read. // Room key + encrypt a message the Python clients can read.
@ -221,9 +230,7 @@ fn handshake(
// Connect WS and send the ciphertext. // Connect WS and send the ciphertext.
let ws_scheme = if no_tls { "ws" } else { "wss" }; let ws_scheme = if no_tls { "ws" } else { "wss" };
let ws_url = format!( let ws_url = format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}");
"{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}"
);
let (mut sock, _resp) = let (mut sock, _resp) =
tungstenite::connect(&ws_url).context("ws connect (insecure wss not yet wired)")?; tungstenite::connect(&ws_url).context("ws connect (insecure wss not yet wired)")?;
println!("websocket attached to the house"); println!("websocket attached to the house");

View File

@ -84,13 +84,15 @@ pub fn authenticate(
.json()?; .json()?;
let server_hamk = STANDARD.decode(verify["H_AMK"].as_str().context("no H_AMK")?)?; 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 ws_token = verify["ws_token"].as_str().context("no ws_token")?;
let fernet = crypto::room_fernet(password.as_bytes(), &room_salt)?; let fernet = crypto::room_fernet(password.as_bytes(), &room_salt)?;
let ws_scheme = if no_tls { "ws" } else { "wss" }; let ws_scheme = if no_tls { "ws" } else { "wss" };
let ws_url = let ws_url = format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}");
format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}");
Ok(Session { Ok(Session {
username: user.to_string(), 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. // Control frames are live-only — never replayed from the stored snapshot.
if t.starts_with("{\"_sbx\":") { if t.starts_with("{\"_sbx\":") {
return if live { 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 { } else {
Decoded::Skip Decoded::Skip
}; };
} }
if t.starts_with("{\"_ft\":") { if t.starts_with("{\"_ft\":") {
return if live { 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 { } else {
Decoded::Skip 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), Err(_) => ("[unreadable — wrong room password?]".to_string(), true),
}; };
let stamp = m["timestamp"].as_str().unwrap_or(""); 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 { Decoded::Chat(ChatLine {
ts, ts,
username: m["username"].as_str().unwrap_or("?").to_string(), username: m["username"].as_str().unwrap_or("?").to_string(),
@ -232,7 +242,11 @@ fn parse_perm(text: &str) -> Option<Net> {
} }
/// Read websocket frames forever, forwarding decoded `Net` events to the UI. /// Read websocket frames forever, forwarding decoded `Net` events to the UI.
pub async fn reader(mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungstenite::tungstenite::Error>> + Unpin, room: Arc<fernet::Fernet>, tx: UnboundedSender<Net>) { pub async fn reader(
mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungstenite::tungstenite::Error>> + Unpin,
room: Arc<fernet::Fernet>,
tx: UnboundedSender<Net>,
) {
while let Some(frame) = read.next().await { while let Some(frame) = read.next().await {
let txt = match frame { let txt = match frame {
Ok(WsMsg::Text(t)) => t, Ok(WsMsg::Text(t)) => t,
@ -254,7 +268,10 @@ pub async fn reader(mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungsten
_ => None, _ => None,
}) })
.collect(); .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) { "message" => match decode_msg(&room, &v["data"], true) {
Decoded::Chat(l) => tx.send(Net::Message(l)), Decoded::Chat(l) => tx.send(Net::Message(l)),
@ -265,7 +282,9 @@ pub async fn reader(mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungsten
users: parse_users(&v["users"]), users: parse_users(&v["users"]),
capacity: v["capacity"].as_u64().unwrap_or(0) as usize, capacity: v["capacity"].as_u64().unwrap_or(0) as usize,
}), }),
"user_joined" => 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())), "user_left" => tx.send(Net::Left(v["user_id"].as_str().unwrap_or("").to_string())),
_ => Ok(()), _ => Ok(()),
}; };

View File

@ -36,7 +36,10 @@ fn start_docker_daemon() -> Result<()> {
.context("running ensure-docker.sh")?; .context("running ensure-docker.sh")?;
if !out.status.success() { if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr); 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}"); anyhow::bail!("{last}");
} }
Ok(()) 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 // Capture output so it can't bleed onto the TUI surface; surface
// the failure reason through the returned error instead. // the failure reason through the returned error instead.
let out = Command::new("multipass") 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() .output()
.context("multipass launch (is multipass installed?)")?; .context("multipass launch (is multipass installed?)")?;
if !out.status.success() { if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr); 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 { } else {
let _ = Command::new("multipass").args(["start", name]) let _ = Command::new("multipass")
.stdout(Stdio::null()).stderr(Stdio::null()).status(); .args(["start", name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
} }
Ok(()) 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. // Persistent container so we can exec in to provision users + shells.
let _ = Command::new("docker").args(["rm", "-f", name]) let _ = Command::new("docker")
.stdout(Stdio::null()).stderr(Stdio::null()).status(); .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 // Capture output so a failure can't paint over the TUI; the reason is
// surfaced through the returned error (shown in the error popup). // surfaced through the returned error (shown in the error popup).
let out = Command::new("docker") 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() .output()
.context("docker run (is docker installed?)")?; .context("docker run (is docker installed?)")?;
if !out.status.success() { if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr); 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(()) Ok(())
} }
@ -142,12 +172,18 @@ pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) ->
pub fn teardown(backend: Backend, name: &str) { pub fn teardown(backend: Backend, name: &str) {
match backend { match backend {
Backend::Multipass => { Backend::Multipass => {
let _ = Command::new("multipass").args(["delete", name, "--purge"]) let _ = Command::new("multipass")
.stdout(Stdio::null()).stderr(Stdio::null()).status(); .args(["delete", name, "--purge"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
} }
Backend::Docker => { Backend::Docker => {
let _ = Command::new("docker").args(["rm", "-f", name]) let _ = Command::new("docker")
.stdout(Stdio::null()).stderr(Stdio::null()).status(); .args(["rm", "-f", name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
} }
Backend::Local => {} Backend::Local => {}
} }
@ -163,7 +199,11 @@ fn command_for(backend: Backend, name: &str, run_user: &str) -> CommandBuilder {
c c
} }
Backend::Docker => { 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"); let mut c = CommandBuilder::new("docker");
c.args(["exec", "-it", "-u", user, name, "bash", "-il"]); c.args(["exec", "-it", "-u", user, name, "bash", "-il"]);
c c
@ -196,14 +236,20 @@ fn mp(name: &str, args: &[&str]) {
let mut a = vec!["exec", name, "--"]; let mut a = vec!["exec", name, "--"];
a.extend_from_slice(args); a.extend_from_slice(args);
// Null stdio so provisioning chatter never bleeds onto the TUI surface. // Null stdio so provisioning chatter never bleeds onto the TUI surface.
let _ = Command::new("multipass").args(a) let _ = Command::new("multipass")
.stdout(Stdio::null()).stderr(Stdio::null()).status(); .args(a)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
} }
fn dk(name: &str, args: &[&str]) { fn dk(name: &str, args: &[&str]) {
let mut a = vec!["exec", name]; let mut a = vec!["exec", name];
a.extend_from_slice(args); a.extend_from_slice(args);
let _ = Command::new("docker").args(a) let _ = Command::new("docker")
.stdout(Stdio::null()).stderr(Stdio::null()).status(); .args(a)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
} }
/// Grant a Multipass user real *passwordless* sudo (group + sudoers.d drop-in) /// Grant a Multipass user real *passwordless* sudo (group + sudoers.d drop-in)
@ -362,8 +408,8 @@ mod tests {
#[test] #[test]
fn local_shell_pty_roundtrip() { fn local_shell_pty_roundtrip() {
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx) let mut sb =
.expect("launch local shell"); Sandbox::launch(Backend::Local, "test", "", 24, 80, tx).expect("launch local shell");
sb.write_input(b"echo HELLO_PTY_42\n").unwrap(); sb.write_input(b"echo HELLO_PTY_42\n").unwrap();
let mut acc = String::new(); let mut acc = String::new();
@ -401,8 +447,7 @@ mod relay_tests {
use base64::Engine; use base64::Engine;
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx) let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx).expect("launch");
.expect("launch");
sb.write_input(b"echo RELAY_MARKER_7\n").unwrap(); sb.write_input(b"echo RELAY_MARKER_7\n").unwrap();
// Remote client side: a vt100 parser fed from decoded data frames. // Remote client side: a vt100 parser fed from decoded data frames.

View File

@ -41,18 +41,18 @@ impl Default for Theme {
fn default() -> Self { fn default() -> Self {
Self { Self {
name: "crypt".into(), name: "crypt".into(),
border: Color::Rgb(0x6e, 0x6e, 0x7a), // gray chrome, defined against the surface border: Color::Rgb(0x6e, 0x6e, 0x7a), // gray chrome, defined against the surface
title: Color::Rgb(0xff, 0xff, 0xff), // white title: Color::Rgb(0xff, 0xff, 0xff), // white
accent: Color::Rgb(0xff, 0xff, 0xff), // white (sigil / prompt) accent: Color::Rgb(0xff, 0xff, 0xff), // white (sigil / prompt)
dim: Color::Rgb(0x9a, 0x9a, 0xa6), // timestamps — readable mid-gray dim: Color::Rgb(0x9a, 0x9a, 0xa6), // timestamps — readable mid-gray
me: Color::Rgb(0xff, 0xff, 0xff), // your messages = white me: Color::Rgb(0xff, 0xff, 0xff), // your messages = white
other: Color::Rgb(0xc8, 0xc8, 0xd0), // others = bright gray other: Color::Rgb(0xc8, 0xc8, 0xd0), // others = bright gray
system: Color::Rgb(0xb0, 0xb0, 0xbc), // system / occult = legible muted gray system: Color::Rgb(0xb0, 0xb0, 0xbc), // system / occult = legible muted gray
input: Color::Rgb(0xff, 0xff, 0xff), input: Color::Rgb(0xff, 0xff, 0xff),
roster_me: Color::Rgb(0xff, 0xff, 0xff), // you / owner = white roster_me: Color::Rgb(0xff, 0xff, 0xff), // you / owner = white
bg: Color::Rgb(0x1c, 0x1c, 0x22), // slate panel lifts text off pure-black bg: Color::Rgb(0x1c, 0x1c, 0x22), // slate panel lifts text off pure-black
roster_width: 22, roster_width: 22,
sigil: "".into(), // inverted cross / crypt sigil: "".into(), // inverted cross / crypt
} }
} }
} }

View File

@ -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 // 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 // gaps between them) sits on the same surface. With bg = Reset this is a no-op
// and we ride the terminal's own colour. // 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([ let rows = Layout::vertical([
Constraint::Length(1), 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). /// 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) { fn draw_error(f: &mut Frame, area: Rect, theme: &Theme, msg: &str) {
let text = format!("{msg}"); 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 inner = w.saturating_sub(2).max(1);
let rows = (text.chars().count() as u16).div_ceil(inner) + 2; let rows = (text.chars().count() as u16).div_ceil(inner) + 2;
let h = rows.min(area.height.saturating_sub(2)).max(3); 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 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 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); f.render_widget(Clear, rect);
let popup = Paragraph::new(text) let popup = Paragraph::new(text)
.style(Style::default().fg(theme.title).bg(theme.bg)) .style(Style::default().fg(theme.title).bg(theme.bg))
.block( .block(
Block::bordered() 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( .title(Span::styled(
format!(" {} error · any key ", theme.sigil), 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 }); .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) { 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 key = Style::default().fg(theme.title);
let dim = Style::default().fg(theme.system); let dim = Style::default().fg(theme.system);
let kv = |k: &str, v: &str| { 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 head = |s: &str| Line::from(Span::styled(format!("{sig} {s}"), acc));
let lines = vec![ let lines = vec![
head("COMMANDS (type in the input bar)"), 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("/sbx stop", "tear down the sandbox (purges the VM)"),
kv("/drive", "type into the shared shell (Esc releases)"), kv("/drive", "type into the shared shell (Esc releases)"),
kv("/grant <user>", "let a member drive the shell (owner)"), kv(
kv("/revoke <user>", "take back drive permission (owner)"), "/grant <user>",
kv("/sudo <user>", "delegate VM superuser (real sudo) (owner)"), "let a member drive the shell (owner)",
kv("/unsudo <user>", "revoke VM superuser (owner)"), ),
kv(
"/revoke <user>",
"take back drive permission (owner)",
),
kv(
"/sudo <user>",
"delegate VM superuser (real sudo) (owner)",
),
kv(
"/unsudo <user>",
"revoke VM superuser (owner)",
),
kv("/send <file>", "offer a file to the room"), kv("/send <file>", "offer a file to the room"),
kv("/sendd <dir>", "offer a directory (sent as a tar)"), kv("/sendd <dir>", "offer a directory (sent as a tar)"),
kv("/accept · /reject", "respond to an incoming file offer"), 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("/pw", "show this room's password (local only)"),
kv("/help", "show / hide this menu"), kv("/help", "show / hide this menu"),
Line::from(""), 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("F2 · /drive", "take the shell · Esc releases it"),
kv("Ctrl-C (while driving)", "interrupt the running command"), kv("Ctrl-C (while driving)", "interrupt the running command"),
kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"), kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"),
kv("PgUp / PgDn (driving)", "scroll the sandbox terminal's scrollback"), kv(
kv("Up / Down · wheel", "scroll the sandbox terminal (mouse works while driving)"), "PgUp / PgDn (driving)",
kv("Ctrl-R (when closed)", "reconnect to the house after a drop / AFK"), "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"), kv("Ctrl-C · Ctrl-Q", "quit hack-house"),
Line::from(""), Line::from(""),
head("ROSTER GLYPHS"), 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(""),
Line::from(Span::styled( Line::from(Span::styled(
" malware bless · press any key to close", " 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); 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)) .border_style(Style::default().fg(theme.accent))
.title(Span::styled( .title(Span::styled(
format!(" {0} hack-house — help {0} ", theme.sigil), 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 }); .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() " · /drive (or F2) · ↑/↓/wheel scroll".to_string()
}; };
let title = format!(" sandbox · {}{} ", sv.backend, drive); 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( let pane = Paragraph::new(lines).block(
Block::bordered() Block::bordered()
.border_style(Style::default().fg(border)) .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) { 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 { let status = if app.connected {
"🔒 e2e" "🔒 e2e"
} else if app.reconnecting { } 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![ let bar = Line::from(vec![
Span::styled( Span::styled(
format!(" {0} hack-house {0} ", theme.sigil), 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(format!("· {status} "), Style::default().fg(theme.dim)),
Span::styled( 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); let text = l.text.replace('⛧', &theme.sigil);
return Line::from(Span::styled( return Line::from(Span::styled(
format!(" {} {}", theme.sigil, text), 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![ Line::from(vec![
Span::styled(format!("{} ", l.ts), Style::default().fg(theme.dim)), Span::styled(format!("{} ", l.ts), Style::default().fg(theme.dim)),
Span::styled( Span::styled(
@ -305,8 +371,13 @@ fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &The
})) }))
.title(Span::styled( .title(Span::styled(
match &app.pending_offer { match &app.pending_offer {
Some(o) => format!(" {} incoming: {} — /accept or /reject ", theme.sigil, o.name), Some(o) => format!(
None if app.driving => format!(" {} DRIVING the shell — Esc to release ", theme.sigil), " {} 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(), None => " message · enter send · /drive for shell · ctrl-q quit ".to_string(),
}, },
Style::default().fg(theme.title), Style::default().fg(theme.title),