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:
+201
-59
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user