feat(sbx,ft): share VirtualBox VMs — vbox launch grammar, picker & streaming transfer

Unify sandbox summoning under `/sbx launch <docker|multipass|vbox>`, each
running on the invoker's own machine. For vbox:
- `/sbx launch vbox` opens an arrow-navigable VM picker (↑↓/Enter/Tab/Esc).
- `/sbx launch vbox [gui] <vm>` boots frictionlessly for a host that already
  has VirtualBox + the VM imported; a non-host appends `yes` to install
  VirtualBox, import the shared appliance, and/or free VT-x first.
- `/sbx gui <vm> [yes]` kept as an alias.

Add `vm_registered()` + `import_appliance()` (VBoxManage import) in sbx.rs.

Make file transfer stream disk-to-disk so multi-GB VM images can be shared
(the old 50 MB in-memory cap blocked them): `STREAM_MAX` = 16 GiB for `/send`
(`MAX_SIZE` now guards only the in-memory tar_path for sandbox injection).
`prepare_send` stat+stream-hashes off the UI thread; `Sink`/`commit` write
incoming chunks straight to a temp `.part` file and verify SHA before moving
into place. Wire frames (offer/accept/chunk/done, 64 KB, base64) unchanged.

A received `.ova`/`.ovf` auto-imports so the recipient can immediately
`/sbx launch vbox gui <vm>`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-06 19:45:45 -07:00
parent 1fa8c332ed
commit 98e202e0fe
4 changed files with 821 additions and 402 deletions
+491 -326
View File
File diff suppressed because it is too large Load Diff
+201 -59
View File
@@ -9,9 +9,17 @@ use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
/// In-memory ceiling — used only by `tar_path` (sandbox injection), which builds
/// the archive in RAM. Streamed `/send` transfers use `STREAM_MAX` instead.
pub const MAX_SIZE: usize = 50 * 1024 * 1024;
/// Streamed-transfer ceiling (disk-to-disk). Far larger than `MAX_SIZE` because
/// `/send` writes chunks straight to a temp file and reads them back the same
/// way — memory stays flat regardless of size. This only guards against a sender
/// filling the receiver's disk (e.g. a multi-GB VM appliance is fine).
pub const STREAM_MAX: u64 = 16 * 1024 * 1024 * 1024; // 16 GiB
pub const CHUNK: usize = 64 * 1024;
#[derive(Clone)]
@@ -22,6 +30,11 @@ pub struct Offer {
pub sha256: String,
pub dir: bool,
pub from: String,
/// Direct-send recipient: `Some(username)` means only that member should be
/// prompted; `None` (or absent/empty on the wire) means the whole room. The
/// relay still broadcasts to everyone, so this is an advisory app-layer
/// filter — non-recipients drop the offer instead of prompting.
pub to: Option<String>,
}
pub enum Ft {
@@ -32,12 +45,6 @@ pub enum Ft {
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 {
@@ -49,43 +56,103 @@ pub fn human(size: usize) -> String {
format!("{s:.1} TB")
}
/// Read the payload to offer for a path → (name, bytes, is_dir).
pub fn read_payload(path: &str) -> Result<(String, Vec<u8>, bool)> {
/// What to stream for an outgoing `/send`: a single file on disk — the original
/// file, or a temp `.tar` we built for a directory. The sender reads `src` in
/// `CHUNK` blocks, so memory stays flat no matter how large the payload is.
pub struct SendSrc {
pub name: String,
pub src: PathBuf,
/// `src` is a temp tar we created and should delete once sending finishes.
pub temp: bool,
pub size: u64,
pub sha256: String,
pub dir: bool,
}
/// Prepare a path for streamed sending: stat + stream-hash a file directly, or
/// tar a directory to a temp file first (so even large trees stream from disk).
/// Enforces `STREAM_MAX`. This walks the bytes once to hash — call it off the UI
/// thread for large payloads.
pub fn prepare_send(path: &str) -> Result<SendSrc> {
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))
let base = p
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("dir")
.to_string();
let tmp = std::env::temp_dir().join(format!(
"hh-send-{}-{}.tar",
std::process::id(),
sanitize(&base)
));
tar_to_file(p, &tmp)?;
let (size, sha256) = hash_file(&tmp)?;
Ok(SendSrc {
name: format!("{base}.tar"),
src: tmp,
temp: true,
size,
sha256,
dir: true,
})
} else {
anyhow::ensure!(
meta.len() as usize <= MAX_SIZE,
"file too large (max 50 MB)"
);
let bytes = std::fs::read(p)?;
let (size, sha256) = hash_file(p)?;
let name = p
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("file")
.to_string();
Ok((name, bytes, false))
Ok(SendSrc {
name,
src: p.to_path_buf(),
temp: false,
size,
sha256,
dir: false,
})
}
}
fn tar_dir(dir: &Path) -> Result<Vec<u8>> {
let mut buf = Vec::new();
{
let mut tb = tar::Builder::new(&mut buf);
let base = dir.file_name().unwrap_or_default();
tb.append_dir_all(base, dir).context("tar directory")?;
tb.finish()?;
/// Tar a directory to a file on disk (constant memory, unlike `tar_path`).
fn tar_to_file(dir: &Path, out: &Path) -> Result<()> {
let f = std::fs::File::create(out).with_context(|| format!("create {}", out.display()))?;
let mut tb = tar::Builder::new(std::io::BufWriter::new(f));
let base = dir.file_name().unwrap_or_default();
tb.append_dir_all(base, dir).context("tar directory")?;
tb.finish()?;
Ok(())
}
/// Stream-hash a file → `(size, sha256-hex)`, enforcing `STREAM_MAX`. Reads in
/// `CHUNK` blocks so memory stays flat.
fn hash_file(path: &Path) -> Result<(u64, String)> {
let mut f = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; CHUNK];
let mut total: u64 = 0;
loop {
let n = f.read(&mut buf)?;
if n == 0 {
break;
}
total += n as u64;
anyhow::ensure!(
total <= STREAM_MAX,
"too large (max {})",
human(STREAM_MAX as usize)
);
hasher.update(&buf[..n]);
}
Ok(buf)
Ok((total, hex::encode(hasher.finalize())))
}
/// Filesystem-safe slug for temp-file names (transfer ids, dir basenames).
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '_' })
.collect()
}
/// Tar any local path (file or directory) to bytes for injecting into a
@@ -113,13 +180,67 @@ pub fn tar_path(path: &Path) -> Result<(String, Vec<u8>)> {
Ok((base, buf))
}
/// Persist received bytes under `downloads`. Directories (tar) are extracted
/// with a guard rejecting absolute paths and `..` escapes (zip-slip).
pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
/// Disk-backed receiver: incoming chunks are written straight to a temp `.part`
/// file under `downloads/` and hashed as they arrive, so a multi-GB transfer
/// never sits in RAM. `finish` returns the temp path + computed digest for
/// verification; `commit` then moves/extracts it into place.
pub struct Sink {
file: std::fs::File,
path: PathBuf,
hasher: Sha256,
written: u64,
}
impl Sink {
/// Create the temp file under `downloads/` (same filesystem as the final
/// destination → atomic rename on commit). Named from the transfer id.
pub fn create(downloads: &Path, id: &str) -> Result<Self> {
std::fs::create_dir_all(downloads)?;
let path = downloads.join(format!(".hh-{}.part", sanitize(id)));
let file =
std::fs::File::create(&path).with_context(|| format!("create {}", path.display()))?;
Ok(Self {
file,
path,
hasher: Sha256::new(),
written: 0,
})
}
pub fn write(&mut self, data: &[u8]) -> Result<()> {
self.file.write_all(data)?;
self.hasher.update(data);
self.written += data.len() as u64;
Ok(())
}
pub fn written(&self) -> u64 {
self.written
}
/// Flush and return `(temp_path, sha256-hex)`.
pub fn finish(mut self) -> Result<(PathBuf, String)> {
self.file.flush()?;
let sha = hex::encode(self.hasher.finalize());
Ok((self.path, sha))
}
/// Best-effort temp-file cleanup (reject / overflow / write error).
pub fn abort(self) {
drop(self.file);
let _ = std::fs::remove_file(&self.path);
}
}
/// Finalize a verified streamed transfer sitting at `tmp` into `downloads/`:
/// extract a directory tar (zip-slip guarded) or move the file to a unique name.
/// Consumes `tmp` (renamed away or removed).
pub fn commit(downloads: &Path, offer: &Offer, tmp: &Path) -> Result<PathBuf> {
std::fs::create_dir_all(downloads)?;
if offer.dir {
// Extract the tar's own top-level dir directly under downloads/.
let mut ar = tar::Archive::new(data);
let f = std::fs::File::open(tmp).with_context(|| format!("open {}", tmp.display()))?;
let mut ar = tar::Archive::new(std::io::BufReader::new(f));
let mut top: Option<std::ffi::OsString> = None;
for entry in ar.entries()? {
let mut e = entry?;
@@ -136,6 +257,7 @@ pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
e.unpack_in(downloads)
.with_context(|| format!("extract {}", path.display()))?;
}
let _ = std::fs::remove_file(tmp);
Ok(top
.map(|t| downloads.join(t))
.unwrap_or_else(|| downloads.to_path_buf()))
@@ -149,7 +271,11 @@ pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
.and_then(|s| s.to_str())
.unwrap_or("");
let dest = unique(downloads, stem, ext);
std::fs::write(&dest, data)?;
// Same-filesystem rename; fall back to copy for a cross-device temp dir.
if std::fs::rename(tmp, &dest).is_err() {
std::fs::copy(tmp, &dest)?;
let _ = std::fs::remove_file(tmp);
}
Ok(dest)
}
}
@@ -187,6 +313,10 @@ pub fn parse(text: &str, sender: &str) -> Option<Ft> {
sha256: v["sha256"].as_str().unwrap_or("").to_string(),
dir: v["dir"].as_bool().unwrap_or(false),
from: sender.to_string(),
to: match v["to"].as_str() {
Some(s) if !s.is_empty() => Some(s.to_string()),
_ => None,
},
})),
"accept" => Some(Ft::Accept(v["id"].as_str()?.to_string())),
"reject" => Some(Ft::Reject(v["id"].as_str()?.to_string())),
@@ -203,6 +333,34 @@ pub fn parse(text: &str, sender: &str) -> Option<Ft> {
mod tests {
use super::*;
/// Drive a full streamed transfer: prepare the source, feed `src` through a
/// disk-backed `Sink` in `CHUNK` blocks (as the wire would), verify the hash,
/// then commit into `downloads/`. Returns the committed path.
fn stream_roundtrip(downloads: &Path, src: &SendSrc, id: &str) -> PathBuf {
let mut sink = Sink::create(downloads, id).unwrap();
let mut f = std::fs::File::open(&src.src).unwrap();
let mut buf = vec![0u8; CHUNK];
loop {
let n = f.read(&mut buf).unwrap();
if n == 0 {
break;
}
sink.write(&buf[..n]).unwrap();
}
let offer = Offer {
id: id.into(),
name: src.name.clone(),
size: src.size,
sha256: src.sha256.clone(),
dir: src.dir,
from: "x".into(),
to: None,
};
let (tmp, sha) = sink.finish().unwrap();
assert_eq!(sha, offer.sha256, "streamed hash matches the offer");
commit(downloads, &offer, &tmp).unwrap()
}
#[test]
fn file_payload_roundtrip() {
let dir = std::env::temp_dir().join(format!("hh-ft-{}", std::process::id()));
@@ -210,19 +368,11 @@ mod tests {
let src = dir.join("note.txt");
std::fs::write(&src, b"offering to the clergy").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 prepared = prepare_send(src.to_str().unwrap()).unwrap();
assert_eq!(prepared.name, "note.txt");
assert!(!prepared.dir);
let dl = dir.join("dl");
let out = save(&dl, &offer, &bytes).unwrap();
let out = stream_roundtrip(&dl, &prepared, "1");
assert_eq!(std::fs::read(&out).unwrap(), b"offering to the clergy");
std::fs::remove_dir_all(&dir).ok();
}
@@ -235,19 +385,11 @@ mod tests {
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 prepared = prepare_send(proj.to_str().unwrap()).unwrap();
assert_eq!(prepared.name, "proj.tar");
assert!(prepared.dir);
let dl = dir.join("dl");
let out = save(&dl, &offer, &bytes).unwrap(); // -> dl/proj
let out = stream_roundtrip(&dl, &prepared, "1"); // -> 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");
+39
View File
@@ -108,6 +108,45 @@ pub fn list_vms() -> Result<Vec<String>> {
.collect())
}
/// Is a VM with this name registered locally? (i.e. present in `list_vms`).
/// Used to tell a "host" (already has the appliance imported) from someone who
/// still needs it pulled in before `gui_launch` can find it.
pub fn vm_registered(name: &str) -> bool {
list_vms()
.map(|vms| vms.iter().any(|v| v == name))
.unwrap_or(false)
}
/// Import a VirtualBox appliance (`.ova`/`.ovf`) so its VM registers locally and
/// becomes launchable via `gui_launch`. This is how a non-host "pulls" a shared
/// VM onto their own machine after the appliance arrives over the encrypted
/// channel. Blocking — run off the UI thread. Returns the imported VM's name.
pub fn import_appliance(ova: &std::path::Path) -> Result<String> {
let before = list_vms().unwrap_or_default();
let out = Command::new("VBoxManage")
.arg("import")
.arg(ova)
.output()
.context("VBoxManage import (is VirtualBox installed?)")?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
anyhow::bail!(
"VBoxManage import failed: {}",
err.lines().last().unwrap_or("").trim()
);
}
// The imported name is whatever's newly registered; fall back to the file's
// stem if the diff is ambiguous (e.g. a re-import of an existing name).
let after = list_vms().unwrap_or_default();
let added = after.into_iter().find(|v| !before.contains(v));
Ok(added.unwrap_or_else(|| {
ova.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("imported-vm")
.to_string()
}))
}
/// Is a VM currently running? (`VBoxManage list runningvms`)
pub fn vm_running(name: &str) -> bool {
Command::new("VBoxManage")
+90 -17
View File
@@ -47,6 +47,9 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
if app.show_help {
draw_help(f, f.area(), app, theme);
}
if app.vbox_picker.is_some() {
draw_vbox_picker(f, f.area(), app, theme);
}
if let Some(msg) = &app.error {
draw_error(f, f.area(), theme, msg);
}
@@ -150,8 +153,8 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
title: "SANDBOX",
items: vec![
kv(
"/sbx launch [backend]",
"summon a sandbox: local | docker | multipass",
"/sbx launch <docker|multipass|vbox>",
"summon a sandbox on YOUR machine — docker/multipass relay a shared shell; vbox <vm> opens a local GUI (or local)",
),
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
kv(
@@ -172,22 +175,26 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
HelpCluster {
title: "VIRTUALBOX (local GUI VM)",
items: vec![
kv(
"/sbx launch vbox",
"open the arrow-navigable VM picker (↑↓ move · Enter/Tab boot · Esc dismiss)",
),
kv(
"/sbx launch vbox [gui] <vm>",
"boot a VM's GUI on YOUR machine — host with the VM already imported launches instantly",
),
kv(
"/sbx launch vbox gui <vm> yes",
"non-host opt-in: append yes to install VirtualBox and/or import the shared .ova, then boot",
),
kv(
"/sbx gui <vm> [yes]",
"alias of /sbx launch vbox gui <vm> [yes]",
),
kv("/sbx vms", "detect VirtualBox + list local VMs"),
kv(
"/sbx gui <vm> [--install]",
"open a shared VM locally — host & guest each get their own copy",
),
kv(
"/sbx gui yes",
"confirm a pending launch (advance the consent gate)",
),
kv(
"/sbx gui cancel",
"abort a pending launch (nothing stopped or installed)",
),
kv(
"/sbx vmsave <vm> [label] [--local]",
"snapshot a VM (--local also exports a portable .ova)",
"snapshot a VM (--local also exports a portable .ova to /send)",
),
kv("/sbx vmsnaps <vm>", "list a VM's snapshots"),
],
@@ -238,8 +245,8 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
HelpCluster {
title: "FILES",
items: vec![
kv("/send <file>", "offer a file to the room"),
kv("/sendd <dir>", "offer a directory (sent as a tar)"),
kv("/send <user> <path>", "send a file/dir directly to one member"),
kv("/sendroom <path>", "offer a file/dir to the whole room"),
kv("/accept · /reject", "respond to an incoming file offer"),
],
},
@@ -277,6 +284,7 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
"reconnect to the house after a drop / AFK",
),
kv("/pw", "show this room's password (local only)"),
kv("/clear", "wipe your chat scrollback (local only)"),
kv("Ctrl-C · Ctrl-Q", "quit hack-house"),
],
},
@@ -343,6 +351,71 @@ fn help_render_lines(app: &App, theme: &Theme) -> Vec<Line<'static>> {
lines
}
/// Arrow-navigable VirtualBox VM picker — a small dropdown anchored just above
/// the input box. The highlighted row is inverted (theme bg on accent); Enter or
/// Tab boots it, Esc dismisses. Key handling lives in the run loop.
fn draw_vbox_picker(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let Some(picker) = &app.vbox_picker else {
return;
};
// Width: widest VM name (plus a little chrome), clamped to the terminal.
let longest = picker
.vms
.iter()
.map(|v| v.chars().count())
.max()
.unwrap_or(0);
let w = (longest as u16 + 6).clamp(24, area.width.saturating_sub(2)).max(8);
// Height: one row per VM + borders, capped so it never swallows the screen.
let max_rows = area.height.saturating_sub(6).max(1);
let body = (picker.vms.len() as u16).min(max_rows);
let h = body + 2;
// Anchor bottom-left, riding just above the 3-row input box.
let x = area.x + 1;
let y = area
.y
.saturating_add(area.height.saturating_sub(h + 3));
let rect = Rect {
x,
y,
width: w,
height: h,
};
// Scroll the window so the selection stays visible in tall lists.
let first = picker.selected.saturating_sub(body.saturating_sub(1) as usize);
let items: Vec<ListItem> = picker
.vms
.iter()
.enumerate()
.skip(first)
.take(body as usize)
.map(|(i, vm)| {
let style = if i == picker.selected {
Style::default()
.fg(theme.bg)
.bg(theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.title)
};
let marker = if i == picker.selected { "" } else { " " };
ListItem::new(Line::from(Span::styled(format!("{marker}{vm}"), style)))
})
.collect();
f.render_widget(Clear, rect);
let list = List::new(items).style(Style::default().bg(theme.bg)).block(
Block::bordered()
.border_style(Style::default().fg(theme.accent))
.title(Span::styled(
format!(" {} pick a VM · ↑↓ ⏎ Esc ", theme.sigil),
Style::default().fg(theme.title).add_modifier(Modifier::BOLD),
)),
);
f.render_widget(list, rect);
}
fn draw_help(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let w = help_popup(area);
let inner_w = w.width.saturating_sub(2);