From 0655bdc71b69988967197d44bb9abaf1aa97d604 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 13:46:06 +0200 Subject: [PATCH 01/25] Centralise vsock frame proto --- src/daemon.rs | 34 ++++------------------- src/main.rs | 20 +++----------- src/vscomm/mod.rs | 69 +++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 66 insertions(+), 57 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index b763969..8a8d073 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -446,23 +446,12 @@ fn is_allowed(passthrough: &[String], command: &str, args: &[String]) -> bool { } async fn read_exec_request(reader: &mut R) -> Result { - let mut header = [0u8; 6]; - reader.read_exact(&mut header).await.map_err(|e| format!("read header: {e}"))?; - - let frame_type_raw = u16::from_le_bytes([header[0], header[1]]); - let ft = FrameType::from_u16(frame_type_raw).ok_or_else(|| format!("unknown frame type: {frame_type_raw}"))?; - - if !matches!(ft, FrameType::ExecReq) { - return Err(format!("expected ExecReq, got {:?}", ft as u16)); + let frame = Frame::read_async(reader).await.map_err(|e| format!("read frame: {e}"))?; + if !matches!(frame.frame_type, FrameType::ExecReq) { + return Err(format!("expected ExecReq, got {:?}", frame.frame_type as u16)); } - let payload_len = u32::from_le_bytes([header[2], header[3], header[4], header[5]]) as usize; - let mut payload = vec![0u8; payload_len]; - if payload_len > 0 { - reader.read_exact(&mut payload).await.map_err(|e| format!("read payload: {e}"))?; - } - - ExecRequest::deserialize(&payload) + ExecRequest::deserialize(&frame.payload) } fn bwrap_status_pipe() -> Result<(File, File), String> { @@ -546,20 +535,7 @@ async fn pump_to_channel(mut reader: R, frame_type: Fra } async fn write_frame(writer: &mut W, frame: &Frame) -> Result<(), String> { - let frame_type_raw = frame.frame_type as u16; - let payload_len = frame.payload.len() as u32; - - let mut header = [0u8; 6]; - header[0..2].copy_from_slice(&frame_type_raw.to_le_bytes()); - header[2..6].copy_from_slice(&payload_len.to_le_bytes()); - - writer.write_all(&header).await.map_err(|e| format!("write header: {e}"))?; - if !frame.payload.is_empty() { - writer.write_all(&frame.payload).await.map_err(|e| format!("write payload: {e}"))?; - } - writer.flush().await.map_err(|e| format!("flush: {e}"))?; - - Ok(()) + frame.write_async(writer).await.map_err(|e| format!("write frame: {e}")) } fn find_netrelay_binary() -> Result { diff --git a/src/main.rs b/src/main.rs index 83ecce4..784d643 100644 --- a/src/main.rs +++ b/src/main.rs @@ -435,28 +435,14 @@ async fn status_listener( let overlay = overlay.clone(); tokio::spawn(async move { - let mut header = [0u8; 6]; - if stream.read_exact(&mut header).await.is_err() { - return; - } - - let frame_type_raw = u16::from_le_bytes([header[0], header[1]]); - let payload_len = u32::from_le_bytes([header[2], header[3], header[4], header[5]]) as usize; - - let Some(ft) = vscomm::FrameType::from_u16(frame_type_raw) else { + let Ok(frame) = vscomm::Frame::read_async(&mut stream).await else { return; }; - - if !matches!(ft, vscomm::FrameType::UiCommand) { - return; - } - - let mut payload = vec![0u8; payload_len]; - if payload_len > 0 && stream.read_exact(&mut payload).await.is_err() { + if !matches!(frame.frame_type, vscomm::FrameType::UiCommand) { return; } - if let Some((widget, cmd, opts, val)) = vscomm::decode_ui_payload(&payload) { + if let Some((widget, cmd, opts, val)) = vscomm::decode_ui_payload(&frame.payload) { if widget == "error" && cmd == "show" { let title = if opts.is_empty() { "Bunkerbox error" } else { opts }; logging::diagnostic(&format!("TUI error [{title}]: {val}")); diff --git a/src/vscomm/mod.rs b/src/vscomm/mod.rs index 00365f6..3ee4b17 100644 --- a/src/vscomm/mod.rs +++ b/src/vscomm/mod.rs @@ -7,12 +7,15 @@ use std::path::Path; #[cfg(unix)] use std::os::unix::ffi::OsStrExt; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; pub mod buildsys; pub const TOOLCHAIN_PORT: u32 = 9999; // Keep UI traffic on a separate vsock endpoint from command execution. pub const TUI_STATUS_PORT: u32 = 10000; pub const VSCOMM_BIN_DIR: &str = "/usr/local/bunkerbox/bin"; +/// Maximum payload accepted in one vsock frame. +pub const MAX_FRAME_PAYLOAD: usize = 1024 * 1024; #[repr(u16)] #[derive(Clone, Copy)] @@ -119,11 +122,7 @@ impl Frame { let mut header = [0u8; 6]; reader.read_exact(&mut header)?; - let frame_type_raw = u16::from_le_bytes([header[0], header[1]]); - let frame_type = FrameType::from_u16(frame_type_raw) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("unknown frame type: {frame_type_raw}")))?; - - let payload_len = u32::from_le_bytes([header[2], header[3], header[4], header[5]]) as usize; + let (frame_type, payload_len) = decode_header(&header)?; let mut payload = vec![0u8; payload_len]; if payload_len > 0 { @@ -133,13 +132,21 @@ impl Frame { Ok(Self { frame_type, payload }) } - pub fn write(&self, writer: &mut W) -> io::Result<()> { - let frame_type_raw = self.frame_type as u16; - let payload_len = self.payload.len() as u32; - + pub async fn read_async(reader: &mut R) -> io::Result { let mut header = [0u8; 6]; - header[0..2].copy_from_slice(&frame_type_raw.to_le_bytes()); - header[2..6].copy_from_slice(&payload_len.to_le_bytes()); + reader.read_exact(&mut header).await?; + + let (frame_type, payload_len) = decode_header(&header)?; + let mut payload = vec![0u8; payload_len]; + if payload_len > 0 { + reader.read_exact(&mut payload).await?; + } + + Ok(Self { frame_type, payload }) + } + + pub fn write(&self, writer: &mut W) -> io::Result<()> { + let header = self.header()?; writer.write_all(&header)?; if !self.payload.is_empty() { @@ -149,6 +156,46 @@ impl Frame { writer.flush()?; Ok(()) } + + pub async fn write_async(&self, writer: &mut W) -> io::Result<()> { + let header = self.header()?; + writer.write_all(&header).await?; + + if !self.payload.is_empty() { + writer.write_all(&self.payload).await?; + } + + writer.flush().await + } + + fn header(&self) -> io::Result<[u8; 6]> { + validate_payload_size(self.payload.len())?; + + let mut header = [0u8; 6]; + header[0..2].copy_from_slice(&(self.frame_type as u16).to_le_bytes()); + header[2..6].copy_from_slice(&(self.payload.len() as u32).to_le_bytes()); + Ok(header) + } +} + +fn decode_header(header: &[u8; 6]) -> io::Result<(FrameType, usize)> { + let frame_type_raw = u16::from_le_bytes([header[0], header[1]]); + let frame_type = FrameType::from_u16(frame_type_raw) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("unknown frame type: {frame_type_raw}")))?; + let payload_len = u32::from_le_bytes([header[2], header[3], header[4], header[5]]) as usize; + validate_payload_size(payload_len)?; + Ok((frame_type, payload_len)) +} + +fn validate_payload_size(payload_len: usize) -> io::Result<()> { + if payload_len > MAX_FRAME_PAYLOAD { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("frame payload too large: {payload_len} bytes (maximum {MAX_FRAME_PAYLOAD})"), + )); + } + + Ok(()) } impl ExecRequest { From e5dfb116bdb87e3868de943326917c544aff0e29 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 13:46:21 +0200 Subject: [PATCH 02/25] Add centralised vsock proto UT --- src/vscomm/ut.rs | 191 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 src/vscomm/ut.rs diff --git a/src/vscomm/ut.rs b/src/vscomm/ut.rs new file mode 100644 index 0000000..eede4af --- /dev/null +++ b/src/vscomm/ut.rs @@ -0,0 +1,191 @@ +use super::*; +use std::io::{self, Cursor, Read}; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tokio::io::{AsyncRead, ReadBuf}; + +fn encoded_frame(frame_type: u16, payload_len: u32, payload: &[u8]) -> Vec { + let mut encoded = Vec::with_capacity(6 + payload.len()); + encoded.extend_from_slice(&frame_type.to_le_bytes()); + encoded.extend_from_slice(&payload_len.to_le_bytes()); + encoded.extend_from_slice(payload); + encoded +} + +fn frame_error(result: io::Result) -> io::Error { + match result { + Ok(_) => panic!("expected frame read to fail"), + Err(error) => error, + } +} + +struct FragmentedReader { + data: Vec, + offset: usize, + chunk_size: usize, +} + +impl FragmentedReader { + fn new(data: Vec, chunk_size: usize) -> Self { + Self { data, offset: 0, chunk_size } + } +} + +impl Read for FragmentedReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.offset == self.data.len() { + return Ok(0); + } + + let amount = self.chunk_size.min(buf.len()).min(self.data.len() - self.offset); + buf[..amount].copy_from_slice(&self.data[self.offset..self.offset + amount]); + self.offset += amount; + Ok(amount) + } +} + +impl AsyncRead for FragmentedReader { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + if self.offset == self.data.len() { + return Poll::Ready(Ok(())); + } + + let amount = self.chunk_size.min(buf.remaining()).min(self.data.len() - self.offset); + buf.put_slice(&self.data[self.offset..self.offset + amount]); + self.offset += amount; + Poll::Ready(Ok(())) + } +} + +#[test] +fn read_zero_length_frame() { + let frame = Frame::read(&mut Cursor::new(encoded_frame(FrameType::Stdout as u16, 0, &[]))).unwrap(); + assert!(matches!(frame.frame_type, FrameType::Stdout)); + assert!(frame.payload.is_empty()); +} + +#[test] +fn read_maximum_size_frame() { + let payload = vec![0xA5; MAX_FRAME_PAYLOAD]; + let frame = Frame::read(&mut Cursor::new(encoded_frame(FrameType::Stdout as u16, MAX_FRAME_PAYLOAD as u32, &payload))).unwrap(); + assert_eq!(frame.payload, payload); +} + +#[test] +fn read_oversized_frame_before_payload_read() { + let encoded = encoded_frame(FrameType::Stdout as u16, (MAX_FRAME_PAYLOAD + 1) as u32, &[]); + let error = frame_error(Frame::read(&mut Cursor::new(encoded))); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains("frame payload too large")); +} + +#[test] +fn read_unknown_frame_type() { + let error = frame_error(Frame::read(&mut Cursor::new(encoded_frame(99, 0, &[])))); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains("unknown frame type")); +} + +#[test] +fn read_unknown_frame_type_with_oversized_payload() { + let error = frame_error(Frame::read(&mut Cursor::new(encoded_frame(99, (MAX_FRAME_PAYLOAD + 1) as u32, &[])))); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains("unknown frame type")); +} + +#[test] +fn read_truncated_header() { + let error = frame_error(Frame::read(&mut Cursor::new(vec![1, 0, 0]))); + assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof); +} + +#[test] +fn read_truncated_payload() { + let error = frame_error(Frame::read(&mut Cursor::new(encoded_frame(FrameType::Stdout as u16, 4, &[1, 2])))); + assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof); +} + +#[test] +fn read_fragmented_frame() { + let payload = b"fragmented"; + let frame = Frame::read(&mut FragmentedReader::new(encoded_frame(FrameType::Stderr as u16, payload.len() as u32, payload), 1)).unwrap(); + assert!(matches!(frame.frame_type, FrameType::Stderr)); + assert_eq!(frame.payload, payload); +} + +#[test] +fn write_preserves_wire_format() { + let mut encoded = Vec::new(); + Frame::new(FrameType::Exit, vec![1, 2, 3]).write(&mut encoded).unwrap(); + assert_eq!(encoded, encoded_frame(FrameType::Exit as u16, 3, &[1, 2, 3])); +} + +#[test] +fn write_rejects_oversized_frame() { + let mut encoded = Vec::new(); + let error = Frame::new(FrameType::Stdout, vec![0; MAX_FRAME_PAYLOAD + 1]).write(&mut encoded).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(encoded.is_empty()); +} + +#[tokio::test] +async fn async_read_fragmented_frame() { + let payload = b"fragmented async"; + let mut reader = FragmentedReader::new(encoded_frame(FrameType::Stdout as u16, payload.len() as u32, payload), 1); + let frame = Frame::read_async(&mut reader).await.unwrap(); + assert!(matches!(frame.frame_type, FrameType::Stdout)); + assert_eq!(frame.payload, payload); +} + +#[tokio::test] +async fn async_read_zero_length_frame() { + let mut reader = FragmentedReader::new(encoded_frame(FrameType::Stdout as u16, 0, &[]), 1); + let frame = Frame::read_async(&mut reader).await.unwrap(); + assert!(matches!(frame.frame_type, FrameType::Stdout)); + assert!(frame.payload.is_empty()); +} + +#[tokio::test] +async fn async_read_maximum_size_frame() { + let payload = vec![0x5A; MAX_FRAME_PAYLOAD]; + let mut reader = FragmentedReader::new(encoded_frame(FrameType::Stdout as u16, MAX_FRAME_PAYLOAD as u32, &payload), MAX_FRAME_PAYLOAD); + let frame = Frame::read_async(&mut reader).await.unwrap(); + assert_eq!(frame.payload, payload); +} + +#[tokio::test] +async fn async_read_oversized_frame() { + let mut reader = FragmentedReader::new(encoded_frame(FrameType::Stdout as u16, (MAX_FRAME_PAYLOAD + 1) as u32, &[]), 1); + let error = frame_error(Frame::read_async(&mut reader).await); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); +} + +#[tokio::test] +async fn async_read_unknown_frame_type() { + let mut reader = FragmentedReader::new(encoded_frame(99, 0, &[]), 1); + let error = frame_error(Frame::read_async(&mut reader).await); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains("unknown frame type")); +} + +#[tokio::test] +async fn async_read_truncated_header() { + let mut reader = FragmentedReader::new(vec![1, 0, 0], 1); + let error = frame_error(Frame::read_async(&mut reader).await); + assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof); +} + +#[tokio::test] +async fn async_read_truncated_payload() { + let mut reader = FragmentedReader::new(encoded_frame(FrameType::Stdout as u16, 4, &[1, 2]), 1); + let error = frame_error(Frame::read_async(&mut reader).await); + assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof); +} + +#[tokio::test] +async fn async_write_rejects_oversized_frame() { + let mut encoded = Vec::new(); + let error = Frame::new(FrameType::Stdout, vec![0; MAX_FRAME_PAYLOAD + 1]).write_async(&mut encoded).await.unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(encoded.is_empty()); +} From 212222129f16d5d152324744fe79edbf5f4bea20 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 14:06:03 +0200 Subject: [PATCH 03/25] Validate guest working directories and workspace paths --- src/daemon.rs | 26 +++++++++----------- src/workspace.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 8a8d073..006d305 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3,6 +3,7 @@ use crate::logging; use crate::proxy::{FilterProxy, UnixProxyHandle}; use crate::sandbox::{resolve_profile, MergedProfile, NetworkMode}; use crate::vscomm::{validate_exec_request, validate_process_path, validate_process_string, ExecRequest, Frame, FrameType, TOOLCHAIN_PORT}; +use crate::workspace::WorkspaceCwd; use rand::Rng; use std::fs::File; use std::io::{BufRead, BufReader}; @@ -183,12 +184,7 @@ async fn handle_connection(stream: tokio_vsock::VsockStream, session: &VsockSess async fn execute_request(writer: &mut W, session: &VsockSession, req: &ExecRequest) -> Result<(), String> { validate_exec_request(req)?; - let sandbox_cwd = req.cwd.clone(); - let host_cwd = if req.cwd.starts_with("/workspace") { - session.workspace.join(req.cwd.strip_prefix("/workspace").unwrap_or(&req.cwd).trim_start_matches('/')) - } else { - PathBuf::from(&req.cwd) - }; + let cwd = WorkspaceCwd::resolve(&session.workspace, Path::new(&req.cwd))?; let (status_reader, status_writer) = if session.merged_profile.is_some() { let (reader, writer) = bwrap_status_pipe()?; @@ -196,7 +192,7 @@ async fn execute_request(writer: &mut W, session: &Vso } else { (None, None) }; - let mut cmd = build_command(session, req, &host_cwd, &sandbox_cwd)?; + let mut cmd = build_command(session, req, &cwd)?; if let Some(status_writer) = status_writer.as_ref() { attach_bwrap_status_fd(&mut cmd, status_writer.as_raw_fd()); } @@ -267,12 +263,12 @@ async fn execute_request(writer: &mut W, session: &Vso Ok(()) } -fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, sandbox_cwd: &str) -> Result { +fn build_command(session: &VsockSession, req: &ExecRequest, cwd: &WorkspaceCwd) -> Result { validate_exec_request(req)?; validate_process_path("workspace path", &session.workspace)?; - validate_process_path("host working directory", host_cwd)?; - validate_process_string("sandbox working directory", sandbox_cwd)?; - + validate_process_path("host working directory", cwd.host_path())?; + let sandbox_cwd = cwd.guest_path(); + validate_process_path("sandbox working directory", &sandbox_cwd)?; if let Some(ref merged) = session.merged_profile { let mut cmd = Command::new("bwrap"); @@ -321,10 +317,10 @@ fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, san cmd.arg("--dev").arg("/dev"); cmd.arg("--tmpfs").arg("/tmp"); - if !sandbox_cwd.is_empty() && sandbox_cwd != "/" { - cmd.arg("--dir").arg(sandbox_cwd); + if sandbox_cwd != Path::new("/") { + cmd.arg("--dir").arg(&sandbox_cwd); } - cmd.arg("--chdir").arg(sandbox_cwd); + cmd.arg("--chdir").arg(&sandbox_cwd); cmd.arg("--clearenv"); cmd.arg("--setenv").arg("PATH").arg("/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"); @@ -383,7 +379,7 @@ fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, san } else { let mut cmd = Command::new(&req.command); cmd.args(&req.args); - cmd.current_dir(host_cwd); + cmd.current_dir(cwd.host_path()); if session.env_mode == EnvMode::Relaxed { for (key, val) in &req.env { diff --git a/src/workspace.rs b/src/workspace.rs index 769121a..8a3e7cd 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -3,7 +3,7 @@ use crate::overlay::CowWorkspace; use std::ffi::OsStr; use std::fs; use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::process::{Command, Stdio}; pub fn prepare(reset: bool) -> Result<(), String> { @@ -18,6 +18,63 @@ pub enum WorkspaceHandle { Isolated { path: PathBuf }, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceCwd { + host: PathBuf, + relative: PathBuf, +} + +impl WorkspaceCwd { + pub fn resolve(workspace: &Path, guest_cwd: &Path) -> Result { + if !guest_cwd.is_absolute() { + return Err(format!("working directory must be absolute: {}", guest_cwd.display())); + } + + let relative = guest_cwd + .strip_prefix(Path::new("/workspace")) + .map_err(|_| format!("working directory must be under /workspace: {}", guest_cwd.display()))? + .components() + .try_fold(PathBuf::new(), |mut relative, component| match component { + Component::CurDir => Ok(relative), + Component::Normal(name) => { + relative.push(name); + Ok(relative) + } + Component::ParentDir => Err(format!("working directory contains '..': {}", guest_cwd.display())), + Component::RootDir | Component::Prefix(_) => Err(format!("invalid workspace path: {}", guest_cwd.display())), + })?; + + let canonical_workspace = fs::canonicalize(workspace).map_err(|err| format!("failed to resolve workspace {}: {err}", workspace.display()))?; + let candidate = canonical_workspace.join(&relative); + let canonical_host = + fs::canonicalize(&candidate).map_err(|err| format!("failed to resolve working directory {}: {err}", guest_cwd.display()))?; + + canonical_host.strip_prefix(&canonical_workspace).map_err(|_| format!("working directory escapes workspace: {}", guest_cwd.display()))?; + + if !fs::metadata(&canonical_host).map(|metadata| metadata.is_dir()).unwrap_or(false) { + return Err(format!("working directory is not a directory: {}", guest_cwd.display())); + } + + Ok(Self { host: canonical_host, relative }) + } + + pub fn host_path(&self) -> &Path { + &self.host + } + + pub fn relative_path(&self) -> &Path { + &self.relative + } + + pub fn guest_path(&self) -> PathBuf { + if self.relative.as_os_str().is_empty() { + PathBuf::from("/workspace") + } else { + Path::new("/workspace").join(&self.relative) + } + } +} + impl WorkspaceHandle { pub fn path(&self) -> &Path { match self { @@ -152,3 +209,7 @@ fn copy_dir(source: &Path, destination: &Path) -> Result<(), String> { fn should_skip(name: &OsStr) -> bool { matches!(name.to_str(), Some(".bunker") | Some(".bunkerbox") | Some(".git") | Some("target")) } + +#[cfg(test)] +#[path = "workspace_ut.rs"] +mod tests; From c238907d227a049ff51d6976fb5f109805b737d8 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 14:06:11 +0200 Subject: [PATCH 04/25] Add workspace unit tests --- src/workspace_ut.rs | 166 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src/workspace_ut.rs diff --git a/src/workspace_ut.rs b/src/workspace_ut.rs new file mode 100644 index 0000000..1cd1071 --- /dev/null +++ b/src/workspace_ut.rs @@ -0,0 +1,166 @@ +use super::*; +use std::os::unix::fs::symlink; +use tempfile::TempDir; + +fn workspace() -> TempDir { + TempDir::new().unwrap() +} + +fn directory(root: &Path, relative: &str) -> PathBuf { + let path = root.join(relative); + std::fs::create_dir_all(&path).unwrap(); + path +} + +fn resolve(root: &Path, cwd: &str) -> WorkspaceCwd { + WorkspaceCwd::resolve(root, Path::new(cwd)).unwrap() +} + +#[test] +fn resolves_workspace_root() { + let root = workspace(); + let cwd = resolve(root.path(), "/workspace"); + + assert_eq!(cwd.host_path(), root.path().canonicalize().unwrap()); + assert_eq!(cwd.relative_path(), Path::new("")); + assert_eq!(cwd.guest_path(), Path::new("/workspace")); +} + +#[test] +fn resolves_nested_directory() { + let root = workspace(); + let nested = directory(root.path(), "src/lib"); + let cwd = resolve(root.path(), "/workspace/src/lib"); + + assert_eq!(cwd.host_path(), nested.canonicalize().unwrap()); + assert_eq!(cwd.relative_path(), Path::new("src/lib")); + assert_eq!(cwd.guest_path(), Path::new("/workspace/src/lib")); +} + +#[test] +fn rejects_parent_directory_component() { + let root = workspace(); + directory(root.path(), "bar"); + + assert!(WorkspaceCwd::resolve(root.path(), Path::new("/workspace/foo/../bar")).is_err()); +} + +#[test] +fn normalizes_repeated_separators() { + let root = workspace(); + directory(root.path(), "foo"); + let cwd = resolve(root.path(), "/workspace//foo"); + + assert_eq!(cwd.relative_path(), Path::new("foo")); + assert_eq!(cwd.guest_path(), Path::new("/workspace/foo")); +} + +#[test] +fn normalizes_current_directory_components() { + let root = workspace(); + directory(root.path(), "foo"); + let cwd = resolve(root.path(), "/workspace/./foo"); + + assert_eq!(cwd.relative_path(), Path::new("foo")); + assert_eq!(cwd.guest_path(), Path::new("/workspace/foo")); +} + +#[test] +fn rejects_workspace_string_prefix_sibling() { + let root = workspace(); + + assert!(WorkspaceCwd::resolve(root.path(), Path::new("/workspace-other/foo")).is_err()); +} + +#[test] +fn rejects_unrelated_absolute_path() { + let root = workspace(); + + assert!(WorkspaceCwd::resolve(root.path(), Path::new("/tmp/foo")).is_err()); +} + +#[test] +fn rejects_relative_path() { + let root = workspace(); + + assert!(WorkspaceCwd::resolve(root.path(), Path::new("foo")).is_err()); +} + +#[test] +fn rejects_missing_path() { + let root = workspace(); + + assert!(WorkspaceCwd::resolve(root.path(), Path::new("/workspace/missing")).is_err()); +} + +#[test] +fn rejects_file_as_working_directory() { + let root = workspace(); + std::fs::write(root.path().join("file"), b"data").unwrap(); + + assert!(WorkspaceCwd::resolve(root.path(), Path::new("/workspace/file")).is_err()); +} + +#[test] +fn rejects_symlink_outside_workspace() { + let root = workspace(); + let outside = workspace(); + symlink(outside.path(), root.path().join("outside-link")).unwrap(); + + assert!(WorkspaceCwd::resolve(root.path(), Path::new("/workspace/outside-link")).is_err()); +} + +#[test] +fn accepts_inside_symlink_and_preserves_guest_path() { + let root = workspace(); + let target = directory(root.path(), "real/foo"); + symlink(&target, root.path().join("foo-link")).unwrap(); + + let cwd = resolve(root.path(), "/workspace/foo-link"); + + assert_eq!(cwd.host_path(), target.canonicalize().unwrap()); + assert_eq!(cwd.relative_path(), Path::new("foo-link")); + assert_eq!(cwd.guest_path(), Path::new("/workspace/foo-link")); +} + +#[test] +fn rejects_dangling_symlink() { + let root = workspace(); + symlink(root.path().join("missing"), root.path().join("dangling-link")).unwrap(); + + assert!(WorkspaceCwd::resolve(root.path(), Path::new("/workspace/dangling-link")).is_err()); +} + +#[test] +fn rejects_symlink_to_common_prefix_sibling() { + let parent = workspace(); + let root = parent.path().join("project"); + let sibling = parent.path().join("project-other"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&sibling).unwrap(); + symlink(&sibling, root.join("sibling-link")).unwrap(); + + assert!(WorkspaceCwd::resolve(&root, Path::new("/workspace/sibling-link")).is_err()); +} + +#[test] +fn accepts_workspace_root_symlink() { + let parent = workspace(); + let target = workspace(); + let link = parent.path().join("workspace-link"); + symlink(target.path(), &link).unwrap(); + + let cwd = resolve(&link, "/workspace"); + + assert_eq!(cwd.host_path(), target.path().canonicalize().unwrap()); + assert_eq!(cwd.relative_path(), Path::new("")); +} + +#[test] +fn rejects_symlink_loop() { + let root = workspace(); + symlink(root.path().join("loop-b"), root.path().join("loop-a")).unwrap(); + symlink(root.path().join("loop-a"), root.path().join("loop-b")).unwrap(); + + assert!(WorkspaceCwd::resolve(root.path(), Path::new("/workspace/loop-a")).is_err()); +} From 52aff66cbb508485c3bf8c937516be1d4c242847 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 21:37:22 +0200 Subject: [PATCH 05/25] Lints --- src/daemon.rs | 2 +- src/main.rs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 006d305..4640315 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2,7 +2,7 @@ use crate::cfg::EnvMode; use crate::logging; use crate::proxy::{FilterProxy, UnixProxyHandle}; use crate::sandbox::{resolve_profile, MergedProfile, NetworkMode}; -use crate::vscomm::{validate_exec_request, validate_process_path, validate_process_string, ExecRequest, Frame, FrameType, TOOLCHAIN_PORT}; +use crate::vscomm::{validate_exec_request, validate_process_path, ExecRequest, Frame, FrameType, TOOLCHAIN_PORT}; use crate::workspace::WorkspaceCwd; use rand::Rng; use std::fs::File; diff --git a/src/main.rs b/src/main.rs index 784d643..a7ec69c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -422,8 +422,6 @@ fn start_status_listener(overlay: Arc>) -> Result>, mut shutdown_rx: tokio::sync::oneshot::Receiver<()>, ) { - use tokio::io::AsyncReadExt; - loop { let (mut stream, _peer) = tokio::select! { result = listener.accept() => match result { From 2caa623b76c35355de0ec223055515ee9352288e Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 2 Aug 2026 01:36:39 +0200 Subject: [PATCH 06/25] Define the versioned remote operation proto --- src/vscomm/mod.rs | 461 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 461 insertions(+) diff --git a/src/vscomm/mod.rs b/src/vscomm/mod.rs index 3ee4b17..be54f54 100644 --- a/src/vscomm/mod.rs +++ b/src/vscomm/mod.rs @@ -16,6 +16,15 @@ pub const TUI_STATUS_PORT: u32 = 10000; pub const VSCOMM_BIN_DIR: &str = "/usr/local/bunkerbox/bin"; /// Maximum payload accepted in one vsock frame. pub const MAX_FRAME_PAYLOAD: usize = 1024 * 1024; +pub const REMOTE_PROTOCOL_VERSION: u16 = 1; +pub const MAX_REMOTE_STRING_BYTES: usize = 4 * 1024; +pub const MAX_REMOTE_TOOL_BYTES: usize = 256; +pub const MAX_REMOTE_ARG_COUNT: usize = 256; +pub const MAX_REMOTE_ARG_BYTES: usize = 4 * 1024; +pub const MAX_REMOTE_ENV_COUNT: usize = 64; +pub const MAX_REMOTE_ENV_KEY_BYTES: usize = 256; +pub const MAX_REMOTE_ENV_VALUE_BYTES: usize = 4 * 1024; +pub const MAX_REMOTE_ERROR_BYTES: usize = 4 * 1024; #[repr(u16)] #[derive(Clone, Copy)] @@ -26,6 +35,8 @@ pub enum FrameType { Exit = 4, Disconnect = 5, UiCommand = 10, + RemoteRequest = 20, + RemoteEvent = 21, } impl FrameType { @@ -37,6 +48,8 @@ impl FrameType { 4 => Some(Self::Exit), 5 => Some(Self::Disconnect), 10 => Some(Self::UiCommand), + 20 => Some(Self::RemoteRequest), + 21 => Some(Self::RemoteEvent), _ => None, } } @@ -49,6 +62,454 @@ pub struct ExecRequest { pub env: Vec<(String, String)>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RequestId(pub [u8; 16]); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WorkspaceSessionId(pub [u8; 16]); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceRelativePath(String); + +impl WorkspaceRelativePath { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_remote_string("remote cwd", &value, MAX_REMOTE_STRING_BYTES)?; + if value.is_empty() { + return Ok(Self(value)); + } + + let path = Path::new(&value); + if path.is_absolute() || value.split('/').any(|component| component.is_empty() || component == "." || component == "..") { + return Err("remote cwd must be a normalized relative path".to_string()); + } + + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteTool(String); + +impl RemoteTool { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_remote_string("remote tool", &value, MAX_REMOTE_TOOL_BYTES)?; + if value.is_empty() { + return Err("remote tool is empty".to_string()); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteBuild { + pub cwd: WorkspaceRelativePath, + pub tool: RemoteTool, + pub argv: Vec, + pub env: Vec<(String, String)>, +} + +impl RemoteBuild { + pub fn new(cwd: WorkspaceRelativePath, tool: RemoteTool, argv: Vec, env: Vec<(String, String)>) -> Result { + validate_remote_build_fields(&cwd, &tool, &argv, &env)?; + Ok(Self { cwd, tool, argv, env }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteOperation { + Sync(RemoteSync), + Build(RemoteBuild), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteSync; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteRequest { + pub request_id: RequestId, + pub workspace_session_id: WorkspaceSessionId, + pub operation: RemoteOperation, +} + +impl RemoteRequest { + pub fn sync(request_id: RequestId, workspace_session_id: WorkspaceSessionId) -> Self { + Self { request_id, workspace_session_id, operation: RemoteOperation::Sync(RemoteSync) } + } + + pub fn build(request_id: RequestId, workspace_session_id: WorkspaceSessionId, build: RemoteBuild) -> Self { + Self { request_id, workspace_session_id, operation: RemoteOperation::Build(build) } + } + + pub fn to_frame(&self) -> Result { + let mut writer = WireWriter::new(*b"BBR1"); + writer.u16(REMOTE_PROTOCOL_VERSION); + writer.u8(match &self.operation { + RemoteOperation::Sync(_) => 1, + RemoteOperation::Build(_) => 2, + }); + writer.u8(0); + writer.bytes(&self.request_id.0); + writer.bytes(&self.workspace_session_id.0); + + if let RemoteOperation::Build(build) = &self.operation { + encode_remote_build(&mut writer, build)?; + } + + writer.into_frame(FrameType::RemoteRequest) + } + + pub fn from_frame(frame: Frame) -> Result { + if !matches!(frame.frame_type, FrameType::RemoteRequest) { + return Err("expected RemoteRequest frame".to_string()); + } + + let mut reader = WireReader::new(&frame.payload); + reader.magic(*b"BBR1")?; + reader.version()?; + let operation_kind = reader.u8()?; + reader.zero_reserved()?; + let request_id = RequestId(reader.array16()?); + let workspace_session_id = WorkspaceSessionId(reader.array16()?); + let operation = match operation_kind { + 1 => RemoteOperation::Sync(RemoteSync), + 2 => RemoteOperation::Build(decode_remote_build(&mut reader)?), + value => return Err(format!("unknown remote operation: {value}")), + }; + let request = Self { request_id, workspace_session_id, operation }; + reader.finish()?; + Ok(request) + } +} + +fn encode_remote_build(writer: &mut WireWriter, build: &RemoteBuild) -> Result<(), String> { + validate_remote_build_fields(&build.cwd, &build.tool, &build.argv, &build.env)?; + writer.string(build.cwd.as_str(), MAX_REMOTE_STRING_BYTES, "remote cwd")?; + writer.string(build.tool.as_str(), MAX_REMOTE_TOOL_BYTES, "remote tool")?; + writer.count(build.argv.len(), MAX_REMOTE_ARG_COUNT, "remote argv")?; + for arg in &build.argv { + writer.string(arg, MAX_REMOTE_ARG_BYTES, "remote argument")?; + } + writer.count(build.env.len(), MAX_REMOTE_ENV_COUNT, "remote environment")?; + for (key, value) in &build.env { + writer.string(key, MAX_REMOTE_ENV_KEY_BYTES, "remote environment key")?; + writer.string(value, MAX_REMOTE_ENV_VALUE_BYTES, "remote environment value")?; + } + Ok(()) +} + +fn decode_remote_build(reader: &mut WireReader<'_>) -> Result { + let cwd = WorkspaceRelativePath::new(reader.string(MAX_REMOTE_STRING_BYTES, "remote cwd")?)?; + let tool = RemoteTool::new(reader.string(MAX_REMOTE_TOOL_BYTES, "remote tool")?)?; + let argv = (0..reader.count(MAX_REMOTE_ARG_COUNT, "remote argv")?) + .map(|_| reader.string(MAX_REMOTE_ARG_BYTES, "remote argument")) + .collect::, _>>()?; + let env = (0..reader.count(MAX_REMOTE_ENV_COUNT, "remote environment")?) + .map(|_| { + Ok(( + reader.string(MAX_REMOTE_ENV_KEY_BYTES, "remote environment key")?, + reader.string(MAX_REMOTE_ENV_VALUE_BYTES, "remote environment value")?, + )) + }) + .collect::, String>>()?; + RemoteBuild::new(cwd, tool, argv, env) +} + +fn validate_remote_build_fields(cwd: &WorkspaceRelativePath, tool: &RemoteTool, argv: &[String], env: &[(String, String)]) -> Result<(), String> { + validate_remote_string("remote cwd", cwd.as_str(), MAX_REMOTE_STRING_BYTES)?; + validate_remote_string("remote tool", tool.as_str(), MAX_REMOTE_TOOL_BYTES)?; + validate_remote_count(argv.len(), MAX_REMOTE_ARG_COUNT, "remote argv")?; + argv.iter().try_for_each(|arg| validate_remote_string("remote argument", arg, MAX_REMOTE_ARG_BYTES))?; + validate_remote_count(env.len(), MAX_REMOTE_ENV_COUNT, "remote environment")?; + env.iter().try_for_each(|(key, value)| { + validate_remote_string("remote environment key", key, MAX_REMOTE_ENV_KEY_BYTES)?; + validate_env_key("remote environment key", key)?; + validate_remote_string("remote environment value", value, MAX_REMOTE_ENV_VALUE_BYTES) + }) +} + +fn validate_remote_string(field: &str, value: &str, max: usize) -> Result<(), String> { + validate_process_string(field, value)?; + if value.len() > max { + return Err(format!("{field} exceeds maximum length {max}")); + } + Ok(()) +} + +fn validate_remote_count(count: usize, max: usize, field: &str) -> Result<(), String> { + if count > max { + return Err(format!("{field} exceeds maximum count {max}")); + } + Ok(()) +} + +struct WireWriter { + bytes: Vec, +} + +impl WireWriter { + fn new(magic: [u8; 4]) -> Self { + Self { bytes: magic.to_vec() } + } + + fn u8(&mut self, value: u8) { + self.bytes.push(value); + } + + fn u16(&mut self, value: u16) { + self.bytes.extend_from_slice(&value.to_le_bytes()); + } + + fn u64(&mut self, value: u64) { + self.bytes.extend_from_slice(&value.to_le_bytes()); + } + + fn i32(&mut self, value: i32) { + self.bytes.extend_from_slice(&value.to_le_bytes()); + } + + fn bytes(&mut self, value: &[u8]) { + self.bytes.extend_from_slice(value); + } + + fn count(&mut self, count: usize, max: usize, field: &str) -> Result<(), String> { + validate_remote_count(count, max, field)?; + self.u16(count as u16); + Ok(()) + } + + fn string(&mut self, value: &str, max: usize, field: &str) -> Result<(), String> { + validate_remote_string(field, value, max)?; + let length = u16::try_from(value.len()).map_err(|_| format!("{field} is too long"))?; + self.u16(length); + self.bytes(value.as_bytes()); + Ok(()) + } + + fn blob(&mut self, value: &[u8], max: usize, field: &str) -> Result<(), String> { + if value.len() > max { + return Err(format!("{field} exceeds maximum length {max}")); + } + let length = u32::try_from(value.len()).map_err(|_| format!("{field} is too long"))?; + self.bytes.extend_from_slice(&length.to_le_bytes()); + self.bytes(value); + Ok(()) + } + + fn into_frame(self, frame_type: FrameType) -> Result { + if self.bytes.len() > MAX_FRAME_PAYLOAD { + return Err(format!("remote payload exceeds frame limit {MAX_FRAME_PAYLOAD}")); + } + Ok(Frame::new(frame_type, self.bytes)) + } +} + +struct WireReader<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> WireReader<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn take(&mut self, length: usize) -> Result<&'a [u8], String> { + let end = self.offset.checked_add(length).ok_or_else(|| "remote payload length overflow".to_string())?; + let value = self.bytes.get(self.offset..end).ok_or_else(|| "truncated remote payload".to_string())?; + self.offset = end; + Ok(value) + } + + fn magic(&mut self, expected: [u8; 4]) -> Result<(), String> { + if self.take(4)? != expected { + return Err("invalid remote payload magic".to_string()); + } + Ok(()) + } + + fn version(&mut self) -> Result<(), String> { + let version = self.u16()?; + if version != REMOTE_PROTOCOL_VERSION { + return Err(format!("unsupported remote protocol version: {version}")); + } + Ok(()) + } + + fn zero_reserved(&mut self) -> Result<(), String> { + if self.u8()? != 0 { + return Err("remote payload reserved byte is nonzero".to_string()); + } + Ok(()) + } + + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + fn u16(&mut self) -> Result { + let bytes = self.take(2)?; + Ok(u16::from_le_bytes([bytes[0], bytes[1]])) + } + + fn u64(&mut self) -> Result { + let bytes = self.take(8)?; + Ok(u64::from_le_bytes(bytes.try_into().map_err(|_| "invalid remote integer".to_string())?)) + } + + fn i32(&mut self) -> Result { + let bytes = self.take(4)?; + Ok(i32::from_le_bytes(bytes.try_into().map_err(|_| "invalid remote integer".to_string())?)) + } + + fn array16(&mut self) -> Result<[u8; 16], String> { + self.take(16)?.try_into().map_err(|_| "invalid remote identifier".to_string()) + } + + fn count(&mut self, max: usize, field: &str) -> Result { + let count = self.u16()? as usize; + validate_remote_count(count, max, field)?; + Ok(count) + } + + fn string(&mut self, max: usize, field: &str) -> Result { + let length = self.u16()? as usize; + if length > max { + return Err(format!("{field} exceeds maximum length {max}")); + } + let value = std::str::from_utf8(self.take(length)?).map_err(|_| format!("{field} is not valid UTF-8"))?; + validate_remote_string(field, value, max)?; + Ok(value.to_string()) + } + + fn blob(&mut self, max: usize, field: &str) -> Result, String> { + let bytes = self.take(4)?; + let length = u32::from_le_bytes(bytes.try_into().map_err(|_| "invalid remote blob length".to_string())?) as usize; + if length > max { + return Err(format!("{field} exceeds maximum length {max}")); + } + Ok(self.take(length)?.to_vec()) + } + + fn finish(self) -> Result<(), String> { + if self.offset == self.bytes.len() { + Ok(()) + } else { + Err("trailing bytes in remote payload".to_string()) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteErrorCode { + Failed = 1, +} + +impl RemoteErrorCode { + fn from_u16(value: u16) -> Result { + match value { + 1 => Ok(Self::Failed), + _ => Err(format!("unknown remote error code: {value}")), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteEventKind { + SyncProgress { completed_bytes: u64, total_bytes: Option }, + Stdout(Vec), + Stderr(Vec), + Error { code: RemoteErrorCode, message: String }, + Cancelled, + Completed { exit_code: i32 }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteEvent { + pub request_id: RequestId, + pub kind: RemoteEventKind, +} + +impl RemoteEvent { + pub fn to_frame(&self) -> Result { + let mut writer = WireWriter::new(*b"BBE1"); + writer.u16(REMOTE_PROTOCOL_VERSION); + writer.u8(match &self.kind { + RemoteEventKind::SyncProgress { .. } => 1, + RemoteEventKind::Stdout(_) => 2, + RemoteEventKind::Stderr(_) => 3, + RemoteEventKind::Error { .. } => 4, + RemoteEventKind::Cancelled => 5, + RemoteEventKind::Completed { .. } => 6, + }); + writer.u8(0); + writer.bytes(&self.request_id.0); + + match &self.kind { + RemoteEventKind::SyncProgress { completed_bytes, total_bytes } => { + writer.u64(*completed_bytes); + writer.u8(u8::from(total_bytes.is_some())); + if let Some(total_bytes) = total_bytes { + writer.u64(*total_bytes); + } + } + RemoteEventKind::Stdout(data) | RemoteEventKind::Stderr(data) => writer.blob(data, MAX_FRAME_PAYLOAD, "remote output")?, + RemoteEventKind::Error { code, message } => { + writer.u16(*code as u16); + writer.string(message, MAX_REMOTE_ERROR_BYTES, "remote error")?; + } + RemoteEventKind::Cancelled => {} + RemoteEventKind::Completed { exit_code } => writer.i32(*exit_code), + } + + writer.into_frame(FrameType::RemoteEvent) + } + + pub fn from_frame(frame: Frame) -> Result { + if !matches!(frame.frame_type, FrameType::RemoteEvent) { + return Err("expected RemoteEvent frame".to_string()); + } + + let mut reader = WireReader::new(&frame.payload); + reader.magic(*b"BBE1")?; + reader.version()?; + let event_kind = reader.u8()?; + reader.zero_reserved()?; + let request_id = RequestId(reader.array16()?); + let kind = match event_kind { + 1 => { + let completed_bytes = reader.u64()?; + let total_bytes = match reader.u8()? { + 0 => None, + 1 => Some(reader.u64()?), + value => return Err(format!("invalid remote progress total flag: {value}")), + }; + RemoteEventKind::SyncProgress { completed_bytes, total_bytes } + } + 2 => RemoteEventKind::Stdout(reader.blob(MAX_FRAME_PAYLOAD, "remote stdout")?), + 3 => RemoteEventKind::Stderr(reader.blob(MAX_FRAME_PAYLOAD, "remote stderr")?), + 4 => RemoteEventKind::Error { + code: RemoteErrorCode::from_u16(reader.u16()?)?, + message: reader.string(MAX_REMOTE_ERROR_BYTES, "remote error")?, + }, + 5 => RemoteEventKind::Cancelled, + 6 => RemoteEventKind::Completed { exit_code: reader.i32()? }, + value => return Err(format!("unknown remote event kind: {value}")), + }; + reader.finish()?; + Ok(Self { request_id, kind }) + } +} + pub fn validate_process_string(field: &str, value: &str) -> Result<(), String> { if value.as_bytes().contains(&0) { return Err(format!("{field} contains a NUL byte")); From 537bddb2d8938dbd631e0c1fd9a9af3c9c14f517 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 2 Aug 2026 01:36:52 +0200 Subject: [PATCH 07/25] Add remote operation proto UT --- src/vscomm/mod_ut.rs | 168 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/src/vscomm/mod_ut.rs b/src/vscomm/mod_ut.rs index f52e0a9..c9874e2 100644 --- a/src/vscomm/mod_ut.rs +++ b/src/vscomm/mod_ut.rs @@ -1,4 +1,37 @@ -use super::{validate_exec_request, ExecRequest, FrameType, TOOLCHAIN_PORT, TUI_STATUS_PORT}; +use super::*; + +fn ids() -> (RequestId, WorkspaceSessionId) { + (RequestId([1; 16]), WorkspaceSessionId([2; 16])) +} + +fn build(argv: Vec, env: Vec<(String, String)>) -> Result { + RemoteBuild::new(WorkspaceRelativePath::new("src").unwrap(), RemoteTool::new("make").unwrap(), argv, env) +} + +fn raw_build_frame(argv_count: u16, arg: Option<&str>, env_count: u16, env: Option<(&str, &str)>) -> Frame { + let mut writer = WireWriter::new(*b"BBR1"); + writer.u16(REMOTE_PROTOCOL_VERSION); + writer.u8(2); + writer.u8(0); + writer.bytes(&[1; 16]); + writer.bytes(&[2; 16]); + writer.u16(0); + writer.u16(4); + writer.bytes(b"make"); + writer.u16(argv_count); + if let Some(arg) = arg { + writer.u16(arg.len() as u16); + writer.bytes(arg.as_bytes()); + } + writer.u16(env_count); + if let Some((key, value)) = env { + writer.u16(key.len() as u16); + writer.bytes(key.as_bytes()); + writer.u16(value.len() as u16); + writer.bytes(value.as_bytes()); + } + writer.into_frame(FrameType::RemoteRequest).unwrap() +} #[test] fn execution_and_tui_channels_are_distinct() { @@ -33,3 +66,136 @@ fn accept_valid_cargo_request() { assert!(validate_exec_request(&request).is_ok()); } + +#[test] +fn remote_sync_round_trips() { + let (request_id, session_id) = ids(); + let request = RemoteRequest::sync(request_id, session_id); + let decoded = RemoteRequest::from_frame(request.to_frame().unwrap()).unwrap(); + assert_eq!(decoded, request); +} + +#[test] +fn remote_build_round_trips_structured_arguments_and_environment() { + let (request_id, session_id) = ids(); + let request = RemoteRequest::build( + request_id, + session_id, + build(vec!["release mode".into(), "$(not-a-shell-command)".into()], vec![("MODE".into(), "debug value".into())]).unwrap(), + ); + let decoded = RemoteRequest::from_frame(request.to_frame().unwrap()).unwrap(); + assert_eq!(decoded, request); +} + +#[test] +fn every_remote_event_round_trips() { + let request_id = ids().0; + let events = vec![ + RemoteEventKind::SyncProgress { completed_bytes: 4, total_bytes: Some(9) }, + RemoteEventKind::Stdout(b"out".to_vec()), + RemoteEventKind::Stderr(b"err".to_vec()), + RemoteEventKind::Error { code: RemoteErrorCode::Failed, message: "failed".into() }, + RemoteEventKind::Cancelled, + RemoteEventKind::Completed { exit_code: 17 }, + ]; + + for kind in events { + let event = RemoteEvent { request_id, kind }; + assert_eq!(RemoteEvent::from_frame(event.to_frame().unwrap()).unwrap(), event); + } +} + +#[test] +fn supported_remote_version_is_encoded() { + let frame = RemoteRequest::sync(ids().0, ids().1).to_frame().unwrap(); + assert_eq!(u16::from_le_bytes([frame.payload[4], frame.payload[5]]), REMOTE_PROTOCOL_VERSION); +} + +#[test] +fn unknown_remote_version_is_rejected() { + let mut frame = RemoteRequest::sync(ids().0, ids().1).to_frame().unwrap(); + frame.payload[4..6].copy_from_slice(&(REMOTE_PROTOCOL_VERSION + 1).to_le_bytes()); + assert!(RemoteRequest::from_frame(frame).unwrap_err().contains("unsupported remote protocol version")); +} + +#[test] +fn unknown_remote_operation_is_rejected() { + let mut frame = RemoteRequest::sync(ids().0, ids().1).to_frame().unwrap(); + frame.payload[6] = 99; + assert!(RemoteRequest::from_frame(frame).unwrap_err().contains("unknown remote operation")); +} + +#[test] +fn unknown_remote_event_kind_is_rejected() { + let event = RemoteEvent { request_id: ids().0, kind: RemoteEventKind::Cancelled }; + let mut frame = event.to_frame().unwrap(); + frame.payload[6] = 99; + assert!(RemoteEvent::from_frame(frame).unwrap_err().contains("unknown remote event kind")); +} + +#[test] +fn truncated_remote_payload_is_rejected() { + assert!(RemoteRequest::from_frame(Frame::new(FrameType::RemoteRequest, b"BBR1".to_vec())).is_err()); +} + +#[test] +fn oversized_remote_string_is_rejected() { + assert!(RemoteTool::new("x".repeat(MAX_REMOTE_TOOL_BYTES + 1)).is_err()); + let mut writer = WireWriter::new(*b"BBR1"); + writer.u16(REMOTE_PROTOCOL_VERSION); + writer.u8(2); + writer.u8(0); + writer.bytes(&[1; 16]); + writer.bytes(&[2; 16]); + writer.u16((MAX_REMOTE_STRING_BYTES + 1) as u16); + let frame = writer.into_frame(FrameType::RemoteRequest).unwrap(); + assert!(RemoteRequest::from_frame(frame).is_err()); +} + +#[test] +fn excessive_argv_count_is_rejected() { + assert!(build(vec!["arg".into(); MAX_REMOTE_ARG_COUNT + 1], Vec::new()).is_err()); + assert!(RemoteRequest::from_frame(raw_build_frame((MAX_REMOTE_ARG_COUNT + 1) as u16, None, 0, None)).is_err()); +} + +#[test] +fn oversized_individual_argument_is_rejected() { + assert!(build(vec!["x".repeat(MAX_REMOTE_ARG_BYTES + 1)], Vec::new()).is_err()); + assert!(RemoteRequest::from_frame(raw_build_frame(1, Some(&"x".repeat(MAX_REMOTE_ARG_BYTES + 1)), 0, None)).is_err()); +} + +#[test] +fn excessive_environment_count_is_rejected() { + let env = (0..MAX_REMOTE_ENV_COUNT + 1).map(|i| (format!("KEY{i}"), "value".into())).collect(); + assert!(build(Vec::new(), env).is_err()); + assert!(RemoteRequest::from_frame(raw_build_frame(0, None, (MAX_REMOTE_ENV_COUNT + 1) as u16, None)).is_err()); +} + +#[test] +fn oversized_environment_key_and_value_are_rejected() { + assert!(build(Vec::new(), vec![("K".repeat(MAX_REMOTE_ENV_KEY_BYTES + 1), "value".into())]).is_err()); + assert!(build(Vec::new(), vec![("KEY".into(), "V".repeat(MAX_REMOTE_ENV_VALUE_BYTES + 1))]).is_err()); + assert!(RemoteRequest::from_frame(raw_build_frame(0, None, 1, Some((&"K".repeat(MAX_REMOTE_ENV_KEY_BYTES + 1), "value")))).is_err()); + assert!(RemoteRequest::from_frame(raw_build_frame(0, None, 1, Some(("KEY", &"V".repeat(MAX_REMOTE_ENV_VALUE_BYTES + 1))))).is_err()); +} + +#[test] +fn invalid_remote_cwd_is_rejected() { + assert!(WorkspaceRelativePath::new("/absolute").is_err()); + assert!(WorkspaceRelativePath::new("foo/../bar").is_err()); + assert!(WorkspaceRelativePath::new("foo/./bar").is_err()); + assert!(WorkspaceRelativePath::new("foo//bar").is_err()); +} + +#[test] +fn existing_exec_request_wire_format_is_unchanged() { + let request = + ExecRequest { cwd: "/workspace".into(), command: "make".into(), args: vec!["release".into()], env: vec![("MODE".into(), "debug".into())] }; + let encoded = request.serialize(); + assert_eq!(encoded, b"/workspace\0make\0release\0\0MODE=debug\0\0"); + let decoded = ExecRequest::deserialize(&encoded).unwrap(); + assert_eq!(decoded.cwd, request.cwd); + assert_eq!(decoded.command, request.command); + assert_eq!(decoded.args, request.args); + assert_eq!(decoded.env, request.env); +} From 4b2ace81a05793a358d55031a0d833546d5e82ae Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 2 Aug 2026 17:53:58 +0200 Subject: [PATCH 08/25] Add remote auth and backend interfaces --- src/bin/bunkerbox-status.rs | 2 + src/bin/bunkerbox-vscomm.rs | 2 + src/daemon.rs | 74 +++++++++-- src/lib.rs | 1 + src/remote.rs | 258 ++++++++++++++++++++++++++++++++++++ src/vscomm/mod.rs | 30 +++++ 6 files changed, 353 insertions(+), 14 deletions(-) create mode 100644 src/remote.rs diff --git a/src/bin/bunkerbox-status.rs b/src/bin/bunkerbox-status.rs index 51833b9..2024646 100644 --- a/src/bin/bunkerbox-status.rs +++ b/src/bin/bunkerbox-status.rs @@ -1,3 +1,5 @@ +#[path = "../remote.rs"] +mod remote; #[path = "../vscomm/mod.rs"] mod vscomm; diff --git a/src/bin/bunkerbox-vscomm.rs b/src/bin/bunkerbox-vscomm.rs index 3cf906d..77fff63 100644 --- a/src/bin/bunkerbox-vscomm.rs +++ b/src/bin/bunkerbox-vscomm.rs @@ -1,3 +1,5 @@ +#[path = "../remote.rs"] +mod remote; #[path = "../vscomm/mod.rs"] mod vscomm; diff --git a/src/daemon.rs b/src/daemon.rs index 4640315..d95a5f9 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,6 +1,9 @@ use crate::cfg::EnvMode; use crate::logging; use crate::proxy::{FilterProxy, UnixProxyHandle}; +use crate::remote::{ + RemoteAuthorizationError, RemoteAuthorizationPolicy, RemoteBackend, RemoteBackendError, RemoteBackendEvent, RemoteExecutionContext, RemoteRequest, +}; use crate::sandbox::{resolve_profile, MergedProfile, NetworkMode}; use crate::vscomm::{validate_exec_request, validate_process_path, ExecRequest, Frame, FrameType, TOOLCHAIN_PORT}; use crate::workspace::WorkspaceCwd; @@ -17,8 +20,14 @@ use tokio::process::Command; const BWRAP_STATUS_FD: RawFd = 3; +#[derive(Clone, Copy)] +enum ProcessStream { + Stdout, + Stderr, +} + enum ChildEvent { - Output(FrameType, Vec), + Output(ProcessStream, Vec), StreamClosed, LauncherStarted, LauncherFailed(String), @@ -30,6 +39,36 @@ struct SandboxProxyConfig { netrelay_path: PathBuf, } +#[derive(Debug, PartialEq, Eq)] +pub enum RemoteDispatchError { + Unauthorized(RemoteAuthorizationError), + Backend(RemoteBackendError), + EventSinkClosed, +} + +pub struct RemoteBroker { + policy: RemoteAuthorizationPolicy, + context: RemoteExecutionContext, + backend: Arc, +} + +impl RemoteBroker { + pub fn new(policy: RemoteAuthorizationPolicy, context: RemoteExecutionContext, backend: Arc) -> Self { + Self { policy, context, backend } + } + + pub async fn dispatch(&self, request: RemoteRequest, events: tokio::sync::mpsc::Sender) -> Result<(), RemoteDispatchError> { + let authorized = self.policy.authorize(&self.context, request).map_err(RemoteDispatchError::Unauthorized)?; + match self.backend.execute(authorized, events.clone()).await { + Ok(()) => Ok(()), + Err(error) => { + events.send(error.event()).await.map_err(|_| RemoteDispatchError::EventSinkClosed)?; + Err(RemoteDispatchError::Backend(error)) + } + } + } +} + struct VsockSession { passthrough: Arc>, env_mode: EnvMode, @@ -209,8 +248,8 @@ async fn execute_request(writer: &mut W, session: &Vso let child_stderr = child.stderr.take().ok_or_else(|| "no stderr".to_string())?; let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); - let stdout_task = tokio::spawn(pump_to_channel(child_stdout, FrameType::Stdout, event_tx.clone())); - let stderr_task = tokio::spawn(pump_to_channel(child_stderr, FrameType::Stderr, event_tx.clone())); + let stdout_task = tokio::spawn(pump_to_channel(child_stdout, ProcessStream::Stdout, event_tx.clone())); + let stderr_task = tokio::spawn(pump_to_channel(child_stderr, ProcessStream::Stderr, event_tx.clone())); let status_task = status_reader.map(|reader| { let status_tx = event_tx.clone(); tokio::task::spawn_blocking(move || monitor_bwrap_status(reader, status_tx)) @@ -224,26 +263,26 @@ async fn execute_request(writer: &mut W, session: &Vso while closed_streams < 2 || (session.merged_profile.is_some() && !launcher_started && !launcher_failed) { let Some(event) = event_rx.recv().await else { break }; match event { - ChildEvent::Output(frame_type, data) if launcher_failed => { - let stream = if matches!(frame_type, FrameType::Stdout) { "bwrap stdout" } else { "bwrap stderr" }; + ChildEvent::Output(stream_kind, data) if launcher_failed => { + let stream = if matches!(stream_kind, ProcessStream::Stdout) { "bwrap stdout" } else { "bwrap stderr" }; logging::diagnostic_bytes(stream, &data); } - ChildEvent::Output(frame_type, data) if launcher_started => { - write_frame(writer, &Frame::new(frame_type, data)).await?; + ChildEvent::Output(stream_kind, data) if launcher_started => { + write_frame(writer, &Frame::new(process_stream_frame_type(stream_kind), data)).await?; } - ChildEvent::Output(frame_type, data) => buffered_output.push((frame_type, data)), + ChildEvent::Output(stream_kind, data) => buffered_output.push((stream_kind, data)), ChildEvent::StreamClosed => closed_streams += 1, ChildEvent::LauncherStarted => { launcher_started = true; - for (frame_type, data) in buffered_output.drain(..) { - write_frame(writer, &Frame::new(frame_type, data)).await?; + for (stream_kind, data) in buffered_output.drain(..) { + write_frame(writer, &Frame::new(process_stream_frame_type(stream_kind), data)).await?; } } ChildEvent::LauncherFailed(err) => { launcher_failed = true; logging::diagnostic(&format!("bwrap setup failed: {err}")); - for (frame_type, data) in buffered_output.drain(..) { - let stream = if matches!(frame_type, FrameType::Stdout) { "bwrap stdout" } else { "bwrap stderr" }; + for (stream_kind, data) in buffered_output.drain(..) { + let stream = if matches!(stream_kind, ProcessStream::Stdout) { "bwrap stdout" } else { "bwrap stderr" }; logging::diagnostic_bytes(stream, &data); } } @@ -514,13 +553,13 @@ fn monitor_bwrap_status(reader: File, tx: tokio::sync::mpsc::UnboundedSender(mut reader: R, frame_type: FrameType, tx: tokio::sync::mpsc::UnboundedSender) { +async fn pump_to_channel(mut reader: R, stream_kind: ProcessStream, tx: tokio::sync::mpsc::UnboundedSender) { let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf).await { Ok(0) => break, Ok(n) => { - if tx.send(ChildEvent::Output(frame_type, buf[..n].to_vec())).is_err() { + if tx.send(ChildEvent::Output(stream_kind, buf[..n].to_vec())).is_err() { return; } } @@ -530,6 +569,13 @@ async fn pump_to_channel(mut reader: R, frame_type: Fra let _ = tx.send(ChildEvent::StreamClosed); } +fn process_stream_frame_type(stream: ProcessStream) -> FrameType { + match stream { + ProcessStream::Stdout => FrameType::Stdout, + ProcessStream::Stderr => FrameType::Stderr, + } +} + async fn write_frame(writer: &mut W, frame: &Frame) -> Result<(), String> { frame.write_async(writer).await.map_err(|e| format!("write frame: {e}")) } diff --git a/src/lib.rs b/src/lib.rs index 2433330..b0e64d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod logging; pub mod netrelay; pub mod overlay; pub mod proxy; +pub mod remote; pub mod sandbox; pub mod tui; pub mod vscomm; diff --git a/src/remote.rs b/src/remote.rs new file mode 100644 index 0000000..4cdfb6c --- /dev/null +++ b/src/remote.rs @@ -0,0 +1,258 @@ +#![allow(dead_code)] + +use std::future::Future; +use std::pin::Pin; +use tokio::sync::mpsc; + +pub const MAX_REMOTE_STRING_BYTES: usize = 4 * 1024; +pub const MAX_REMOTE_TOOL_BYTES: usize = 256; +pub const MAX_REMOTE_ARG_COUNT: usize = 256; +pub const MAX_REMOTE_ARG_BYTES: usize = 4 * 1024; +pub const MAX_REMOTE_ENV_COUNT: usize = 64; +pub const MAX_REMOTE_ENV_KEY_BYTES: usize = 256; +pub const MAX_REMOTE_ENV_VALUE_BYTES: usize = 4 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RequestId(pub [u8; 16]); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WorkspaceSessionId(pub [u8; 16]); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteTargetId(pub [u8; 16]); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceRelativePath(String); + +impl WorkspaceRelativePath { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_string("remote cwd", &value, MAX_REMOTE_STRING_BYTES)?; + if value.is_empty() { + return Ok(Self(value)); + } + + if value.starts_with('/') || value.split('/').any(|part| part.is_empty() || part == "." || part == "..") { + return Err("remote cwd must be a normalized relative path".to_string()); + } + + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteTool(String); + +impl RemoteTool { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_string("remote tool", &value, MAX_REMOTE_TOOL_BYTES)?; + if value.is_empty() { + return Err("remote tool is empty".to_string()); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteBuild { + cwd: WorkspaceRelativePath, + tool: RemoteTool, + argv: Vec, + env: Vec<(String, String)>, +} + +impl RemoteBuild { + pub fn new(cwd: WorkspaceRelativePath, tool: RemoteTool, argv: Vec, env: Vec<(String, String)>) -> Result { + validate_count(argv.len(), MAX_REMOTE_ARG_COUNT, "remote argv")?; + argv.iter().try_for_each(|arg| validate_string("remote argument", arg, MAX_REMOTE_ARG_BYTES))?; + validate_count(env.len(), MAX_REMOTE_ENV_COUNT, "remote environment")?; + env.iter().try_for_each(|(key, value)| { + validate_string("remote environment key", key, MAX_REMOTE_ENV_KEY_BYTES)?; + if key.is_empty() || key.contains('=') { + return Err("remote environment key is invalid".to_string()); + } + validate_string("remote environment value", value, MAX_REMOTE_ENV_VALUE_BYTES) + })?; + Ok(Self { cwd, tool, argv, env }) + } + + pub fn cwd(&self) -> &WorkspaceRelativePath { + &self.cwd + } + + pub fn tool(&self) -> &RemoteTool { + &self.tool + } + + pub fn argv(&self) -> &[String] { + &self.argv + } + + pub fn env(&self) -> &[(String, String)] { + &self.env + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteOperation { + Sync, + Build(RemoteBuild), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteRequest { + request_id: RequestId, + workspace_session_id: WorkspaceSessionId, + operation: RemoteOperation, +} + +impl RemoteRequest { + pub fn sync(request_id: RequestId, workspace_session_id: WorkspaceSessionId) -> Self { + Self { request_id, workspace_session_id, operation: RemoteOperation::Sync } + } + + pub fn build(request_id: RequestId, workspace_session_id: WorkspaceSessionId, build: RemoteBuild) -> Self { + Self { request_id, workspace_session_id, operation: RemoteOperation::Build(build) } + } + + pub fn request_id(&self) -> RequestId { + self.request_id + } + + pub fn workspace_session_id(&self) -> WorkspaceSessionId { + self.workspace_session_id + } + + pub fn operation(&self) -> &RemoteOperation { + &self.operation + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteExecutionContext { + pub target: RemoteTargetId, + pub workspace_session_id: WorkspaceSessionId, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorizedRemoteRequest { + request: RemoteRequest, + target: RemoteTargetId, +} + +impl AuthorizedRemoteRequest { + pub fn request_id(&self) -> RequestId { + self.request.request_id + } + + pub fn target(&self) -> RemoteTargetId { + self.target + } + + pub fn request(&self) -> &RemoteRequest { + &self.request + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteAuthorizationError { + SessionMismatch, + TargetNotAllowed, + ToolNotAllowed(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteAuthorizationPolicy { + allowed_target: RemoteTargetId, + allowed_session: WorkspaceSessionId, + allowed_tools: Vec, +} + +impl RemoteAuthorizationPolicy { + pub fn new(allowed_target: RemoteTargetId, allowed_session: WorkspaceSessionId, allowed_tools: Vec) -> Self { + Self { allowed_target, allowed_session, allowed_tools } + } + + pub fn authorize(&self, context: &RemoteExecutionContext, request: RemoteRequest) -> Result { + if context.workspace_session_id != self.allowed_session || request.workspace_session_id != context.workspace_session_id { + return Err(RemoteAuthorizationError::SessionMismatch); + } + if context.target != self.allowed_target { + return Err(RemoteAuthorizationError::TargetNotAllowed); + } + + if let RemoteOperation::Build(build) = request.operation() { + if !self.allowed_tools.iter().any(|tool| tool == build.tool().as_str()) { + return Err(RemoteAuthorizationError::ToolNotAllowed(build.tool().as_str().to_string())); + } + } + + Ok(AuthorizedRemoteRequest { request, target: context.target }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteBackendEvent { + SyncProgress { completed_bytes: u64, total_bytes: Option }, + Stdout(Vec), + Stderr(Vec), + Error { message: String }, + Cancelled, + Completed { exit_code: i32 }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteBackendError { + Failed(String), + Spawn(String), + Timeout, + Cancelled, +} + +impl RemoteBackendError { + pub fn event(&self) -> RemoteBackendEvent { + match self { + Self::Failed(message) | Self::Spawn(message) => RemoteBackendEvent::Error { message: message.clone() }, + Self::Timeout => RemoteBackendEvent::Error { message: "remote backend timed out".to_string() }, + Self::Cancelled => RemoteBackendEvent::Cancelled, + } + } +} + +pub type RemoteFuture<'a, T> = Pin + Send + 'a>>; + +pub trait RemoteBackend: Send + Sync { + fn execute<'a>( + &'a self, request: AuthorizedRemoteRequest, events: mpsc::Sender, + ) -> RemoteFuture<'a, Result<(), RemoteBackendError>>; +} + +fn validate_string(field: &str, value: &str, max: usize) -> Result<(), String> { + if value.as_bytes().contains(&0) { + return Err(format!("{field} contains a NUL byte")); + } + if value.len() > max { + return Err(format!("{field} exceeds maximum length {max}")); + } + Ok(()) +} + +fn validate_count(count: usize, max: usize, field: &str) -> Result<(), String> { + if count > max { + return Err(format!("{field} exceeds maximum count {max}")); + } + Ok(()) +} + +#[cfg(test)] +#[path = "remote_ut.rs"] +mod tests; diff --git a/src/vscomm/mod.rs b/src/vscomm/mod.rs index be54f54..71f3474 100644 --- a/src/vscomm/mod.rs +++ b/src/vscomm/mod.rs @@ -5,6 +5,7 @@ use std::ffi::OsStr; use std::io::{self, Read, Write}; use std::path::Path; +use crate::remote as remote_domain; #[cfg(unix)] use std::os::unix::ffi::OsStrExt; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; @@ -189,6 +190,20 @@ impl RemoteRequest { reader.finish()?; Ok(request) } + + pub fn into_domain(self) -> Result { + let request_id = remote_domain::RequestId(self.request_id.0); + let session_id = remote_domain::WorkspaceSessionId(self.workspace_session_id.0); + match self.operation { + RemoteOperation::Sync(_) => Ok(remote_domain::RemoteRequest::sync(request_id, session_id)), + RemoteOperation::Build(build) => { + let cwd = remote_domain::WorkspaceRelativePath::new(build.cwd.as_str())?; + let tool = remote_domain::RemoteTool::new(build.tool.as_str())?; + let build = remote_domain::RemoteBuild::new(cwd, tool, build.argv, build.env)?; + Ok(remote_domain::RemoteRequest::build(request_id, session_id, build)) + } + } + } } fn encode_remote_build(writer: &mut WireWriter, build: &RemoteBuild) -> Result<(), String> { @@ -440,6 +455,21 @@ pub struct RemoteEvent { } impl RemoteEvent { + pub fn from_backend_event(request_id: remote_domain::RequestId, event: remote_domain::RemoteBackendEvent) -> Self { + let request_id = RequestId(request_id.0); + let kind = match event { + remote_domain::RemoteBackendEvent::SyncProgress { completed_bytes, total_bytes } => { + RemoteEventKind::SyncProgress { completed_bytes, total_bytes } + } + remote_domain::RemoteBackendEvent::Stdout(data) => RemoteEventKind::Stdout(data), + remote_domain::RemoteBackendEvent::Stderr(data) => RemoteEventKind::Stderr(data), + remote_domain::RemoteBackendEvent::Error { message } => RemoteEventKind::Error { code: RemoteErrorCode::Failed, message }, + remote_domain::RemoteBackendEvent::Cancelled => RemoteEventKind::Cancelled, + remote_domain::RemoteBackendEvent::Completed { exit_code } => RemoteEventKind::Completed { exit_code }, + }; + Self { request_id, kind } + } + pub fn to_frame(&self) -> Result { let mut writer = WireWriter::new(*b"BBE1"); writer.u16(REMOTE_PROTOCOL_VERSION); From ffecebc7505a1ea5879be0c8a1d6b16befc255a4 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 2 Aug 2026 17:54:08 +0200 Subject: [PATCH 09/25] Add Unit tests for the remote auth --- src/remote_ut.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ src/vscomm/mod_ut.rs | 24 ++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/remote_ut.rs diff --git a/src/remote_ut.rs b/src/remote_ut.rs new file mode 100644 index 0000000..4066e22 --- /dev/null +++ b/src/remote_ut.rs @@ -0,0 +1,43 @@ +use super::*; + +fn request(tool: &str) -> RemoteRequest { + RemoteRequest::build( + RequestId([1; 16]), + WorkspaceSessionId([2; 16]), + RemoteBuild::new( + WorkspaceRelativePath::new("src").unwrap(), + RemoteTool::new(tool).unwrap(), + vec!["build".into()], + vec![("MODE".into(), "debug".into())], + ) + .unwrap(), + ) +} + +fn context() -> RemoteExecutionContext { + RemoteExecutionContext { target: RemoteTargetId([3; 16]), workspace_session_id: WorkspaceSessionId([2; 16]) } +} + +#[test] +fn policy_authorizes_typed_request() { + let policy = RemoteAuthorizationPolicy::new(RemoteTargetId([3; 16]), WorkspaceSessionId([2; 16]), vec!["make".into()]); + let authorized = policy.authorize(&context(), request("make")).unwrap(); + + assert_eq!(authorized.request_id(), RequestId([1; 16])); + assert_eq!(authorized.target(), RemoteTargetId([3; 16])); + assert_eq!(authorized.request().workspace_session_id, WorkspaceSessionId([2; 16])); +} + +#[test] +fn policy_rejects_unapproved_tool() { + let policy = RemoteAuthorizationPolicy::new(RemoteTargetId([3; 16]), WorkspaceSessionId([2; 16]), vec!["cargo".into()]); + + assert_eq!(policy.authorize(&context(), request("make")), Err(RemoteAuthorizationError::ToolNotAllowed("make".into()))); +} + +#[test] +fn backend_errors_have_typed_events() { + assert_eq!(RemoteBackendError::Spawn("could not start".into()).event(), RemoteBackendEvent::Error { message: "could not start".into() }); + assert_eq!(RemoteBackendError::Timeout.event(), RemoteBackendEvent::Error { message: "remote backend timed out".into() }); + assert_eq!(RemoteBackendError::Cancelled.event(), RemoteBackendEvent::Cancelled); +} diff --git a/src/vscomm/mod_ut.rs b/src/vscomm/mod_ut.rs index c9874e2..a4bac24 100644 --- a/src/vscomm/mod_ut.rs +++ b/src/vscomm/mod_ut.rs @@ -199,3 +199,27 @@ fn existing_exec_request_wire_format_is_unchanged() { assert_eq!(decoded.args, request.args); assert_eq!(decoded.env, request.env); } + +#[test] +fn protocol_request_converts_to_transport_independent_domain_request() { + let request = RemoteRequest::build(ids().0, ids().1, build(vec!["--release".into()], vec![("MODE".into(), "debug".into())]).unwrap()); + let domain = request.into_domain().unwrap(); + + assert_eq!(domain.request_id(), crate::remote::RequestId([1; 16])); + let crate::remote::RemoteOperation::Build(build) = domain.operation() else { panic!("expected build") }; + assert_eq!(build.cwd().as_str(), "src"); + assert_eq!(build.tool().as_str(), "make"); + assert_eq!(build.argv(), ["--release"]); + assert_eq!(build.env(), [("MODE".into(), "debug".into())]); +} + +#[test] +fn backend_events_convert_to_protocol_events_without_transport_in_backend() { + let request_id = crate::remote::RequestId([7; 16]); + let event = RemoteEvent::from_backend_event(request_id, crate::remote::RemoteBackendEvent::Stdout(b"out".to_vec())); + assert_eq!(event.request_id, RequestId([7; 16])); + assert_eq!(event.kind, RemoteEventKind::Stdout(b"out".to_vec())); + + let event = RemoteEvent::from_backend_event(request_id, crate::remote::RemoteBackendEvent::Completed { exit_code: 3 }); + assert_eq!(event.kind, RemoteEventKind::Completed { exit_code: 3 }); +} From e2c08137c1fa06ecb09b42052742104f03d2a8f3 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 2 Aug 2026 21:02:26 +0200 Subject: [PATCH 10/25] Add fake remote backend and tests --- src/bin/bunkerbox-vscomm.rs | 36 +++++++++++++++++- src/bunkerbox-vscomm_ut.rs | 75 ++++++++++++++++++++++++++++++++++++- src/daemon.rs | 64 +++++++++++++++++++++++++------ src/remote.rs | 6 +++ 4 files changed, 168 insertions(+), 13 deletions(-) diff --git a/src/bin/bunkerbox-vscomm.rs b/src/bin/bunkerbox-vscomm.rs index 77fff63..9ea316e 100644 --- a/src/bin/bunkerbox-vscomm.rs +++ b/src/bin/bunkerbox-vscomm.rs @@ -10,7 +10,10 @@ use std::mem; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use vscomm::{encode_ui_payload, validate_exec_request, ExecRequest, Frame, FrameType, TOOLCHAIN_PORT, TUI_STATUS_PORT, VSCOMM_BIN_DIR}; +use vscomm::{ + encode_ui_payload, validate_exec_request, ExecRequest, Frame, FrameType, RemoteBuild, RemoteEvent, RemoteEventKind, RemoteRequest, RemoteTool, + RequestId, WorkspaceRelativePath, WorkspaceSessionId, TOOLCHAIN_PORT, TUI_STATUS_PORT, VSCOMM_BIN_DIR, +}; const HOST_CID: u32 = 2; @@ -85,6 +88,37 @@ fn handle_response_to(response: Frame, stdout: &mut WO } } +pub fn remote_sync_request(request_id: RequestId, session_id: WorkspaceSessionId) -> RemoteRequest { + RemoteRequest::sync(request_id, session_id) +} + +pub fn remote_build_request( + request_id: RequestId, session_id: WorkspaceSessionId, cwd: impl Into, tool: impl Into, argv: Vec, + env: Vec<(String, String)>, +) -> Result { + let build = RemoteBuild::new(WorkspaceRelativePath::new(cwd)?, RemoteTool::new(tool)?, argv, env)?; + Ok(RemoteRequest::build(request_id, session_id, build)) +} + +pub fn execute_remote_request_to( + stream: &mut S, request: RemoteRequest, stdout: &mut WOut, stderr: &mut WErr, +) -> Result { + request.to_frame()?.write(stream).map_err(|e| format!("send remote request: {e}"))?; + + loop { + let frame = Frame::read(stream).map_err(|e| format!("read remote event: {e}"))?; + let event = RemoteEvent::from_frame(frame)?; + match event.kind { + RemoteEventKind::SyncProgress { .. } => {} + RemoteEventKind::Stdout(data) => stdout.write_all(&data).map_err(|e| format!("stdout: {e}"))?, + RemoteEventKind::Stderr(data) => stderr.write_all(&data).map_err(|e| format!("stderr: {e}"))?, + RemoteEventKind::Error { message, .. } => return Err(message), + RemoteEventKind::Cancelled => return Err("remote operation cancelled".to_string()), + RemoteEventKind::Completed { exit_code } => return Ok(exit_code), + } + } +} + fn notify_tui_error(message: &str) { let Ok(mut stream) = vsock_connect(HOST_CID, TUI_STATUS_PORT) else { return; diff --git a/src/bunkerbox-vscomm_ut.rs b/src/bunkerbox-vscomm_ut.rs index 9ddee4e..71a4d76 100644 --- a/src/bunkerbox-vscomm_ut.rs +++ b/src/bunkerbox-vscomm_ut.rs @@ -1,4 +1,38 @@ -use super::{handle_response, handle_response_to, Frame, FrameType}; +use super::vscomm::{RemoteEvent, RemoteEventKind, RequestId, WorkspaceSessionId}; +use super::{execute_remote_request_to, handle_response, handle_response_to, remote_build_request, Frame, FrameType}; +use std::io::{self, Read, Write}; + +struct MemoryStream { + input: io::Cursor>, + output: Vec, +} + +impl MemoryStream { + fn new(frames: Vec) -> Self { + let mut input = Vec::new(); + for frame in frames { + frame.write(&mut input).unwrap(); + } + Self { input: io::Cursor::new(input), output: Vec::new() } + } +} + +impl Read for MemoryStream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.input.read(buf) + } +} + +impl Write for MemoryStream { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.output.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} #[test] fn forward_stdout_and_stderr_frames() { @@ -15,3 +49,42 @@ fn forward_stdout_and_stderr_frames() { fn preserve_exit_status() { assert_eq!(handle_response(Frame::new(FrameType::Exit, (-17i32).to_le_bytes().to_vec())).unwrap(), Some(-17)); } + +#[test] +fn explicit_remote_client_preserves_streams_status_and_request_id() { + let request_id = RequestId([9; 16]); + let request = remote_build_request(request_id, WorkspaceSessionId([8; 16]), "src", "make", vec!["release mode".into()], vec![]).unwrap(); + let responses = vec![ + RemoteEvent { request_id, kind: RemoteEventKind::Stdout(b"out".to_vec()) }.to_frame().unwrap(), + RemoteEvent { request_id, kind: RemoteEventKind::Stderr(b"err".to_vec()) }.to_frame().unwrap(), + RemoteEvent { request_id, kind: RemoteEventKind::Completed { exit_code: 23 } }.to_frame().unwrap(), + ]; + let mut stream = MemoryStream::new(responses); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + assert_eq!(execute_remote_request_to(&mut stream, request, &mut stdout, &mut stderr).unwrap(), 23); + assert_eq!(stdout, b"out"); + assert_eq!(stderr, b"err"); + + let sent = Frame::read(&mut io::Cursor::new(stream.output)).unwrap(); + let decoded = super::vscomm::RemoteRequest::from_frame(sent).unwrap(); + assert_eq!(decoded.request_id, request_id); + let super::vscomm::RemoteOperation::Build(build) = decoded.operation else { panic!("expected build") }; + assert_eq!(build.cwd.as_str(), "src"); + assert_eq!(build.argv, ["release mode"]); +} + +#[test] +fn explicit_remote_client_returns_remote_failure_without_local_fallback() { + let request_id = RequestId([4; 16]); + let request = super::remote_sync_request(request_id, WorkspaceSessionId([5; 16])); + let response = RemoteEvent { + request_id, + kind: RemoteEventKind::Error { code: super::vscomm::RemoteErrorCode::Failed, message: "backend unavailable".into() }, + }; + let mut stream = MemoryStream::new(vec![response.to_frame().unwrap()]); + + let error = execute_remote_request_to(&mut stream, request, &mut Vec::new(), &mut Vec::new()).unwrap_err(); + assert_eq!(error, "backend unavailable"); +} diff --git a/src/daemon.rs b/src/daemon.rs index d95a5f9..e296d8b 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -58,7 +58,13 @@ impl RemoteBroker { } pub async fn dispatch(&self, request: RemoteRequest, events: tokio::sync::mpsc::Sender) -> Result<(), RemoteDispatchError> { - let authorized = self.policy.authorize(&self.context, request).map_err(RemoteDispatchError::Unauthorized)?; + let authorized = match self.policy.authorize(&self.context, request) { + Ok(authorized) => authorized, + Err(error) => { + events.send(error.event()).await.map_err(|_| RemoteDispatchError::EventSinkClosed)?; + return Err(RemoteDispatchError::Unauthorized(error)); + } + }; match self.backend.execute(authorized, events.clone()).await { Ok(()) => Ok(()), Err(error) => { @@ -69,12 +75,23 @@ impl RemoteBroker { } } +struct UnavailableRemoteBackend; + +impl RemoteBackend for UnavailableRemoteBackend { + fn execute<'a>( + &'a self, _request: crate::remote::AuthorizedRemoteRequest, _events: tokio::sync::mpsc::Sender, + ) -> crate::remote::RemoteFuture<'a, Result<(), RemoteBackendError>> { + Box::pin(async { Err(RemoteBackendError::Failed("remote backend is unavailable".to_string())) }) + } +} + struct VsockSession { passthrough: Arc>, env_mode: EnvMode, workspace: PathBuf, merged_profile: Option>, proxy_config: Option>, + remote_broker: Arc, } pub struct VsockDaemon { @@ -112,6 +129,7 @@ impl VsockDaemon { if merged_profile.is_some() && !allow.is_empty() { let rt = tokio::runtime::Handle::current(); + let netrelay_path = find_netrelay_binary()?; let netrelay_path = find_netrelay_binary()?; let dir = make_proxy_runtime_dir()?; @@ -125,12 +143,23 @@ impl VsockDaemon { proxy_config = Some(SandboxProxyConfig { socket_path, netrelay_path }); } + let remote_target = crate::remote::RemoteTargetId([0; 16]); + let remote_session = crate::remote::WorkspaceSessionId([0; 16]); + let remote_policy = RemoteAuthorizationPolicy::new(remote_target, remote_session, Vec::new()); + let remote_backend: Arc = Arc::new(UnavailableRemoteBackend); + let remote_broker = Arc::new(RemoteBroker::new( + remote_policy, + RemoteExecutionContext { target: remote_target, workspace_session_id: remote_session }, + remote_backend, + )); + let session = Arc::new(VsockSession { passthrough: Arc::new(passthrough), env_mode, workspace, merged_profile, proxy_config: proxy_config.map(Arc::new), + remote_broker, }); let listener = tokio_vsock::VsockListener::bind(tokio_vsock::VsockAddr::new(libc::VMADDR_CID_ANY, TOOLCHAIN_PORT)).map_err(|e| { @@ -197,7 +226,14 @@ async fn daemon_loop( async fn handle_connection(stream: tokio_vsock::VsockStream, session: &VsockSession) -> Result<(), String> { let (mut reader, mut writer) = tokio::io::split(stream); - let req = read_exec_request(&mut reader).await?; + let frame = Frame::read_async(&mut reader).await.map_err(|e| format!("read frame: {e}"))?; + if matches!(frame.frame_type, FrameType::RemoteRequest) { + return dispatch_remote_frame(frame, &session.remote_broker, &mut writer).await; + } + if !matches!(frame.frame_type, FrameType::ExecReq) { + return Err(format!("expected ExecReq or RemoteRequest, got {:?}", frame.frame_type as u16)); + } + let req = ExecRequest::deserialize(&frame.payload)?; if let Err(err) = validate_exec_request(&req) { logging::diagnostic(&format!("bunkerbox-vscomm: invalid request: {err}")); @@ -221,6 +257,21 @@ async fn handle_connection(stream: tokio_vsock::VsockStream, session: &VsockSess Ok(()) } +pub async fn dispatch_remote_frame(frame: Frame, broker: &RemoteBroker, writer: &mut W) -> Result<(), String> { + let request = crate::vscomm::RemoteRequest::from_frame(frame).map_err(|err| format!("decode remote request: {err}"))?.into_domain()?; + let request_id = request.request_id(); + let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(64); + let dispatch_result = broker.dispatch(request, event_tx).await; + + while let Some(event) = event_rx.recv().await { + let response = + crate::vscomm::RemoteEvent::from_backend_event(request_id, event).to_frame().map_err(|err| format!("encode remote event: {err}"))?; + write_frame(writer, &response).await?; + } + + dispatch_result.map_err(|err| format!("remote dispatch failed: {err:?}")) +} + async fn execute_request(writer: &mut W, session: &VsockSession, req: &ExecRequest) -> Result<(), String> { validate_exec_request(req)?; let cwd = WorkspaceCwd::resolve(&session.workspace, Path::new(&req.cwd))?; @@ -480,15 +531,6 @@ fn is_allowed(passthrough: &[String], command: &str, args: &[String]) -> bool { false } -async fn read_exec_request(reader: &mut R) -> Result { - let frame = Frame::read_async(reader).await.map_err(|e| format!("read frame: {e}"))?; - if !matches!(frame.frame_type, FrameType::ExecReq) { - return Err(format!("expected ExecReq, got {:?}", frame.frame_type as u16)); - } - - ExecRequest::deserialize(&frame.payload) -} - fn bwrap_status_pipe() -> Result<(File, File), String> { let mut fds = [-1; 2]; if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 { diff --git a/src/remote.rs b/src/remote.rs index 4cdfb6c..eef8337 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -170,6 +170,12 @@ pub enum RemoteAuthorizationError { ToolNotAllowed(String), } +impl RemoteAuthorizationError { + pub fn event(&self) -> RemoteBackendEvent { + RemoteBackendEvent::Error { message: format!("remote authorization rejected: {self:?}") } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemoteAuthorizationPolicy { allowed_target: RemoteTargetId, From bc1f3a8aa3d968f5450791e50c45dbbea84e485a Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 2 Aug 2026 21:10:25 +0200 Subject: [PATCH 11/25] Add concurrent backend streaming --- Cargo.toml | 2 +- src/bin/bunkerbox-vscomm.rs | 14 +++++++++++-- src/bunkerbox-vscomm_ut.rs | 39 +++++++++++++++++++++++++++++++++---- src/daemon.rs | 31 ++++++++++++++++++++++------- 4 files changed, 72 insertions(+), 14 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4ccabc4..ea873e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" sha2 = "0.10" -tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "sync", "io-util", "process"] } +tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "sync", "io-util", "process", "time"] } tokio-vsock = "0.7" ratatui = "0.30" crossterm = "0.28" diff --git a/src/bin/bunkerbox-vscomm.rs b/src/bin/bunkerbox-vscomm.rs index 9ea316e..6382fae 100644 --- a/src/bin/bunkerbox-vscomm.rs +++ b/src/bin/bunkerbox-vscomm.rs @@ -103,15 +103,25 @@ pub fn remote_build_request( pub fn execute_remote_request_to( stream: &mut S, request: RemoteRequest, stdout: &mut WOut, stderr: &mut WErr, ) -> Result { + let request_id = request.request_id; request.to_frame()?.write(stream).map_err(|e| format!("send remote request: {e}"))?; loop { let frame = Frame::read(stream).map_err(|e| format!("read remote event: {e}"))?; let event = RemoteEvent::from_frame(frame)?; + if event.request_id != request_id { + return Err("remote event request ID mismatch".to_string()); + } match event.kind { RemoteEventKind::SyncProgress { .. } => {} - RemoteEventKind::Stdout(data) => stdout.write_all(&data).map_err(|e| format!("stdout: {e}"))?, - RemoteEventKind::Stderr(data) => stderr.write_all(&data).map_err(|e| format!("stderr: {e}"))?, + RemoteEventKind::Stdout(data) => { + stdout.write_all(&data).map_err(|e| format!("stdout: {e}"))?; + stdout.flush().map_err(|e| format!("flush stdout: {e}"))?; + } + RemoteEventKind::Stderr(data) => { + stderr.write_all(&data).map_err(|e| format!("stderr: {e}"))?; + stderr.flush().map_err(|e| format!("flush stderr: {e}"))?; + } RemoteEventKind::Error { message, .. } => return Err(message), RemoteEventKind::Cancelled => return Err("remote operation cancelled".to_string()), RemoteEventKind::Completed { exit_code } => return Ok(exit_code), diff --git a/src/bunkerbox-vscomm_ut.rs b/src/bunkerbox-vscomm_ut.rs index 71a4d76..1b0c815 100644 --- a/src/bunkerbox-vscomm_ut.rs +++ b/src/bunkerbox-vscomm_ut.rs @@ -7,6 +7,23 @@ struct MemoryStream { output: Vec, } +struct FlushWriter { + bytes: Vec, + flushes: usize, +} + +impl Write for FlushWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.bytes.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.flushes += 1; + Ok(()) + } +} + impl MemoryStream { fn new(frames: Vec) -> Self { let mut input = Vec::new(); @@ -60,12 +77,14 @@ fn explicit_remote_client_preserves_streams_status_and_request_id() { RemoteEvent { request_id, kind: RemoteEventKind::Completed { exit_code: 23 } }.to_frame().unwrap(), ]; let mut stream = MemoryStream::new(responses); - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); + let mut stdout = FlushWriter { bytes: Vec::new(), flushes: 0 }; + let mut stderr = FlushWriter { bytes: Vec::new(), flushes: 0 }; assert_eq!(execute_remote_request_to(&mut stream, request, &mut stdout, &mut stderr).unwrap(), 23); - assert_eq!(stdout, b"out"); - assert_eq!(stderr, b"err"); + assert_eq!(stdout.bytes, b"out"); + assert_eq!(stderr.bytes, b"err"); + assert_eq!(stdout.flushes, 1); + assert_eq!(stderr.flushes, 1); let sent = Frame::read(&mut io::Cursor::new(stream.output)).unwrap(); let decoded = super::vscomm::RemoteRequest::from_frame(sent).unwrap(); @@ -88,3 +107,15 @@ fn explicit_remote_client_returns_remote_failure_without_local_fallback() { let error = execute_remote_request_to(&mut stream, request, &mut Vec::new(), &mut Vec::new()).unwrap_err(); assert_eq!(error, "backend unavailable"); } + +#[test] +fn explicit_remote_client_rejects_mismatched_request_id() { + let request_id = RequestId([4; 16]); + let response = RemoteEvent { request_id: RequestId([5; 16]), kind: RemoteEventKind::Completed { exit_code: 0 } }; + let mut stream = MemoryStream::new(vec![response.to_frame().unwrap()]); + + let error = + execute_remote_request_to(&mut stream, super::remote_sync_request(request_id, WorkspaceSessionId([5; 16])), &mut Vec::new(), &mut Vec::new()) + .unwrap_err(); + assert_eq!(error, "remote event request ID mismatch"); +} diff --git a/src/daemon.rs b/src/daemon.rs index e296d8b..46ce195 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -261,15 +261,32 @@ pub async fn dispatch_remote_frame(frame: Frame, broke let request = crate::vscomm::RemoteRequest::from_frame(frame).map_err(|err| format!("decode remote request: {err}"))?.into_domain()?; let request_id = request.request_id(); let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(64); - let dispatch_result = broker.dispatch(request, event_tx).await; + let mut dispatch = Box::pin(broker.dispatch(request, event_tx)); + let mut dispatch_result: Option> = None; - while let Some(event) = event_rx.recv().await { - let response = - crate::vscomm::RemoteEvent::from_backend_event(request_id, event).to_frame().map_err(|err| format!("encode remote event: {err}"))?; - write_frame(writer, &response).await?; - } + loop { + if dispatch_result.is_some() { + let Some(event) = event_rx.recv().await else { + let result = dispatch_result.take().expect("dispatch result is present"); + return result.map_err(|err| format!("remote dispatch failed: {err:?}")); + }; + let response = + crate::vscomm::RemoteEvent::from_backend_event(request_id, event).to_frame().map_err(|err| format!("encode remote event: {err}"))?; + write_frame(writer, &response).await?; + continue; + } - dispatch_result.map_err(|err| format!("remote dispatch failed: {err:?}")) + tokio::select! { + result = &mut dispatch => dispatch_result = Some(result), + event = event_rx.recv() => { + let Some(event) = event else { return Err("remote event stream closed before backend completion".to_string()) }; + let response = crate::vscomm::RemoteEvent::from_backend_event(request_id, event) + .to_frame() + .map_err(|err| format!("encode remote event: {err}"))?; + write_frame(writer, &response).await?; + } + } + } } async fn execute_request(writer: &mut W, session: &VsockSession, req: &ExecRequest) -> Result<(), String> { From d36d56721e7f06cd5e3e27661988a8fc1b265b69 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 2 Aug 2026 22:41:32 +0200 Subject: [PATCH 12/25] Add unit tests for loopback remote harness --- src/bunkerbox-remote_ut.rs | 154 +++++++++++++++++++++ src/bunkerbox-vscomm_ut.rs | 17 +-- src/loopback_ut.rs | 116 ++++++++++++++++ src/main_ut.rs | 38 +++++- src/snapshot_ut.rs | 272 +++++++++++++++++++++++++++++++++++++ src/vscomm/mod_ut.rs | 14 ++ 6 files changed, 602 insertions(+), 9 deletions(-) create mode 100644 src/bunkerbox-remote_ut.rs create mode 100644 src/loopback_ut.rs create mode 100644 src/snapshot_ut.rs diff --git a/src/bunkerbox-remote_ut.rs b/src/bunkerbox-remote_ut.rs new file mode 100644 index 0000000..6b1604f --- /dev/null +++ b/src/bunkerbox-remote_ut.rs @@ -0,0 +1,154 @@ +use super::*; +use bunkerbox::vscomm::{Frame, RemoteEvent, RemoteEventKind}; + +struct MemoryStream { + input: io::Cursor>, + output: Vec, +} + +impl MemoryStream { + fn new(events: Vec) -> Self { + let mut input = Vec::new(); + for event in events { + event.to_frame().unwrap().write(&mut input).unwrap(); + } + Self { input: io::Cursor::new(input), output: Vec::new() } + } +} + +impl Read for MemoryStream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.input.read(buf) + } +} + +impl Write for MemoryStream { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.output.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[test] +fn parses_sync_command() { + assert_eq!(parse_command(&["sync".into()]), Ok(RemoteCommand::Sync)); +} + +#[test] +fn parses_build_tool_and_args_without_joining() { + assert_eq!( + parse_command(&["build".into(), "make".into(), "release mode".into(), "$(literal)".into()]), + Ok(RemoteCommand::Build { tool: "make".into(), args: vec!["release mode".into(), "$(literal)".into()] }) + ); +} + +#[test] +fn build_request_preserves_logical_cwd_and_arguments() { + let request = build_request( + RemoteCommand::Build { tool: "make".into(), args: vec!["release mode".into(), "$(literal)".into()] }, + "src".into(), + RequestId([1; 16]), + WorkspaceSessionId([2; 16]), + ) + .unwrap(); + let frame = request.to_frame().unwrap(); + let decoded = RemoteRequest::from_frame(frame).unwrap(); + let bunkerbox::vscomm::RemoteOperation::Build(build) = decoded.operation else { panic!("expected build") }; + assert_eq!(build.cwd.as_str(), "src"); + assert_eq!(build.tool.as_str(), "make"); + assert_eq!(build.argv, ["release mode", "$(literal)"]); +} + +#[test] +fn sync_success_uses_existing_remote_helper_and_returns_status() { + let request_id = RequestId([3; 16]); + let mut stream = MemoryStream::new(vec![RemoteEvent { request_id, kind: RemoteEventKind::Completed { exit_code: 0 } }]); + let status = execute_remote_request_to( + &mut stream, + build_request(RemoteCommand::Sync, String::new(), request_id, WorkspaceSessionId([2; 16])).unwrap(), + &mut Vec::new(), + &mut Vec::new(), + ) + .unwrap(); + assert_eq!(status, 0); + assert!(Frame::read(&mut io::Cursor::new(stream.output)).is_ok()); +} + +#[test] +fn build_success_preserves_output_bytes_and_nonzero_exit_code() { + let request_id = RequestId([6; 16]); + let mut stream = MemoryStream::new(vec![ + RemoteEvent { request_id, kind: RemoteEventKind::Stdout(vec![b'o', b'\n', 0xff]) }, + RemoteEvent { request_id, kind: RemoteEventKind::Stderr(b"err\n".to_vec()) }, + RemoteEvent { request_id, kind: RemoteEventKind::Completed { exit_code: 17 } }, + ]); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + let status = execute_remote_request_to( + &mut stream, + build_request( + RemoteCommand::Build { tool: "make".into(), args: vec!["release".into()] }, + "src".into(), + request_id, + WorkspaceSessionId([2; 16]), + ) + .unwrap(), + &mut stdout, + &mut stderr, + ) + .unwrap(); + + assert_eq!(status, 17); + assert_eq!(stdout, vec![b'o', b'\n', 0xff]); + assert_eq!(stderr, b"err\n"); +} + +#[test] +fn remote_failures_return_errors_without_local_fallback() { + for kind in + [RemoteEventKind::Error { code: bunkerbox::vscomm::RemoteErrorCode::Failed, message: "unavailable".into() }, RemoteEventKind::Cancelled] + { + let request_id = RequestId([4; 16]); + let mut stream = MemoryStream::new(vec![RemoteEvent { request_id, kind }]); + let result = execute_remote_request_to( + &mut stream, + build_request(RemoteCommand::Sync, String::new(), request_id, WorkspaceSessionId([2; 16])).unwrap(), + &mut Vec::new(), + &mut Vec::new(), + ); + assert!(result.is_err()); + } +} + +#[test] +fn rejected_tool_and_mismatched_response_fail_closed() { + let request_id = RequestId([7; 16]); + let rejected = RemoteEvent { + request_id, + kind: RemoteEventKind::Error { code: bunkerbox::vscomm::RemoteErrorCode::Failed, message: "authorization rejected".into() }, + }; + let mut stream = MemoryStream::new(vec![rejected]); + let request = build_request( + RemoteCommand::Build { tool: "cargo".into(), args: vec!["build".into()] }, + String::new(), + request_id, + WorkspaceSessionId([2; 16]), + ) + .unwrap(); + assert_eq!(execute_remote_request_to(&mut stream, request, &mut Vec::new(), &mut Vec::new()).unwrap_err(), "authorization rejected"); + + let mut stream = MemoryStream::new(vec![RemoteEvent { request_id: RequestId([8; 16]), kind: RemoteEventKind::Completed { exit_code: 0 } }]); + let request = build_request(RemoteCommand::Sync, String::new(), request_id, WorkspaceSessionId([2; 16])).unwrap(); + assert_eq!(execute_remote_request_to(&mut stream, request, &mut Vec::new(), &mut Vec::new()).unwrap_err(), "remote event request ID mismatch"); +} + +#[test] +fn logical_cwd_is_workspace_relative_only() { + assert_eq!(logical_workspace_cwd(Path::new("/workspace/project/src")).unwrap(), "project/src"); + assert!(logical_workspace_cwd(Path::new("/tmp/project")).is_err()); +} diff --git a/src/bunkerbox-vscomm_ut.rs b/src/bunkerbox-vscomm_ut.rs index 1b0c815..b07d66c 100644 --- a/src/bunkerbox-vscomm_ut.rs +++ b/src/bunkerbox-vscomm_ut.rs @@ -1,5 +1,6 @@ -use super::vscomm::{RemoteEvent, RemoteEventKind, RequestId, WorkspaceSessionId}; -use super::{execute_remote_request_to, handle_response, handle_response_to, remote_build_request, Frame, FrameType}; +use super::{handle_response, handle_response_to, Frame, FrameType}; +use bunkerbox::remote_client::{execute_remote_request_to, remote_build_request, remote_sync_request}; +use bunkerbox::vscomm::{Frame as RemoteFrame, RemoteEvent, RemoteEventKind, RequestId, WorkspaceSessionId}; use std::io::{self, Read, Write}; struct MemoryStream { @@ -25,7 +26,7 @@ impl Write for FlushWriter { } impl MemoryStream { - fn new(frames: Vec) -> Self { + fn new(frames: Vec) -> Self { let mut input = Vec::new(); for frame in frames { frame.write(&mut input).unwrap(); @@ -86,10 +87,10 @@ fn explicit_remote_client_preserves_streams_status_and_request_id() { assert_eq!(stdout.flushes, 1); assert_eq!(stderr.flushes, 1); - let sent = Frame::read(&mut io::Cursor::new(stream.output)).unwrap(); - let decoded = super::vscomm::RemoteRequest::from_frame(sent).unwrap(); + let sent = RemoteFrame::read(&mut io::Cursor::new(stream.output)).unwrap(); + let decoded = bunkerbox::vscomm::RemoteRequest::from_frame(sent).unwrap(); assert_eq!(decoded.request_id, request_id); - let super::vscomm::RemoteOperation::Build(build) = decoded.operation else { panic!("expected build") }; + let bunkerbox::vscomm::RemoteOperation::Build(build) = decoded.operation else { panic!("expected build") }; assert_eq!(build.cwd.as_str(), "src"); assert_eq!(build.argv, ["release mode"]); } @@ -97,10 +98,10 @@ fn explicit_remote_client_preserves_streams_status_and_request_id() { #[test] fn explicit_remote_client_returns_remote_failure_without_local_fallback() { let request_id = RequestId([4; 16]); - let request = super::remote_sync_request(request_id, WorkspaceSessionId([5; 16])); + let request = remote_sync_request(request_id, WorkspaceSessionId([5; 16])); let response = RemoteEvent { request_id, - kind: RemoteEventKind::Error { code: super::vscomm::RemoteErrorCode::Failed, message: "backend unavailable".into() }, + kind: RemoteEventKind::Error { code: bunkerbox::vscomm::RemoteErrorCode::Failed, message: "backend unavailable".into() }, }; let mut stream = MemoryStream::new(vec![response.to_frame().unwrap()]); diff --git a/src/loopback_ut.rs b/src/loopback_ut.rs new file mode 100644 index 0000000..4da344f --- /dev/null +++ b/src/loopback_ut.rs @@ -0,0 +1,116 @@ +use super::*; +use crate::remote::{RemoteAuthorizationPolicy, RemoteBuild, RemoteExecutionContext, RemoteRequest, RemoteTool, RequestId, WorkspaceRelativePath}; +use tempfile::TempDir; + +fn fixture() -> (TempDir, Arc, RemoteTargetId, WorkspaceSessionId) { + let temp = tempfile::tempdir().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir(&workspace).unwrap(); + fs::create_dir(workspace.join("src")).unwrap(); + fs::write(workspace.join("src/input.txt"), b"snapshot input\n").unwrap(); + let session_id = WorkspaceSessionId([1; 16]); + let target = RemoteTargetId([2; 16]); + let snapshot_store = SnapshotStore::new(temp.path().join("snapshots")); + let exclusions = crate::snapshot::SnapshotExclusionPolicy::from_patterns(Vec::::new()).unwrap(); + let builder = SnapshotBuilder::new(snapshot_store.clone(), crate::snapshot::SnapshotLimits::default(), exclusions); + let session = Arc::new(RunRemoteSession::new(session_id, target, workspace, snapshot_store, builder, temp.path().join("jobs")).unwrap()); + (temp, session, target, session_id) +} + +fn authorized_build( + target: RemoteTargetId, session: WorkspaceSessionId, tool: &str, args: Vec, env: Vec<(String, String)>, +) -> crate::remote::AuthorizedRemoteRequest { + let build = RemoteBuild::new(WorkspaceRelativePath::new("src").unwrap(), RemoteTool::new(tool).unwrap(), args, env).unwrap(); + let request = RemoteRequest::build(RequestId([3; 16]), session, build); + let policy = RemoteAuthorizationPolicy::new(target, session, vec![tool.to_string()]); + policy.authorize(&RemoteExecutionContext { target, workspace_session_id: session }, request).unwrap() +} + +async fn collect_events(mut receiver: mpsc::Receiver) -> Vec { + let mut events = Vec::new(); + while let Some(event) = receiver.recv().await { + events.push(event); + } + events +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sync_and_build_materialize_a_bound_snapshot() { + let (_temp, session, target, session_id) = fixture(); + let session_state = session.clone(); + let mut tools = BTreeMap::new(); + let printf = ["/usr/local/bin/printf", "/usr/bin/printf", "/bin/printf"].into_iter().map(PathBuf::from).find(|path| path.is_file()).unwrap(); + tools.insert("printf".to_string(), printf); + let backend = LoopbackBackend::new(session, tools); + + let (sync_tx, sync_rx) = mpsc::channel(8); + let sync = RemoteRequest::sync(RequestId([4; 16]), session_id); + let policy = RemoteAuthorizationPolicy::new(target, session_id, Vec::new()); + let authorized = policy.authorize(&RemoteExecutionContext { target, workspace_session_id: session_id }, sync).unwrap(); + assert_eq!(backend.execute(authorized, sync_tx).await, Ok(())); + assert_eq!( + collect_events(sync_rx).await, + vec![RemoteBackendEvent::SyncProgress { completed_bytes: 0, total_bytes: None }, RemoteBackendEvent::Completed { exit_code: 0 },] + ); + + let (build_tx, build_rx) = mpsc::channel(8); + let authorized = authorized_build(target, session_id, "printf", vec!["value with spaces:$(literal)".to_string()], Vec::new()); + assert_eq!(backend.execute(authorized, build_tx).await, Ok(())); + assert_eq!( + collect_events(build_rx).await, + vec![RemoteBackendEvent::Stdout(b"value with spaces:$(literal)".to_vec()), RemoteBackendEvent::Completed { exit_code: 0 },] + ); + assert!(fs::read_dir(&session_state.jobs_root).unwrap().next().is_none()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn nonempty_remote_environment_is_rejected() { + let (_temp, session, target, session_id) = fixture(); + let backend = LoopbackBackend::new(session, BTreeMap::new()); + let (events, _receiver) = mpsc::channel(8); + let request = authorized_build(target, session_id, "printf", Vec::new(), vec![("UNTRUSTED".to_string(), "1".to_string())]); + assert_eq!( + backend.execute(request, events).await, + Err(RemoteBackendError::Failed("remote environment is unavailable until remote environment policy is configured".to_string(),)) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn build_preserves_argument_bytes_and_reports_nonzero_stderr() { + let (_temp, session, target, session_id) = fixture(); + session.sync_snapshot().unwrap(); + let tools = resolve_fixed_tools(["ls".to_string()]); + if tools.is_empty() { + return; + } + let backend = LoopbackBackend::new(session, tools); + let (events, receiver) = mpsc::channel(8); + let request = authorized_build(target, session_id, "ls", vec!["$(not-a-shell-argument)".to_string()], Vec::new()); + assert_eq!(backend.execute(request, events).await, Ok(())); + let events = collect_events(receiver).await; + assert!(events.iter().any(|event| matches!(event, RemoteBackendEvent::Stderr(bytes) if !bytes.is_empty()))); + assert!(events.iter().any(|event| matches!(event, RemoteBackendEvent::Completed { exit_code } if *exit_code != 0))); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn missing_tool_fails_before_execution() { + let (_temp, session, target, session_id) = fixture(); + let backend = LoopbackBackend::new(session, BTreeMap::new()); + let (events, _receiver) = mpsc::channel(8); + let request = authorized_build(target, session_id, "missing-tool", Vec::new(), Vec::new()); + assert_eq!(backend.execute(request, events).await, Err(RemoteBackendError::Spawn("loopback tool is not configured: missing-tool".to_string()))); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn timeout_kills_a_direct_child_process() { + let (_temp, session, target, session_id) = fixture(); + session.sync_snapshot().unwrap(); + let tools = resolve_fixed_tools(["sleep".to_string()]); + if tools.is_empty() { + return; + } + let backend = LoopbackBackend::new(session, tools).with_timeout(Duration::from_millis(50)); + let (events, _receiver) = mpsc::channel(8); + let request = authorized_build(target, session_id, "sleep", vec!["5".to_string()], Vec::new()); + assert_eq!(backend.execute(request, events).await, Err(RemoteBackendError::Timeout)); +} diff --git a/src/main_ut.rs b/src/main_ut.rs index 730facf..b1e0af1 100644 --- a/src/main_ut.rs +++ b/src/main_ut.rs @@ -1,4 +1,7 @@ -use super::{decode_workspace_handoff, encode_workspace_handoff}; +use super::{decode_workspace_handoff, encode_workspace_handoff, read_run_handoff, remote_tool_names, write_run_handoff}; +use bunkerbox::vscomm::WorkspaceSessionId; +use std::fs::File; +use std::os::fd::FromRawFd; use std::path::Path; #[test] @@ -22,3 +25,36 @@ fn workspace_handoff_rejects_truncated_payload() { assert!(decode_workspace_handoff(&frame[..frame.len() - 1]).is_err()); } + +#[test] +fn run_handoff_round_trips_path_and_session() { + let (parent, child) = unsafe { + let mut fds = [-1; 2]; + assert_eq!(libc::pipe(fds.as_mut_ptr()), 0); + (File::from_raw_fd(fds[0]), File::from_raw_fd(fds[1])) + }; + let mut child = child; + write_run_handoff(&mut child, Path::new("/workspace/project"), WorkspaceSessionId([7; 16])).unwrap(); + drop(child); + let mut parent = parent; + let (path, session) = read_run_handoff(&mut parent).unwrap(); + assert_eq!(path, Path::new("/workspace/project")); + assert_eq!(session, WorkspaceSessionId([7; 16])); +} + +#[test] +fn run_handoff_rejects_zero_session() { + let mut payload = b"/workspace/project".to_vec(); + payload.push(0); + payload.extend_from_slice(&[0; 16]); + let frame = encode_workspace_handoff(&payload).unwrap(); + let path = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(path.path(), frame).unwrap(); + let mut file = File::open(path.path()).unwrap(); + assert!(read_run_handoff(&mut file).is_err()); +} + +#[test] +fn remote_tool_names_reduce_passthrough_entries_to_executables() { + assert_eq!(remote_tool_names(&["make *".into(), "cargo build".into(), "make test".into()]), vec!["cargo", "make"]); +} diff --git a/src/snapshot_ut.rs b/src/snapshot_ut.rs new file mode 100644 index 0000000..173774e --- /dev/null +++ b/src/snapshot_ut.rs @@ -0,0 +1,272 @@ +use super::*; +use crate::cfg::{ProjectConfig, ProjectSection}; +use crate::remote::WorkspaceSessionId; +use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; +use std::os::unix::net::UnixListener; +use std::path::Path; +use std::time::Duration; +use tempfile::TempDir; + +fn session(value: u8) -> WorkspaceSessionId { + WorkspaceSessionId([value; 16]) +} + +fn builder(store: &TempDir, limits: SnapshotLimits, patterns: &[&str]) -> SnapshotBuilder { + SnapshotBuilder::new( + SnapshotStore::new(store.path()), + limits, + SnapshotExclusionPolicy::from_patterns(patterns.iter().map(|pattern| (*pattern).to_string())).unwrap(), + ) +} + +fn build_at(source: &TempDir, store: &TempDir, limits: SnapshotLimits, patterns: &[&str]) -> Result { + builder(store, limits, patterns).build_root(source.path(), session(1)) +} + +fn write_file(root: &Path, path: &str, contents: &[u8]) { + let path = root.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); +} + +#[test] +fn snapshots_nested_modified_and_untracked_files_with_modes() { + let source = TempDir::new().unwrap(); + let store = TempDir::new().unwrap(); + write_file(source.path(), "src/main.rs", b"modified"); + write_file(source.path(), "agent/new.txt", b"created"); + let executable = source.path().join("tool.sh"); + fs::write(&executable, b"#!/bin/sh\n").unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + + let snapshot = build_at(&source, &store, SnapshotLimits::default(), &[]).unwrap(); + let paths = snapshot.entries().iter().map(|entry| entry.path().as_str()).collect::>(); + assert_eq!(paths, vec!["agent", "agent/new.txt", "src", "src/main.rs", "tool.sh"]); + assert_eq!(snapshot.total_file_bytes(), 8 + 7 + 10); + let tool = snapshot.entries().iter().find(|entry| entry.path().as_str() == "tool.sh").unwrap(); + assert_eq!(tool.kind(), SnapshotEntryKind::RegularFile); + assert_eq!(tool.mode(), 0o755); + assert!(tool.content_digest().is_some()); + assert!(store.path().to_string_lossy().is_empty() || format!("{:?}", snapshot).contains("SnapshotId")); +} + +#[test] +fn identical_trees_have_deterministic_manifest_identity() { + let source_a = TempDir::new().unwrap(); + let source_b = TempDir::new().unwrap(); + let store_a = TempDir::new().unwrap(); + let store_b = TempDir::new().unwrap(); + for source in [&source_a, &source_b] { + write_file(source.path(), "b/file", b"same"); + write_file(source.path(), "a.txt", b"content"); + } + + let first = build_at(&source_a, &store_a, SnapshotLimits::default(), &[]).unwrap(); + let second = build_at(&source_b, &store_b, SnapshotLimits::default(), &[]).unwrap(); + assert_eq!(first.handle().snapshot_id(), second.handle().snapshot_id()); + assert_eq!(first.entries().iter().map(|entry| entry.path().as_str()).collect::>(), vec!["a.txt", "b", "b/file"]); +} + +#[test] +fn snapshot_store_resolves_only_the_bound_session() { + let source = TempDir::new().unwrap(); + let store_dir = TempDir::new().unwrap(); + write_file(source.path(), "file", b"data"); + let snapshot = build_at(&source, &store_dir, SnapshotLimits::default(), &[]).unwrap(); + let store = SnapshotStore::new(store_dir.path()); + assert_eq!(store.resolve(snapshot.handle()).unwrap(), snapshot); + + let wrong_session = SnapshotHandle { session_id: session(2), snapshot_id: snapshot.handle().snapshot_id() }; + assert!(store.resolve(&wrong_session).is_err()); + assert!(!format!("{:?}", snapshot.handle()).contains(&source.path().to_string_lossy().to_string())); +} + +#[test] +fn exclusions_prune_defaults_basenames_and_anchored_subtrees() { + let source = TempDir::new().unwrap(); + let store = TempDir::new().unwrap(); + for path in ["target/out", "nested/target/out", "docs/generated/file", ".git/config", ".bunkerbox/state", ".env", ".ssh/key"] { + write_file(source.path(), path, b"excluded"); + } + write_file(source.path(), "docs/keep/file", b"included"); + let snapshot = build_at(&source, &store, SnapshotLimits::default(), &["target/", "docs/generated/"]).unwrap(); + let paths = snapshot.entries().iter().map(|entry| entry.path().as_str()).collect::>(); + assert_eq!(paths, vec!["docs", "docs/keep", "docs/keep/file", "nested"]); +} + +#[test] +fn config_and_runtime_exclusions_use_explicit_snapshot_semantics() { + let config = ProjectConfig { project: ProjectSection { exclude: vec!["vendor/".into()], ..Default::default() }, ..Default::default() }; + let policy = SnapshotExclusionPolicy::from_config(&config, Some(&["generated/tree/".to_string()])).unwrap(); + assert!(policy.excludes("vendor/file")); + assert!(policy.excludes("generated/tree/file")); + assert!(!policy.excludes("vendorized/file")); +} + +#[test] +fn malformed_exclusions_are_rejected() { + for pattern in ["/absolute", "foo/../bar", "foo//bar", "foo/./bar", ""] { + assert!(SnapshotExclusionPolicy::from_patterns([pattern.to_string()]).is_err(), "{pattern}"); + } +} + +#[test] +fn every_symlink_is_rejected_without_following_it() { + let cases = ["internal-file", "internal-dir", "external", "dangling", "loop-a"]; + for case in cases { + let source = TempDir::new().unwrap(); + let store = TempDir::new().unwrap(); + write_file(source.path(), "real/file", b"data"); + match case { + "internal-file" => symlink("real/file", source.path().join(case)).unwrap(), + "internal-dir" => symlink("real", source.path().join(case)).unwrap(), + "external" => { + let outside = TempDir::new().unwrap(); + symlink(outside.path(), source.path().join(case)).unwrap(); + } + "dangling" => symlink("missing", source.path().join(case)).unwrap(), + "loop-a" => { + symlink("loop-b", source.path().join("loop-a")).unwrap(); + symlink("loop-a", source.path().join("loop-b")).unwrap(); + } + _ => unreachable!(), + } + assert!(build_at(&source, &store, SnapshotLimits::default(), &[]).is_err(), "{case}"); + } +} + +#[test] +fn special_files_and_hard_links_are_rejected() { + let source = TempDir::new().unwrap(); + let store = TempDir::new().unwrap(); + write_file(source.path(), "regular", b"data"); + fs::hard_link(source.path().join("regular"), source.path().join("alias")).unwrap(); + assert!(build_at(&source, &store, SnapshotLimits::default(), &[]).is_err()); + + let fifo = source.path().join("pipe"); + let fifo_name = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(fifo_name.as_ptr(), 0o600) }, 0); + assert!(build_at(&source, &store, SnapshotLimits::default(), &[]).is_err()); + + fs::remove_file(&fifo).unwrap(); + let socket_path = source.path().join("socket"); + let _listener = UnixListener::bind(&socket_path).unwrap(); + assert!(build_at(&source, &store, SnapshotLimits::default(), &[]).is_err()); +} + +#[test] +fn trusted_limits_reject_entries_and_cleanup_staging() { + let source = TempDir::new().unwrap(); + let store = TempDir::new().unwrap(); + write_file(source.path(), "a", b"1234"); + write_file(source.path(), "b", b"5678"); + let limits = SnapshotLimits { max_entries: 1, ..SnapshotLimits::default() }; + assert!(build_at(&source, &store, limits, &[]).is_err()); + assert_eq!(fs::read_dir(store.path().join(".staging")).unwrap().count(), 0); + + let limits = SnapshotLimits { max_file_bytes: 3, ..SnapshotLimits::default() }; + assert!(build_at(&source, &store, limits, &[]).is_err()); + assert_eq!(fs::read_dir(store.path().join(".staging")).unwrap().count(), 0); + + let limits = SnapshotLimits { max_total_bytes: 5, ..SnapshotLimits::default() }; + assert!(build_at(&source, &store, limits, &[]).is_err()); + assert_eq!(fs::read_dir(store.path().join(".staging")).unwrap().count(), 0); +} + +#[test] +fn path_manifest_and_deadline_limits_are_enforced() { + let source = TempDir::new().unwrap(); + let store = TempDir::new().unwrap(); + write_file(source.path(), "long-name", b"data"); + let limits = SnapshotLimits { max_component_bytes: 4, ..SnapshotLimits::default() }; + assert!(build_at(&source, &store, limits, &[]).is_err()); + + let limits = SnapshotLimits { max_manifest_bytes: 1, ..SnapshotLimits::default() }; + assert!(build_at(&source, &store, limits, &[]).is_err()); + + let limits = SnapshotLimits { max_duration: Duration::from_nanos(1), ..SnapshotLimits::default() }; + assert!(build_at(&source, &store, limits, &[]).is_err()); +} + +#[test] +fn zero_session_is_not_accepted_as_snapshot_authority() { + let source = TempDir::new().unwrap(); + let store = TempDir::new().unwrap(); + write_file(source.path(), "file", b"data"); + assert!(builder(&store, SnapshotLimits::default(), &[]).build_root(source.path(), WorkspaceSessionId([0; 16])).is_err()); +} + +#[test] +fn unreadable_workspace_entry_fails_when_supported() { + let source = TempDir::new().unwrap(); + let store = TempDir::new().unwrap(); + let path = source.path().join("private"); + fs::write(&path, b"secret").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).unwrap(); + let result = build_at(&source, &store, SnapshotLimits::default(), &[]); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + if unsafe { libc::geteuid() } != 0 { + assert!(result.is_err()); + } +} + +#[test] +fn source_path_is_not_in_snapshot_metadata() { + let source = TempDir::new().unwrap(); + let store = TempDir::new().unwrap(); + write_file(source.path(), "file", b"data"); + let snapshot = build_at(&source, &store, SnapshotLimits::default(), &[]).unwrap(); + let debug = format!("{snapshot:?}"); + assert!(!debug.contains(&source.path().to_string_lossy().to_string())); + assert!(!debug.contains(&store.path().to_string_lossy().to_string())); +} + +#[test] +fn materialization_recreates_nested_files_and_modes() { + let source = TempDir::new().unwrap(); + let store_dir = TempDir::new().unwrap(); + write_file(source.path(), "src/main.rs", b"fn main() {}\n"); + let executable = source.path().join("tool.sh"); + fs::write(&executable, b"#!/bin/sh\n").unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + let snapshot = build_at(&source, &store_dir, SnapshotLimits::default(), &[]).unwrap(); + let store = SnapshotStore::new(store_dir.path()); + let destination = store_dir.path().join("materialized"); + + let materialized = store.materialize(snapshot.handle(), &destination).unwrap(); + assert_eq!(materialized.root(), destination); + assert_eq!(fs::read(destination.join("src/main.rs")).unwrap(), b"fn main() {}\n"); + assert_eq!(fs::metadata(destination.join("tool.sh")).unwrap().permissions().mode() & 0o777, 0o755); +} + +#[test] +fn materialization_rejects_existing_destination_and_cleans_digest_failures() { + let source = TempDir::new().unwrap(); + let store_dir = TempDir::new().unwrap(); + write_file(source.path(), "file", b"contents"); + let snapshot = build_at(&source, &store_dir, SnapshotLimits::default(), &[]).unwrap(); + let store = SnapshotStore::new(store_dir.path()); + let existing = store_dir.path().join("existing"); + fs::create_dir(&existing).unwrap(); + assert!(store.materialize(snapshot.handle(), &existing).is_err()); + + let staged_file = store.snapshot_path(snapshot.handle()).join("files/file"); + fs::write(staged_file, b"tampered").unwrap(); + let destination = store_dir.path().join("failed"); + assert!(store.materialize(snapshot.handle(), &destination).is_err()); + assert!(!destination.exists()); +} + +#[test] +fn materialization_rejects_destination_symlink() { + let source = TempDir::new().unwrap(); + let store_dir = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + write_file(source.path(), "file", b"contents"); + let snapshot = build_at(&source, &store_dir, SnapshotLimits::default(), &[]).unwrap(); + let destination = store_dir.path().join("link"); + symlink(outside.path(), &destination).unwrap(); + assert!(SnapshotStore::new(store_dir.path()).materialize(snapshot.handle(), &destination).is_err()); + assert!(outside.path().read_dir().unwrap().next().is_none()); +} diff --git a/src/vscomm/mod_ut.rs b/src/vscomm/mod_ut.rs index a4bac24..00f0f7f 100644 --- a/src/vscomm/mod_ut.rs +++ b/src/vscomm/mod_ut.rs @@ -4,6 +4,20 @@ fn ids() -> (RequestId, WorkspaceSessionId) { (RequestId([1; 16]), WorkspaceSessionId([2; 16])) } +#[test] +fn workspace_session_hex_round_trips_without_accepting_zero() { + let session = WorkspaceSessionId([0xab; 16]); + assert_eq!(WorkspaceSessionId::from_hex(&session.to_hex()), Ok(session)); + assert!(WorkspaceSessionId::from_hex(&"0".repeat(32)).is_err()); +} + +#[test] +fn workspace_session_hex_rejects_non_ascii_without_panicking() { + let mut value = "0".repeat(30); + value.push('\u{00e9}'); + assert!(WorkspaceSessionId::from_hex(&value).is_err()); +} + fn build(argv: Vec, env: Vec<(String, String)>) -> Result { RemoteBuild::new(WorkspaceRelativePath::new("src").unwrap(), RemoteTool::new("make").unwrap(), argv, env) } From b9c97f9a95ed8a9af0fcb1162fae8e451016dec7 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 2 Aug 2026 22:41:49 +0200 Subject: [PATCH 13/25] Implement remote loopback harness --- src/bin/bunkerbox-remote.rs | 134 ++++++ src/bin/bunkerbox-vscomm.rs | 47 +- src/daemon.rs | 81 ++-- src/kata.rs | 12 +- src/lib.rs | 3 + src/loopback.rs | 383 ++++++++++++++++ src/main.rs | 258 +++++++---- src/remote_client.rs | 43 ++ src/snapshot.rs | 892 ++++++++++++++++++++++++++++++++++++ src/vscomm/mod.rs | 31 ++ 10 files changed, 1727 insertions(+), 157 deletions(-) create mode 100644 src/bin/bunkerbox-remote.rs create mode 100644 src/loopback.rs create mode 100644 src/remote_client.rs create mode 100644 src/snapshot.rs diff --git a/src/bin/bunkerbox-remote.rs b/src/bin/bunkerbox-remote.rs new file mode 100644 index 0000000..d8439e0 --- /dev/null +++ b/src/bin/bunkerbox-remote.rs @@ -0,0 +1,134 @@ +use bunkerbox::remote_client::{execute_remote_request_to, remote_build_request, remote_sync_request}; +use bunkerbox::vscomm::{RemoteRequest, RequestId, WorkspaceSessionId, TOOLCHAIN_PORT}; +use rand::RngCore; +use std::env; +use std::io::{self, Read, Write}; +use std::mem; +use std::path::Path; + +const HOST_CID: u32 = 2; + +#[derive(Debug, PartialEq, Eq)] +enum RemoteCommand { + Sync, + Build { tool: String, args: Vec }, +} + +fn main() { + match run() { + Ok(code) => std::process::exit(code), + Err(error) => { + eprintln!("bunkerbox-remote: remote operation failed: {error}"); + std::process::exit(1); + } + } +} + +fn run() -> Result { + let args = env::args().skip(1).collect::>(); + let command = parse_command(&args)?; + match &command { + RemoteCommand::Sync => eprintln!("bunkerbox-remote: syncing"), + RemoteCommand::Build { tool, .. } => eprintln!("bunkerbox-remote: building {tool}"), + } + let cwd = logical_workspace_cwd(&env::current_dir().map_err(|error| format!("current directory: {error}"))?)?; + let session = env::var("BUNKERBOX_REMOTE_SESSION") + .map_err(|_| "BUNKERBOX_REMOTE_SESSION is missing".to_string()) + .and_then(|value| WorkspaceSessionId::from_hex(&value))?; + let request = build_request(command, cwd, new_request_id(), session)?; + let mut stream = connect_toolchain()?; + let mut stdout = io::stdout(); + let mut stderr = io::stderr(); + execute_remote_request_to(&mut stream, request, &mut stdout, &mut stderr) +} + +fn parse_command(args: &[String]) -> Result { + match args { + [command] if command == "sync" => Ok(RemoteCommand::Sync), + [command, tool, rest @ ..] if command == "build" && !tool.is_empty() => Ok(RemoteCommand::Build { tool: tool.clone(), args: rest.to_vec() }), + [command, ..] if command == "build" => Err("usage: bunkerbox-remote build [args...]".to_string()), + [] => Err("usage: bunkerbox-remote sync | build [args...]".to_string()), + _ => Err("usage: bunkerbox-remote sync | build [args...]".to_string()), + } +} + +fn build_request(command: RemoteCommand, cwd: String, request_id: RequestId, session_id: WorkspaceSessionId) -> Result { + match command { + RemoteCommand::Sync => Ok(remote_sync_request(request_id, session_id)), + RemoteCommand::Build { tool, args } => remote_build_request(request_id, session_id, cwd, tool, args, Vec::new()), + } +} + +fn logical_workspace_cwd(path: &Path) -> Result { + let relative = path.strip_prefix("/workspace").map_err(|_| "current directory must be under /workspace".to_string())?; + let value = relative.to_str().ok_or_else(|| "current directory is not valid UTF-8".to_string())?.to_string(); + bunkerbox::remote::WorkspaceRelativePath::new(&value)?; + Ok(value) +} + +fn new_request_id() -> RequestId { + let mut bytes = [0; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + RequestId(bytes) +} + +fn connect_toolchain() -> Result { + vsock_connect(HOST_CID, TOOLCHAIN_PORT).map_err(|error| format!("toolchain vsock connect: {error}")) +} + +fn vsock_connect(cid: u32, port: u32) -> io::Result { + unsafe { + let fd = libc::socket(libc::AF_VSOCK, libc::SOCK_STREAM, 0); + if fd < 0 { + return Err(io::Error::last_os_error()); + } + + let addr = libc::sockaddr_vm { svm_family: libc::AF_VSOCK as u16, svm_reserved1: 0, svm_port: port, svm_cid: cid, svm_zero: [0u8; 4] }; + let addr_ptr = &addr as *const libc::sockaddr_vm as *const libc::sockaddr; + let addr_len = mem::size_of::() as libc::socklen_t; + if libc::connect(fd, addr_ptr, addr_len) < 0 { + let error = io::Error::last_os_error(); + libc::close(fd); + return Err(error); + } + Ok(VsockStream { fd }) + } +} + +struct VsockStream { + fd: libc::c_int, +} + +impl Read for VsockStream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let result = unsafe { libc::read(self.fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + Ok(result as usize) + } +} + +impl Write for VsockStream { + fn write(&mut self, buf: &[u8]) -> io::Result { + let result = unsafe { libc::write(self.fd, buf.as_ptr() as *const libc::c_void, buf.len()) }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + Ok(result as usize) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl Drop for VsockStream { + fn drop(&mut self) { + unsafe { libc::close(self.fd) }; + } +} + +#[cfg(test)] +#[path = "../bunkerbox-remote_ut.rs"] +mod tests; diff --git a/src/bin/bunkerbox-vscomm.rs b/src/bin/bunkerbox-vscomm.rs index 6382fae..aea40b5 100644 --- a/src/bin/bunkerbox-vscomm.rs +++ b/src/bin/bunkerbox-vscomm.rs @@ -10,10 +10,8 @@ use std::mem; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use vscomm::{ - encode_ui_payload, validate_exec_request, ExecRequest, Frame, FrameType, RemoteBuild, RemoteEvent, RemoteEventKind, RemoteRequest, RemoteTool, - RequestId, WorkspaceRelativePath, WorkspaceSessionId, TOOLCHAIN_PORT, TUI_STATUS_PORT, VSCOMM_BIN_DIR, -}; +pub use bunkerbox::remote_client::{execute_remote_request_to, remote_build_request, remote_sync_request}; +use vscomm::{encode_ui_payload, validate_exec_request, ExecRequest, Frame, FrameType, TOOLCHAIN_PORT, TUI_STATUS_PORT, VSCOMM_BIN_DIR}; const HOST_CID: u32 = 2; @@ -88,47 +86,6 @@ fn handle_response_to(response: Frame, stdout: &mut WO } } -pub fn remote_sync_request(request_id: RequestId, session_id: WorkspaceSessionId) -> RemoteRequest { - RemoteRequest::sync(request_id, session_id) -} - -pub fn remote_build_request( - request_id: RequestId, session_id: WorkspaceSessionId, cwd: impl Into, tool: impl Into, argv: Vec, - env: Vec<(String, String)>, -) -> Result { - let build = RemoteBuild::new(WorkspaceRelativePath::new(cwd)?, RemoteTool::new(tool)?, argv, env)?; - Ok(RemoteRequest::build(request_id, session_id, build)) -} - -pub fn execute_remote_request_to( - stream: &mut S, request: RemoteRequest, stdout: &mut WOut, stderr: &mut WErr, -) -> Result { - let request_id = request.request_id; - request.to_frame()?.write(stream).map_err(|e| format!("send remote request: {e}"))?; - - loop { - let frame = Frame::read(stream).map_err(|e| format!("read remote event: {e}"))?; - let event = RemoteEvent::from_frame(frame)?; - if event.request_id != request_id { - return Err("remote event request ID mismatch".to_string()); - } - match event.kind { - RemoteEventKind::SyncProgress { .. } => {} - RemoteEventKind::Stdout(data) => { - stdout.write_all(&data).map_err(|e| format!("stdout: {e}"))?; - stdout.flush().map_err(|e| format!("flush stdout: {e}"))?; - } - RemoteEventKind::Stderr(data) => { - stderr.write_all(&data).map_err(|e| format!("stderr: {e}"))?; - stderr.flush().map_err(|e| format!("flush stderr: {e}"))?; - } - RemoteEventKind::Error { message, .. } => return Err(message), - RemoteEventKind::Cancelled => return Err("remote operation cancelled".to_string()), - RemoteEventKind::Completed { exit_code } => return Ok(exit_code), - } - } -} - fn notify_tui_error(message: &str) { let Ok(mut stream) = vsock_connect(HOST_CID, TUI_STATUS_PORT) else { return; diff --git a/src/daemon.rs b/src/daemon.rs index 46ce195..1d35fc2 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,5 +1,6 @@ use crate::cfg::EnvMode; use crate::logging; +use crate::loopback::{LoopbackBackend, RunRemoteSession}; use crate::proxy::{FilterProxy, UnixProxyHandle}; use crate::remote::{ RemoteAuthorizationError, RemoteAuthorizationPolicy, RemoteBackend, RemoteBackendError, RemoteBackendEvent, RemoteExecutionContext, RemoteRequest, @@ -14,7 +15,7 @@ use std::os::fd::{AsRawFd, FromRawFd, RawFd}; use std::os::unix::fs::DirBuilderExt; use std::path::{Path, PathBuf}; use std::process::Stdio; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::process::Command; @@ -75,16 +76,6 @@ impl RemoteBroker { } } -struct UnavailableRemoteBackend; - -impl RemoteBackend for UnavailableRemoteBackend { - fn execute<'a>( - &'a self, _request: crate::remote::AuthorizedRemoteRequest, _events: tokio::sync::mpsc::Sender, - ) -> crate::remote::RemoteFuture<'a, Result<(), RemoteBackendError>> { - Box::pin(async { Err(RemoteBackendError::Failed("remote backend is unavailable".to_string())) }) - } -} - struct VsockSession { passthrough: Arc>, env_mode: EnvMode, @@ -101,9 +92,42 @@ pub struct VsockDaemon { sandbox_proxy_dir: Option, } +pub struct RemoteDaemonConfig { + session: Arc, + allowed_tools: Vec, + tools: std::collections::BTreeMap, +} + +impl RemoteDaemonConfig { + pub fn new(session: Arc, allowed_tools: Vec, tools: std::collections::BTreeMap) -> Self { + Self { session, allowed_tools, tools } + } +} + +struct RemoteComponents { + context: RemoteExecutionContext, + policy: RemoteAuthorizationPolicy, + backend: Arc, +} + impl VsockDaemon { - pub fn start( + pub fn start_with_remote( + passthrough: Vec, env_mode: EnvMode, workspace: PathBuf, profiles: Vec, share_dir: PathBuf, allow: Vec, + remote: RemoteDaemonConfig, + ) -> Result { + let remote_policy = RemoteAuthorizationPolicy::new(remote.session.target(), remote.session.session_id(), remote.allowed_tools); + let remote_context = RemoteExecutionContext { target: remote.session.target(), workspace_session_id: remote.session.session_id() }; + let remote_components = RemoteComponents { + context: remote_context, + policy: remote_policy, + backend: Arc::new(LoopbackBackend::new(remote.session, remote.tools)), + }; + Self::start_inner(passthrough, env_mode, workspace, profiles, share_dir, allow, remote_components) + } + + fn start_inner( passthrough: Vec, env_mode: EnvMode, workspace: PathBuf, profiles: Vec, share_dir: PathBuf, allow: Vec, + remote: RemoteComponents, ) -> Result { let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); @@ -129,7 +153,6 @@ impl VsockDaemon { if merged_profile.is_some() && !allow.is_empty() { let rt = tokio::runtime::Handle::current(); - let netrelay_path = find_netrelay_binary()?; let netrelay_path = find_netrelay_binary()?; let dir = make_proxy_runtime_dir()?; @@ -143,16 +166,8 @@ impl VsockDaemon { proxy_config = Some(SandboxProxyConfig { socket_path, netrelay_path }); } - let remote_target = crate::remote::RemoteTargetId([0; 16]); - let remote_session = crate::remote::WorkspaceSessionId([0; 16]); - let remote_policy = RemoteAuthorizationPolicy::new(remote_target, remote_session, Vec::new()); - let remote_backend: Arc = Arc::new(UnavailableRemoteBackend); - let remote_broker = Arc::new(RemoteBroker::new( - remote_policy, - RemoteExecutionContext { target: remote_target, workspace_session_id: remote_session }, - remote_backend, - )); - + let connections = Arc::new(Mutex::new(Vec::new())); + let remote_broker = Arc::new(RemoteBroker::new(remote.policy, remote.context, remote.backend)); let session = Arc::new(VsockSession { passthrough: Arc::new(passthrough), env_mode, @@ -162,18 +177,18 @@ impl VsockDaemon { remote_broker, }); - let listener = tokio_vsock::VsockListener::bind(tokio_vsock::VsockAddr::new(libc::VMADDR_CID_ANY, TOOLCHAIN_PORT)).map_err(|e| { + let listener = tokio_vsock::VsockListener::bind(tokio_vsock::VsockAddr::new(libc::VMADDR_CID_ANY, TOOLCHAIN_PORT)).map_err(|error| { if let Some(h) = sandbox_proxy.take() { h.stop(); } if let Some(d) = sandbox_proxy_dir.take() { let _ = std::fs::remove_dir_all(&d); } - format!("failed to bind toolchain vsock port {TOOLCHAIN_PORT}: {e}") + format!("failed to bind toolchain vsock port {TOOLCHAIN_PORT}: {error}") })?; let join_handle = tokio::spawn(async move { - let result = daemon_loop(session, listener, shutdown_rx).await; + let result = daemon_loop(session, listener, shutdown_rx, connections).await; if let Err(err) = result { logging::diagnostic(&format!("bunkerbox: vsock daemon: {err}")); } @@ -196,6 +211,7 @@ impl VsockDaemon { async fn daemon_loop( session: Arc, listener: tokio_vsock::VsockListener, mut shutdown_rx: tokio::sync::oneshot::Receiver<()>, + connections: Arc>>>, ) -> Result<(), String> { loop { tokio::select! { @@ -203,11 +219,14 @@ async fn daemon_loop( match result { Ok((stream, _peer)) => { let session = session.clone(); - tokio::spawn(async move { + let connection = tokio::spawn(async move { if let Err(err) = handle_connection(stream, &session).await { logging::diagnostic(&format!("bunkerbox: toolchain vsock session failed: {err}")); } }); + let mut active = connections.lock().map_err(|_| "connection task lock poisoned".to_string())?; + active.retain(|task| !task.is_finished()); + active.push(connection); } Err(e) => { logging::diagnostic(&format!("bunkerbox: vsock accept error: {e}")); @@ -220,6 +239,14 @@ async fn daemon_loop( } } + let tasks = connections.lock().map_err(|_| "connection task lock poisoned".to_string())?.drain(..).collect::>(); + for task in &tasks { + task.abort(); + } + for task in tasks { + let _ = task.await; + } + Ok(()) } diff --git a/src/kata.rs b/src/kata.rs index 2cac0da..2ac635d 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -1,6 +1,6 @@ use crate::cfg::{HomeMode, NetworkMode, RuntimeConfig}; +use crate::vscomm::WorkspaceSessionId; use crate::vscomm::TOOLCHAIN_PORT; -use crate::workspace::WorkspaceHandle; use aes_gcm::aead::consts::U12; use aes_gcm::aead::Aead; use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; @@ -17,6 +17,11 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::thread; +pub struct WorkspaceBinding<'a> { + pub path: &'a Path, + pub remote_session: WorkspaceSessionId, +} + const BRIDGE_SUBNET: &str = "10.247.0.0/24"; const BRIDGE_NAME: &str = "bunkerbox0"; @@ -40,7 +45,7 @@ fn cleanup_partial_session(session_dir: Option<&PathBuf>, home_path: Option<&Pat } pub fn run( - config: &RuntimeConfig, workspace: WorkspaceHandle, container_name: &str, _share_dir: &Path, app_name: &str, vsock_enabled: bool, + config: &RuntimeConfig, workspace: WorkspaceBinding<'_>, container_name: &str, _share_dir: &Path, app_name: &str, vsock_enabled: bool, _status_fd: RawFd, ) -> Result<(), String> { if !config.oci.is_file() { @@ -168,7 +173,7 @@ pub fn run( ensure_bridge_egress_firewall(config, resolv_conf.as_deref())?; } let resolv_conf_mount = resolv_conf.as_ref().map(|path| format!("type=bind,src={},dst=/etc/resolv.conf,options=rbind:ro", path.display())); - let workspace_mount = format!("type=bind,src={},dst=/workspace,options=rbind:rw", workspace.path().display()); + let workspace_mount = format!("type=bind,src={},dst=/workspace,options=rbind:rw", workspace.path.display()); let mut container_env = Vec::new(); let mut tools_mount: Option = None; let init_cmd = String::from("/bunkerbox-tools/init.sh"); @@ -191,6 +196,7 @@ pub fn run( if vsock_enabled { container_env.push(format!("BUNKERBOX_TOOLCHAIN_PORT={TOOLCHAIN_PORT}")); + container_env.push(format!("BUNKERBOX_REMOTE_SESSION={}", workspace.remote_session.to_hex())); } if let Some(ref cmds) = config.command { diff --git a/src/lib.rs b/src/lib.rs index b0e64d5..c7b0059 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,10 +6,13 @@ pub mod daemon; pub mod kata; pub mod logging; pub mod netrelay; +pub mod loopback; pub mod overlay; pub mod proxy; pub mod remote; +pub mod remote_client; pub mod sandbox; +pub mod snapshot; pub mod tui; pub mod vscomm; pub mod workspace; diff --git a/src/loopback.rs b/src/loopback.rs new file mode 100644 index 0000000..5c938e7 --- /dev/null +++ b/src/loopback.rs @@ -0,0 +1,383 @@ +use crate::remote::{ + AuthorizedRemoteRequest, RemoteBackend, RemoteBackendError, RemoteBackendEvent, RemoteFuture, RemoteOperation, RemoteTargetId, WorkspaceSessionId, +}; +use crate::snapshot::{SnapshotBuilder, SnapshotHandle, SnapshotStore}; +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::io::AsyncReadExt; +use tokio::process::Command; +use tokio::sync::mpsc; +use tokio::time::sleep; + +pub const LOOPBACK_BUILD_TIMEOUT: Duration = Duration::from_secs(30); +const LOOPBACK_PATH: &str = "/usr/local/bin:/usr/bin:/bin"; +static NEXT_JOB_ID: AtomicU64 = AtomicU64::new(1); + +pub struct RunRemoteSession { + session_id: WorkspaceSessionId, + target: RemoteTargetId, + workspace_root: PathBuf, + snapshot_store: SnapshotStore, + snapshot_builder: SnapshotBuilder, + current_snapshot: Mutex>, + snapshot_operation: Mutex<()>, + jobs_root: PathBuf, +} + +impl RunRemoteSession { + pub fn new( + session_id: WorkspaceSessionId, target: RemoteTargetId, workspace_root: PathBuf, snapshot_store: SnapshotStore, + snapshot_builder: SnapshotBuilder, jobs_root: PathBuf, + ) -> Result { + if session_id.0 == [0; 16] { + return Err("remote session ID must be nonzero".to_string()); + } + if target.0 == [0; 16] { + return Err("remote target ID must be nonzero".to_string()); + } + let snapshot_root = snapshot_store.root_for_cleanup(); + create_private_root(&snapshot_root, "snapshot store")?; + if let Err(error) = create_private_root(&jobs_root, "loopback jobs") { + let _ = fs::remove_dir_all(&snapshot_root); + return Err(error); + } + Ok(Self { + session_id, + target, + workspace_root, + snapshot_store, + snapshot_builder, + current_snapshot: Mutex::new(None), + snapshot_operation: Mutex::new(()), + jobs_root, + }) + } + + pub fn session_id(&self) -> WorkspaceSessionId { + self.session_id + } + + pub fn target(&self) -> RemoteTargetId { + self.target + } + + pub fn workspace_root(&self) -> &Path { + &self.workspace_root + } + + pub fn snapshot_store(&self) -> SnapshotStore { + self.snapshot_store.clone() + } + + pub fn current_snapshot(&self) -> Result, String> { + self.current_snapshot.lock().map(|current| current.clone()).map_err(|_| "remote session state lock poisoned".to_string()) + } + + pub fn sync_snapshot(&self) -> Result { + let _operation = self.snapshot_operation.lock().map_err(|_| "remote session operation lock poisoned".to_string())?; + let snapshot = self.snapshot_builder.build_root(&self.workspace_root, self.session_id)?; + let new_handle = snapshot.handle().clone(); + let old_handle = self.current_snapshot()?.clone(); + if let Some(old_handle) = old_handle.filter(|old| old != &new_handle) { + self.snapshot_store.remove(&old_handle)?; + } + self.current_snapshot.lock().map_err(|_| "remote session state lock poisoned".to_string())?.replace(new_handle.clone()); + Ok(new_handle) + } + + fn materialize_current_snapshot(&self, destination: &Path) -> Result<(), String> { + let _operation = self.snapshot_operation.lock().map_err(|_| "remote session operation lock poisoned".to_string())?; + let handle = self.current_snapshot()?.ok_or_else(|| "remote build requires a successful sync".to_string())?; + self.snapshot_store.materialize(&handle, destination).map(|_| ()) + } + + fn new_job_path(&self) -> Result { + let path = self.jobs_root.join(format!("job-{}", NEXT_JOB_ID.fetch_add(1, Ordering::Relaxed))); + if path.exists() { + return Err("loopback job path already exists".to_string()); + } + Ok(path) + } +} + +impl Drop for RunRemoteSession { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.jobs_root); + let _ = fs::remove_dir_all(self.snapshot_store_root()); + } +} + +impl RunRemoteSession { + fn snapshot_store_root(&self) -> PathBuf { + // SnapshotStore deliberately exposes no public root path; this private + // cleanup path is kept alongside the run-owned workspace state. + self.snapshot_store.root_for_cleanup() + } +} + +pub struct LoopbackBackend { + session: Arc, + tools: Arc>, + timeout: Duration, +} + +impl LoopbackBackend { + pub fn new(session: Arc, tools: BTreeMap) -> Self { + Self { session, tools: Arc::new(tools), timeout: LOOPBACK_BUILD_TIMEOUT } + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } +} + +impl RemoteBackend for LoopbackBackend { + fn execute<'a>( + &'a self, request: AuthorizedRemoteRequest, events: mpsc::Sender, + ) -> RemoteFuture<'a, Result<(), RemoteBackendError>> { + let session = self.session.clone(); + let tools = self.tools.clone(); + let timeout = self.timeout; + Box::pin(async move { + match request.request().operation() { + RemoteOperation::Sync => execute_sync(session, events).await, + RemoteOperation::Build(build) => execute_build(session, tools, timeout, build, events).await, + } + }) + } +} + +async fn execute_sync(session: Arc, events: mpsc::Sender) -> Result<(), RemoteBackendError> { + send_event(&events, RemoteBackendEvent::SyncProgress { completed_bytes: 0, total_bytes: None }).await?; + let sync = tokio::task::spawn_blocking(move || session.sync_snapshot()) + .await + .map_err(|error| RemoteBackendError::Failed(format!("snapshot worker failed: {error}")))?; + sync.map_err(RemoteBackendError::Failed)?; + send_event(&events, RemoteBackendEvent::Completed { exit_code: 0 }).await +} + +async fn execute_build( + session: Arc, tools: Arc>, timeout_duration: Duration, build: &crate::remote::RemoteBuild, + events: mpsc::Sender, +) -> Result<(), RemoteBackendError> { + if !build.env().is_empty() { + return Err(RemoteBackendError::Failed("remote environment is unavailable until remote environment policy is configured".to_string())); + } + let executable = tools + .get(build.tool().as_str()) + .ok_or_else(|| RemoteBackendError::Spawn(format!("loopback tool is not configured: {}", build.tool().as_str())))?; + let job_path = session.new_job_path().map_err(RemoteBackendError::Failed)?; + let _job = JobGuard { path: job_path.clone() }; + let destination = job_path.clone(); + tokio::task::spawn_blocking({ + let session = session.clone(); + move || session.materialize_current_snapshot(&destination) + }) + .await + .map_err(|error| RemoteBackendError::Failed(format!("materialization worker failed: {error}")))? + .map_err(RemoteBackendError::Failed)?; + + let cwd = job_path.join(build.cwd().as_str()); + let cwd_metadata = fs::symlink_metadata(&cwd).map_err(|error| RemoteBackendError::Failed(format!("remote cwd is unavailable: {error}")))?; + if !cwd_metadata.file_type().is_dir() { + return Err(RemoteBackendError::Failed("remote cwd is not a directory".to_string())); + } + + let mut command = Command::new(executable); + command + .args(build.argv()) + .current_dir(&cwd) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + command.env_clear().env("PATH", LOOPBACK_PATH); + unsafe { + command.pre_exec(|| { + if libc::setpgid(0, 0) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + command.kill_on_drop(true); + let mut child = command.spawn().map_err(|error| RemoteBackendError::Spawn(format!("spawn loopback tool: {error}")))?; + let process_group = child.id().map(|pid| ProcessGroupGuard { pgid: pid as i32, active: true }); + let stdout = child.stdout.take().ok_or_else(|| RemoteBackendError::Failed("loopback child has no stdout".to_string()))?; + let stderr = child.stderr.take().ok_or_else(|| RemoteBackendError::Failed("loopback child has no stderr".to_string()))?; + let mut stdout_task = Box::pin(tokio::spawn(pump(stdout, RemoteStream::Stdout, events.clone()))); + let mut stderr_task = Box::pin(tokio::spawn(pump(stderr, RemoteStream::Stderr, events.clone()))); + let mut child_wait = Box::pin(child.wait()); + let mut timeout_sleep = Box::pin(sleep(timeout_duration)); + let mut child_status = None; + let mut stdout_done = false; + let mut stderr_done = false; + let mut failure = None; + + while child_status.is_none() || !stdout_done || !stderr_done { + tokio::select! { + status = &mut child_wait, if child_status.is_none() => { + child_status = Some(status.map_err(|error| RemoteBackendError::Failed(format!("wait for loopback tool: {error}")))); + } + result = &mut stdout_task, if !stdout_done => { + stdout_done = true; + if let Err(error) = join_pump(result).await { failure.get_or_insert(error); kill_process_group(process_group.as_ref()); } + } + result = &mut stderr_task, if !stderr_done => { + stderr_done = true; + if let Err(error) = join_pump(result).await { failure.get_or_insert(error); kill_process_group(process_group.as_ref()); } + } + _ = &mut timeout_sleep, if child_status.is_none() => { + failure.get_or_insert(RemoteBackendError::Timeout); + kill_process_group(process_group.as_ref()); + } + } + } + + if failure.is_none() { + kill_process_group(process_group.as_ref()); + } + if let Some(mut process_group) = process_group { + process_group.active = false; + } + if let Some(error) = failure { + return Err(error); + } + let status = child_status.unwrap()?; + send_event(&events, RemoteBackendEvent::Completed { exit_code: status.code().unwrap_or(-1) }).await +} + +#[derive(Clone, Copy)] +enum RemoteStream { + Stdout, + Stderr, +} + +async fn pump( + mut reader: R, stream: RemoteStream, events: mpsc::Sender, +) -> Result<(), RemoteBackendError> { + let mut buffer = [0u8; 8192]; + loop { + let count = reader.read(&mut buffer).await.map_err(|error| RemoteBackendError::Failed(format!("read loopback output: {error}")))?; + if count == 0 { + return Ok(()); + } + let event = match stream { + RemoteStream::Stdout => RemoteBackendEvent::Stdout(buffer[..count].to_vec()), + RemoteStream::Stderr => RemoteBackendEvent::Stderr(buffer[..count].to_vec()), + }; + events.send(event).await.map_err(|_| RemoteBackendError::Cancelled)?; + } +} + +async fn join_pump(result: Result, tokio::task::JoinError>) -> Result<(), RemoteBackendError> { + result.map_err(|error| RemoteBackendError::Failed(format!("loopback output task failed: {error}")))? +} + +async fn send_event(events: &mpsc::Sender, event: RemoteBackendEvent) -> Result<(), RemoteBackendError> { + events.send(event).await.map_err(|_| RemoteBackendError::Cancelled) +} + +struct JobGuard { + path: PathBuf, +} + +impl Drop for JobGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +struct ProcessGroupGuard { + pgid: i32, + active: bool, +} + +impl Drop for ProcessGroupGuard { + fn drop(&mut self) { + if self.active { + kill_process_group(Some(self)); + } + } +} + +fn kill_process_group(group: Option<&ProcessGroupGuard>) { + if let Some(group) = group { + unsafe { + libc::kill(-group.pgid, libc::SIGTERM); + libc::kill(-group.pgid, libc::SIGKILL); + } + } +} + +pub fn resolve_fixed_tools(names: impl IntoIterator) -> BTreeMap { + let mut tools = BTreeMap::new(); + for name in names { + if name.is_empty() || name.contains('/') || name.as_bytes().contains(&0) { + continue; + } + for directory in ["/usr/local/bin", "/usr/bin", "/bin"] { + let path = Path::new(directory).join(&name); + if path.is_file() { + if let Ok(metadata) = fs::metadata(&path) { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o111 != 0 { + tools.insert(name.clone(), path); + break; + } + } + } + } + } + tools +} + +pub fn cleanup_stale_roots(parent: &Path) -> Result<(), String> { + let current_uid = unsafe { libc::geteuid() }; + for entry in fs::read_dir(parent).map_err(|error| format!("read temporary runtime roots: {error}"))? { + let entry = entry.map_err(|error| format!("read temporary runtime root: {error}"))?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !name.starts_with("bunkerbox-loopback-") && !name.starts_with("bunkerbox-snapshots-") { + continue; + } + let Some(pid) = name.split('-').nth(2).and_then(|value| value.parse::().ok()) else { + continue; + }; + if process_is_alive(pid) { + continue; + } + let metadata = fs::symlink_metadata(entry.path()).map_err(|error| format!("inspect stale runtime root: {error}"))?; + if metadata.file_type().is_dir() && metadata.uid() == current_uid { + fs::remove_dir_all(entry.path()).map_err(|error| format!("remove stale runtime root: {error}"))?; + } + } + Ok(()) +} + +fn process_is_alive(pid: libc::pid_t) -> bool { + if pid <= 0 { + return false; + } + let result = unsafe { libc::kill(pid, 0) }; + result == 0 || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +fn create_private_root(path: &Path, label: &str) -> Result<(), String> { + fs::create_dir(path).map_err(|error| format!("create {label}: {error}"))?; + if let Err(error) = fs::set_permissions(path, fs::Permissions::from_mode(0o700)) { + let _ = fs::remove_dir(path); + return Err(format!("set private {label} mode: {error}")); + } + Ok(()) +} + +#[cfg(test)] +#[path = "loopback_ut.rs"] +mod tests; diff --git a/src/main.rs b/src/main.rs index a7ec69c..79a9087 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use bunkerbox::cfg::{ProjectConfig, WorkspaceMode}; -use bunkerbox::{cfg, cfgsetup, clidef, cmdrun, daemon, kata, logging, overlay, tui, vscomm, workspace}; +use bunkerbox::{cfg, cfgsetup, clidef, cmdrun, daemon, kata, logging, loopback, overlay, snapshot, tui, vscomm, workspace}; +use rand::RngCore; use std::ffi::OsString; use std::fs::File; use std::io; @@ -28,9 +29,11 @@ fn run() -> Result<(), String> { if cfg::RuntimeConfig::invoked_name()? != clidef::APPNAME { let share_dir = share_dir_from_args()?; if let Some(config) = cfg::RuntimeConfig::for_invoked_name(&share_dir)? { - let rt = tokio::runtime::Runtime::new().map_err(|e| format!("tokio: {e}"))?; - let _guard = rt.enter(); - return run_packaged_runtime(config, workspace_override, &share_dir); + return tokio::runtime::Runtime::new().map_err(|e| format!("tokio: {e}"))?.block_on(async move { + tokio::task::spawn_blocking(move || run_packaged_runtime(config, workspace_override, &share_dir)) + .await + .map_err(|error| format!("packaged runtime thread failed: {error}"))? + }); } } @@ -192,7 +195,8 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option>> = Arc::new(Mutex::new(None)); + + ensure_sudo()?; let mut sock_fds = [-1i32, -1]; if unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, sock_fds.as_mut_ptr()) } != 0 { @@ -225,52 +229,17 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option 0, Err(e) => { eprintln!("bunkerbox: {e}"); @@ -295,23 +264,60 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option> = Arc::new(Mutex::new(tui::OverlayState::new())); let status_listener = start_status_listener(overlay.clone())?; - let setup_handle = tokio::runtime::Handle::current().clone(); - let daemon_slot = daemon_holder.clone(); - let setup_thread = std::thread::spawn(move || -> Result<(), String> { - let workspace = read_workspace_handoff(setup_parent_fd)?; - if passthrough.is_empty() { - return Ok(()); + let setup_result = (|| -> Result<(workspace::WorkspaceHandle, Arc, daemon::VsockDaemon), String> { + let workspace = workspace::resolve(workspace_mode, quota, exclude.as_deref(), &name)?; + let remote_session = new_session_id(); + let target = new_target_id(); + loopback::cleanup_stale_roots(&std::env::temp_dir())?; + let snapshot_root = std::env::temp_dir().join(format!("bunkerbox-snapshots-{}-{}", std::process::id(), remote_session.to_hex())); + let jobs_root = std::env::temp_dir().join(format!("bunkerbox-loopback-{}-{}", std::process::id(), remote_session.to_hex())); + let snapshot_store = snapshot::SnapshotStore::new(&snapshot_root); + let exclusion_policy = snapshot::SnapshotExclusionPolicy::from_config(&env, exclude.as_deref())?; + let snapshot_builder = snapshot::SnapshotBuilder::new(snapshot_store.clone(), snapshot::SnapshotLimits::default(), exclusion_policy); + let session = Arc::new(loopback::RunRemoteSession::new( + bunkerbox::remote::WorkspaceSessionId(remote_session.0), + target, + workspace.path().to_path_buf(), + snapshot_store, + snapshot_builder, + jobs_root, + )?); + let allowed_tools = remote_tool_names(&passthrough); + let tools = loopback::resolve_fixed_tools(allowed_tools.clone()); + let daemon = daemon::VsockDaemon::start_with_remote( + passthrough, + env_mode, + workspace.path().to_path_buf(), + profiles, + share_dir_owned, + merged_allow, + daemon::RemoteDaemonConfig::new(session.clone(), allowed_tools, tools), + )?; + if let Err(error) = write_run_handoff(&mut setup_parent, workspace.path(), remote_session) { + tokio::runtime::Handle::current().block_on(daemon.shutdown()); + return Err(error); } - - let _guard = setup_handle.enter(); - let daemon = daemon::VsockDaemon::start(passthrough, env_mode, workspace, profiles, share_dir_owned, merged_allow)?; - *daemon_slot.lock().map_err(|_| "daemon state lock poisoned".to_string())? = Some(daemon); - Ok(()) - }); + Ok((workspace, session, daemon)) + })(); + + let (workspace, remote_session, daemon) = match setup_result { + Ok(value) => value, + Err(error) => { + unsafe { + libc::kill(pid, libc::SIGTERM); + libc::waitpid(pid, std::ptr::null_mut(), 0); + libc::close(master); + } + tokio::runtime::Handle::current().block_on(status_listener.shutdown()); + return Err(error); + } + }; + drop(setup_parent); let tui_result = tui::event_loop(master, rows, cols, parent_fd, overlay); @@ -319,20 +325,13 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option result, - Err(_) => Err("workspace setup thread panicked".to_string()), - }; - - if let Some(d) = daemon_holder.lock().map_err(|_| "daemon state lock poisoned".to_string())?.take() { - tokio::runtime::Handle::current().block_on(d.shutdown()); - } + tokio::runtime::Handle::current().block_on(daemon.shutdown()); + drop(remote_session); + drop(workspace); tokio::runtime::Handle::current().block_on(status_listener.shutdown()); tui_result?; - setup_result?; - if status != 0 { return Err(format!("child exited with status {status}")); } @@ -340,17 +339,112 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option Result<(), String> { - let bytes = path.as_os_str().as_bytes(); - let frame = encode_workspace_handoff(bytes)?; - let mut file = unsafe { File::from_raw_fd(fd) }; - io::Write::write_all(&mut file, &frame).map_err(|err| format!("write workspace handoff: {err}")) +fn new_session_id() -> vscomm::WorkspaceSessionId { + loop { + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + if bytes != [0; 16] { + return vscomm::WorkspaceSessionId(bytes); + } + } +} + +fn new_target_id() -> bunkerbox::remote::RemoteTargetId { + loop { + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + if bytes != [0; 16] { + return bunkerbox::remote::RemoteTargetId(bytes); + } + } +} + +fn remote_tool_names(entries: &[String]) -> Vec { + let mut names = std::collections::BTreeSet::new(); + for entry in entries { + let command = entry.trim().strip_suffix(" *").unwrap_or(entry.trim()); + if let Some(tool) = command.split_whitespace().next().filter(|tool| !tool.is_empty() && !tool.contains('/')) { + names.insert(tool.to_string()); + } + } + names.into_iter().collect() +} + +fn write_run_handoff(file: &mut File, path: &Path, session_id: vscomm::WorkspaceSessionId) -> Result<(), String> { + if !path.is_absolute() { + return Err("workspace handoff path must be absolute".to_string()); + } + let mut payload = path.as_os_str().as_bytes().to_vec(); + if payload.contains(&0) { + return Err("workspace path contains NUL".to_string()); + } + payload.push(0); + payload.extend_from_slice(&session_id.0); + let frame = encode_workspace_handoff(&payload)?; + io::Write::write_all(file, &frame).map_err(|err| format!("write run handoff: {err}")) +} + +fn ensure_sudo() -> Result<(), String> { + if !std::process::Command::new("sudo") + .arg("-n") + .arg("true") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) + { + let pass = bunkerbox::logging::prompt_password("Sudo password", "Enter your sudo password")?; + let mut child = std::process::Command::new("sudo") + .arg("-S") + .arg("-v") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|error| format!("failed to run sudo: {error}"))?; + io::Write::write_all(child.stdin.as_mut().ok_or_else(|| "failed to open sudo stdin".to_string())?, pass.as_bytes()) + .map_err(|error| format!("failed to write sudo password: {error}"))?; + drop(child.stdin.take()); + if !child.wait().map_err(|error| format!("sudo failed: {error}"))?.success() { + return Err("sudo: authentication failed".to_string()); + } + } + + std::thread::spawn(|| loop { + std::thread::sleep(std::time::Duration::from_secs(240)); + let _ = std::process::Command::new("sudo") + .arg("-n") + .arg("-v") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + }); + Ok(()) +} + +fn read_run_handoff(file: &mut File) -> Result<(PathBuf, vscomm::WorkspaceSessionId), String> { + let payload = read_workspace_handoff(file)?; + let bytes = payload.as_os_str().as_bytes(); + if bytes.len() < 17 || bytes[bytes.len() - 17] != 0 { + return Err("run handoff is malformed".to_string()); + } + let path = PathBuf::from(OsString::from_vec(bytes[..bytes.len() - 17].to_vec())); + if !path.is_absolute() { + return Err("run handoff path must be absolute".to_string()); + } + let mut session = [0u8; 16]; + session.copy_from_slice(&bytes[bytes.len() - 16..]); + if session == [0; 16] { + return Err("run handoff session is zero".to_string()); + } + Ok((path, vscomm::WorkspaceSessionId(session))) } -fn read_workspace_handoff(fd: RawFd) -> Result { - let mut file = unsafe { File::from_raw_fd(fd) }; +fn read_workspace_handoff(file: &mut File) -> Result { let mut header = [0u8; 8]; - io::Read::read_exact(&mut file, &mut header).map_err(|err| format!("read workspace handoff header: {err}"))?; + io::Read::read_exact(file, &mut header).map_err(|err| format!("read workspace handoff header: {err}"))?; let payload_len = u32::from_le_bytes([header[4], header[5], header[6], header[7]]) as usize; if payload_len > MAX_WORKSPACE_HANDOFF_BYTES { @@ -358,7 +452,7 @@ fn read_workspace_handoff(fd: RawFd) -> Result { } let mut payload = vec![0u8; payload_len]; - io::Read::read_exact(&mut file, &mut payload).map_err(|err| format!("read workspace handoff: {err}"))?; + io::Read::read_exact(file, &mut payload).map_err(|err| format!("read workspace handoff: {err}"))?; let mut frame = header.to_vec(); frame.extend_from_slice(&payload); diff --git a/src/remote_client.rs b/src/remote_client.rs new file mode 100644 index 0000000..16f226c --- /dev/null +++ b/src/remote_client.rs @@ -0,0 +1,43 @@ +use crate::vscomm::{RemoteBuild, RemoteEvent, RemoteEventKind, RemoteRequest, RemoteTool, RequestId, WorkspaceRelativePath, WorkspaceSessionId}; +use std::io::{Read, Write}; + +pub fn remote_sync_request(request_id: RequestId, session_id: WorkspaceSessionId) -> RemoteRequest { + RemoteRequest::sync(request_id, session_id) +} + +pub fn remote_build_request( + request_id: RequestId, session_id: WorkspaceSessionId, cwd: impl Into, tool: impl Into, argv: Vec, + env: Vec<(String, String)>, +) -> Result { + let build = RemoteBuild::new(WorkspaceRelativePath::new(cwd)?, RemoteTool::new(tool)?, argv, env)?; + Ok(RemoteRequest::build(request_id, session_id, build)) +} + +pub fn execute_remote_request_to( + stream: &mut S, request: RemoteRequest, stdout: &mut WOut, stderr: &mut WErr, +) -> Result { + let request_id = request.request_id; + request.to_frame()?.write(stream).map_err(|e| format!("send remote request: {e}"))?; + + loop { + let frame = crate::vscomm::Frame::read(stream).map_err(|e| format!("read remote event: {e}"))?; + let event = RemoteEvent::from_frame(frame)?; + if event.request_id != request_id { + return Err("remote event request ID mismatch".to_string()); + } + match event.kind { + RemoteEventKind::SyncProgress { .. } => {} + RemoteEventKind::Stdout(data) => { + stdout.write_all(&data).map_err(|e| format!("stdout: {e}"))?; + stdout.flush().map_err(|e| format!("flush stdout: {e}"))?; + } + RemoteEventKind::Stderr(data) => { + stderr.write_all(&data).map_err(|e| format!("stderr: {e}"))?; + stderr.flush().map_err(|e| format!("flush stderr: {e}"))?; + } + RemoteEventKind::Error { message, .. } => return Err(message), + RemoteEventKind::Cancelled => return Err("remote operation cancelled".to_string()), + RemoteEventKind::Completed { exit_code } => return Ok(exit_code), + } + } +} diff --git a/src/snapshot.rs b/src/snapshot.rs new file mode 100644 index 0000000..bda717a --- /dev/null +++ b/src/snapshot.rs @@ -0,0 +1,892 @@ +use crate::cfg::ProjectConfig; +use crate::remote::WorkspaceSessionId; +use crate::workspace::WorkspaceHandle; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::ffi::{CStr, CString, OsStr, OsString}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::os::fd::{AsRawFd, FromRawFd, RawFd}; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +pub const MAX_SNAPSHOT_ENTRIES: usize = 10_000; +pub const MAX_SNAPSHOT_TOTAL_BYTES: u64 = 512 * 1024 * 1024; +pub const MAX_SNAPSHOT_FILE_BYTES: u64 = 64 * 1024 * 1024; +pub const MAX_SNAPSHOT_PATH_BYTES: usize = 4 * 1024; +pub const MAX_SNAPSHOT_COMPONENT_BYTES: usize = 255; +pub const MAX_SNAPSHOT_DEPTH: usize = 64; +pub const MAX_SNAPSHOT_MANIFEST_BYTES: usize = 16 * 1024 * 1024; +pub const SNAPSHOT_COPY_BUFFER_BYTES: usize = 64 * 1024; + +static NEXT_STAGING_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SnapshotId([u8; 32]); + +impl SnapshotId { + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotRelativePath(String); + +impl SnapshotRelativePath { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_relative_path(&value, MAX_SNAPSHOT_PATH_BYTES, MAX_SNAPSHOT_COMPONENT_BYTES, MAX_SNAPSHOT_DEPTH)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SnapshotEntryKind { + Directory, + RegularFile, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotEntry { + path: SnapshotRelativePath, + kind: SnapshotEntryKind, + mode: u16, + size: u64, + content_digest: Option<[u8; 32]>, +} + +impl SnapshotEntry { + pub fn path(&self) -> &SnapshotRelativePath { + &self.path + } + + pub fn kind(&self) -> SnapshotEntryKind { + self.kind + } + + pub fn mode(&self) -> u16 { + self.mode + } + + pub fn size(&self) -> u64 { + self.size + } + + pub fn content_digest(&self) -> Option<&[u8; 32]> { + self.content_digest.as_ref() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SnapshotLimits { + pub max_entries: usize, + pub max_total_bytes: u64, + pub max_file_bytes: u64, + pub max_path_bytes: usize, + pub max_component_bytes: usize, + pub max_depth: usize, + pub max_manifest_bytes: usize, + pub max_duration: Duration, +} + +impl Default for SnapshotLimits { + fn default() -> Self { + Self { + max_entries: MAX_SNAPSHOT_ENTRIES, + max_total_bytes: MAX_SNAPSHOT_TOTAL_BYTES, + max_file_bytes: MAX_SNAPSHOT_FILE_BYTES, + max_path_bytes: MAX_SNAPSHOT_PATH_BYTES, + max_component_bytes: MAX_SNAPSHOT_COMPONENT_BYTES, + max_depth: MAX_SNAPSHOT_DEPTH, + max_manifest_bytes: MAX_SNAPSHOT_MANIFEST_BYTES, + max_duration: Duration::from_secs(60), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotExclusionPolicy { + basename_prunes: BTreeSet, + anchored_prunes: BTreeSet, +} + +impl SnapshotExclusionPolicy { + pub fn from_config(config: &ProjectConfig, runtime_exclude: Option<&[String]>) -> Result { + Self::from_patterns(config.effective_exclude(runtime_exclude)) + } + + pub fn from_patterns(patterns: impl IntoIterator) -> Result { + let mut policy = Self { basename_prunes: BTreeSet::new(), anchored_prunes: BTreeSet::new() }; + for name in [".git", ".bunker", ".bunkerbox", ".env", ".envrc", ".ssh"] { + policy.basename_prunes.insert(name.to_string()); + } + for pattern in patterns { + policy.add_pattern(&pattern)?; + } + Ok(policy) + } + + pub fn excludes(&self, path: &str) -> bool { + let components = path.split('/'); + if components.clone().any(|component| self.basename_prunes.contains(component)) { + return true; + } + self.anchored_prunes.iter().any(|prefix| path == prefix || path.starts_with(&format!("{prefix}/"))) + } + + fn add_pattern(&mut self, raw: &str) -> Result<(), String> { + let pattern = raw.trim().trim_end_matches('/'); + if pattern.is_empty() || pattern.starts_with('/') || pattern.contains('\\') { + return Err(format!("invalid snapshot exclusion: {raw}")); + } + let components = pattern.split('/').collect::>(); + if components.iter().any(|component| component.is_empty() || *component == "." || *component == "..") { + return Err(format!("invalid snapshot exclusion: {raw}")); + } + for component in &components { + if component.len() > MAX_SNAPSHOT_COMPONENT_BYTES || component.as_bytes().contains(&0) { + return Err(format!("snapshot exclusion component is too long: {raw}")); + } + } + if components.len() == 1 { + self.basename_prunes.insert(components[0].to_string()); + } else { + let normalized = components.join("/"); + if normalized.len() > MAX_SNAPSHOT_PATH_BYTES { + return Err(format!("snapshot exclusion is too long: {raw}")); + } + self.anchored_prunes.insert(normalized); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotHandle { + session_id: WorkspaceSessionId, + snapshot_id: SnapshotId, +} + +impl SnapshotHandle { + pub fn session_id(&self) -> WorkspaceSessionId { + self.session_id + } + + pub fn snapshot_id(&self) -> SnapshotId { + self.snapshot_id + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceSnapshot { + handle: SnapshotHandle, + entries: Vec, + total_file_bytes: u64, +} + +impl WorkspaceSnapshot { + pub fn handle(&self) -> &SnapshotHandle { + &self.handle + } + + pub fn entries(&self) -> &[SnapshotEntry] { + &self.entries + } + + pub fn total_file_bytes(&self) -> u64 { + self.total_file_bytes + } +} + +#[derive(Debug, Clone)] +pub struct SnapshotStore { + root: PathBuf, +} + +impl SnapshotStore { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn resolve(&self, handle: &SnapshotHandle) -> Result { + let manifest_path = self.manifest_path(handle); + let metadata = fs::symlink_metadata(&manifest_path).map_err(|error| format!("snapshot is unavailable: {error}"))?; + if !metadata.file_type().is_file() { + return Err("snapshot manifest is not a regular file".to_string()); + } + if metadata.len() > MAX_SNAPSHOT_MANIFEST_BYTES as u64 { + return Err("stored snapshot manifest exceeds limit".to_string()); + } + let stored: StoredSnapshot = serde_json::from_slice(&fs::read(&manifest_path).map_err(|error| format!("read snapshot manifest: {error}"))?) + .map_err(|error| format!("decode snapshot manifest: {error}"))?; + if stored.session_id != handle.session_id.0 { + return Err("snapshot session mismatch".to_string()); + } + let (entries, total_file_bytes) = stored.into_entries()?; + let snapshot_id = snapshot_id(&entries); + if snapshot_id != handle.snapshot_id { + return Err("snapshot manifest identity mismatch".to_string()); + } + Ok(WorkspaceSnapshot { handle: handle.clone(), entries, total_file_bytes }) + } + + pub fn remove(&self, handle: &SnapshotHandle) -> Result<(), String> { + let path = self.snapshot_path(handle); + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_dir() => fs::remove_dir_all(&path).map_err(|error| format!("remove snapshot: {error}")), + Ok(_) => Err("snapshot publication is not a directory".to_string()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("inspect snapshot for removal: {error}")), + } + } + + pub fn materialize(&self, handle: &SnapshotHandle, destination: &Path) -> Result { + let snapshot = self.resolve(handle)?; + if fs::symlink_metadata(destination).is_ok() { + return Err("materialization destination already exists".to_string()); + } + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).map_err(|error| format!("create materialization parent: {error}"))?; + } + fs::create_dir(destination).map_err(|error| format!("create materialization destination: {error}"))?; + let mut cleanup = MaterializationGuard { path: destination.to_path_buf(), committed: false }; + set_mode(destination, 0o700)?; + let source_root = open_directory(&self.files_path(handle))?; + let destination_root = open_directory(destination)?; + + for entry in snapshot.entries() { + match entry.kind { + SnapshotEntryKind::Directory => ensure_destination_directory(&destination_root, entry.path.as_str(), entry.mode)?, + SnapshotEntryKind::RegularFile => { + let source = open_relative_file(&source_root, entry.path.as_str(), libc::O_RDONLY)?; + let destination_file = create_relative_file(&destination_root, entry.path.as_str(), entry.mode)?; + copy_materialized_file( + &source, + &destination_file, + entry.size, + entry.content_digest.ok_or_else(|| "regular file has no digest".to_string())?, + entry.path.as_str(), + )?; + } + } + } + + cleanup.committed = true; + Ok(MaterializedWorkspace { root: destination.to_path_buf() }) + } + + fn manifest_path(&self, handle: &SnapshotHandle) -> PathBuf { + self.snapshot_path(handle).join("manifest.json") + } + + fn snapshot_path(&self, handle: &SnapshotHandle) -> PathBuf { + self.root.join(hex(&handle.session_id.0)).join(hex(&handle.snapshot_id.0)) + } + + fn files_path(&self, handle: &SnapshotHandle) -> PathBuf { + self.snapshot_path(handle).join("files") + } + + pub(crate) fn root_for_cleanup(&self) -> PathBuf { + self.root.clone() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MaterializedWorkspace { + root: PathBuf, +} + +impl MaterializedWorkspace { + pub fn root(&self) -> &Path { + &self.root + } +} + +struct MaterializationGuard { + path: PathBuf, + committed: bool, +} + +impl Drop for MaterializationGuard { + fn drop(&mut self) { + if !self.committed { + let _ = fs::remove_dir_all(&self.path); + } + } +} + +pub struct SnapshotBuilder { + store: SnapshotStore, + limits: SnapshotLimits, + exclusions: SnapshotExclusionPolicy, +} + +impl SnapshotBuilder { + pub fn new(store: SnapshotStore, limits: SnapshotLimits, exclusions: SnapshotExclusionPolicy) -> Self { + Self { store, limits, exclusions } + } + + pub fn build(&self, workspace: &WorkspaceHandle, session_id: WorkspaceSessionId) -> Result { + self.build_root(workspace.path(), session_id) + } + + pub(crate) fn build_root(&self, workspace_root: &Path, session_id: WorkspaceSessionId) -> Result { + validate_limits(&self.limits)?; + if session_id.0 == [0; 16] { + return Err("snapshot requires an authoritative nonzero workspace session".to_string()); + } + let started = Instant::now(); + let canonical_root = fs::canonicalize(workspace_root).map_err(|error| format!("resolve snapshot workspace: {error}"))?; + let root = open_directory(&canonical_root)?; + let root_stat = stat_fd(root.as_raw_fd())?; + let root_device = root_stat.st_dev; + let store_root = prepare_store_root(&self.store.root)?; + let staging_root = store_root.join(".staging"); + fs::create_dir_all(&staging_root).map_err(|error| format!("create snapshot staging root: {error}"))?; + set_mode(&staging_root, 0o700)?; + let stage = staging_root.join(format!("{}-{}", hex(&session_id.0), NEXT_STAGING_ID.fetch_add(1, Ordering::Relaxed))); + fs::create_dir(&stage).map_err(|error| format!("create snapshot staging directory: {error}"))?; + set_mode(&stage, 0o700)?; + let mut cleanup = StagingGuard { path: stage.clone(), committed: false }; + let files_root = stage.join("files"); + fs::create_dir(&files_root).map_err(|error| format!("create snapshot content directory: {error}"))?; + + let mut state = WalkState { + entries: Vec::new(), + total_file_bytes: 0, + started, + root_device, + stage_files: files_root, + next_buffer: vec![0; SNAPSHOT_COPY_BUFFER_BYTES], + }; + walk_directory(root.as_raw_fd(), "", 0, &self.limits, &self.exclusions, &mut state)?; + state.entries.sort_by(|left, right| left.path.as_str().cmp(right.path.as_str())); + let id = snapshot_id(&state.entries); + let stored = StoredSnapshot::from_entries(session_id, &state.entries, state.total_file_bytes); + let manifest = serde_json::to_vec(&stored).map_err(|error| format!("encode snapshot manifest: {error}"))?; + if manifest.len() > self.limits.max_manifest_bytes { + return Err(format!("snapshot manifest exceeds maximum size {}", self.limits.max_manifest_bytes)); + } + fs::write(stage.join("manifest.json"), manifest).map_err(|error| format!("write snapshot manifest: {error}"))?; + + let final_session = store_root.join(hex(&session_id.0)); + fs::create_dir_all(&final_session).map_err(|error| format!("create snapshot session directory: {error}"))?; + set_mode(&final_session, 0o700)?; + let final_path = final_session.join(hex(&id.0)); + if !final_path.exists() { + fs::rename(&stage, &final_path).map_err(|error| format!("publish snapshot: {error}"))?; + } else { + if !fs::symlink_metadata(&final_path).map_err(|error| format!("inspect existing snapshot: {error}"))?.file_type().is_dir() { + return Err("existing snapshot publication is not a directory".to_string()); + } + fs::remove_dir_all(&stage).map_err(|error| format!("discard duplicate snapshot staging: {error}"))?; + } + cleanup.committed = true; + Ok(WorkspaceSnapshot { + handle: SnapshotHandle { session_id, snapshot_id: id }, + entries: state.entries, + total_file_bytes: state.total_file_bytes, + }) + } +} + +struct StagingGuard { + path: PathBuf, + committed: bool, +} + +impl Drop for StagingGuard { + fn drop(&mut self) { + if !self.committed { + let _ = fs::remove_dir_all(&self.path); + } + } +} + +struct WalkState { + entries: Vec, + total_file_bytes: u64, + started: Instant, + root_device: libc::dev_t, + stage_files: PathBuf, + next_buffer: Vec, +} + +fn walk_directory( + directory_fd: RawFd, parent: &str, depth: usize, limits: &SnapshotLimits, exclusions: &SnapshotExclusionPolicy, state: &mut WalkState, +) -> Result<(), String> { + check_deadline(state.started, limits)?; + if depth > limits.max_depth { + return Err(format!("snapshot exceeds maximum depth {}", limits.max_depth)); + } + for name in read_directory_names(directory_fd)? { + check_deadline(state.started, limits)?; + let component = name.to_str().ok_or_else(|| "snapshot contains a non-UTF-8 path component".to_string())?; + validate_component(component, limits.max_component_bytes)?; + let relative = if parent.is_empty() { component.to_string() } else { format!("{parent}/{component}") }; + validate_relative_path(&relative, limits.max_path_bytes, limits.max_component_bytes, limits.max_depth)?; + if exclusions.excludes(&relative) { + continue; + } + let child_stat = stat_at(directory_fd, &name)?; + if child_stat.st_dev != state.root_device { + return Err(format!("snapshot entry crosses filesystem boundary: {relative}")); + } + let entry_kind = child_kind(&child_stat, &relative)?; + match entry_kind { + SnapshotEntryKind::Directory => { + add_entry_limit(state.entries.len(), limits)?; + let child = open_child_directory(directory_fd, &name, &relative)?; + let mode = normalized_mode(child_stat.st_mode); + state.entries.push(SnapshotEntry { + path: SnapshotRelativePath::new(relative.clone())?, + kind: SnapshotEntryKind::Directory, + mode, + size: 0, + content_digest: None, + }); + walk_directory(child.as_raw_fd(), &relative, depth + 1, limits, exclusions, state)?; + } + SnapshotEntryKind::RegularFile => { + add_entry_limit(state.entries.len(), limits)?; + if child_stat.st_nlink > 1 { + return Err(format!("snapshot rejects hard-linked file: {relative}")); + } + let size = checked_file_size(child_stat.st_size, limits.max_file_bytes, &relative)?; + let new_total = state.total_file_bytes.checked_add(size).ok_or_else(|| "snapshot total size overflow".to_string())?; + if new_total > limits.max_total_bytes { + return Err(format!("snapshot exceeds maximum total size {}", limits.max_total_bytes)); + } + let (digest, bytes_read) = copy_and_hash_file(directory_fd, &name, &relative, size, &child_stat, limits, state)?; + if bytes_read != size { + return Err(format!("file changed while snapshotting: {relative}")); + } + state.total_file_bytes = new_total; + state.entries.push(SnapshotEntry { + path: SnapshotRelativePath::new(relative)?, + kind: SnapshotEntryKind::RegularFile, + mode: normalized_mode(child_stat.st_mode), + size, + content_digest: Some(digest), + }); + } + } + } + Ok(()) +} + +fn copy_and_hash_file( + parent_fd: RawFd, name: &OsStr, relative: &str, expected_size: u64, expected_stat: &libc::stat, limits: &SnapshotLimits, state: &mut WalkState, +) -> Result<([u8; 32], u64), String> { + let file = open_child_file(parent_fd, name, relative)?; + let opened_stat = stat_fd(file.as_raw_fd())?; + compare_file_stat(expected_stat, &opened_stat, relative)?; + let destination = state.stage_files.join(relative); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).map_err(|error| format!("create staged parent for {relative}: {error}"))?; + } + let mut staged = File::create(&destination).map_err(|error| format!("create staged file {relative}: {error}"))?; + let mut hasher = Sha256::new(); + let mut read_bytes = 0u64; + loop { + check_deadline(state.started, limits)?; + let count = (&file).read(&mut state.next_buffer).map_err(|error| format!("read snapshot file {relative}: {error}"))?; + if count == 0 { + break; + } + read_bytes = read_bytes.checked_add(count as u64).ok_or_else(|| format!("snapshot file size overflow: {relative}"))?; + if read_bytes > expected_size || read_bytes > limits.max_file_bytes { + return Err(format!("file changed beyond snapshot limit: {relative}")); + } + hasher.update(&state.next_buffer[..count]); + staged.write_all(&state.next_buffer[..count]).map_err(|error| format!("stage snapshot file {relative}: {error}"))?; + } + staged.sync_all().map_err(|error| format!("flush staged file {relative}: {error}"))?; + let final_stat = stat_fd(file.as_raw_fd())?; + compare_file_stat(expected_stat, &final_stat, relative)?; + if read_bytes != expected_size { + return Err(format!("file changed while snapshotting: {relative}")); + } + set_mode(&destination, normalized_mode(expected_stat.st_mode))?; + Ok((hasher.finalize().into(), read_bytes)) +} + +fn check_deadline(started: Instant, limits: &SnapshotLimits) -> Result<(), String> { + if started.elapsed() >= limits.max_duration { + return Err("snapshot creation deadline exceeded".to_string()); + } + Ok(()) +} + +fn add_entry_limit(count: usize, limits: &SnapshotLimits) -> Result<(), String> { + if count >= limits.max_entries { + return Err(format!("snapshot exceeds maximum entry count {}", limits.max_entries)); + } + Ok(()) +} + +fn validate_limits(limits: &SnapshotLimits) -> Result<(), String> { + if limits.max_entries == 0 + || limits.max_total_bytes == 0 + || limits.max_file_bytes == 0 + || limits.max_path_bytes == 0 + || limits.max_path_bytes > MAX_SNAPSHOT_PATH_BYTES + || limits.max_component_bytes == 0 + || limits.max_component_bytes > MAX_SNAPSHOT_COMPONENT_BYTES + || limits.max_depth == 0 + || limits.max_manifest_bytes == 0 + || limits.max_duration.is_zero() + { + return Err("invalid snapshot limits".to_string()); + } + Ok(()) +} + +fn checked_file_size(size: libc::off_t, max: u64, path: &str) -> Result { + if size < 0 { + return Err(format!("snapshot file has invalid size: {path}")); + } + let size = size as u64; + if size > max { + return Err(format!("snapshot file exceeds maximum size {max}: {path}")); + } + Ok(size) +} + +fn validate_relative_path(value: &str, max_path: usize, max_component: usize, max_depth: usize) -> Result<(), String> { + if value.is_empty() || value.starts_with('/') || value.contains('\\') || value.len() > max_path { + return Err(format!("invalid snapshot relative path: {value}")); + } + let components = value.split('/').collect::>(); + if components.len() > max_depth || components.iter().any(|part| part.is_empty() || *part == "." || *part == "..") { + return Err(format!("invalid snapshot relative path: {value}")); + } + components.iter().try_for_each(|part| validate_component(part, max_component)) +} + +fn validate_component(value: &str, max: usize) -> Result<(), String> { + if value.is_empty() || value == "." || value == ".." || value.len() > max || value.as_bytes().contains(&0) { + return Err(format!("invalid snapshot path component: {value}")); + } + Ok(()) +} + +fn child_kind(stat: &libc::stat, path: &str) -> Result { + match stat.st_mode & libc::S_IFMT { + libc::S_IFDIR => Ok(SnapshotEntryKind::Directory), + libc::S_IFREG => Ok(SnapshotEntryKind::RegularFile), + libc::S_IFLNK => Err(format!("snapshot rejects symlink: {path}")), + _ => Err(format!("snapshot rejects special file: {path}")), + } +} + +fn normalized_mode(mode: libc::mode_t) -> u16 { + (mode & 0o777) as u16 +} + +fn compare_file_stat(expected: &libc::stat, actual: &libc::stat, path: &str) -> Result<(), String> { + if expected.st_dev != actual.st_dev + || expected.st_ino != actual.st_ino + || expected.st_mode & libc::S_IFMT != actual.st_mode & libc::S_IFMT + || expected.st_size != actual.st_size + || expected.st_nlink != actual.st_nlink + || expected.st_mode & 0o777 != actual.st_mode & 0o777 + { + return Err(format!("file changed while snapshotting: {path}")); + } + Ok(()) +} + +fn set_mode(path: &Path, mode: u16) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(mode as u32)).map_err(|error| format!("set staged file mode: {error}")) +} + +fn prepare_store_root(root: &Path) -> Result { + fs::create_dir_all(root).map_err(|error| format!("create snapshot store: {error}"))?; + fs::canonicalize(root).map_err(|error| format!("resolve snapshot store: {error}")) +} + +fn open_directory(path: &Path) -> Result { + OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path) + .map_err(|error| format!("open snapshot workspace: {error}")) +} + +fn open_child_directory(parent: RawFd, name: &OsStr, path: &str) -> Result { + open_at(parent, name, libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .map_err(|error| format!("open snapshot directory {path}: {error}")) +} + +fn open_child_file(parent: RawFd, name: &OsStr, path: &str) -> Result { + open_at(parent, name, libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC).map_err(|error| format!("open snapshot file {path}: {error}")) +} + +fn open_at(parent: RawFd, name: &OsStr, flags: i32) -> io::Result { + let name = CString::new(name.as_bytes()).map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL in snapshot path"))?; + let fd = unsafe { libc::openat(parent, name.as_ptr(), flags, 0) }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + Ok(unsafe { File::from_raw_fd(fd) }) +} + +fn open_at_mode(parent: RawFd, name: &OsStr, flags: i32, mode: u32) -> io::Result { + let name = CString::new(name.as_bytes()).map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL in snapshot path"))?; + let fd = unsafe { libc::openat(parent, name.as_ptr(), flags, mode) }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + Ok(unsafe { File::from_raw_fd(fd) }) +} + +fn ensure_destination_directory(root: &File, relative: &str, mode: u16) -> Result<(), String> { + let mut current = root.try_clone().map_err(|error| format!("clone materialization root: {error}"))?; + for component in relative.split('/') { + let name = OsStr::new(component); + let name = CString::new(name.as_bytes()).map_err(|_| "NUL in materialization path".to_string())?; + let result = unsafe { libc::mkdirat(current.as_raw_fd(), name.as_ptr(), 0o700) }; + if result != 0 { + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::AlreadyExists { + return Err(format!("create materialized directory {relative}: {error}")); + } + } + current = open_at(current.as_raw_fd(), OsStr::new(component), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .map_err(|error| format!("open materialized directory {relative}: {error}"))?; + } + if unsafe { libc::fchmod(current.as_raw_fd(), mode as libc::mode_t) } != 0 { + return Err(format!("set materialized directory mode {relative}: {}", io::Error::last_os_error())); + } + Ok(()) +} + +fn create_relative_file(root: &File, relative: &str, mode: u16) -> Result { + let mut components = relative.split('/').collect::>(); + let file_name = components.pop().ok_or_else(|| "empty materialization path".to_string())?; + let mut parent = root.try_clone().map_err(|error| format!("clone materialization root: {error}"))?; + for component in components { + parent = open_at(parent.as_raw_fd(), OsStr::new(component), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .map_err(|error| format!("open materialized parent {relative}: {error}"))?; + } + open_at_mode( + parent.as_raw_fd(), + OsStr::new(file_name), + libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC, + (mode & 0o777) as u32, + ) + .map_err(|error| format!("create materialized file {relative}: {error}")) +} + +fn open_relative_file(root: &File, relative: &str, flags: i32) -> Result { + let mut components = relative.split('/').collect::>(); + let file_name = components.pop().ok_or_else(|| "empty snapshot content path".to_string())?; + let mut parent = root.try_clone().map_err(|error| format!("clone snapshot content root: {error}"))?; + for component in components { + parent = open_at(parent.as_raw_fd(), OsStr::new(component), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .map_err(|error| format!("open snapshot content parent {relative}: {error}"))?; + } + open_at(parent.as_raw_fd(), OsStr::new(file_name), flags | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .map_err(|error| format!("open snapshot content {relative}: {error}")) +} + +fn copy_materialized_file(source: &File, destination: &File, expected_size: u64, expected_digest: [u8; 32], path: &str) -> Result<(), String> { + let mut source = source.try_clone().map_err(|error| format!("clone snapshot content {path}: {error}"))?; + let mut destination = destination.try_clone().map_err(|error| format!("clone materialized file {path}: {error}"))?; + let mut buffer = vec![0u8; SNAPSHOT_COPY_BUFFER_BYTES]; + let mut hasher = Sha256::new(); + let mut copied = 0u64; + loop { + let count = source.read(&mut buffer).map_err(|error| format!("read snapshot content {path}: {error}"))?; + if count == 0 { + break; + } + copied = copied.checked_add(count as u64).ok_or_else(|| format!("materialized size overflow: {path}"))?; + if copied > expected_size { + return Err(format!("snapshot content is larger than manifest: {path}")); + } + hasher.update(&buffer[..count]); + destination.write_all(&buffer[..count]).map_err(|error| format!("write materialized file {path}: {error}"))?; + } + if copied != expected_size || hasher.finalize().as_slice() != expected_digest { + return Err(format!("snapshot content digest mismatch: {path}")); + } + destination.sync_all().map_err(|error| format!("flush materialized file {path}: {error}"))?; + Ok(()) +} + +fn stat_at(parent: RawFd, name: &OsStr) -> Result { + let name = CString::new(name.as_bytes()).map_err(|_| "NUL in snapshot path".to_string())?; + let mut stat = unsafe { std::mem::zeroed::() }; + if unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) } != 0 { + return Err(format!("stat snapshot entry: {}", io::Error::last_os_error())); + } + Ok(stat) +} + +fn stat_fd(fd: RawFd) -> Result { + let mut stat = unsafe { std::mem::zeroed::() }; + if unsafe { libc::fstat(fd, &mut stat) } != 0 { + return Err(format!("stat snapshot descriptor: {}", io::Error::last_os_error())); + } + Ok(stat) +} + +fn read_directory_names(fd: RawFd) -> Result, String> { + let duplicate = unsafe { libc::dup(fd) }; + if duplicate < 0 { + return Err(format!("duplicate snapshot directory: {}", io::Error::last_os_error())); + } + let stream = unsafe { libc::fdopendir(duplicate) }; + if stream.is_null() { + unsafe { libc::close(duplicate) }; + return Err(format!("open snapshot directory stream: {}", io::Error::last_os_error())); + } + let stream = DirectoryStream(stream); + let mut names = Vec::new(); + loop { + set_errno(0); + let entry = unsafe { libc::readdir(stream.0) }; + if entry.is_null() { + let errno = get_errno(); + if errno != 0 { + return Err(format!("read snapshot directory: {errno}")); + } + break; + } + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes(); + if name != b"." && name != b".." { + names.push(OsString::from_vec(name.to_vec())); + } + } + names.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes())); + Ok(names) +} + +struct DirectoryStream(*mut libc::DIR); + +impl Drop for DirectoryStream { + fn drop(&mut self) { + unsafe { libc::closedir(self.0) }; + } +} + +fn set_errno(value: i32) { + unsafe { *libc::__errno_location() = value }; +} + +fn get_errno() -> i32 { + unsafe { *libc::__errno_location() } +} + +#[derive(Serialize, Deserialize)] +struct StoredSnapshot { + session_id: [u8; 16], + entries: Vec, + total_file_bytes: u64, +} + +#[derive(Serialize, Deserialize)] +struct StoredEntry { + path: String, + kind: SnapshotEntryKind, + mode: u16, + size: u64, + content_digest: Option<[u8; 32]>, +} + +impl StoredSnapshot { + fn from_entries(session_id: WorkspaceSessionId, entries: &[SnapshotEntry], total_file_bytes: u64) -> Self { + Self { + session_id: session_id.0, + entries: entries + .iter() + .map(|entry| StoredEntry { + path: entry.path.as_str().to_string(), + kind: entry.kind, + mode: entry.mode, + size: entry.size, + content_digest: entry.content_digest, + }) + .collect(), + total_file_bytes, + } + } + + fn into_entries(self) -> Result<(Vec, u64), String> { + if self.entries.len() > MAX_SNAPSHOT_ENTRIES { + return Err("stored snapshot exceeds maximum entry count".to_string()); + } + let mut total_file_bytes = 0u64; + let mut previous_path = None; + let entries = self + .entries + .into_iter() + .map(|entry| { + let path = SnapshotRelativePath::new(entry.path)?; + if previous_path.as_deref().is_some_and(|previous: &str| previous >= path.as_str()) { + return Err("snapshot manifest entries are not strictly ordered".to_string()); + } + previous_path = Some(path.as_str().to_string()); + if matches!(entry.kind, SnapshotEntryKind::Directory) && (entry.size != 0 || entry.content_digest.is_some()) { + return Err("invalid directory snapshot entry".to_string()); + } + if matches!(entry.kind, SnapshotEntryKind::RegularFile) && entry.content_digest.is_none() { + return Err("invalid regular-file snapshot entry".to_string()); + } + if matches!(entry.kind, SnapshotEntryKind::RegularFile) { + if entry.size > MAX_SNAPSHOT_FILE_BYTES { + return Err("stored snapshot file exceeds maximum size".to_string()); + } + total_file_bytes = total_file_bytes.checked_add(entry.size).ok_or_else(|| "snapshot manifest size overflow".to_string())?; + if total_file_bytes > MAX_SNAPSHOT_TOTAL_BYTES { + return Err("stored snapshot exceeds maximum total size".to_string()); + } + } + Ok(SnapshotEntry { path, kind: entry.kind, mode: entry.mode & 0o777, size: entry.size, content_digest: entry.content_digest }) + }) + .collect::, String>>()?; + if total_file_bytes != self.total_file_bytes { + return Err("snapshot manifest total size mismatch".to_string()); + } + Ok((entries, total_file_bytes)) + } +} + +fn snapshot_id(entries: &[SnapshotEntry]) -> SnapshotId { + let mut canonical = Vec::new(); + for entry in entries { + canonical.push(match entry.kind { + SnapshotEntryKind::Directory => 0, + SnapshotEntryKind::RegularFile => 1, + }); + canonical.extend_from_slice(&(entry.path.as_str().len() as u32).to_le_bytes()); + canonical.extend_from_slice(entry.path.as_str().as_bytes()); + canonical.extend_from_slice(&entry.mode.to_le_bytes()); + canonical.extend_from_slice(&entry.size.to_le_bytes()); + if let Some(digest) = entry.content_digest { + canonical.extend_from_slice(&digest); + } + } + SnapshotId(Sha256::digest(canonical).into()) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +#[path = "snapshot_ut.rs"] +mod tests; diff --git a/src/vscomm/mod.rs b/src/vscomm/mod.rs index 71f3474..50c1b07 100644 --- a/src/vscomm/mod.rs +++ b/src/vscomm/mod.rs @@ -69,6 +69,37 @@ pub struct RequestId(pub [u8; 16]); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct WorkspaceSessionId(pub [u8; 16]); +impl WorkspaceSessionId { + pub fn from_hex(value: &str) -> Result { + if value.len() != 32 { + return Err("remote session ID must contain 32 hexadecimal characters".to_string()); + } + let mut bytes = [0u8; 16]; + for (index, byte) in bytes.iter_mut().enumerate() { + let high = hex_digit(value.as_bytes()[index * 2]).ok_or_else(|| "remote session ID is not hexadecimal".to_string())?; + let low = hex_digit(value.as_bytes()[index * 2 + 1]).ok_or_else(|| "remote session ID is not hexadecimal".to_string())?; + *byte = (high << 4) | low; + } + if bytes == [0; 16] { + return Err("remote session ID must be nonzero".to_string()); + } + Ok(Self(bytes)) + } + + pub fn to_hex(self) -> String { + self.0.iter().map(|byte| format!("{byte:02x}")).collect() + } +} + +fn hex_digit(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkspaceRelativePath(String); From a32c90d19247cf9ffaa02f2ce3f37da69c01d080 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 2 Aug 2026 23:00:40 +0200 Subject: [PATCH 14/25] Fix regression: TUI rendering on a wrong channel before the first boot phase --- src/logging.rs | 32 +++++---- src/main.rs | 169 +++++++++++++++++++++++++++++++++-------------- src/tui.rs | 69 ++++++++++++------- src/tui/popup.rs | 3 + 4 files changed, 187 insertions(+), 86 deletions(-) diff --git a/src/logging.rs b/src/logging.rs index 868b6f4..e243936 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -56,6 +56,10 @@ pub fn set_status_fd(fd: RawFd) { STATUS_FD.with(|f| *f.borrow_mut() = Some(fd)); } +pub fn clear_status_fd() { + STATUS_FD.with(|f| *f.borrow_mut() = None); +} + /// Sends a password prompt to the TUI, blocks reading the response from the status fd. pub fn prompt_password(title: &str, prompt: &str) -> Result { let fd = STATUS_FD.with(|f| f.borrow().ok_or("status fd not set".to_string()))?; @@ -68,18 +72,22 @@ pub fn prompt_password(title: &str, prompt: &str) -> Result { libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()); } - let mut response = Vec::new(); - let mut byte = [0u8; 1]; - loop { - let n = unsafe { libc::read(fd, byte.as_mut_ptr() as *mut libc::c_void, 1) }; - if n <= 0 { - return Err("failed to read password response".to_string()); - } - if byte[0] == b'\n' { - break; + let result = (|| { + let mut response = Vec::new(); + let mut byte = [0u8; 1]; + loop { + let n = unsafe { libc::read(fd, byte.as_mut_ptr() as *mut libc::c_void, 1) }; + if n <= 0 { + return Err("failed to read password response".to_string()); + } + if byte[0] == b'\n' { + break; + } + response.push(byte[0]); } - response.push(byte[0]); - } + + String::from_utf8(response).map_err(|e| format!("invalid password encoding: {e}")) + })(); let payload = crate::vscomm::encode_ui_payload("password", "hide", "", ""); let mut buf = b"@".to_vec(); @@ -89,7 +97,7 @@ pub fn prompt_password(title: &str, prompt: &str) -> Result { libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()); } - String::from_utf8(response).map_err(|e| format!("invalid password encoding: {e}")) + result } pub fn log(msg: &str) { diff --git a/src/main.rs b/src/main.rs index 79a9087..d664b23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,12 +4,13 @@ use rand::RngCore; use std::ffi::OsString; use std::fs::File; use std::io; -use std::os::fd::{FromRawFd, RawFd}; +use std::os::fd::{AsRawFd, FromRawFd, RawFd}; use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; const WORKSPACE_HANDOFF_MAGIC: &[u8; 4] = b"WS01"; +const STARTUP_READY_MAGIC: &[u8; 4] = b"RDY1"; const MAX_WORKSPACE_HANDOFF_BYTES: usize = 64 * 1024; fn main() { @@ -196,8 +197,6 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option> = Arc::new(Mutex::new(tui::OverlayState::new())); - let status_listener = start_status_listener(overlay.clone())?; - - let setup_result = (|| -> Result<(workspace::WorkspaceHandle, Arc, daemon::VsockDaemon), String> { - let workspace = workspace::resolve(workspace_mode, quota, exclude.as_deref(), &name)?; - let remote_session = new_session_id(); - let target = new_target_id(); - loopback::cleanup_stale_roots(&std::env::temp_dir())?; - let snapshot_root = std::env::temp_dir().join(format!("bunkerbox-snapshots-{}-{}", std::process::id(), remote_session.to_hex())); - let jobs_root = std::env::temp_dir().join(format!("bunkerbox-loopback-{}-{}", std::process::id(), remote_session.to_hex())); - let snapshot_store = snapshot::SnapshotStore::new(&snapshot_root); - let exclusion_policy = snapshot::SnapshotExclusionPolicy::from_config(&env, exclude.as_deref())?; - let snapshot_builder = snapshot::SnapshotBuilder::new(snapshot_store.clone(), snapshot::SnapshotLimits::default(), exclusion_policy); - let session = Arc::new(loopback::RunRemoteSession::new( - bunkerbox::remote::WorkspaceSessionId(remote_session.0), - target, - workspace.path().to_path_buf(), - snapshot_store, - snapshot_builder, - jobs_root, - )?); - let allowed_tools = remote_tool_names(&passthrough); - let tools = loopback::resolve_fixed_tools(allowed_tools.clone()); - let daemon = daemon::VsockDaemon::start_with_remote( - passthrough, - env_mode, - workspace.path().to_path_buf(), - profiles, - share_dir_owned, - merged_allow, - daemon::RemoteDaemonConfig::new(session.clone(), allowed_tools, tools), - )?; - if let Err(error) = write_run_handoff(&mut setup_parent, workspace.path(), remote_session) { - tokio::runtime::Handle::current().block_on(daemon.shutdown()); - return Err(error); + let mut startup_fds = [-1i32, -1]; + if unsafe { libc::pipe(startup_fds.as_mut_ptr()) } != 0 { + unsafe { + libc::kill(pid, libc::SIGTERM); + libc::waitpid(pid, std::ptr::null_mut(), 0); + libc::close(master); } - Ok((workspace, session, daemon)) - })(); + return Err(format!("startup status pipe: {}", std::io::Error::last_os_error())); + } + let (startup_status_read, startup_status_write) = (startup_fds[0], startup_fds[1]); - let (workspace, remote_session, daemon) = match setup_result { - Ok(value) => value, + let overlay: Arc> = Arc::new(Mutex::new(tui::OverlayState::new())); + let status_listener = match start_status_listener(overlay.clone()) { + Ok(listener) => listener, Err(error) => { unsafe { + libc::close(startup_status_read); + libc::close(startup_status_write); libc::kill(pid, libc::SIGTERM); libc::waitpid(pid, std::ptr::null_mut(), 0); libc::close(master); } - tokio::runtime::Handle::current().block_on(status_listener.shutdown()); return Err(error); } }; - drop(setup_parent); - let tui_result = tui::event_loop(master, rows, cols, parent_fd, overlay); + let setup_handle = tokio::runtime::Handle::current().clone(); + let setup_thread = + std::thread::spawn(move || -> Result<(workspace::WorkspaceHandle, Arc, daemon::VsockDaemon), String> { + let mut setup_parent = unsafe { File::from_raw_fd(setup_parent_fd) }; + let startup_status = unsafe { File::from_raw_fd(startup_status_write) }; + logging::set_status_fd(startup_status.as_raw_fd()); + let _runtime_guard = setup_handle.enter(); + + if let Err(error) = read_startup_ready(&mut setup_parent) { + logging::log(&format!("Startup failed: {error}")); + return Err(error); + } + + let setup_result = (|| -> Result<(workspace::WorkspaceHandle, Arc, daemon::VsockDaemon), String> { + logging::log("Preparing workspace..."); + let workspace = workspace::resolve(workspace_mode, quota, exclude.as_deref(), &name)?; + let remote_session = new_session_id(); + let target = new_target_id(); + logging::log("Preparing remote session..."); + loopback::cleanup_stale_roots(&std::env::temp_dir())?; + let snapshot_root = std::env::temp_dir().join(format!("bunkerbox-snapshots-{}-{}", std::process::id(), remote_session.to_hex())); + let jobs_root = std::env::temp_dir().join(format!("bunkerbox-loopback-{}-{}", std::process::id(), remote_session.to_hex())); + let snapshot_store = snapshot::SnapshotStore::new(&snapshot_root); + let exclusion_policy = snapshot::SnapshotExclusionPolicy::from_config(&env, exclude.as_deref())?; + let snapshot_builder = snapshot::SnapshotBuilder::new(snapshot_store.clone(), snapshot::SnapshotLimits::default(), exclusion_policy); + let session = Arc::new(loopback::RunRemoteSession::new( + bunkerbox::remote::WorkspaceSessionId(remote_session.0), + target, + workspace.path().to_path_buf(), + snapshot_store, + snapshot_builder, + jobs_root, + )?); + let allowed_tools = remote_tool_names(&passthrough); + let tools = loopback::resolve_fixed_tools(allowed_tools.clone()); + logging::log("Starting remote daemon..."); + let daemon = daemon::VsockDaemon::start_with_remote( + passthrough, + env_mode, + workspace.path().to_path_buf(), + profiles, + share_dir_owned, + merged_allow, + daemon::RemoteDaemonConfig::new(session.clone(), allowed_tools, tools), + )?; + if let Err(error) = write_run_handoff(&mut setup_parent, workspace.path(), remote_session) { + tokio::runtime::Handle::current().block_on(daemon.shutdown()); + return Err(error); + } + Ok((workspace, session, daemon)) + })(); + + if let Err(error) = &setup_result { + logging::log(&format!("Startup failed: {error}")); + } + setup_result + }); + + let tui_result = tui::event_loop(master, rows, cols, parent_fd, startup_status_read, overlay); + let tui_error = tui_result.err(); + if tui_error.is_some() { + unsafe { libc::kill(pid, libc::SIGTERM) }; + } + + let setup_result = match setup_thread.join() { + Ok(result) => result, + Err(_) => Err("workspace setup thread panicked".to_string()), + }; let mut status: i32 = 0; unsafe { libc::waitpid(pid, &mut status, 0) }; unsafe { libc::close(master) }; + unsafe { libc::close(startup_status_read) }; - tokio::runtime::Handle::current().block_on(daemon.shutdown()); - drop(remote_session); - drop(workspace); + let (setup_state, setup_error) = match setup_result { + Ok((workspace, remote_session, daemon)) => { + tokio::runtime::Handle::current().block_on(daemon.shutdown()); + drop(remote_session); + drop(workspace); + (true, None) + } + Err(error) => (false, Some(error)), + }; tokio::runtime::Handle::current().block_on(status_listener.shutdown()); - tui_result?; + if let Some(error) = tui_error { + return Err(error); + } + if let Some(error) = setup_error { + return Err(error); + } + debug_assert!(setup_state); if status != 0 { return Err(format!("child exited with status {status}")); } @@ -384,6 +440,19 @@ fn write_run_handoff(file: &mut File, path: &Path, session_id: vscomm::Workspace io::Write::write_all(file, &frame).map_err(|err| format!("write run handoff: {err}")) } +fn write_startup_ready(file: &mut File) -> Result<(), String> { + io::Write::write_all(file, STARTUP_READY_MAGIC).map_err(|err| format!("write startup readiness: {err}")) +} + +fn read_startup_ready(file: &mut File) -> Result<(), String> { + let mut ready = [0u8; STARTUP_READY_MAGIC.len()]; + io::Read::read_exact(file, &mut ready).map_err(|err| format!("read startup readiness: {err}"))?; + if &ready != STARTUP_READY_MAGIC { + return Err("startup readiness has an invalid type".to_string()); + } + Ok(()) +} + fn ensure_sudo() -> Result<(), String> { if !std::process::Command::new("sudo") .arg("-n") diff --git a/src/tui.rs b/src/tui.rs index 2aefed8..930b574 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -550,7 +550,9 @@ fn screen_has_ascii_alphanumeric(screen: &vt100::Screen) -> bool { /// /// `overlay` is shared with the VSOCK status listener so VM-originated /// UI commands can update popups, progress bars, and status text. -pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, overlay: Arc>) -> Result<(), String> { +pub fn event_loop( + master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, startup_status_fd: RawFd, overlay: Arc>, +) -> Result<(), String> { let stdin_fd = io::stdin().as_raw_fd(); let mut stdout = io::stdout(); @@ -569,6 +571,8 @@ pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, over let mut last_cols = cols; let mut status_buf = Vec::new(); + let mut startup_status_buf = Vec::new(); + let mut startup_status_fd = startup_status_fd; let mut mouse_capture_enabled = false; unsafe { @@ -594,9 +598,10 @@ pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, over libc::pollfd { fd: master_fd, events: libc::POLLIN, revents: 0 }, libc::pollfd { fd: stdin_fd, events: libc::POLLIN, revents: 0 }, libc::pollfd { fd: status_fd, events: libc::POLLIN, revents: 0 }, + libc::pollfd { fd: startup_status_fd, events: libc::POLLIN, revents: 0 }, ]; - let ret = unsafe { libc::poll(fds.as_mut_ptr(), 3, 16) }; + let ret = unsafe { libc::poll(fds.as_mut_ptr(), 4, 16) }; if ret == -1 { let err = io::Error::last_os_error(); @@ -638,6 +643,16 @@ pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, over } } + if fds[2].revents & (libc::POLLIN | libc::POLLHUP) != 0 { + read_status_messages(status_fd, &mut status_buf, &overlay); + } + if startup_status_fd >= 0 + && fds[3].revents & (libc::POLLIN | libc::POLLHUP) != 0 + && read_status_messages(startup_status_fd, &mut startup_status_buf, &overlay) == 0 + { + startup_status_fd = -1; + } + if fds[1].revents & libc::POLLIN != 0 { if let Ok(input_event) = event::read() { match input_event { @@ -667,28 +682,6 @@ pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, over } } - if fds[2].revents & (libc::POLLIN | libc::POLLHUP) != 0 { - let mut chunk = [0u8; 256]; - let n = unsafe { libc::read(status_fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) }; - if n > 0 { - status_buf.extend_from_slice(&chunk[..n as usize]); - } - while let Some(pos) = status_buf.iter().position(|&b| b == b'\n') { - let line = String::from_utf8_lossy(&status_buf[..pos]).into_owned(); - status_buf.drain(..=pos); - if let Some(cmd) = line.strip_prefix('@') { - if let Some((widget, cmd, opts, val)) = vscomm::decode_ui_payload(cmd.as_bytes()) { - let mut state = overlay.lock().unwrap(); - dispatch_ui_command(&mut state, widget, cmd, opts, val); - } - } else { - let mut state = overlay.lock().unwrap(); - let title = state.popup_title.clone(); - state.popup.show_info(title, &line, Some(palette::FG), Some(palette::ACCENT)); - } - } - } - { let mut state = overlay.lock().unwrap(); let now = Instant::now(); @@ -756,6 +749,34 @@ pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, over Ok(()) } +fn read_status_messages(fd: RawFd, buffer: &mut Vec, overlay: &Arc>) -> isize { + let mut chunk = [0u8; 256]; + let n = unsafe { libc::read(fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) }; + if n <= 0 { + return 0; + } + process_status_bytes(buffer, &chunk[..n as usize], overlay); + n +} + +fn process_status_bytes(buffer: &mut Vec, bytes: &[u8], overlay: &Arc>) { + buffer.extend_from_slice(bytes); + while let Some(pos) = buffer.iter().position(|&byte| byte == b'\n') { + let line = String::from_utf8_lossy(&buffer[..pos]).into_owned(); + buffer.drain(..=pos); + if let Some(command) = line.strip_prefix('@') { + if let Some((widget, command, options, value)) = vscomm::decode_ui_payload(command.as_bytes()) { + let mut state = overlay.lock().unwrap(); + dispatch_ui_command(&mut state, widget, command, options, value); + } + } else { + let mut state = overlay.lock().unwrap(); + let title = state.popup_title.clone(); + state.popup.show_info(title, &line, Some(palette::FG), Some(palette::ACCENT)); + } + } +} + fn cleanup_terminal(terminal: &mut Terminal>, mouse_capture_enabled: bool) { if mouse_capture_enabled { terminal.backend_mut().execute(DisableMouseCapture).ok(); diff --git a/src/tui/popup.rs b/src/tui/popup.rs index c1f27b4..fea682e 100644 --- a/src/tui/popup.rs +++ b/src/tui/popup.rs @@ -93,6 +93,9 @@ impl PopupWidget { pub fn hide(&mut self) { self.visible = false; + if matches!(&self.content, PopupContent::Password { .. }) { + self.content = PopupContent::Info { title: None, message: String::new(), fg: palette::FG }; + } } pub fn handle_password_key(&mut self, key: &crossterm::event::KeyEvent) { From d0080dbe2069e812080689cf74d9593050bf6490 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 2 Aug 2026 23:00:55 +0200 Subject: [PATCH 15/25] Add UT for TUI rendering --- src/logging_ut.rs | 30 +++++++++++++++++++++++++++++- src/main_ut.rs | 27 ++++++++++++++++++++++++++- src/tui_ut.rs | 27 ++++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/logging_ut.rs b/src/logging_ut.rs index e216451..de3eef6 100644 --- a/src/logging_ut.rs +++ b/src/logging_ut.rs @@ -1,5 +1,8 @@ -use super::{configure, diagnostic, diagnostic_bytes}; +use super::{clear_status_fd, configure, diagnostic, diagnostic_bytes, prompt_password, set_status_fd}; use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::thread; #[test] fn diagnostics_write_to_file_without_terminal_output() { @@ -16,3 +19,28 @@ fn diagnostics_write_to_file_without_terminal_output() { configure(false, None); } + +#[test] +fn password_prompt_uses_one_response_channel_and_hides_on_completion() { + let mut fds = [-1; 2]; + assert_eq!(unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, fds.as_mut_ptr()) }, 0); + let input = unsafe { std::fs::File::from_raw_fd(fds[0]) }; + let mut peer = unsafe { std::fs::File::from_raw_fd(fds[1]) }; + let peer_thread = thread::spawn(move || { + let mut reader = BufReader::new(peer.try_clone().unwrap()); + let mut show = String::new(); + reader.read_line(&mut show).unwrap(); + assert!(show.contains("password")); + peer.write_all(b"secret\n").unwrap(); + let mut hide = String::new(); + reader.read_line(&mut hide).unwrap(); + assert!(hide.contains("password")); + assert!(!hide.contains("secret")); + }); + + set_status_fd(input.as_raw_fd()); + assert_eq!(prompt_password("Password", "Enter password").unwrap(), "secret"); + clear_status_fd(); + drop(input); + peer_thread.join().unwrap(); +} diff --git a/src/main_ut.rs b/src/main_ut.rs index b1e0af1..cb5108a 100644 --- a/src/main_ut.rs +++ b/src/main_ut.rs @@ -1,4 +1,7 @@ -use super::{decode_workspace_handoff, encode_workspace_handoff, read_run_handoff, remote_tool_names, write_run_handoff}; +use super::{ + decode_workspace_handoff, encode_workspace_handoff, read_run_handoff, read_startup_ready, remote_tool_names, write_run_handoff, + write_startup_ready, +}; use bunkerbox::vscomm::WorkspaceSessionId; use std::fs::File; use std::os::fd::FromRawFd; @@ -58,3 +61,25 @@ fn run_handoff_rejects_zero_session() { fn remote_tool_names_reduce_passthrough_entries_to_executables() { assert_eq!(remote_tool_names(&["make *".into(), "cargo build".into(), "make test".into()]), vec!["cargo", "make"]); } + +#[test] +fn startup_ready_handoff_round_trips() { + let (parent, child) = unsafe { + let mut fds = [-1; 2]; + assert_eq!(libc::pipe(fds.as_mut_ptr()), 0); + (File::from_raw_fd(fds[0]), File::from_raw_fd(fds[1])) + }; + let mut child = child; + write_startup_ready(&mut child).unwrap(); + drop(child); + let mut parent = parent; + read_startup_ready(&mut parent).unwrap(); +} + +#[test] +fn startup_ready_handoff_rejects_wrong_type() { + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), b"BAD!").unwrap(); + let mut file = File::open(file.path()).unwrap(); + assert!(read_startup_ready(&mut file).is_err()); +} diff --git a/src/tui_ut.rs b/src/tui_ut.rs index e2cc37e..22c7e4f 100644 --- a/src/tui_ut.rs +++ b/src/tui_ut.rs @@ -1,5 +1,6 @@ -use super::{dispatch_ui_command, mouse_to_bytes, MouseEncoding, MouseTracking, OverlayState, Term}; +use super::{dispatch_ui_command, mouse_to_bytes, process_status_bytes, MouseEncoding, MouseTracking, OverlayState, Term}; use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use std::sync::{Arc, Mutex}; #[test] fn internal_error_creates_a_non_modal_toast() { @@ -14,6 +15,30 @@ fn internal_error_creates_a_non_modal_toast() { assert_eq!(toast.message, "connection failed"); } +#[test] +fn startup_status_is_processed_before_child_status() { + let overlay = Arc::new(Mutex::new(OverlayState::new())); + let mut buffer = Vec::new(); + let mut message = b"@".to_vec(); + message.extend_from_slice(&crate::vscomm::encode_ui_payload("status", "set", "", "Preparing workspace...")); + message.push(b'\n'); + + let split = message.len() / 2; + process_status_bytes(&mut buffer, &message[..split], &overlay); + assert!(!overlay.lock().unwrap().popup.visible); + process_status_bytes(&mut buffer, &message[split..], &overlay); + assert!(overlay.lock().unwrap().popup.visible); +} + +#[test] +fn hiding_password_clears_sensitive_popup_state() { + let mut state = OverlayState::new(); + dispatch_ui_command(&mut state, "password", "show", "Password", "Enter password"); + assert!(state.popup.password_value().is_some()); + state.popup.hide(); + assert!(state.popup.password_value().is_none()); +} + #[test] fn cursor_report_uses_position_after_prior_bytes_in_same_chunk() { let mut term = Term::new(24, 80); From 93eb8e71c4048bdd20974283ad88bf7c2be1ef12 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 3 Aug 2026 01:18:03 +0200 Subject: [PATCH 16/25] ADd unit tests for strict remote env and configurable policies --- src/cfg_ut.rs | 19 +++++++++++++ src/loopback_ut.rs | 53 +++++++++++++++++++++++++++++++------ src/main_ut.rs | 8 ++++-- src/remote_ut.rs | 63 +++++++++++++++++++++++++++++++++++++++++++- src/vscomm/mod_ut.rs | 6 +++++ 5 files changed, 138 insertions(+), 11 deletions(-) diff --git a/src/cfg_ut.rs b/src/cfg_ut.rs index e31f48b..d932fbf 100644 --- a/src/cfg_ut.rs +++ b/src/cfg_ut.rs @@ -487,3 +487,22 @@ fn load_or_create_accepts_paranoid_exact() { write_project_conf(root.path(), "project:\n env: paranoid\n passthrough:\n - \"make\"\n - \"cargo\"\n"); assert!(ProjectConfig::load_or_create(root.path()).is_ok()); } + +#[test] +fn load_or_create_validates_remote_policy_configuration() { + let root = TempDir::new().unwrap(); + write_project_conf( + root.path(), + "project:\n remote:\n environment:\n - PROJECT_MODE\n tools:\n - name: make\n allow-args: false\n", + ); + let cfg = ProjectConfig::load_or_create(root.path()).unwrap(); + assert_eq!(cfg.project.remote.environment, vec!["PROJECT_MODE"]); + assert_eq!(cfg.project.remote.tools, vec![RemoteToolSpec { name: "make".into(), allow_args: false }]); +} + +#[test] +fn load_or_create_rejects_forbidden_remote_environment() { + let root = TempDir::new().unwrap(); + write_project_conf(root.path(), "project:\n remote:\n environment:\n - SSH_AUTH_SOCK\n"); + assert!(ProjectConfig::load_or_create(root.path()).is_err()); +} diff --git a/src/loopback_ut.rs b/src/loopback_ut.rs index 4da344f..21cb637 100644 --- a/src/loopback_ut.rs +++ b/src/loopback_ut.rs @@ -63,15 +63,21 @@ async fn sync_and_build_materialize_a_bound_snapshot() { assert!(fs::read_dir(&session_state.jobs_root).unwrap().next().is_none()); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn nonempty_remote_environment_is_rejected() { - let (_temp, session, target, session_id) = fixture(); - let backend = LoopbackBackend::new(session, BTreeMap::new()); - let (events, _receiver) = mpsc::channel(8); - let request = authorized_build(target, session_id, "printf", Vec::new(), vec![("UNTRUSTED".to_string(), "1".to_string())]); +#[test] +fn unapproved_remote_environment_is_rejected_before_backend_execution() { + let (_temp, _session, target, session_id) = fixture(); + let build = RemoteBuild::new( + WorkspaceRelativePath::new("src").unwrap(), + RemoteTool::new("printf").unwrap(), + Vec::new(), + vec![("UNTRUSTED".to_string(), "1".to_string())], + ) + .unwrap(); + let request = RemoteRequest::build(RequestId([3; 16]), session_id, build); + let policy = RemoteAuthorizationPolicy::new(target, session_id, vec!["printf".to_string()]); assert_eq!( - backend.execute(request, events).await, - Err(RemoteBackendError::Failed("remote environment is unavailable until remote environment policy is configured".to_string(),)) + policy.authorize(&RemoteExecutionContext { target, workspace_session_id: session_id }, request), + Err(crate::remote::RemoteAuthorizationError::EnvironmentNotAllowed("UNTRUSTED".to_string())) ); } @@ -92,6 +98,23 @@ async fn build_preserves_argument_bytes_and_reports_nonzero_stderr() { assert!(events.iter().any(|event| matches!(event, RemoteBackendEvent::Completed { exit_code } if *exit_code != 0))); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn child_receives_guest_environment_but_trusted_target_wins() { + let (_temp, session, target, session_id) = fixture(); + session.sync_snapshot().unwrap(); + let tools = resolve_fixed_tools(["printenv".to_string()]); + if tools.is_empty() { + return; + } + let backend = LoopbackBackend::new(session, tools).with_target_environment(BTreeMap::from([("CC".into(), "trusted-target".into())])); + let (events, receiver) = mpsc::channel(8); + let request = authorized_build(target, session_id, "printenv", vec!["CC".into()], vec![("CC".into(), "guest-value".into())]); + assert_eq!(backend.execute(request, events).await, Ok(())); + let events = collect_events(receiver).await; + assert!(events.iter().any(|event| matches!(event, RemoteBackendEvent::Stdout(bytes) if bytes == b"trusted-target\n"))); + assert!(events.iter().any(|event| matches!(event, RemoteBackendEvent::Completed { exit_code: 0 }))); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn missing_tool_fails_before_execution() { let (_temp, session, target, session_id) = fixture(); @@ -114,3 +137,17 @@ async fn timeout_kills_a_direct_child_process() { let request = authorized_build(target, session_id, "sleep", vec!["5".to_string()], Vec::new()); assert_eq!(backend.execute(request, events).await, Err(RemoteBackendError::Timeout)); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn output_limit_kills_a_flooding_direct_child() { + let (_temp, session, target, session_id) = fixture(); + session.sync_snapshot().unwrap(); + let tools = resolve_fixed_tools(["printf".to_string()]); + if tools.is_empty() { + return; + } + let backend = LoopbackBackend::new(session, tools).with_output_limit(8); + let (events, _receiver) = mpsc::channel(8); + let request = authorized_build(target, session_id, "printf", vec!["0123456789".into()], Vec::new()); + assert_eq!(backend.execute(request, events).await, Err(RemoteBackendError::OutputLimit { limit: 8 })); +} diff --git a/src/main_ut.rs b/src/main_ut.rs index cb5108a..2affc20 100644 --- a/src/main_ut.rs +++ b/src/main_ut.rs @@ -2,6 +2,7 @@ use super::{ decode_workspace_handoff, encode_workspace_handoff, read_run_handoff, read_startup_ready, remote_tool_names, write_run_handoff, write_startup_ready, }; +use bunkerbox::cfg::RemoteToolSpec; use bunkerbox::vscomm::WorkspaceSessionId; use std::fs::File; use std::os::fd::FromRawFd; @@ -58,8 +59,11 @@ fn run_handoff_rejects_zero_session() { } #[test] -fn remote_tool_names_reduce_passthrough_entries_to_executables() { - assert_eq!(remote_tool_names(&["make *".into(), "cargo build".into(), "make test".into()]), vec!["cargo", "make"]); +fn remote_tool_names_preserve_configured_order() { + assert_eq!( + remote_tool_names(&[RemoteToolSpec { name: "make".into(), allow_args: true }, RemoteToolSpec { name: "cargo".into(), allow_args: false },]), + vec!["make", "cargo"] + ); } #[test] diff --git a/src/remote_ut.rs b/src/remote_ut.rs index 4066e22..8e061f5 100644 --- a/src/remote_ut.rs +++ b/src/remote_ut.rs @@ -8,7 +8,7 @@ fn request(tool: &str) -> RemoteRequest { WorkspaceRelativePath::new("src").unwrap(), RemoteTool::new(tool).unwrap(), vec!["build".into()], - vec![("MODE".into(), "debug".into())], + vec![("CC".into(), "cc".into())], ) .unwrap(), ) @@ -41,3 +41,64 @@ fn backend_errors_have_typed_events() { assert_eq!(RemoteBackendError::Timeout.event(), RemoteBackendEvent::Error { message: "remote backend timed out".into() }); assert_eq!(RemoteBackendError::Cancelled.event(), RemoteBackendEvent::Cancelled); } + +#[test] +fn environment_policy_preserves_allowed_entries() { + let policy = RemoteAuthorizationPolicy::new(RemoteTargetId([3; 16]), WorkspaceSessionId([2; 16]), vec!["make".into()]); + let authorized = policy.authorize(&context(), request("make")).unwrap(); + let RemoteOperation::Build(build) = authorized.request().operation() else { panic!("expected build") }; + assert_eq!(build.env(), [("CC".into(), "cc".into())]); +} + +#[test] +fn environment_policy_rejects_forbidden_and_unlisted_entries() { + for (name, expected) in [ + ("SSH_AUTH_SOCK", RemoteAuthorizationError::ForbiddenEnvironment("SSH_AUTH_SOCK".into())), + ("UNTRUSTED", RemoteAuthorizationError::EnvironmentNotAllowed("UNTRUSTED".into())), + ] { + let build = RemoteBuild::new( + WorkspaceRelativePath::new("src").unwrap(), + RemoteTool::new("make").unwrap(), + Vec::new(), + vec![(name.into(), "value".into())], + ) + .unwrap(); + let request = RemoteRequest::build(RequestId([1; 16]), WorkspaceSessionId([2; 16]), build); + let policy = RemoteAuthorizationPolicy::new(RemoteTargetId([3; 16]), WorkspaceSessionId([2; 16]), vec!["make".into()]); + assert_eq!(policy.authorize(&context(), request), Err(expected)); + } +} + +#[test] +fn environment_policy_rejects_duplicates_and_control_data() { + let duplicate = RemoteBuild::new( + WorkspaceRelativePath::new("src").unwrap(), + RemoteTool::new("make").unwrap(), + Vec::new(), + vec![("CC".into(), "one".into()), ("CC".into(), "two".into())], + ) + .unwrap(); + let policy = RemoteAuthorizationPolicy::new(RemoteTargetId([3; 16]), WorkspaceSessionId([2; 16]), vec!["make".into()]); + let request = RemoteRequest::build(RequestId([1; 16]), WorkspaceSessionId([2; 16]), duplicate); + assert_eq!(policy.authorize(&context(), request), Err(RemoteAuthorizationError::DuplicateEnvironment("CC".into()))); + + assert!(RemoteBuild::new( + WorkspaceRelativePath::new("src").unwrap(), + RemoteTool::new("make").unwrap(), + Vec::new(), + vec![("CC".into(), "bad\nvalue".into())], + ) + .is_err()); +} + +#[test] +fn command_policy_rejects_unapproved_arguments() { + let policy = RemoteAuthorizationPolicy::from_policies( + RemoteTargetId([3; 16]), + WorkspaceSessionId([2; 16]), + [("make".into(), RemoteToolPolicy::new(false))], + RemoteEnvironmentPolicy::default(), + ) + .unwrap(); + assert_eq!(policy.authorize(&context(), request("make")), Err(RemoteAuthorizationError::ToolArgumentsNotAllowed("make".into()))); +} diff --git a/src/vscomm/mod_ut.rs b/src/vscomm/mod_ut.rs index 00f0f7f..e7e021e 100644 --- a/src/vscomm/mod_ut.rs +++ b/src/vscomm/mod_ut.rs @@ -193,6 +193,12 @@ fn oversized_environment_key_and_value_are_rejected() { assert!(RemoteRequest::from_frame(raw_build_frame(0, None, 1, Some(("KEY", &"V".repeat(MAX_REMOTE_ENV_VALUE_BYTES + 1))))).is_err()); } +#[test] +fn control_data_in_remote_environment_value_is_rejected() { + assert!(build(Vec::new(), vec![("CC".into(), "bad\nvalue".into())]).is_err()); + assert!(RemoteRequest::from_frame(raw_build_frame(0, None, 1, Some(("CC", "bad\nvalue")))).is_err()); +} + #[test] fn invalid_remote_cwd_is_rejected() { assert!(WorkspaceRelativePath::new("/absolute").is_err()); From 8c866fd02a661fb62187a6df7da549e618653a88 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 3 Aug 2026 01:18:14 +0200 Subject: [PATCH 17/25] Add strict remote env and configurable policies --- src/cfg.rs | 55 ++++++++++- src/cfgsetup.rs | 7 +- src/daemon.rs | 52 +++++++++-- src/loopback.rs | 67 +++++++++---- src/main.rs | 23 ++--- src/remote.rs | 232 +++++++++++++++++++++++++++++++++++++++++++--- src/vscomm/mod.rs | 18 +++- 7 files changed, 397 insertions(+), 57 deletions(-) diff --git a/src/cfg.rs b/src/cfg.rs index 89b5e9c..b308273 100644 --- a/src/cfg.rs +++ b/src/cfg.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; +use crate::remote::{RemoteEnvironmentPolicy, RemoteTool}; use crate::vscomm::buildsys::{self, PassthroughMode}; pub const DEFAULT_SHARE_DIR: &str = "/usr/share/bunkerbox"; @@ -196,6 +197,23 @@ pub struct ProjectSection { pub exclude: Vec, #[serde(default)] pub passthrough: Vec, + #[serde(default)] + pub remote: RemoteSection, +} + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct RemoteSection { + #[serde(default)] + pub environment: Vec, + #[serde(default)] + pub tools: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct RemoteToolSpec { + pub name: String, + #[serde(default, rename = "allow-args")] + pub allow_args: bool, } #[derive(Debug, Default, serde::Serialize, serde::Deserialize)] @@ -238,6 +256,7 @@ impl ProjectConfig { quota: Some("auto".into()), exclude: Vec::new(), passthrough: buildsys::scan(repo_root, PassthroughMode::Relaxed), + remote: RemoteSection::default(), }, image: ImageOverrides::default(), profiles: Vec::new(), @@ -258,6 +277,14 @@ impl ProjectConfig { } } } + RemoteEnvironmentPolicy::from_names(self.project.remote.environment.clone())?; + let mut tools = std::collections::BTreeSet::new(); + for tool in &self.project.remote.tools { + RemoteTool::new(tool.name.clone())?; + if !tools.insert(tool.name.clone()) { + return Err(format!("duplicate remote tool: {}", tool.name)); + } + } Ok(()) } @@ -277,7 +304,13 @@ impl ProjectConfig { .map_err(|e| format!("failed to parse legacy {}: {e}", legacy_path.display()))?; let cfg = ProjectConfig { - project: ProjectSection { env: EnvMode::default(), quota: old.quota, exclude: old.exclude, passthrough: old.passthrough }, + project: ProjectSection { + env: EnvMode::default(), + quota: old.quota, + exclude: old.exclude, + passthrough: old.passthrough, + remote: RemoteSection::default(), + }, image: ImageOverrides::default(), profiles: Vec::new(), }; @@ -348,6 +381,26 @@ impl ProjectConfig { } } + if !self.project.remote.environment.is_empty() || !self.project.remote.tools.is_empty() { + y.push_str(" remote:\n"); + y.push_str(" environment:\n"); + if self.project.remote.environment.is_empty() { + y.push_str(" []\n"); + } else { + for name in &self.project.remote.environment { + y.push_str(&format!(" - \"{name}\"\n")); + } + } + y.push_str(" tools:\n"); + if self.project.remote.tools.is_empty() { + y.push_str(" []\n"); + } else { + for tool in &self.project.remote.tools { + y.push_str(&format!(" - name: \"{}\"\n allow-args: {}\n", tool.name, tool.allow_args)); + } + } + } + if self.image.has_override() { y.push('\n'); y.push_str("# Override shared runtime defaults:\n"); diff --git a/src/cfgsetup.rs b/src/cfgsetup.rs index aea70d5..bf2650a 100644 --- a/src/cfgsetup.rs +++ b/src/cfgsetup.rs @@ -29,8 +29,11 @@ pub fn run(runtime: Option<&RuntimeConfig>) -> Result<(), String> { let profiles = pick_profiles(&detected_relaxed)?; let overrides = pick_overrides(runtime)?; - let cfg = - ProjectConfig { project: ProjectSection { env: env_mode, quota: Some(quota), exclude: Vec::new(), passthrough }, image: overrides, profiles }; + let cfg = ProjectConfig { + project: ProjectSection { env: env_mode, quota: Some(quota), exclude: Vec::new(), passthrough, remote: Default::default() }, + image: overrides, + profiles, + }; let path = repo_root.join(ProjectConfig::PATH); std::fs::create_dir_all(path.parent().unwrap()).map_err(|e| format!("failed to create {}: {e}", path.parent().unwrap().display()))?; diff --git a/src/daemon.rs b/src/daemon.rs index 1d35fc2..34a8449 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3,7 +3,8 @@ use crate::logging; use crate::loopback::{LoopbackBackend, RunRemoteSession}; use crate::proxy::{FilterProxy, UnixProxyHandle}; use crate::remote::{ - RemoteAuthorizationError, RemoteAuthorizationPolicy, RemoteBackend, RemoteBackendError, RemoteBackendEvent, RemoteExecutionContext, RemoteRequest, + RemoteAuthorizationError, RemoteAuthorizationPolicy, RemoteBackend, RemoteBackendError, RemoteBackendEvent, RemoteEnvironmentPolicy, + RemoteExecutionContext, RemoteRequest, RemoteResourcePolicy, RemoteToolPolicy, }; use crate::sandbox::{resolve_profile, MergedProfile, NetworkMode}; use crate::vscomm::{validate_exec_request, validate_process_path, ExecRequest, Frame, FrameType, TOOLCHAIN_PORT}; @@ -95,12 +96,40 @@ pub struct VsockDaemon { pub struct RemoteDaemonConfig { session: Arc, allowed_tools: Vec, + tool_policies: Option>, + environment: Option, tools: std::collections::BTreeMap, + target_environment: std::collections::BTreeMap, + resources: RemoteResourcePolicy, } impl RemoteDaemonConfig { pub fn new(session: Arc, allowed_tools: Vec, tools: std::collections::BTreeMap) -> Self { - Self { session, allowed_tools, tools } + Self { + session, + allowed_tools, + tool_policies: None, + environment: None, + tools, + target_environment: std::collections::BTreeMap::new(), + resources: RemoteResourcePolicy::default(), + } + } + + pub fn with_policy(mut self, tools: Vec<(String, RemoteToolPolicy)>, environment: RemoteEnvironmentPolicy) -> Self { + self.tool_policies = Some(tools); + self.environment = Some(environment); + self + } + + pub fn with_target_environment(mut self, environment: std::collections::BTreeMap) -> Self { + self.target_environment = environment; + self + } + + pub fn with_resources(mut self, resources: RemoteResourcePolicy) -> Self { + self.resources = resources; + self } } @@ -115,13 +144,20 @@ impl VsockDaemon { passthrough: Vec, env_mode: EnvMode, workspace: PathBuf, profiles: Vec, share_dir: PathBuf, allow: Vec, remote: RemoteDaemonConfig, ) -> Result { - let remote_policy = RemoteAuthorizationPolicy::new(remote.session.target(), remote.session.session_id(), remote.allowed_tools); - let remote_context = RemoteExecutionContext { target: remote.session.target(), workspace_session_id: remote.session.session_id() }; - let remote_components = RemoteComponents { - context: remote_context, - policy: remote_policy, - backend: Arc::new(LoopbackBackend::new(remote.session, remote.tools)), + let RemoteDaemonConfig { session, allowed_tools, tool_policies, environment, tools, target_environment, resources } = remote; + let remote_policy = match (tool_policies, environment) { + (Some(tool_policies), Some(environment)) => { + RemoteAuthorizationPolicy::from_policies(session.target(), session.session_id(), tool_policies, environment)? + } + (None, None) => RemoteAuthorizationPolicy::new(session.target(), session.session_id(), allowed_tools), + _ => return Err("remote tool and environment policies must be configured together".to_string()), }; + let remote_context = RemoteExecutionContext { target: session.target(), workspace_session_id: session.session_id() }; + let backend = LoopbackBackend::new(session, tools) + .with_target_environment(target_environment) + .with_timeout(resources.build_timeout) + .with_output_limit(resources.max_output_bytes); + let remote_components = RemoteComponents { context: remote_context, policy: remote_policy, backend: Arc::new(backend) }; Self::start_inner(passthrough, env_mode, workspace, profiles, share_dir, allow, remote_components) } diff --git a/src/loopback.rs b/src/loopback.rs index 5c938e7..39362ae 100644 --- a/src/loopback.rs +++ b/src/loopback.rs @@ -1,5 +1,6 @@ use crate::remote::{ - AuthorizedRemoteRequest, RemoteBackend, RemoteBackendError, RemoteBackendEvent, RemoteFuture, RemoteOperation, RemoteTargetId, WorkspaceSessionId, + AuthorizedRemoteRequest, RemoteBackend, RemoteBackendError, RemoteBackendEvent, RemoteFuture, RemoteOperation, RemoteResourcePolicy, + RemoteTargetId, WorkspaceSessionId, }; use crate::snapshot::{SnapshotBuilder, SnapshotHandle, SnapshotStore}; use std::collections::BTreeMap; @@ -15,7 +16,7 @@ use tokio::process::Command; use tokio::sync::mpsc; use tokio::time::sleep; -pub const LOOPBACK_BUILD_TIMEOUT: Duration = Duration::from_secs(30); +pub const LOOPBACK_BUILD_TIMEOUT: Duration = crate::remote::DEFAULT_REMOTE_BUILD_TIMEOUT; const LOOPBACK_PATH: &str = "/usr/local/bin:/usr/bin:/bin"; static NEXT_JOB_ID: AtomicU64 = AtomicU64::new(1); @@ -124,16 +125,34 @@ impl RunRemoteSession { pub struct LoopbackBackend { session: Arc, tools: Arc>, - timeout: Duration, + target_environment: Arc>, + resources: RemoteResourcePolicy, } impl LoopbackBackend { pub fn new(session: Arc, tools: BTreeMap) -> Self { - Self { session, tools: Arc::new(tools), timeout: LOOPBACK_BUILD_TIMEOUT } + Self { + session, + tools: Arc::new(tools), + target_environment: Arc::new(trusted_target_environment()), + resources: RemoteResourcePolicy::default(), + } } pub fn with_timeout(mut self, timeout: Duration) -> Self { - self.timeout = timeout; + self.resources.build_timeout = timeout; + self + } + + pub fn with_output_limit(mut self, max_output_bytes: u64) -> Self { + self.resources.max_output_bytes = max_output_bytes; + self + } + + pub fn with_target_environment(mut self, environment: BTreeMap) -> Self { + let mut trusted = trusted_target_environment(); + trusted.extend(environment); + self.target_environment = Arc::new(trusted); self } } @@ -144,11 +163,12 @@ impl RemoteBackend for LoopbackBackend { ) -> RemoteFuture<'a, Result<(), RemoteBackendError>> { let session = self.session.clone(); let tools = self.tools.clone(); - let timeout = self.timeout; + let target_environment = self.target_environment.clone(); + let resources = self.resources; Box::pin(async move { match request.request().operation() { RemoteOperation::Sync => execute_sync(session, events).await, - RemoteOperation::Build(build) => execute_build(session, tools, timeout, build, events).await, + RemoteOperation::Build(build) => execute_build(session, tools, target_environment, resources, build, events).await, } }) } @@ -164,12 +184,9 @@ async fn execute_sync(session: Arc, events: mpsc::Sender, tools: Arc>, timeout_duration: Duration, build: &crate::remote::RemoteBuild, - events: mpsc::Sender, + session: Arc, tools: Arc>, target_environment: Arc>, + resources: RemoteResourcePolicy, build: &crate::remote::RemoteBuild, events: mpsc::Sender, ) -> Result<(), RemoteBackendError> { - if !build.env().is_empty() { - return Err(RemoteBackendError::Failed("remote environment is unavailable until remote environment policy is configured".to_string())); - } let executable = tools .get(build.tool().as_str()) .ok_or_else(|| RemoteBackendError::Spawn(format!("loopback tool is not configured: {}", build.tool().as_str())))?; @@ -197,7 +214,13 @@ async fn execute_build( .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); - command.env_clear().env("PATH", LOOPBACK_PATH); + command.env_clear(); + for (key, value) in build.env() { + command.env(key, value); + } + for (key, value) in target_environment.iter() { + command.env(key, value); + } unsafe { command.pre_exec(|| { if libc::setpgid(0, 0) != 0 { @@ -211,10 +234,12 @@ async fn execute_build( let process_group = child.id().map(|pid| ProcessGroupGuard { pgid: pid as i32, active: true }); let stdout = child.stdout.take().ok_or_else(|| RemoteBackendError::Failed("loopback child has no stdout".to_string()))?; let stderr = child.stderr.take().ok_or_else(|| RemoteBackendError::Failed("loopback child has no stderr".to_string()))?; - let mut stdout_task = Box::pin(tokio::spawn(pump(stdout, RemoteStream::Stdout, events.clone()))); - let mut stderr_task = Box::pin(tokio::spawn(pump(stderr, RemoteStream::Stderr, events.clone()))); + let output_bytes = Arc::new(AtomicU64::new(0)); + let mut stdout_task = + Box::pin(tokio::spawn(pump(stdout, RemoteStream::Stdout, events.clone(), output_bytes.clone(), resources.max_output_bytes))); + let mut stderr_task = Box::pin(tokio::spawn(pump(stderr, RemoteStream::Stderr, events.clone(), output_bytes, resources.max_output_bytes))); let mut child_wait = Box::pin(child.wait()); - let mut timeout_sleep = Box::pin(sleep(timeout_duration)); + let mut timeout_sleep = Box::pin(sleep(resources.build_timeout)); let mut child_status = None; let mut stdout_done = false; let mut stderr_done = false; @@ -260,7 +285,7 @@ enum RemoteStream { } async fn pump( - mut reader: R, stream: RemoteStream, events: mpsc::Sender, + mut reader: R, stream: RemoteStream, events: mpsc::Sender, output_bytes: Arc, max_output_bytes: u64, ) -> Result<(), RemoteBackendError> { let mut buffer = [0u8; 8192]; loop { @@ -268,6 +293,10 @@ async fn pump( if count == 0 { return Ok(()); } + let total = output_bytes.fetch_add(count as u64, Ordering::Relaxed).saturating_add(count as u64); + if total > max_output_bytes { + return Err(RemoteBackendError::OutputLimit { limit: max_output_bytes }); + } let event = match stream { RemoteStream::Stdout => RemoteBackendEvent::Stdout(buffer[..count].to_vec()), RemoteStream::Stderr => RemoteBackendEvent::Stderr(buffer[..count].to_vec()), @@ -276,6 +305,10 @@ async fn pump( } } +fn trusted_target_environment() -> BTreeMap { + BTreeMap::from([(String::from("PATH"), String::from(LOOPBACK_PATH))]) +} + async fn join_pump(result: Result, tokio::task::JoinError>) -> Result<(), RemoteBackendError> { result.map_err(|error| RemoteBackendError::Failed(format!("loopback output task failed: {error}")))? } diff --git a/src/main.rs b/src/main.rs index d664b23..137a494 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ -use bunkerbox::cfg::{ProjectConfig, WorkspaceMode}; +use bunkerbox::cfg::{ProjectConfig, RemoteToolSpec, WorkspaceMode}; +use bunkerbox::remote::{RemoteEnvironmentPolicy, RemoteToolPolicy}; use bunkerbox::{cfg, cfgsetup, clidef, cmdrun, daemon, kata, logging, loopback, overlay, snapshot, tui, vscomm, workspace}; use rand::RngCore; use std::ffi::OsString; @@ -195,6 +196,10 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option>(); + let remote_tool_names = remote_tool_names(&env.project.remote.tools); let share_dir_owned = share_dir.to_path_buf(); let mut sock_fds = [-1i32, -1]; @@ -328,8 +333,7 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option bunkerbox::remote::RemoteTargetId { } } -fn remote_tool_names(entries: &[String]) -> Vec { - let mut names = std::collections::BTreeSet::new(); - for entry in entries { - let command = entry.trim().strip_suffix(" *").unwrap_or(entry.trim()); - if let Some(tool) = command.split_whitespace().next().filter(|tool| !tool.is_empty() && !tool.contains('/')) { - names.insert(tool.to_string()); - } - } - names.into_iter().collect() +fn remote_tool_names(entries: &[RemoteToolSpec]) -> Vec { + entries.iter().map(|tool| tool.name.clone()).collect() } fn write_run_handoff(file: &mut File, path: &Path, session_id: vscomm::WorkspaceSessionId) -> Result<(), String> { diff --git a/src/remote.rs b/src/remote.rs index eef8337..1c5c869 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -1,7 +1,9 @@ #![allow(dead_code)] +use std::collections::{BTreeMap, BTreeSet}; use std::future::Future; use std::pin::Pin; +use std::time::Duration; use tokio::sync::mpsc; pub const MAX_REMOTE_STRING_BYTES: usize = 4 * 1024; @@ -11,6 +13,9 @@ pub const MAX_REMOTE_ARG_BYTES: usize = 4 * 1024; pub const MAX_REMOTE_ENV_COUNT: usize = 64; pub const MAX_REMOTE_ENV_KEY_BYTES: usize = 256; pub const MAX_REMOTE_ENV_VALUE_BYTES: usize = 4 * 1024; +pub const DEFAULT_REMOTE_BUILD_TIMEOUT: Duration = Duration::from_secs(30); +pub const DEFAULT_REMOTE_OUTPUT_BYTES: u64 = 64 * 1024 * 1024; +pub const DEFAULT_REMOTE_ENVIRONMENT: &[&str] = &["CC", "CXX", "AR", "RUSTFLAGS", "CFLAGS", "CXXFLAGS", "MAKEFLAGS"]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RequestId(pub [u8; 16]); @@ -51,8 +56,12 @@ impl RemoteTool { pub fn new(value: impl Into) -> Result { let value = value.into(); validate_string("remote tool", &value, MAX_REMOTE_TOOL_BYTES)?; - if value.is_empty() { - return Err("remote tool is empty".to_string()); + if value.is_empty() + || value == "." + || value == ".." + || !value.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'+')) + { + return Err("remote tool must be a single executable identity".to_string()); } Ok(Self(value)) } @@ -76,11 +85,8 @@ impl RemoteBuild { argv.iter().try_for_each(|arg| validate_string("remote argument", arg, MAX_REMOTE_ARG_BYTES))?; validate_count(env.len(), MAX_REMOTE_ENV_COUNT, "remote environment")?; env.iter().try_for_each(|(key, value)| { - validate_string("remote environment key", key, MAX_REMOTE_ENV_KEY_BYTES)?; - if key.is_empty() || key.contains('=') { - return Err("remote environment key is invalid".to_string()); - } - validate_string("remote environment value", value, MAX_REMOTE_ENV_VALUE_BYTES) + validate_environment_key(key)?; + validate_environment_value(value) })?; Ok(Self { cwd, tool, argv, env }) } @@ -102,6 +108,90 @@ impl RemoteBuild { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteResourcePolicy { + pub build_timeout: Duration, + pub max_output_bytes: u64, +} + +impl Default for RemoteResourcePolicy { + fn default() -> Self { + Self { build_timeout: DEFAULT_REMOTE_BUILD_TIMEOUT, max_output_bytes: DEFAULT_REMOTE_OUTPUT_BYTES } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteEnvironmentPolicy { + allowed: BTreeSet, +} + +impl Default for RemoteEnvironmentPolicy { + fn default() -> Self { + Self { allowed: DEFAULT_REMOTE_ENVIRONMENT.iter().map(|name| (*name).to_string()).collect() } + } +} + +impl RemoteEnvironmentPolicy { + pub fn from_names(names: impl IntoIterator) -> Result { + let mut policy = Self::default(); + let mut configured = BTreeSet::new(); + for name in names { + validate_environment_key(&name)?; + if forbidden_environment_name(&name) { + return Err(format!("remote environment variable is forbidden: {name}")); + } + if !configured.insert(name.clone()) { + return Err(format!("duplicate remote environment variable: {name}")); + } + policy.allowed.insert(name); + } + Ok(policy) + } + + pub fn allows(&self, name: &str) -> bool { + self.allowed.contains(name) + } + + pub fn allowed_names(&self) -> impl Iterator { + self.allowed.iter().map(String::as_str) + } + + fn filter(&self, environment: &[(String, String)]) -> Result, RemoteAuthorizationError> { + let mut seen = BTreeSet::new(); + let mut filtered = Vec::with_capacity(environment.len()); + for (key, value) in environment { + validate_environment_key(key).map_err(RemoteAuthorizationError::InvalidEnvironment)?; + validate_environment_value(value).map_err(RemoteAuthorizationError::InvalidEnvironment)?; + if forbidden_environment_name(key) { + return Err(RemoteAuthorizationError::ForbiddenEnvironment(key.clone())); + } + if !self.allowed.contains(key) { + return Err(RemoteAuthorizationError::EnvironmentNotAllowed(key.clone())); + } + if !seen.insert(key.clone()) { + return Err(RemoteAuthorizationError::DuplicateEnvironment(key.clone())); + } + filtered.push((key.clone(), value.clone())); + } + Ok(filtered) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteToolPolicy { + allow_arbitrary_argv: bool, +} + +impl RemoteToolPolicy { + pub fn new(allow_arbitrary_argv: bool) -> Self { + Self { allow_arbitrary_argv } + } + + pub fn allows_arbitrary_argv(self) -> bool { + self.allow_arbitrary_argv + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum RemoteOperation { Sync, @@ -168,6 +258,11 @@ pub enum RemoteAuthorizationError { SessionMismatch, TargetNotAllowed, ToolNotAllowed(String), + ToolArgumentsNotAllowed(String), + InvalidEnvironment(String), + ForbiddenEnvironment(String), + EnvironmentNotAllowed(String), + DuplicateEnvironment(String), } impl RemoteAuthorizationError { @@ -180,12 +275,32 @@ impl RemoteAuthorizationError { pub struct RemoteAuthorizationPolicy { allowed_target: RemoteTargetId, allowed_session: WorkspaceSessionId, - allowed_tools: Vec, + allowed_tools: BTreeMap, + environment: RemoteEnvironmentPolicy, } impl RemoteAuthorizationPolicy { pub fn new(allowed_target: RemoteTargetId, allowed_session: WorkspaceSessionId, allowed_tools: Vec) -> Self { - Self { allowed_target, allowed_session, allowed_tools } + let allowed_tools = allowed_tools.into_iter().map(|tool| (tool, RemoteToolPolicy::new(true))).collect(); + Self { allowed_target, allowed_session, allowed_tools, environment: RemoteEnvironmentPolicy::default() } + } + + pub fn from_policies( + allowed_target: RemoteTargetId, allowed_session: WorkspaceSessionId, tools: impl IntoIterator, + environment: RemoteEnvironmentPolicy, + ) -> Result { + let mut allowed_tools = BTreeMap::new(); + for (tool, policy) in tools { + RemoteTool::new(tool.clone())?; + if allowed_tools.insert(tool.clone(), policy).is_some() { + return Err(format!("duplicate remote tool policy: {tool}")); + } + } + Ok(Self { allowed_target, allowed_session, allowed_tools, environment }) + } + + pub fn allowed_tools(&self) -> impl Iterator { + self.allowed_tools.keys().map(String::as_str) } pub fn authorize(&self, context: &RemoteExecutionContext, request: RemoteRequest) -> Result { @@ -196,11 +311,21 @@ impl RemoteAuthorizationPolicy { return Err(RemoteAuthorizationError::TargetNotAllowed); } - if let RemoteOperation::Build(build) = request.operation() { - if !self.allowed_tools.iter().any(|tool| tool == build.tool().as_str()) { - return Err(RemoteAuthorizationError::ToolNotAllowed(build.tool().as_str().to_string())); + let request = match request.operation() { + RemoteOperation::Sync => request, + RemoteOperation::Build(build) => { + let Some(tool_policy) = self.allowed_tools.get(build.tool().as_str()).copied() else { + return Err(RemoteAuthorizationError::ToolNotAllowed(build.tool().as_str().to_string())); + }; + if !tool_policy.allows_arbitrary_argv() && !build.argv().is_empty() { + return Err(RemoteAuthorizationError::ToolArgumentsNotAllowed(build.tool().as_str().to_string())); + } + let environment = self.environment.filter(build.env())?; + let filtered = RemoteBuild::new(build.cwd.clone(), build.tool.clone(), build.argv.clone(), environment) + .map_err(RemoteAuthorizationError::InvalidEnvironment)?; + RemoteRequest::build(request.request_id, request.workspace_session_id, filtered) } - } + }; Ok(AuthorizedRemoteRequest { request, target: context.target }) } @@ -221,6 +346,7 @@ pub enum RemoteBackendError { Failed(String), Spawn(String), Timeout, + OutputLimit { limit: u64 }, Cancelled, } @@ -229,6 +355,7 @@ impl RemoteBackendError { match self { Self::Failed(message) | Self::Spawn(message) => RemoteBackendEvent::Error { message: message.clone() }, Self::Timeout => RemoteBackendEvent::Error { message: "remote backend timed out".to_string() }, + Self::OutputLimit { limit } => RemoteBackendEvent::Error { message: format!("remote output exceeded limit of {limit} bytes") }, Self::Cancelled => RemoteBackendEvent::Cancelled, } } @@ -252,6 +379,85 @@ fn validate_string(field: &str, value: &str, max: usize) -> Result<(), String> { Ok(()) } +fn validate_environment_key(key: &str) -> Result<(), String> { + validate_string("remote environment key", key, MAX_REMOTE_ENV_KEY_BYTES)?; + let mut characters = key.bytes(); + let Some(first) = characters.next() else { + return Err("remote environment key is empty".to_string()); + }; + if !(first == b'_' || first.is_ascii_alphabetic()) || !characters.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric()) { + return Err("remote environment key is not a valid variable name".to_string()); + } + Ok(()) +} + +fn validate_environment_value(value: &str) -> Result<(), String> { + validate_string("remote environment value", value, MAX_REMOTE_ENV_VALUE_BYTES)?; + if value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { + return Err("remote environment value contains control data".to_string()); + } + Ok(()) +} + +fn forbidden_environment_name(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + matches!( + upper.as_str(), + "SSH_AUTH_SOCK" + | "SSH_AGENT_PID" + | "GITHUB_TOKEN" + | "GITLAB_TOKEN" + | "NPM_TOKEN" + | "KUBECONFIG" + | "HOME" + | "CARGO_HOME" + | "RUSTUP_HOME" + | "XDG_CONFIG_HOME" + | "XDG_DATA_HOME" + | "PATH" + | "PWD" + | "OLDPWD" + | "TMP" + | "TMPDIR" + | "TEMP" + | "USER" + | "LOGNAME" + | "SHELL" + | "BASH_ENV" + | "ENV" + | "CDPATH" + | "LD_PRELOAD" + | "LD_LIBRARY_PATH" + | "PYTHONPATH" + | "PERL5LIB" + | "RUBYLIB" + | "NODE_PATH" + | "GOPATH" + | "GOMODCACHE" + | "TOKEN" + | "PASSWORD" + | "PASS" + | "SECRET" + | "KEY" + | "GIT_SSH_COMMAND" + ) || upper.starts_with("AWS_") + || upper.starts_with("GCP_") + || upper.starts_with("GOOGLE_") + || upper.starts_with("AZURE_") + || upper.starts_with("DOCKER_") + || upper.starts_with("CARGO_REGISTRIES_") + || upper.starts_with("XDG_") + || upper.starts_with("BUNKERBOX_") + || upper.ends_with("_PROXY") + || upper.ends_with("_TOKEN") + || upper.ends_with("_PASSWORD") + || upper.ends_with("_PASS") + || upper.ends_with("_SECRET") + || upper.ends_with("_KEY") + || upper.contains("CREDENTIAL") + || upper.contains("PRIVATE_KEY") +} + fn validate_count(count: usize, max: usize, field: &str) -> Result<(), String> { if count > max { return Err(format!("{field} exceeds maximum count {max}")); diff --git a/src/vscomm/mod.rs b/src/vscomm/mod.rs index 50c1b07..0e1bced 100644 --- a/src/vscomm/mod.rs +++ b/src/vscomm/mod.rs @@ -131,8 +131,12 @@ impl RemoteTool { pub fn new(value: impl Into) -> Result { let value = value.into(); validate_remote_string("remote tool", &value, MAX_REMOTE_TOOL_BYTES)?; - if value.is_empty() { - return Err("remote tool is empty".to_string()); + if value.is_empty() + || value == "." + || value == ".." + || !value.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'+')) + { + return Err("remote tool must be a single executable identity".to_string()); } Ok(Self(value)) } @@ -279,7 +283,8 @@ fn validate_remote_build_fields(cwd: &WorkspaceRelativePath, tool: &RemoteTool, env.iter().try_for_each(|(key, value)| { validate_remote_string("remote environment key", key, MAX_REMOTE_ENV_KEY_BYTES)?; validate_env_key("remote environment key", key)?; - validate_remote_string("remote environment value", value, MAX_REMOTE_ENV_VALUE_BYTES) + validate_remote_string("remote environment value", value, MAX_REMOTE_ENV_VALUE_BYTES)?; + validate_env_value("remote environment value", value) }) } @@ -298,6 +303,13 @@ fn validate_remote_count(count: usize, max: usize, field: &str) -> Result<(), St Ok(()) } +fn validate_env_value(field: &str, value: &str) -> Result<(), String> { + if value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { + return Err(format!("{field} contains control data")); + } + Ok(()) +} + struct WireWriter { bytes: Vec, } From 07f5477122d41ffdefcd3442c7cb7328a0f49154 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 3 Aug 2026 10:01:28 +0200 Subject: [PATCH 18/25] Add remote build policy --- src/loopback.rs | 101 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 91 insertions(+), 10 deletions(-) diff --git a/src/loopback.rs b/src/loopback.rs index 39362ae..d13f62f 100644 --- a/src/loopback.rs +++ b/src/loopback.rs @@ -18,6 +18,8 @@ use tokio::time::sleep; pub const LOOPBACK_BUILD_TIMEOUT: Duration = crate::remote::DEFAULT_REMOTE_BUILD_TIMEOUT; const LOOPBACK_PATH: &str = "/usr/local/bin:/usr/bin:/bin"; +const POST_CHILD_EXIT_DRAIN_TIMEOUT: Duration = Duration::from_millis(100); +const POST_CHILD_EXIT_FINAL_DRAIN_TIMEOUT: Duration = Duration::from_millis(100); static NEXT_JOB_ID: AtomicU64 = AtomicU64::new(1); pub struct RunRemoteSession { @@ -235,37 +237,92 @@ async fn execute_build( let stdout = child.stdout.take().ok_or_else(|| RemoteBackendError::Failed("loopback child has no stdout".to_string()))?; let stderr = child.stderr.take().ok_or_else(|| RemoteBackendError::Failed("loopback child has no stderr".to_string()))?; let output_bytes = Arc::new(AtomicU64::new(0)); - let mut stdout_task = - Box::pin(tokio::spawn(pump(stdout, RemoteStream::Stdout, events.clone(), output_bytes.clone(), resources.max_output_bytes))); - let mut stderr_task = Box::pin(tokio::spawn(pump(stderr, RemoteStream::Stderr, events.clone(), output_bytes, resources.max_output_bytes))); + let mut stdout_task = tokio::spawn(pump(stdout, RemoteStream::Stdout, events.clone(), output_bytes.clone(), resources.max_output_bytes)); + let mut stderr_task = tokio::spawn(pump(stderr, RemoteStream::Stderr, events.clone(), output_bytes, resources.max_output_bytes)); let mut child_wait = Box::pin(child.wait()); let mut timeout_sleep = Box::pin(sleep(resources.build_timeout)); + let mut post_exit_drain_sleep = Box::pin(sleep(POST_CHILD_EXIT_DRAIN_TIMEOUT)); + let mut final_drain_sleep = Box::pin(sleep(POST_CHILD_EXIT_FINAL_DRAIN_TIMEOUT)); let mut child_status = None; let mut stdout_done = false; let mut stderr_done = false; let mut failure = None; + let mut post_exit_drain_active = false; + let mut final_drain_active = false; + let mut group_killed = false; while child_status.is_none() || !stdout_done || !stderr_done { tokio::select! { status = &mut child_wait, if child_status.is_none() => { - child_status = Some(status.map_err(|error| RemoteBackendError::Failed(format!("wait for loopback tool: {error}")))); + let status = status.map_err(|error| RemoteBackendError::Failed(format!("wait for loopback tool: {error}"))); + child_status = Some(status); + if failure.is_some() || child_status.as_ref().is_some_and(Result::is_err) { + kill_process_group(process_group.as_ref()); + group_killed = true; + final_drain_sleep.as_mut().reset(tokio::time::Instant::now() + POST_CHILD_EXIT_FINAL_DRAIN_TIMEOUT); + final_drain_active = true; + } else { + // The child is the process-group leader. Keep draining useful + // pipe data briefly, while terminating descendants that + // inherited the build descriptors. + terminate_process_group(process_group.as_ref()); + post_exit_drain_sleep.as_mut().reset(tokio::time::Instant::now() + POST_CHILD_EXIT_DRAIN_TIMEOUT); + post_exit_drain_active = true; + } } result = &mut stdout_task, if !stdout_done => { stdout_done = true; - if let Err(error) = join_pump(result).await { failure.get_or_insert(error); kill_process_group(process_group.as_ref()); } + if let Err(error) = join_pump(result).await { + failure.get_or_insert(error); + kill_process_group(process_group.as_ref()); + group_killed = true; + post_exit_drain_active = false; + if child_status.is_some() { + final_drain_sleep.as_mut().reset(tokio::time::Instant::now() + POST_CHILD_EXIT_FINAL_DRAIN_TIMEOUT); + final_drain_active = true; + } + } } result = &mut stderr_task, if !stderr_done => { stderr_done = true; - if let Err(error) = join_pump(result).await { failure.get_or_insert(error); kill_process_group(process_group.as_ref()); } + if let Err(error) = join_pump(result).await { + failure.get_or_insert(error); + kill_process_group(process_group.as_ref()); + group_killed = true; + post_exit_drain_active = false; + if child_status.is_some() { + final_drain_sleep.as_mut().reset(tokio::time::Instant::now() + POST_CHILD_EXIT_FINAL_DRAIN_TIMEOUT); + final_drain_active = true; + } + } } _ = &mut timeout_sleep, if child_status.is_none() => { failure.get_or_insert(RemoteBackendError::Timeout); kill_process_group(process_group.as_ref()); + group_killed = true; + } + _ = &mut post_exit_drain_sleep, if post_exit_drain_active => { + post_exit_drain_active = false; + kill_process_group(process_group.as_ref()); + group_killed = true; + final_drain_sleep.as_mut().reset(tokio::time::Instant::now() + POST_CHILD_EXIT_FINAL_DRAIN_TIMEOUT); + final_drain_active = true; + } + _ = &mut final_drain_sleep, if final_drain_active => { + final_drain_active = false; + if !stdout_done { + if let Some(error) = abort_pump(&mut stdout_task).await { failure.get_or_insert(error); } + stdout_done = true; + } + if !stderr_done { + if let Some(error) = abort_pump(&mut stderr_task).await { failure.get_or_insert(error); } + stderr_done = true; + } } } } - if failure.is_none() { + if failure.is_none() || !group_killed { kill_process_group(process_group.as_ref()); } if let Some(mut process_group) = process_group { @@ -313,6 +370,18 @@ async fn join_pump(result: Result, tokio::task::J result.map_err(|error| RemoteBackendError::Failed(format!("loopback output task failed: {error}")))? } +async fn abort_pump(task: &mut tokio::task::JoinHandle>) -> Option { + if !task.is_finished() { + task.abort(); + } + match task.await { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(error), + Err(error) if error.is_cancelled() => None, + Err(error) => Some(RemoteBackendError::Failed(format!("loopback output task failed: {error}"))), + } +} + async fn send_event(events: &mpsc::Sender, event: RemoteBackendEvent) -> Result<(), RemoteBackendError> { events.send(event).await.map_err(|_| RemoteBackendError::Cancelled) } @@ -341,10 +410,22 @@ impl Drop for ProcessGroupGuard { } fn kill_process_group(group: Option<&ProcessGroupGuard>) { + signal_process_group(group, libc::SIGTERM); + signal_process_group(group, libc::SIGKILL); +} + +fn terminate_process_group(group: Option<&ProcessGroupGuard>) { + signal_process_group(group, libc::SIGTERM); +} + +fn signal_process_group(group: Option<&ProcessGroupGuard>, signal: libc::c_int) { if let Some(group) = group { - unsafe { - libc::kill(-group.pgid, libc::SIGTERM); - libc::kill(-group.pgid, libc::SIGKILL); + if group.pgid > 0 { + // A negative PID targets the Unix process group, so this remains + // effective after the original group leader has exited. + unsafe { + libc::kill(-group.pgid, signal); + } } } } From 04b1c3c577f0414fa80e7659a667d10b69457fa7 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 3 Aug 2026 10:01:44 +0200 Subject: [PATCH 19/25] Add remote build policy unit tests --- src/loopback_ut.rs | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/loopback_ut.rs b/src/loopback_ut.rs index 21cb637..456bd84 100644 --- a/src/loopback_ut.rs +++ b/src/loopback_ut.rs @@ -98,6 +98,51 @@ async fn build_preserves_argument_bytes_and_reports_nonzero_stderr() { assert!(events.iter().any(|event| matches!(event, RemoteBackendEvent::Completed { exit_code } if *exit_code != 0))); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn child_exit_does_not_wait_for_inherited_output_pipes() { + let (_temp, session, target, session_id) = fixture(); + fs::write(session.workspace_root().join("src/Makefile"), ".PHONY: leak\nleak:\n\t@sleep 30 & echo $$!\n\t@printf 'direct-output\\n'\n").unwrap(); + session.sync_snapshot().unwrap(); + let tools = resolve_fixed_tools(["make".to_string()]); + if tools.is_empty() { + return; + } + + let backend = LoopbackBackend::new(session.clone(), tools); + let (events, receiver) = mpsc::channel(16); + let request = authorized_build(target, session_id, "make", vec!["leak".into()], Vec::new()); + let started = tokio::time::Instant::now(); + let result = tokio::time::timeout(Duration::from_secs(1), backend.execute(request, events)).await.unwrap(); + assert_eq!(result, Ok(())); + assert!(started.elapsed() < Duration::from_millis(500)); + + let events = collect_events(receiver).await; + let stdout = events + .iter() + .filter_map(|event| match event { + RemoteBackendEvent::Stdout(bytes) => Some(bytes.as_slice()), + _ => None, + }) + .flatten() + .copied() + .collect::>(); + assert!(stdout.windows(b"direct-output\n".len()).any(|window| window == b"direct-output\n")); + let pid = std::str::from_utf8(&stdout).unwrap().split_whitespace().find_map(|value| value.parse::().ok()).unwrap(); + + let terminal_events = events + .iter() + .filter(|event| matches!(event, RemoteBackendEvent::Error { .. } | RemoteBackendEvent::Cancelled | RemoteBackendEvent::Completed { .. })) + .collect::>(); + assert_eq!(terminal_events, vec![&RemoteBackendEvent::Completed { exit_code: 0 }]); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(1); + while super::process_is_alive(pid) && tokio::time::Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(!super::process_is_alive(pid)); + assert!(fs::read_dir(&session.jobs_root).unwrap().next().is_none()); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn child_receives_guest_environment_but_trusted_target_wins() { let (_temp, session, target, session_id) = fixture(); From 932dbdaa785289b2c38c5cd5d49e22c4bb8d6418 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 3 Aug 2026 10:40:52 +0200 Subject: [PATCH 20/25] Implement transparency wrapper on one build command --- src/bin/bunkerbox-image.rs | 29 +++++++- src/bin/bunkerbox-remote.rs | 133 ++++++++++++++++++++++++++++++------ src/daemon.rs | 3 +- src/kata.rs | 8 +++ src/loopback.rs | 123 +++++++++++++++++++++++++++------ src/main.rs | 13 +++- src/remote.rs | 57 ++++++++++++++-- src/remote_client.rs | 87 +++++++++++++++++++++-- src/snapshot.rs | 2 +- src/vscomm/mod.rs | 49 +++++++++++-- 10 files changed, 438 insertions(+), 66 deletions(-) diff --git a/src/bin/bunkerbox-image.rs b/src/bin/bunkerbox-image.rs index f1a7289..0094e95 100644 --- a/src/bin/bunkerbox-image.rs +++ b/src/bin/bunkerbox-image.rs @@ -226,10 +226,13 @@ run_app() {{ }} VSCOMM_BIN="/usr/local/bunkerbox/bin" +if [ -x "$VSCOMM_BIN/bunkerbox-remote" ]; then + "$VSCOMM_BIN/bunkerbox-remote" install +fi if [ -x "$VSCOMM_BIN/bunkerbox-vscomm" ]; then "$VSCOMM_BIN/bunkerbox-vscomm" install - export PATH="$VSCOMM_BIN:$PATH" fi +export PATH="$VSCOMM_BIN:$PATH" if ! command -v bunkerbox-status >/dev/null 2>&1; then bunkerbox-status() {{ :; }} @@ -286,6 +289,11 @@ fn write_build_context(config: &ImageConfig, build_dir: &Path) -> Result<(), Str fs::copy(&vscomm_path, &dest).map_err(|err| format!("failed to copy vscomm binary {}: {err}", dest.display()))?; fs::set_permissions(&dest, fs::Permissions::from_mode(0o755)).map_err(|err| format!("failed to chmod {}: {err}", dest.display()))?; + let remote_path = find_remote_binary()?; + let dest = build_dir.join("bunkerbox-remote"); + fs::copy(&remote_path, &dest).map_err(|err| format!("failed to copy remote binary {}: {err}", dest.display()))?; + fs::set_permissions(&dest, fs::Permissions::from_mode(0o755)).map_err(|err| format!("failed to chmod {}: {err}", dest.display()))?; + let status_path = find_status_binary()?; let dest = build_dir.join("bunkerbox-status"); fs::copy(&status_path, &dest).map_err(|err| format!("failed to copy status binary {}: {err}", dest.display()))?; @@ -295,7 +303,11 @@ fn write_build_context(config: &ImageConfig, build_dir: &Path) -> Result<(), Str if file.path.is_absolute() || file.path.components().any(|part| matches!(part, std::path::Component::ParentDir)) { return Err(format!("unsafe build file path: {}", file.path.display())); } - if file.path == Path::new("bunker-entrypoint") || file.path == Path::new("bunkerbox-vscomm") || file.path == Path::new("bunkerbox-status") { + if file.path == Path::new("bunker-entrypoint") + || file.path == Path::new("bunkerbox-vscomm") + || file.path == Path::new("bunkerbox-status") + || file.path == Path::new("bunkerbox-remote") + { return Err(format!("image config files must not override reserved file: {}", file.path.display())); } @@ -336,6 +348,15 @@ fn find_status_binary() -> Result { } } +fn find_remote_binary() -> Result { + let path = dist_dir()?.join("bunkerbox-remote"); + if path.is_file() { + Ok(path) + } else { + Err("bunkerbox-remote not found in target/dist/. Run: make dev".into()) + } +} + fn podman_build(config: &ImageConfig, build_dir: &Path) -> Result<(), String> { let mut args = vec!["build".to_string(), "--no-cache".to_string()]; @@ -451,3 +472,7 @@ where } } } + +#[cfg(test)] +#[path = "../bunkerbox-image_ut.rs"] +mod tests; diff --git a/src/bin/bunkerbox-remote.rs b/src/bin/bunkerbox-remote.rs index d8439e0..d3cd19e 100644 --- a/src/bin/bunkerbox-remote.rs +++ b/src/bin/bunkerbox-remote.rs @@ -1,9 +1,16 @@ -use bunkerbox::remote_client::{execute_remote_request_to, remote_build_request, remote_sync_request}; -use bunkerbox::vscomm::{RemoteRequest, RequestId, WorkspaceSessionId, TOOLCHAIN_PORT}; -use rand::RngCore; +use bunkerbox::remote::RemoteSnapshotId; +use bunkerbox::remote_client::{ + execute_remote_request_to, logical_workspace_cwd, new_request_id, remote_build_request, remote_environment_names, remote_session_from_env, + remote_sync_request, remote_tool_enabled, selected_remote_environment, RemoteCompletion, +}; +#[cfg(test)] +use bunkerbox::vscomm::RequestId; +use bunkerbox::vscomm::{RemoteRequest, WorkspaceSessionId, TOOLCHAIN_PORT, VSCOMM_BIN_DIR}; use std::env; +use std::fs; use std::io::{self, Read, Write}; use std::mem; +use std::os::unix::fs::symlink; use std::path::Path; const HOST_CID: u32 = 2; @@ -25,21 +32,80 @@ fn main() { } fn run() -> Result { + let invoked_as = + env::args_os().next().and_then(|value| Path::new(&value).file_name().and_then(|name| name.to_str()).map(str::to_owned)).unwrap_or_default(); let args = env::args().skip(1).collect::>(); - let command = parse_command(&args)?; + + if invoked_as == "make" { + return run_transparent_make(&args); + } + if invoked_as != "bunkerbox-remote" { + return Err("bunkerbox-remote must be invoked directly or through the managed make symlink".to_string()); + } + if args.len() == 1 && args[0] == "install" { + install_remote_links()?; + return Ok(0); + } + + run_explicit(&args) +} + +fn run_explicit(args: &[String]) -> Result { + let command = parse_command(args)?; match &command { RemoteCommand::Sync => eprintln!("bunkerbox-remote: syncing"), RemoteCommand::Build { tool, .. } => eprintln!("bunkerbox-remote: building {tool}"), } + + let cwd = logical_workspace_cwd(&env::current_dir().map_err(|error| format!("current directory: {error}"))?)?; + let session = remote_session_from_env()?; + match command { + RemoteCommand::Sync => { + sync_snapshot(session)?; + Ok(0) + } + RemoteCommand::Build { tool, args } => run_build_with_sync(cwd, tool, args, session), + } +} + +fn run_transparent_make(args: &[String]) -> Result { let cwd = logical_workspace_cwd(&env::current_dir().map_err(|error| format!("current directory: {error}"))?)?; - let session = env::var("BUNKERBOX_REMOTE_SESSION") - .map_err(|_| "BUNKERBOX_REMOTE_SESSION is missing".to_string()) - .and_then(|value| WorkspaceSessionId::from_hex(&value))?; - let request = build_request(command, cwd, new_request_id(), session)?; + let session = remote_session_from_env()?; + run_build_with_sync(cwd, "make".to_string(), args.to_vec(), session) +} + +fn run_build_with_sync(cwd: String, tool: String, args: Vec, session: WorkspaceSessionId) -> Result { + let environment = selected_remote_environment(remote_environment_names()); + run_build_with_sync_using(cwd, tool, args, environment, session, execute_request_over_vsock) +} + +fn run_build_with_sync_using( + cwd: String, tool: String, args: Vec, environment: Vec<(String, String)>, session: WorkspaceSessionId, mut execute: F, +) -> Result +where + F: FnMut(RemoteRequest) -> Result, +{ + let snapshot_id = match execute(remote_sync_request(new_request_id(), session))? { + RemoteCompletion::Synced(snapshot_id) => snapshot_id, + RemoteCompletion::Completed(_) => return Err("remote sync returned a build completion".to_string()), + }; + let request = remote_build_request(new_request_id(), session, cwd, tool, args, environment, snapshot_id)?; + match execute(request)? { + RemoteCompletion::Completed(code) => Ok(code), + RemoteCompletion::Synced(_) => Err("remote build returned a sync completion".to_string()), + } +} + +fn execute_request_over_vsock(request: RemoteRequest) -> Result { let mut stream = connect_toolchain()?; - let mut stdout = io::stdout(); - let mut stderr = io::stderr(); - execute_remote_request_to(&mut stream, request, &mut stdout, &mut stderr) + execute_remote_request_to(&mut stream, request, &mut io::stdout(), &mut io::stderr()) +} + +fn sync_snapshot(session: WorkspaceSessionId) -> Result { + match execute_request_over_vsock(remote_sync_request(new_request_id(), session))? { + RemoteCompletion::Synced(snapshot_id) => Ok(snapshot_id), + RemoteCompletion::Completed(_) => Err("remote sync returned a build completion".to_string()), + } } fn parse_command(args: &[String]) -> Result { @@ -52,24 +118,47 @@ fn parse_command(args: &[String]) -> Result { } } -fn build_request(command: RemoteCommand, cwd: String, request_id: RequestId, session_id: WorkspaceSessionId) -> Result { +#[cfg(test)] +fn build_request( + command: RemoteCommand, cwd: String, request_id: RequestId, session_id: WorkspaceSessionId, snapshot_id: RemoteSnapshotId, +) -> Result { match command { RemoteCommand::Sync => Ok(remote_sync_request(request_id, session_id)), - RemoteCommand::Build { tool, args } => remote_build_request(request_id, session_id, cwd, tool, args, Vec::new()), + RemoteCommand::Build { tool, args } => remote_build_request(request_id, session_id, cwd, tool, args, Vec::new(), snapshot_id), } } -fn logical_workspace_cwd(path: &Path) -> Result { - let relative = path.strip_prefix("/workspace").map_err(|_| "current directory must be under /workspace".to_string())?; - let value = relative.to_str().ok_or_else(|| "current directory is not valid UTF-8".to_string())?.to_string(); - bunkerbox::remote::WorkspaceRelativePath::new(&value)?; - Ok(value) +fn install_remote_links() -> Result<(), String> { + fs::create_dir_all(VSCOMM_BIN_DIR).map_err(|error| format!("mkdir {VSCOMM_BIN_DIR}: {error}"))?; + let executable = env::current_exe().map_err(|error| format!("failed to locate remote binary: {error}"))?; + install_remote_make_link(Path::new(VSCOMM_BIN_DIR), &executable, remote_tool_enabled("make")) +} + +fn install_remote_make_link(bin_dir: &Path, executable: &Path, enabled: bool) -> Result<(), String> { + let target = bin_dir.join("make"); + let managed = is_managed_link(&target, executable); + + if enabled { + if target.exists() || fs::symlink_metadata(&target).is_ok() { + if !managed { + return Err(format!("cannot install remote make wrapper over existing {}", target.display())); + } + fs::remove_file(&target).map_err(|error| format!("remove existing remote make wrapper: {error}"))?; + } + symlink(executable, &target).map_err(|error| format!("symlink remote make wrapper: {error}"))?; + } else if managed { + fs::remove_file(&target).map_err(|error| format!("remove disabled remote make wrapper: {error}"))?; + } + Ok(()) } -fn new_request_id() -> RequestId { - let mut bytes = [0; 16]; - rand::thread_rng().fill_bytes(&mut bytes); - RequestId(bytes) +fn is_managed_link(target: &Path, executable: &Path) -> bool { + let Ok(metadata) = fs::symlink_metadata(target) else { return false }; + if !metadata.file_type().is_symlink() { + return false; + } + let Ok(link) = fs::read_link(target) else { return false }; + link == executable } fn connect_toolchain() -> Result { diff --git a/src/daemon.rs b/src/daemon.rs index 34a8449..322df6f 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -151,7 +151,8 @@ impl VsockDaemon { } (None, None) => RemoteAuthorizationPolicy::new(session.target(), session.session_id(), allowed_tools), _ => return Err("remote tool and environment policies must be configured together".to_string()), - }; + } + .with_snapshot_authority(session.clone()); let remote_context = RemoteExecutionContext { target: session.target(), workspace_session_id: session.session_id() }; let backend = LoopbackBackend::new(session, tools) .with_target_environment(target_environment) diff --git a/src/kata.rs b/src/kata.rs index 2ac635d..560675b 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -20,6 +20,8 @@ use std::thread; pub struct WorkspaceBinding<'a> { pub path: &'a Path, pub remote_session: WorkspaceSessionId, + pub remote_tools: &'a [String], + pub remote_environment: &'a [String], } const BRIDGE_SUBNET: &str = "10.247.0.0/24"; @@ -197,6 +199,12 @@ pub fn run( if vsock_enabled { container_env.push(format!("BUNKERBOX_TOOLCHAIN_PORT={TOOLCHAIN_PORT}")); container_env.push(format!("BUNKERBOX_REMOTE_SESSION={}", workspace.remote_session.to_hex())); + if !workspace.remote_tools.is_empty() { + container_env.push(format!("BUNKERBOX_REMOTE_TOOLS={}", workspace.remote_tools.join(","))); + } + if !workspace.remote_environment.is_empty() { + container_env.push(format!("BUNKERBOX_REMOTE_ENV_NAMES={}", workspace.remote_environment.join(","))); + } } if let Some(ref cmds) = config.command { diff --git a/src/loopback.rs b/src/loopback.rs index d13f62f..9c3fb62 100644 --- a/src/loopback.rs +++ b/src/loopback.rs @@ -1,9 +1,10 @@ use crate::remote::{ AuthorizedRemoteRequest, RemoteBackend, RemoteBackendError, RemoteBackendEvent, RemoteFuture, RemoteOperation, RemoteResourcePolicy, - RemoteTargetId, WorkspaceSessionId, + RemoteSnapshotAuthority, RemoteSnapshotId, RemoteTargetId, WorkspaceSessionId, }; use crate::snapshot::{SnapshotBuilder, SnapshotHandle, SnapshotStore}; -use std::collections::BTreeMap; +use rand::RngCore; +use std::collections::{BTreeMap, HashMap}; use std::fs; use std::io; use std::os::unix::fs::{MetadataExt, PermissionsExt}; @@ -28,7 +29,7 @@ pub struct RunRemoteSession { workspace_root: PathBuf, snapshot_store: SnapshotStore, snapshot_builder: SnapshotBuilder, - current_snapshot: Mutex>, + snapshot_capabilities: Mutex, snapshot_operation: Mutex<()>, jobs_root: PathBuf, } @@ -56,7 +57,7 @@ impl RunRemoteSession { workspace_root, snapshot_store, snapshot_builder, - current_snapshot: Mutex::new(None), + snapshot_capabilities: Mutex::new(SnapshotCapabilityRegistry::default()), snapshot_operation: Mutex::new(()), jobs_root, }) @@ -78,26 +79,59 @@ impl RunRemoteSession { self.snapshot_store.clone() } - pub fn current_snapshot(&self) -> Result, String> { - self.current_snapshot.lock().map(|current| current.clone()).map_err(|_| "remote session state lock poisoned".to_string()) - } - - pub fn sync_snapshot(&self) -> Result { + pub fn sync_snapshot(&self) -> Result { let _operation = self.snapshot_operation.lock().map_err(|_| "remote session operation lock poisoned".to_string())?; let snapshot = self.snapshot_builder.build_root(&self.workspace_root, self.session_id)?; - let new_handle = snapshot.handle().clone(); - let old_handle = self.current_snapshot()?.clone(); - if let Some(old_handle) = old_handle.filter(|old| old != &new_handle) { - self.snapshot_store.remove(&old_handle)?; + self.register_snapshot(snapshot.handle().clone()) + } + + fn register_snapshot(&self, handle: SnapshotHandle) -> Result { + let mut registry = self.snapshot_capabilities.lock().map_err(|_| "remote snapshot registry lock poisoned".to_string())?; + let snapshot_id = loop { + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + let candidate = RemoteSnapshotId::from_bytes(bytes); + if !candidate.is_zero() && !registry.capabilities.contains_key(&candidate) { + break candidate; + } + }; + registry.capabilities.insert(snapshot_id, handle.clone()); + *registry.references.entry(handle).or_insert(0) += 1; + Ok(snapshot_id) + } + + fn claim_snapshot(self: &Arc, snapshot_id: RemoteSnapshotId) -> Result { + let mut registry = self.snapshot_capabilities.lock().map_err(|_| "remote snapshot registry lock poisoned".to_string())?; + let handle = registry.capabilities.remove(&snapshot_id).ok_or_else(|| "remote snapshot capability is unavailable".to_string())?; + Ok(SnapshotClaim { session: self.clone(), handle }) + } + + fn release_snapshot(&self, handle: &SnapshotHandle) { + let should_remove = self + .snapshot_capabilities + .lock() + .ok() + .map(|mut registry| { + let Some(references) = registry.references.get_mut(handle) else { + return false; + }; + *references = references.saturating_sub(1); + if *references == 0 { + registry.references.remove(handle); + true + } else { + false + } + }) + .unwrap_or(false); + if should_remove { + let _ = self.snapshot_store.remove(handle); } - self.current_snapshot.lock().map_err(|_| "remote session state lock poisoned".to_string())?.replace(new_handle.clone()); - Ok(new_handle) } - fn materialize_current_snapshot(&self, destination: &Path) -> Result<(), String> { + fn materialize_snapshot(&self, handle: &SnapshotHandle, destination: &Path) -> Result<(), String> { let _operation = self.snapshot_operation.lock().map_err(|_| "remote session operation lock poisoned".to_string())?; - let handle = self.current_snapshot()?.ok_or_else(|| "remote build requires a successful sync".to_string())?; - self.snapshot_store.materialize(&handle, destination).map(|_| ()) + self.snapshot_store.materialize(handle, destination).map(|_| ()) } fn new_job_path(&self) -> Result { @@ -109,6 +143,46 @@ impl RunRemoteSession { } } +#[derive(Default)] +struct SnapshotCapabilityRegistry { + capabilities: BTreeMap, + references: HashMap, +} + +struct SnapshotClaim { + session: Arc, + handle: SnapshotHandle, +} + +impl SnapshotClaim { + fn handle(&self) -> &SnapshotHandle { + &self.handle + } + + fn clone_for_worker(&self) -> Result { + let mut registry = self.session.snapshot_capabilities.lock().map_err(|_| "remote snapshot registry lock poisoned".to_string())?; + let references = + registry.references.get_mut(&self.handle).ok_or_else(|| "remote snapshot capability reference is unavailable".to_string())?; + *references = references.checked_add(1).ok_or_else(|| "remote snapshot capability reference count overflow".to_string())?; + Ok(Self { session: self.session.clone(), handle: self.handle.clone() }) + } +} + +impl Drop for SnapshotClaim { + fn drop(&mut self) { + self.session.release_snapshot(&self.handle); + } +} + +impl RemoteSnapshotAuthority for RunRemoteSession { + fn snapshot_available(&self, session: WorkspaceSessionId, snapshot_id: RemoteSnapshotId) -> bool { + if session != self.session_id { + return false; + } + self.snapshot_capabilities.lock().map(|registry| registry.capabilities.contains_key(&snapshot_id)).unwrap_or(false) + } +} + impl Drop for RunRemoteSession { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.jobs_root); @@ -181,23 +255,30 @@ async fn execute_sync(session: Arc, events: mpsc::Sender, tools: Arc>, target_environment: Arc>, resources: RemoteResourcePolicy, build: &crate::remote::RemoteBuild, events: mpsc::Sender, ) -> Result<(), RemoteBackendError> { + let snapshot = session.claim_snapshot(build.snapshot_id()).map_err(RemoteBackendError::Failed)?; let executable = tools .get(build.tool().as_str()) .ok_or_else(|| RemoteBackendError::Spawn(format!("loopback tool is not configured: {}", build.tool().as_str())))?; let job_path = session.new_job_path().map_err(RemoteBackendError::Failed)?; let _job = JobGuard { path: job_path.clone() }; + let materialization_claim = snapshot.clone_for_worker().map_err(RemoteBackendError::Failed)?; let destination = job_path.clone(); + let snapshot_handle = snapshot.handle().clone(); tokio::task::spawn_blocking({ let session = session.clone(); - move || session.materialize_current_snapshot(&destination) + move || { + let result = session.materialize_snapshot(&snapshot_handle, &destination); + drop(materialization_claim); + result + } }) .await .map_err(|error| RemoteBackendError::Failed(format!("materialization worker failed: {error}")))? diff --git a/src/main.rs b/src/main.rs index 137a494..d32f37d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -197,8 +197,10 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option>(); let remote_tool_policies = env.project.remote.tools.iter().map(|tool| (tool.name.clone(), RemoteToolPolicy::new(tool.allow_args))).collect::>(); + let configured_remote_tool_names = env.project.remote.tools.iter().map(|tool| tool.name.clone()).collect::>(); let remote_tool_names = remote_tool_names(&env.project.remote.tools); let share_dir_owned = share_dir.to_path_buf(); @@ -242,7 +244,12 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option bunkerbox::remote::RemoteTargetId { } fn remote_tool_names(entries: &[RemoteToolSpec]) -> Vec { - entries.iter().map(|tool| tool.name.clone()).collect() + entries.iter().filter(|tool| tool.name == "make").map(|tool| tool.name.clone()).collect() } fn write_run_handoff(file: &mut File, path: &Path, session_id: vscomm::WorkspaceSessionId) -> Result<(), String> { diff --git a/src/remote.rs b/src/remote.rs index 1c5c869..0dbc7da 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::future::Future; use std::pin::Pin; +use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; @@ -20,7 +21,24 @@ pub const DEFAULT_REMOTE_ENVIRONMENT: &[&str] = &["CC", "CXX", "AR", "RUSTFLAGS" #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RequestId(pub [u8; 16]); -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RemoteSnapshotId([u8; 16]); + +impl RemoteSnapshotId { + pub fn from_bytes(bytes: [u8; 16]) -> Self { + Self(bytes) + } + + pub fn as_bytes(&self) -> &[u8; 16] { + &self.0 + } + + pub fn is_zero(self) -> bool { + self.0 == [0; 16] + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct WorkspaceSessionId(pub [u8; 16]); #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -77,10 +95,13 @@ pub struct RemoteBuild { tool: RemoteTool, argv: Vec, env: Vec<(String, String)>, + snapshot_id: RemoteSnapshotId, } impl RemoteBuild { - pub fn new(cwd: WorkspaceRelativePath, tool: RemoteTool, argv: Vec, env: Vec<(String, String)>) -> Result { + pub fn new( + cwd: WorkspaceRelativePath, tool: RemoteTool, argv: Vec, env: Vec<(String, String)>, snapshot_id: RemoteSnapshotId, + ) -> Result { validate_count(argv.len(), MAX_REMOTE_ARG_COUNT, "remote argv")?; argv.iter().try_for_each(|arg| validate_string("remote argument", arg, MAX_REMOTE_ARG_BYTES))?; validate_count(env.len(), MAX_REMOTE_ENV_COUNT, "remote environment")?; @@ -88,7 +109,10 @@ impl RemoteBuild { validate_environment_key(key)?; validate_environment_value(value) })?; - Ok(Self { cwd, tool, argv, env }) + if snapshot_id.is_zero() { + return Err("remote snapshot ID must be nonzero".to_string()); + } + Ok(Self { cwd, tool, argv, env, snapshot_id }) } pub fn cwd(&self) -> &WorkspaceRelativePath { @@ -106,6 +130,10 @@ impl RemoteBuild { pub fn env(&self) -> &[(String, String)] { &self.env } + + pub fn snapshot_id(&self) -> RemoteSnapshotId { + self.snapshot_id + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -263,6 +291,7 @@ pub enum RemoteAuthorizationError { ForbiddenEnvironment(String), EnvironmentNotAllowed(String), DuplicateEnvironment(String), + SnapshotNotAllowed, } impl RemoteAuthorizationError { @@ -271,18 +300,23 @@ impl RemoteAuthorizationError { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone)] pub struct RemoteAuthorizationPolicy { allowed_target: RemoteTargetId, allowed_session: WorkspaceSessionId, allowed_tools: BTreeMap, environment: RemoteEnvironmentPolicy, + snapshot_authority: Option>, +} + +pub trait RemoteSnapshotAuthority: Send + Sync { + fn snapshot_available(&self, session: WorkspaceSessionId, snapshot_id: RemoteSnapshotId) -> bool; } impl RemoteAuthorizationPolicy { pub fn new(allowed_target: RemoteTargetId, allowed_session: WorkspaceSessionId, allowed_tools: Vec) -> Self { let allowed_tools = allowed_tools.into_iter().map(|tool| (tool, RemoteToolPolicy::new(true))).collect(); - Self { allowed_target, allowed_session, allowed_tools, environment: RemoteEnvironmentPolicy::default() } + Self { allowed_target, allowed_session, allowed_tools, environment: RemoteEnvironmentPolicy::default(), snapshot_authority: None } } pub fn from_policies( @@ -296,7 +330,12 @@ impl RemoteAuthorizationPolicy { return Err(format!("duplicate remote tool policy: {tool}")); } } - Ok(Self { allowed_target, allowed_session, allowed_tools, environment }) + Ok(Self { allowed_target, allowed_session, allowed_tools, environment, snapshot_authority: None }) + } + + pub fn with_snapshot_authority(mut self, authority: Arc) -> Self { + self.snapshot_authority = Some(authority); + self } pub fn allowed_tools(&self) -> impl Iterator { @@ -314,6 +353,9 @@ impl RemoteAuthorizationPolicy { let request = match request.operation() { RemoteOperation::Sync => request, RemoteOperation::Build(build) => { + if self.snapshot_authority.as_ref().is_none_or(|authority| !authority.snapshot_available(self.allowed_session, build.snapshot_id())) { + return Err(RemoteAuthorizationError::SnapshotNotAllowed); + } let Some(tool_policy) = self.allowed_tools.get(build.tool().as_str()).copied() else { return Err(RemoteAuthorizationError::ToolNotAllowed(build.tool().as_str().to_string())); }; @@ -321,7 +363,7 @@ impl RemoteAuthorizationPolicy { return Err(RemoteAuthorizationError::ToolArgumentsNotAllowed(build.tool().as_str().to_string())); } let environment = self.environment.filter(build.env())?; - let filtered = RemoteBuild::new(build.cwd.clone(), build.tool.clone(), build.argv.clone(), environment) + let filtered = RemoteBuild::new(build.cwd.clone(), build.tool.clone(), build.argv.clone(), environment, build.snapshot_id()) .map_err(RemoteAuthorizationError::InvalidEnvironment)?; RemoteRequest::build(request.request_id, request.workspace_session_id, filtered) } @@ -334,6 +376,7 @@ impl RemoteAuthorizationPolicy { #[derive(Debug, Clone, PartialEq, Eq)] pub enum RemoteBackendEvent { SyncProgress { completed_bytes: u64, total_bytes: Option }, + SyncCompleted { snapshot_id: RemoteSnapshotId }, Stdout(Vec), Stderr(Vec), Error { message: String }, diff --git a/src/remote_client.rs b/src/remote_client.rs index 16f226c..77912cc 100644 --- a/src/remote_client.rs +++ b/src/remote_client.rs @@ -1,21 +1,93 @@ +use crate::remote::RemoteSnapshotId; use crate::vscomm::{RemoteBuild, RemoteEvent, RemoteEventKind, RemoteRequest, RemoteTool, RequestId, WorkspaceRelativePath, WorkspaceSessionId}; +use rand::RngCore; +use std::env; use std::io::{Read, Write}; +use std::path::Path; pub fn remote_sync_request(request_id: RequestId, session_id: WorkspaceSessionId) -> RemoteRequest { RemoteRequest::sync(request_id, session_id) } +pub fn remote_session_from_env() -> Result { + env::var("BUNKERBOX_REMOTE_SESSION") + .map_err(|_| "BUNKERBOX_REMOTE_SESSION is missing".to_string()) + .and_then(|value| WorkspaceSessionId::from_hex(&value)) +} + +pub fn logical_workspace_cwd(path: &Path) -> Result { + let relative = path.strip_prefix("/workspace").map_err(|_| "current directory must be under /workspace".to_string())?; + let value = relative.to_str().ok_or_else(|| "current directory is not valid UTF-8".to_string())?.to_string(); + crate::remote::WorkspaceRelativePath::new(&value)?; + Ok(value) +} + +pub fn new_request_id() -> RequestId { + let mut bytes = [0; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + RequestId(bytes) +} + +pub fn remote_tool_enabled(tool: &str) -> bool { + env::var("BUNKERBOX_REMOTE_TOOLS").ok().is_some_and(|tools| tools.split(',').any(|candidate| candidate == tool)) +} + +pub fn remote_environment_names() -> Vec { + env::var("BUNKERBOX_REMOTE_ENV_NAMES") + .ok() + .map(|names| names.split(',').filter(|name| !name.is_empty()).map(str::to_string).collect()) + .unwrap_or_default() +} + +pub fn selected_remote_environment(names: impl IntoIterator) -> Vec<(String, String)> { + names + .into_iter() + .filter(|name| !never_forward_environment(name)) + .filter_map(|name| env::var_os(&name).and_then(|value| value.into_string().ok().map(|value| (name, value)))) + .collect() +} + +fn never_forward_environment(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + matches!(upper.as_str(), "PATH" | "HOME" | "SSH_AUTH_SOCK" | "SSH_AGENT_PID" | "GITHUB_TOKEN" | "GITLAB_TOKEN" | "NPM_TOKEN" | "KUBECONFIG") + || upper.starts_with("BUNKERBOX_") + || upper.starts_with("AWS_") + || upper.starts_with("GCP_") + || upper.starts_with("GOOGLE_") + || upper.starts_with("AZURE_") + || upper.starts_with("DOCKER_") + || upper.starts_with("CARGO_REGISTRIES_") + || upper.starts_with("XDG_") + || upper.ends_with("_PROXY") + || upper.ends_with("_TOKEN") + || upper.ends_with("_PASSWORD") + || upper.ends_with("_PASS") + || upper.ends_with("_SECRET") + || upper.ends_with("_KEY") +} + +#[cfg(test)] +#[path = "remote_client_ut.rs"] +mod tests; + pub fn remote_build_request( request_id: RequestId, session_id: WorkspaceSessionId, cwd: impl Into, tool: impl Into, argv: Vec, - env: Vec<(String, String)>, + env: Vec<(String, String)>, snapshot_id: RemoteSnapshotId, ) -> Result { - let build = RemoteBuild::new(WorkspaceRelativePath::new(cwd)?, RemoteTool::new(tool)?, argv, env)?; + let snapshot_id = crate::vscomm::RemoteSnapshotId(*snapshot_id.as_bytes()); + let build = RemoteBuild::new(WorkspaceRelativePath::new(cwd)?, RemoteTool::new(tool)?, argv, env, snapshot_id)?; Ok(RemoteRequest::build(request_id, session_id, build)) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteCompletion { + Synced(RemoteSnapshotId), + Completed(i32), +} + pub fn execute_remote_request_to( stream: &mut S, request: RemoteRequest, stdout: &mut WOut, stderr: &mut WErr, -) -> Result { +) -> Result { let request_id = request.request_id; request.to_frame()?.write(stream).map_err(|e| format!("send remote request: {e}"))?; @@ -27,6 +99,13 @@ pub fn execute_remote_request_to( } match event.kind { RemoteEventKind::SyncProgress { .. } => {} + RemoteEventKind::SyncCompleted { snapshot_id } => { + let snapshot_id = RemoteSnapshotId::from_bytes(snapshot_id.0); + if snapshot_id.is_zero() { + return Err("remote snapshot ID must be nonzero".to_string()); + } + return Ok(RemoteCompletion::Synced(snapshot_id)); + } RemoteEventKind::Stdout(data) => { stdout.write_all(&data).map_err(|e| format!("stdout: {e}"))?; stdout.flush().map_err(|e| format!("flush stdout: {e}"))?; @@ -37,7 +116,7 @@ pub fn execute_remote_request_to( } RemoteEventKind::Error { message, .. } => return Err(message), RemoteEventKind::Cancelled => return Err("remote operation cancelled".to_string()), - RemoteEventKind::Completed { exit_code } => return Ok(exit_code), + RemoteEventKind::Completed { exit_code } => return Ok(RemoteCompletion::Completed(exit_code)), } } } diff --git a/src/snapshot.rs b/src/snapshot.rs index bda717a..1df733b 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -170,7 +170,7 @@ impl SnapshotExclusionPolicy { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct SnapshotHandle { session_id: WorkspaceSessionId, snapshot_id: SnapshotId, diff --git a/src/vscomm/mod.rs b/src/vscomm/mod.rs index 0e1bced..8dc7041 100644 --- a/src/vscomm/mod.rs +++ b/src/vscomm/mod.rs @@ -17,7 +17,7 @@ pub const TUI_STATUS_PORT: u32 = 10000; pub const VSCOMM_BIN_DIR: &str = "/usr/local/bunkerbox/bin"; /// Maximum payload accepted in one vsock frame. pub const MAX_FRAME_PAYLOAD: usize = 1024 * 1024; -pub const REMOTE_PROTOCOL_VERSION: u16 = 1; +pub const REMOTE_PROTOCOL_VERSION: u16 = 2; pub const MAX_REMOTE_STRING_BYTES: usize = 4 * 1024; pub const MAX_REMOTE_TOOL_BYTES: usize = 256; pub const MAX_REMOTE_ARG_COUNT: usize = 256; @@ -66,6 +66,15 @@ pub struct ExecRequest { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RequestId(pub [u8; 16]); +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RemoteSnapshotId(pub [u8; 16]); + +impl RemoteSnapshotId { + pub fn is_zero(self) -> bool { + self.0 == [0; 16] + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct WorkspaceSessionId(pub [u8; 16]); @@ -152,12 +161,18 @@ pub struct RemoteBuild { pub tool: RemoteTool, pub argv: Vec, pub env: Vec<(String, String)>, + pub snapshot_id: RemoteSnapshotId, } impl RemoteBuild { - pub fn new(cwd: WorkspaceRelativePath, tool: RemoteTool, argv: Vec, env: Vec<(String, String)>) -> Result { + pub fn new( + cwd: WorkspaceRelativePath, tool: RemoteTool, argv: Vec, env: Vec<(String, String)>, snapshot_id: RemoteSnapshotId, + ) -> Result { validate_remote_build_fields(&cwd, &tool, &argv, &env)?; - Ok(Self { cwd, tool, argv, env }) + if snapshot_id.is_zero() { + return Err("remote snapshot ID must be nonzero".to_string()); + } + Ok(Self { cwd, tool, argv, env, snapshot_id }) } } @@ -234,7 +249,8 @@ impl RemoteRequest { RemoteOperation::Build(build) => { let cwd = remote_domain::WorkspaceRelativePath::new(build.cwd.as_str())?; let tool = remote_domain::RemoteTool::new(build.tool.as_str())?; - let build = remote_domain::RemoteBuild::new(cwd, tool, build.argv, build.env)?; + let snapshot_id = remote_domain::RemoteSnapshotId::from_bytes(build.snapshot_id.0); + let build = remote_domain::RemoteBuild::new(cwd, tool, build.argv, build.env, snapshot_id)?; Ok(remote_domain::RemoteRequest::build(request_id, session_id, build)) } } @@ -243,8 +259,12 @@ impl RemoteRequest { fn encode_remote_build(writer: &mut WireWriter, build: &RemoteBuild) -> Result<(), String> { validate_remote_build_fields(&build.cwd, &build.tool, &build.argv, &build.env)?; + if build.snapshot_id.is_zero() { + return Err("remote snapshot ID must be nonzero".to_string()); + } writer.string(build.cwd.as_str(), MAX_REMOTE_STRING_BYTES, "remote cwd")?; writer.string(build.tool.as_str(), MAX_REMOTE_TOOL_BYTES, "remote tool")?; + writer.bytes(&build.snapshot_id.0); writer.count(build.argv.len(), MAX_REMOTE_ARG_COUNT, "remote argv")?; for arg in &build.argv { writer.string(arg, MAX_REMOTE_ARG_BYTES, "remote argument")?; @@ -260,6 +280,7 @@ fn encode_remote_build(writer: &mut WireWriter, build: &RemoteBuild) -> Result<( fn decode_remote_build(reader: &mut WireReader<'_>) -> Result { let cwd = WorkspaceRelativePath::new(reader.string(MAX_REMOTE_STRING_BYTES, "remote cwd")?)?; let tool = RemoteTool::new(reader.string(MAX_REMOTE_TOOL_BYTES, "remote tool")?)?; + let snapshot_id = RemoteSnapshotId(reader.array16()?); let argv = (0..reader.count(MAX_REMOTE_ARG_COUNT, "remote argv")?) .map(|_| reader.string(MAX_REMOTE_ARG_BYTES, "remote argument")) .collect::, _>>()?; @@ -271,7 +292,7 @@ fn decode_remote_build(reader: &mut WireReader<'_>) -> Result, String>>()?; - RemoteBuild::new(cwd, tool, argv, env) + RemoteBuild::new(cwd, tool, argv, env, snapshot_id) } fn validate_remote_build_fields(cwd: &WorkspaceRelativePath, tool: &RemoteTool, argv: &[String], env: &[(String, String)]) -> Result<(), String> { @@ -484,6 +505,7 @@ impl RemoteErrorCode { #[derive(Debug, Clone, PartialEq, Eq)] pub enum RemoteEventKind { SyncProgress { completed_bytes: u64, total_bytes: Option }, + SyncCompleted { snapshot_id: RemoteSnapshotId }, Stdout(Vec), Stderr(Vec), Error { code: RemoteErrorCode, message: String }, @@ -504,6 +526,9 @@ impl RemoteEvent { remote_domain::RemoteBackendEvent::SyncProgress { completed_bytes, total_bytes } => { RemoteEventKind::SyncProgress { completed_bytes, total_bytes } } + remote_domain::RemoteBackendEvent::SyncCompleted { snapshot_id } => { + RemoteEventKind::SyncCompleted { snapshot_id: RemoteSnapshotId(snapshot_id.as_bytes().to_owned()) } + } remote_domain::RemoteBackendEvent::Stdout(data) => RemoteEventKind::Stdout(data), remote_domain::RemoteBackendEvent::Stderr(data) => RemoteEventKind::Stderr(data), remote_domain::RemoteBackendEvent::Error { message } => RemoteEventKind::Error { code: RemoteErrorCode::Failed, message }, @@ -518,6 +543,7 @@ impl RemoteEvent { writer.u16(REMOTE_PROTOCOL_VERSION); writer.u8(match &self.kind { RemoteEventKind::SyncProgress { .. } => 1, + RemoteEventKind::SyncCompleted { .. } => 7, RemoteEventKind::Stdout(_) => 2, RemoteEventKind::Stderr(_) => 3, RemoteEventKind::Error { .. } => 4, @@ -535,6 +561,12 @@ impl RemoteEvent { writer.u64(*total_bytes); } } + RemoteEventKind::SyncCompleted { snapshot_id } => { + if snapshot_id.is_zero() { + return Err("remote snapshot ID must be nonzero".to_string()); + } + writer.bytes(&snapshot_id.0); + } RemoteEventKind::Stdout(data) | RemoteEventKind::Stderr(data) => writer.blob(data, MAX_FRAME_PAYLOAD, "remote output")?, RemoteEventKind::Error { code, message } => { writer.u16(*code as u16); @@ -568,6 +600,13 @@ impl RemoteEvent { }; RemoteEventKind::SyncProgress { completed_bytes, total_bytes } } + 7 => { + let snapshot_id = RemoteSnapshotId(reader.array16()?); + if snapshot_id.is_zero() { + return Err("remote snapshot ID must be nonzero".to_string()); + } + RemoteEventKind::SyncCompleted { snapshot_id } + } 2 => RemoteEventKind::Stdout(reader.blob(MAX_FRAME_PAYLOAD, "remote stdout")?), 3 => RemoteEventKind::Stderr(reader.blob(MAX_FRAME_PAYLOAD, "remote stderr")?), 4 => RemoteEventKind::Error { From e5899565b0f8c9938a72f02085f1c4fc82896e87 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 3 Aug 2026 10:41:08 +0200 Subject: [PATCH 21/25] Update images --- Makefile | 10 +++++----- images/crush.conf | 1 + images/kilocode.conf | 1 + images/opencode.conf | 1 + 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index a02fd41..2cdebb6 100644 --- a/Makefile +++ b/Makefile @@ -43,8 +43,7 @@ ensure-toolchain: dev: ensure-toolchain cargo build --bin bunkerbox --bin bunkerbox-image - cargo build --bin bunkerbox-netrelay --target $(VSCOMM_TARGET) - cargo build --bin bunkerbox-vscomm --target $(VSCOMM_TARGET) + cargo build --bin bunkerbox-netrelay --bin bunkerbox-vscomm --bin bunkerbox-remote --target $(VSCOMM_TARGET) cargo build --bin bunkerbox-status --target $(VSCOMM_TARGET) rm -rf target/dist mkdir -p target/dist @@ -52,13 +51,13 @@ dev: ensure-toolchain cp target/debug/bunkerbox-image target/dist/ cp target/$(VSCOMM_TARGET)/debug/bunkerbox-netrelay target/dist/ cp target/$(VSCOMM_TARGET)/debug/bunkerbox-vscomm target/dist/ + cp target/$(VSCOMM_TARGET)/debug/bunkerbox-remote target/dist/ cp target/$(VSCOMM_TARGET)/debug/bunkerbox-status target/dist/ cp target/$(VSCOMM_TARGET)/debug/bunkerbox-netrelay target/debug/bunkerbox-netrelay release: ensure-toolchain cargo build --bin bunkerbox --bin bunkerbox-image --release - cargo build --bin bunkerbox-netrelay --target $(VSCOMM_TARGET) --release - cargo build --bin bunkerbox-vscomm --target $(VSCOMM_TARGET) --release + cargo build --bin bunkerbox-netrelay --bin bunkerbox-vscomm --bin bunkerbox-remote --target $(VSCOMM_TARGET) --release cargo build --bin bunkerbox-status --target $(VSCOMM_TARGET) --release rm -rf target/dist mkdir -p target/dist @@ -66,6 +65,7 @@ release: ensure-toolchain cp target/release/bunkerbox-image target/dist/ cp target/$(VSCOMM_TARGET)/release/bunkerbox-netrelay target/dist/ cp target/$(VSCOMM_TARGET)/release/bunkerbox-vscomm target/dist/ + cp target/$(VSCOMM_TARGET)/release/bunkerbox-remote target/dist/ cp target/$(VSCOMM_TARGET)/release/bunkerbox-status target/dist/ cp target/$(VSCOMM_TARGET)/release/bunkerbox-netrelay target/release/bunkerbox-netrelay @@ -83,7 +83,7 @@ setup: dev target/debug/bunkerbox setup musl-vscomm: ensure-toolchain - cargo build --bin bunkerbox-vscomm --target $(VSCOMM_TARGET) + cargo build --bin bunkerbox-vscomm --bin bunkerbox-remote --target $(VSCOMM_TARGET) cargo build --bin bunkerbox-status --target $(VSCOMM_TARGET) image: dev diff --git a/images/crush.conf b/images/crush.conf index b785c24..550676c 100644 --- a/images/crush.conf +++ b/images/crush.conf @@ -62,6 +62,7 @@ containerfile: | RUN chmod 0755 /usr/local/bin/bunker-entrypoint COPY bunkerbox-vscomm /usr/local/bunkerbox/bin/bunkerbox-vscomm + COPY bunkerbox-remote /usr/local/bunkerbox/bin/bunkerbox-remote COPY bunkerbox-status /usr/local/bunkerbox/bin/bunkerbox-status ENV HOME=/home/bunkerbox \ diff --git a/images/kilocode.conf b/images/kilocode.conf index c1d7356..cdf0325 100644 --- a/images/kilocode.conf +++ b/images/kilocode.conf @@ -62,6 +62,7 @@ containerfile: | RUN chmod 0755 /usr/local/bin/bunker-entrypoint COPY bunkerbox-vscomm /usr/local/bunkerbox/bin/bunkerbox-vscomm + COPY bunkerbox-remote /usr/local/bunkerbox/bin/bunkerbox-remote COPY bunkerbox-status /usr/local/bunkerbox/bin/bunkerbox-status diff --git a/images/opencode.conf b/images/opencode.conf index 7aa9f20..f6c1e0d 100644 --- a/images/opencode.conf +++ b/images/opencode.conf @@ -62,6 +62,7 @@ containerfile: | RUN chmod 0755 /usr/local/bin/bunker-entrypoint COPY bunkerbox-vscomm /usr/local/bunkerbox/bin/bunkerbox-vscomm + COPY bunkerbox-remote /usr/local/bunkerbox/bin/bunkerbox-remote COPY bunkerbox-status /usr/local/bunkerbox/bin/bunkerbox-status ENV HOME=/home/bunkerbox \ From 77e050bd5a59a24b1d755077e4f0ae3a9d69a7b9 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 3 Aug 2026 10:41:20 +0200 Subject: [PATCH 22/25] Add unit tests for transparency layer --- src/bunkerbox-image_ut.rs | 24 +++++ src/bunkerbox-remote_ut.rs | 99 ++++++++++++++++++-- src/bunkerbox-vscomm_ut.rs | 15 ++- src/loopback_ut.rs | 184 ++++++++++++++++++++++++++++++++----- src/main_ut.rs | 4 +- src/remote_client_ut.rs | 18 ++++ src/remote_ut.rs | 36 ++++++-- src/vscomm/mod_ut.rs | 24 ++++- 8 files changed, 362 insertions(+), 42 deletions(-) create mode 100644 src/bunkerbox-image_ut.rs create mode 100644 src/remote_client_ut.rs diff --git a/src/bunkerbox-image_ut.rs b/src/bunkerbox-image_ut.rs new file mode 100644 index 0000000..7ac099b --- /dev/null +++ b/src/bunkerbox-image_ut.rs @@ -0,0 +1,24 @@ +use super::*; + +fn config() -> ImageConfig { + ImageConfig { + name: "test".into(), + image: "test:latest".into(), + output: "test.oci".into(), + command: Vec::new(), + overwrite: false, + build_args: BTreeMap::new(), + hooks: ImageHooks::default(), + files: Vec::new(), + runtime: None, + containerfile: "FROM scratch".into(), + } +} + +#[test] +fn image_entrypoint_installs_remote_make_before_local_vscomm_links() { + let script = build_entrypoint(&config()).unwrap(); + let remote = script.find("bunkerbox-remote\" install").unwrap(); + let vscomm = script.find("bunkerbox-vscomm\" install").unwrap(); + assert!(remote < vscomm); +} diff --git a/src/bunkerbox-remote_ut.rs b/src/bunkerbox-remote_ut.rs index 6b1604f..7409e69 100644 --- a/src/bunkerbox-remote_ut.rs +++ b/src/bunkerbox-remote_ut.rs @@ -1,6 +1,10 @@ use super::*; use bunkerbox::vscomm::{Frame, RemoteEvent, RemoteEventKind}; +fn snapshot_id() -> bunkerbox::remote::RemoteSnapshotId { + bunkerbox::remote::RemoteSnapshotId::from_bytes([9; 16]) +} + struct MemoryStream { input: io::Cursor>, output: Vec, @@ -53,6 +57,7 @@ fn build_request_preserves_logical_cwd_and_arguments() { "src".into(), RequestId([1; 16]), WorkspaceSessionId([2; 16]), + snapshot_id(), ) .unwrap(); let frame = request.to_frame().unwrap(); @@ -66,15 +71,18 @@ fn build_request_preserves_logical_cwd_and_arguments() { #[test] fn sync_success_uses_existing_remote_helper_and_returns_status() { let request_id = RequestId([3; 16]); - let mut stream = MemoryStream::new(vec![RemoteEvent { request_id, kind: RemoteEventKind::Completed { exit_code: 0 } }]); + let mut stream = MemoryStream::new(vec![RemoteEvent { + request_id, + kind: RemoteEventKind::SyncCompleted { snapshot_id: bunkerbox::vscomm::RemoteSnapshotId([9; 16]) }, + }]); let status = execute_remote_request_to( &mut stream, - build_request(RemoteCommand::Sync, String::new(), request_id, WorkspaceSessionId([2; 16])).unwrap(), + build_request(RemoteCommand::Sync, String::new(), request_id, WorkspaceSessionId([2; 16]), snapshot_id()).unwrap(), &mut Vec::new(), &mut Vec::new(), ) .unwrap(); - assert_eq!(status, 0); + assert_eq!(status, RemoteCompletion::Synced(snapshot_id())); assert!(Frame::read(&mut io::Cursor::new(stream.output)).is_ok()); } @@ -96,6 +104,7 @@ fn build_success_preserves_output_bytes_and_nonzero_exit_code() { "src".into(), request_id, WorkspaceSessionId([2; 16]), + snapshot_id(), ) .unwrap(), &mut stdout, @@ -103,7 +112,7 @@ fn build_success_preserves_output_bytes_and_nonzero_exit_code() { ) .unwrap(); - assert_eq!(status, 17); + assert_eq!(status, RemoteCompletion::Completed(17)); assert_eq!(stdout, vec![b'o', b'\n', 0xff]); assert_eq!(stderr, b"err\n"); } @@ -117,7 +126,7 @@ fn remote_failures_return_errors_without_local_fallback() { let mut stream = MemoryStream::new(vec![RemoteEvent { request_id, kind }]); let result = execute_remote_request_to( &mut stream, - build_request(RemoteCommand::Sync, String::new(), request_id, WorkspaceSessionId([2; 16])).unwrap(), + build_request(RemoteCommand::Sync, String::new(), request_id, WorkspaceSessionId([2; 16]), snapshot_id()).unwrap(), &mut Vec::new(), &mut Vec::new(), ); @@ -138,12 +147,13 @@ fn rejected_tool_and_mismatched_response_fail_closed() { String::new(), request_id, WorkspaceSessionId([2; 16]), + snapshot_id(), ) .unwrap(); assert_eq!(execute_remote_request_to(&mut stream, request, &mut Vec::new(), &mut Vec::new()).unwrap_err(), "authorization rejected"); let mut stream = MemoryStream::new(vec![RemoteEvent { request_id: RequestId([8; 16]), kind: RemoteEventKind::Completed { exit_code: 0 } }]); - let request = build_request(RemoteCommand::Sync, String::new(), request_id, WorkspaceSessionId([2; 16])).unwrap(); + let request = build_request(RemoteCommand::Sync, String::new(), request_id, WorkspaceSessionId([2; 16]), snapshot_id()).unwrap(); assert_eq!(execute_remote_request_to(&mut stream, request, &mut Vec::new(), &mut Vec::new()).unwrap_err(), "remote event request ID mismatch"); } @@ -152,3 +162,80 @@ fn logical_cwd_is_workspace_relative_only() { assert_eq!(logical_workspace_cwd(Path::new("/workspace/project/src")).unwrap(), "project/src"); assert!(logical_workspace_cwd(Path::new("/tmp/project")).is_err()); } + +#[test] +fn configured_remote_make_installation_precedes_native_path_resolution() { + let root = tempfile::tempdir().unwrap(); + let executable = root.path().join("bunkerbox-remote"); + std::fs::write(&executable, b"remote").unwrap(); + install_remote_make_link(root.path(), &executable, true).unwrap(); + assert_eq!(std::fs::read_link(root.path().join("make")).unwrap(), executable); +} + +#[test] +fn disabled_remote_make_removes_only_its_managed_link() { + let root = tempfile::tempdir().unwrap(); + let executable = root.path().join("bunkerbox-remote"); + std::fs::write(&executable, b"remote").unwrap(); + install_remote_make_link(root.path(), &executable, true).unwrap(); + install_remote_make_link(root.path(), &executable, false).unwrap(); + assert!(!root.path().join("make").exists()); + + std::fs::write(root.path().join("make"), b"native").unwrap(); + install_remote_make_link(root.path(), &executable, false).unwrap(); + assert_eq!(std::fs::read(root.path().join("make")).unwrap(), b"native"); +} + +#[test] +fn configured_remote_make_does_not_overwrite_unmanaged_entry() { + let root = tempfile::tempdir().unwrap(); + let executable = root.path().join("bunkerbox-remote"); + std::fs::write(&executable, b"remote").unwrap(); + std::fs::write(root.path().join("make"), b"native").unwrap(); + assert!(install_remote_make_link(root.path(), &executable, true).is_err()); + assert_eq!(std::fs::read(root.path().join("make")).unwrap(), b"native"); +} + +#[test] +fn transparent_build_syncs_first_and_reuses_that_capability() { + let session = WorkspaceSessionId([2; 16]); + let capability = snapshot_id(); + let mut requests = Vec::new(); + let result = run_build_with_sync_using( + "src".into(), + "make".into(), + vec!["release".into(), "space arg".into()], + vec![("CC".into(), "cc".into())], + session, + |request| { + requests.push(request.clone()); + if requests.len() == 1 { + Ok(RemoteCompletion::Synced(capability)) + } else { + Ok(RemoteCompletion::Completed(17)) + } + }, + ) + .unwrap(); + + assert_eq!(result, 17); + assert_eq!(requests.len(), 2); + assert!(matches!(&requests[0].operation, bunkerbox::vscomm::RemoteOperation::Sync(_))); + let bunkerbox::vscomm::RemoteOperation::Build(ref build) = requests[1].operation else { panic!("expected build") }; + assert_eq!(build.tool.as_str(), "make"); + assert_eq!(build.cwd.as_str(), "src"); + assert_eq!(build.argv, ["release", "space arg"]); + assert_eq!(build.snapshot_id, bunkerbox::vscomm::RemoteSnapshotId([9; 16])); + assert_eq!(build.env, [("CC".into(), "cc".into())]); +} + +#[test] +fn transparent_build_does_not_build_after_sync_failure() { + let mut calls = 0; + let result = run_build_with_sync_using("src".into(), "make".into(), vec!["release".into()], Vec::new(), WorkspaceSessionId([2; 16]), |_| { + calls += 1; + Err("sync failed".to_string()) + }); + assert_eq!(result, Err("sync failed".to_string())); + assert_eq!(calls, 1); +} diff --git a/src/bunkerbox-vscomm_ut.rs b/src/bunkerbox-vscomm_ut.rs index b07d66c..a196740 100644 --- a/src/bunkerbox-vscomm_ut.rs +++ b/src/bunkerbox-vscomm_ut.rs @@ -1,5 +1,5 @@ use super::{handle_response, handle_response_to, Frame, FrameType}; -use bunkerbox::remote_client::{execute_remote_request_to, remote_build_request, remote_sync_request}; +use bunkerbox::remote_client::{execute_remote_request_to, remote_build_request, remote_sync_request, RemoteCompletion}; use bunkerbox::vscomm::{Frame as RemoteFrame, RemoteEvent, RemoteEventKind, RequestId, WorkspaceSessionId}; use std::io::{self, Read, Write}; @@ -71,7 +71,16 @@ fn preserve_exit_status() { #[test] fn explicit_remote_client_preserves_streams_status_and_request_id() { let request_id = RequestId([9; 16]); - let request = remote_build_request(request_id, WorkspaceSessionId([8; 16]), "src", "make", vec!["release mode".into()], vec![]).unwrap(); + let request = remote_build_request( + request_id, + WorkspaceSessionId([8; 16]), + "src", + "make", + vec!["release mode".into()], + vec![], + bunkerbox::remote::RemoteSnapshotId::from_bytes([9; 16]), + ) + .unwrap(); let responses = vec![ RemoteEvent { request_id, kind: RemoteEventKind::Stdout(b"out".to_vec()) }.to_frame().unwrap(), RemoteEvent { request_id, kind: RemoteEventKind::Stderr(b"err".to_vec()) }.to_frame().unwrap(), @@ -81,7 +90,7 @@ fn explicit_remote_client_preserves_streams_status_and_request_id() { let mut stdout = FlushWriter { bytes: Vec::new(), flushes: 0 }; let mut stderr = FlushWriter { bytes: Vec::new(), flushes: 0 }; - assert_eq!(execute_remote_request_to(&mut stream, request, &mut stdout, &mut stderr).unwrap(), 23); + assert_eq!(execute_remote_request_to(&mut stream, request, &mut stdout, &mut stderr).unwrap(), RemoteCompletion::Completed(23)); assert_eq!(stdout.bytes, b"out"); assert_eq!(stderr.bytes, b"err"); assert_eq!(stdout.flushes, 1); diff --git a/src/loopback_ut.rs b/src/loopback_ut.rs index 456bd84..89d6390 100644 --- a/src/loopback_ut.rs +++ b/src/loopback_ut.rs @@ -1,5 +1,8 @@ use super::*; -use crate::remote::{RemoteAuthorizationPolicy, RemoteBuild, RemoteExecutionContext, RemoteRequest, RemoteTool, RequestId, WorkspaceRelativePath}; +use crate::remote::{ + RemoteAuthorizationPolicy, RemoteBuild, RemoteExecutionContext, RemoteRequest, RemoteSnapshotAuthority, RemoteSnapshotId, RemoteTool, RequestId, + WorkspaceRelativePath, +}; use tempfile::TempDir; fn fixture() -> (TempDir, Arc, RemoteTargetId, WorkspaceSessionId) { @@ -17,12 +20,20 @@ fn fixture() -> (TempDir, Arc, RemoteTargetId, WorkspaceSessio (temp, session, target, session_id) } +struct TestSnapshotAuthority; + +impl RemoteSnapshotAuthority for TestSnapshotAuthority { + fn snapshot_available(&self, _session: WorkspaceSessionId, _snapshot_id: RemoteSnapshotId) -> bool { + true + } +} + fn authorized_build( - target: RemoteTargetId, session: WorkspaceSessionId, tool: &str, args: Vec, env: Vec<(String, String)>, + target: RemoteTargetId, session: WorkspaceSessionId, tool: &str, args: Vec, env: Vec<(String, String)>, snapshot_id: RemoteSnapshotId, ) -> crate::remote::AuthorizedRemoteRequest { - let build = RemoteBuild::new(WorkspaceRelativePath::new("src").unwrap(), RemoteTool::new(tool).unwrap(), args, env).unwrap(); + let build = RemoteBuild::new(WorkspaceRelativePath::new("src").unwrap(), RemoteTool::new(tool).unwrap(), args, env, snapshot_id).unwrap(); let request = RemoteRequest::build(RequestId([3; 16]), session, build); - let policy = RemoteAuthorizationPolicy::new(target, session, vec![tool.to_string()]); + let policy = RemoteAuthorizationPolicy::new(target, session, vec![tool.to_string()]).with_snapshot_authority(Arc::new(TestSnapshotAuthority)); policy.authorize(&RemoteExecutionContext { target, workspace_session_id: session }, request).unwrap() } @@ -34,6 +45,22 @@ async fn collect_events(mut receiver: mpsc::Receiver) -> Vec events } +async fn sync_capability(backend: &LoopbackBackend, target: RemoteTargetId, session: WorkspaceSessionId) -> RemoteSnapshotId { + let request = RemoteRequest::sync(RequestId([4; 16]), session); + let policy = RemoteAuthorizationPolicy::new(target, session, Vec::new()); + let authorized = policy.authorize(&RemoteExecutionContext { target, workspace_session_id: session }, request).unwrap(); + let (events, receiver) = mpsc::channel(8); + assert_eq!(backend.execute(authorized, events).await, Ok(())); + collect_events(receiver) + .await + .into_iter() + .find_map(|event| match event { + RemoteBackendEvent::SyncCompleted { snapshot_id } => Some(snapshot_id), + _ => None, + }) + .unwrap() +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sync_and_build_materialize_a_bound_snapshot() { let (_temp, session, target, session_id) = fixture(); @@ -48,13 +75,18 @@ async fn sync_and_build_materialize_a_bound_snapshot() { let policy = RemoteAuthorizationPolicy::new(target, session_id, Vec::new()); let authorized = policy.authorize(&RemoteExecutionContext { target, workspace_session_id: session_id }, sync).unwrap(); assert_eq!(backend.execute(authorized, sync_tx).await, Ok(())); - assert_eq!( - collect_events(sync_rx).await, - vec![RemoteBackendEvent::SyncProgress { completed_bytes: 0, total_bytes: None }, RemoteBackendEvent::Completed { exit_code: 0 },] - ); + let sync_events = collect_events(sync_rx).await; + assert!(matches!(sync_events.first(), Some(RemoteBackendEvent::SyncProgress { completed_bytes: 0, total_bytes: None }))); + let snapshot_id = sync_events + .iter() + .find_map(|event| match event { + RemoteBackendEvent::SyncCompleted { snapshot_id } => Some(*snapshot_id), + _ => None, + }) + .unwrap(); let (build_tx, build_rx) = mpsc::channel(8); - let authorized = authorized_build(target, session_id, "printf", vec!["value with spaces:$(literal)".to_string()], Vec::new()); + let authorized = authorized_build(target, session_id, "printf", vec!["value with spaces:$(literal)".to_string()], Vec::new(), snapshot_id); assert_eq!(backend.execute(authorized, build_tx).await, Ok(())); assert_eq!( collect_events(build_rx).await, @@ -63,6 +95,100 @@ async fn sync_and_build_materialize_a_bound_snapshot() { assert!(fs::read_dir(&session_state.jobs_root).unwrap().next().is_none()); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn interleaved_syncs_build_their_own_snapshot_capabilities() { + let (_temp, session, target, session_id) = fixture(); + let tools = resolve_fixed_tools(["cat".to_string()]); + if tools.is_empty() { + return; + } + let backend = LoopbackBackend::new(session.clone(), tools); + fs::write(session.workspace_root().join("src/input.txt"), b"A\n").unwrap(); + let snapshot_a = sync_capability(&backend, target, session_id).await; + fs::write(session.workspace_root().join("src/input.txt"), b"B\n").unwrap(); + let snapshot_b = sync_capability(&backend, target, session_id).await; + assert_ne!(snapshot_a, snapshot_b); + + let authority = session.clone(); + let authorize = |snapshot_id| { + let build = RemoteBuild::new( + WorkspaceRelativePath::new("src").unwrap(), + RemoteTool::new("cat").unwrap(), + vec!["input.txt".into()], + Vec::new(), + snapshot_id, + ) + .unwrap(); + let request = RemoteRequest::build(RequestId([8; 16]), session_id, build); + RemoteAuthorizationPolicy::new(target, session_id, vec!["cat".into()]) + .with_snapshot_authority(authority.clone()) + .authorize(&RemoteExecutionContext { target, workspace_session_id: session_id }, request) + .unwrap() + }; + let request_a = authorize(snapshot_a); + let request_b = authorize(snapshot_b); + let (events_a, receiver_a) = mpsc::channel(8); + let (events_b, receiver_b) = mpsc::channel(8); + let (result_a, result_b) = tokio::join!(backend.execute(request_a, events_a), backend.execute(request_b, events_b)); + assert_eq!(result_a, Ok(())); + assert_eq!(result_b, Ok(())); + let output_a = collect_events(receiver_a).await; + let output_b = collect_events(receiver_b).await; + assert!(output_a.iter().any(|event| matches!(event, RemoteBackendEvent::Stdout(bytes) if bytes == b"A\n"))); + assert!(output_b.iter().any(|event| matches!(event, RemoteBackendEvent::Stdout(bytes) if bytes == b"B\n"))); + assert!(output_a.iter().any(|event| matches!(event, RemoteBackendEvent::Completed { exit_code: 0 }))); + assert!(output_b.iter().any(|event| matches!(event, RemoteBackendEvent::Completed { exit_code: 0 }))); + + let replay = RemoteBuild::new( + WorkspaceRelativePath::new("src").unwrap(), + RemoteTool::new("cat").unwrap(), + vec!["input.txt".into()], + Vec::new(), + snapshot_a, + ) + .unwrap(); + let replay_request = RemoteRequest::build(RequestId([9; 16]), session_id, replay); + assert_eq!( + RemoteAuthorizationPolicy::new(target, session_id, vec!["cat".into()]) + .with_snapshot_authority(session.clone()) + .authorize(&RemoteExecutionContext { target, workspace_session_id: session_id }, replay_request), + Err(crate::remote::RemoteAuthorizationError::SnapshotNotAllowed) + ); + + let unknown = RemoteBuild::new( + WorkspaceRelativePath::new("src").unwrap(), + RemoteTool::new("cat").unwrap(), + vec!["input.txt".into()], + Vec::new(), + RemoteSnapshotId::from_bytes([6; 16]), + ) + .unwrap(); + let unknown_request = RemoteRequest::build(RequestId([10; 16]), session_id, unknown); + assert_eq!( + RemoteAuthorizationPolicy::new(target, session_id, vec!["cat".into()]) + .with_snapshot_authority(session.clone()) + .authorize(&RemoteExecutionContext { target, workspace_session_id: session_id }, unknown_request), + Err(crate::remote::RemoteAuthorizationError::SnapshotNotAllowed) + ); + + let other_session = WorkspaceSessionId([7; 16]); + let cross_session = RemoteBuild::new( + WorkspaceRelativePath::new("src").unwrap(), + RemoteTool::new("cat").unwrap(), + vec!["input.txt".into()], + Vec::new(), + snapshot_b, + ) + .unwrap(); + let cross_request = RemoteRequest::build(RequestId([11; 16]), other_session, cross_session); + assert_eq!( + RemoteAuthorizationPolicy::new(target, other_session, vec!["cat".into()]) + .with_snapshot_authority(session) + .authorize(&RemoteExecutionContext { target, workspace_session_id: other_session }, cross_request), + Err(crate::remote::RemoteAuthorizationError::SnapshotNotAllowed) + ); +} + #[test] fn unapproved_remote_environment_is_rejected_before_backend_execution() { let (_temp, _session, target, session_id) = fixture(); @@ -71,27 +197,38 @@ fn unapproved_remote_environment_is_rejected_before_backend_execution() { RemoteTool::new("printf").unwrap(), Vec::new(), vec![("UNTRUSTED".to_string(), "1".to_string())], + RemoteSnapshotId::from_bytes([9; 16]), ) .unwrap(); let request = RemoteRequest::build(RequestId([3; 16]), session_id, build); - let policy = RemoteAuthorizationPolicy::new(target, session_id, vec!["printf".to_string()]); + let policy = + RemoteAuthorizationPolicy::new(target, session_id, vec!["printf".to_string()]).with_snapshot_authority(Arc::new(TestSnapshotAuthority)); assert_eq!( policy.authorize(&RemoteExecutionContext { target, workspace_session_id: session_id }, request), Err(crate::remote::RemoteAuthorizationError::EnvironmentNotAllowed("UNTRUSTED".to_string())) ); } +#[test] +fn fixed_tool_resolution_never_uses_guest_wrapper_path() { + let tools = resolve_fixed_tools(["make".to_string()]); + if let Some(path) = tools.get("make") { + assert!(matches!(path.to_str(), Some(value) if value == "/usr/local/bin/make" || value == "/usr/bin/make" || value == "/bin/make")); + assert!(!path.starts_with("/usr/local/bunkerbox/bin")); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn build_preserves_argument_bytes_and_reports_nonzero_stderr() { let (_temp, session, target, session_id) = fixture(); - session.sync_snapshot().unwrap(); + let snapshot_id = session.sync_snapshot().unwrap(); let tools = resolve_fixed_tools(["ls".to_string()]); if tools.is_empty() { return; } let backend = LoopbackBackend::new(session, tools); let (events, receiver) = mpsc::channel(8); - let request = authorized_build(target, session_id, "ls", vec!["$(not-a-shell-argument)".to_string()], Vec::new()); + let request = authorized_build(target, session_id, "ls", vec!["$(not-a-shell-argument)".to_string()], Vec::new(), snapshot_id); assert_eq!(backend.execute(request, events).await, Ok(())); let events = collect_events(receiver).await; assert!(events.iter().any(|event| matches!(event, RemoteBackendEvent::Stderr(bytes) if !bytes.is_empty()))); @@ -102,7 +239,7 @@ async fn build_preserves_argument_bytes_and_reports_nonzero_stderr() { async fn child_exit_does_not_wait_for_inherited_output_pipes() { let (_temp, session, target, session_id) = fixture(); fs::write(session.workspace_root().join("src/Makefile"), ".PHONY: leak\nleak:\n\t@sleep 30 & echo $$!\n\t@printf 'direct-output\\n'\n").unwrap(); - session.sync_snapshot().unwrap(); + let snapshot_id = session.sync_snapshot().unwrap(); let tools = resolve_fixed_tools(["make".to_string()]); if tools.is_empty() { return; @@ -110,11 +247,9 @@ async fn child_exit_does_not_wait_for_inherited_output_pipes() { let backend = LoopbackBackend::new(session.clone(), tools); let (events, receiver) = mpsc::channel(16); - let request = authorized_build(target, session_id, "make", vec!["leak".into()], Vec::new()); - let started = tokio::time::Instant::now(); - let result = tokio::time::timeout(Duration::from_secs(1), backend.execute(request, events)).await.unwrap(); + let request = authorized_build(target, session_id, "make", vec!["leak".into()], Vec::new(), snapshot_id); + let result = tokio::time::timeout(Duration::from_secs(2), backend.execute(request, events)).await.unwrap(); assert_eq!(result, Ok(())); - assert!(started.elapsed() < Duration::from_millis(500)); let events = collect_events(receiver).await; let stdout = events @@ -146,14 +281,14 @@ async fn child_exit_does_not_wait_for_inherited_output_pipes() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn child_receives_guest_environment_but_trusted_target_wins() { let (_temp, session, target, session_id) = fixture(); - session.sync_snapshot().unwrap(); + let snapshot_id = session.sync_snapshot().unwrap(); let tools = resolve_fixed_tools(["printenv".to_string()]); if tools.is_empty() { return; } let backend = LoopbackBackend::new(session, tools).with_target_environment(BTreeMap::from([("CC".into(), "trusted-target".into())])); let (events, receiver) = mpsc::channel(8); - let request = authorized_build(target, session_id, "printenv", vec!["CC".into()], vec![("CC".into(), "guest-value".into())]); + let request = authorized_build(target, session_id, "printenv", vec!["CC".into()], vec![("CC".into(), "guest-value".into())], snapshot_id); assert_eq!(backend.execute(request, events).await, Ok(())); let events = collect_events(receiver).await; assert!(events.iter().any(|event| matches!(event, RemoteBackendEvent::Stdout(bytes) if bytes == b"trusted-target\n"))); @@ -163,36 +298,37 @@ async fn child_receives_guest_environment_but_trusted_target_wins() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn missing_tool_fails_before_execution() { let (_temp, session, target, session_id) = fixture(); + let snapshot_id = session.sync_snapshot().unwrap(); let backend = LoopbackBackend::new(session, BTreeMap::new()); let (events, _receiver) = mpsc::channel(8); - let request = authorized_build(target, session_id, "missing-tool", Vec::new(), Vec::new()); + let request = authorized_build(target, session_id, "missing-tool", Vec::new(), Vec::new(), snapshot_id); assert_eq!(backend.execute(request, events).await, Err(RemoteBackendError::Spawn("loopback tool is not configured: missing-tool".to_string()))); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn timeout_kills_a_direct_child_process() { let (_temp, session, target, session_id) = fixture(); - session.sync_snapshot().unwrap(); + let snapshot_id = session.sync_snapshot().unwrap(); let tools = resolve_fixed_tools(["sleep".to_string()]); if tools.is_empty() { return; } let backend = LoopbackBackend::new(session, tools).with_timeout(Duration::from_millis(50)); let (events, _receiver) = mpsc::channel(8); - let request = authorized_build(target, session_id, "sleep", vec!["5".to_string()], Vec::new()); + let request = authorized_build(target, session_id, "sleep", vec!["5".to_string()], Vec::new(), snapshot_id); assert_eq!(backend.execute(request, events).await, Err(RemoteBackendError::Timeout)); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn output_limit_kills_a_flooding_direct_child() { let (_temp, session, target, session_id) = fixture(); - session.sync_snapshot().unwrap(); + let snapshot_id = session.sync_snapshot().unwrap(); let tools = resolve_fixed_tools(["printf".to_string()]); if tools.is_empty() { return; } let backend = LoopbackBackend::new(session, tools).with_output_limit(8); let (events, _receiver) = mpsc::channel(8); - let request = authorized_build(target, session_id, "printf", vec!["0123456789".into()], Vec::new()); + let request = authorized_build(target, session_id, "printf", vec!["0123456789".into()], Vec::new(), snapshot_id); assert_eq!(backend.execute(request, events).await, Err(RemoteBackendError::OutputLimit { limit: 8 })); } diff --git a/src/main_ut.rs b/src/main_ut.rs index 2affc20..66bcb2b 100644 --- a/src/main_ut.rs +++ b/src/main_ut.rs @@ -59,10 +59,10 @@ fn run_handoff_rejects_zero_session() { } #[test] -fn remote_tool_names_preserve_configured_order() { +fn remote_tool_names_only_enables_the_make_wrapper() { assert_eq!( remote_tool_names(&[RemoteToolSpec { name: "make".into(), allow_args: true }, RemoteToolSpec { name: "cargo".into(), allow_args: false },]), - vec!["make", "cargo"] + vec!["make"] ); } diff --git a/src/remote_client_ut.rs b/src/remote_client_ut.rs new file mode 100644 index 0000000..93d15df --- /dev/null +++ b/src/remote_client_ut.rs @@ -0,0 +1,18 @@ +use super::*; +use std::path::Path; + +#[test] +fn logical_cwd_is_workspace_relative() { + assert_eq!(logical_workspace_cwd(Path::new("/workspace/foo/bar")).unwrap(), "foo/bar"); + assert!(logical_workspace_cwd(Path::new("/tmp/foo")).is_err()); +} + +#[test] +fn selected_environment_uses_only_targeted_names() { + std::env::set_var("BB_TEST_REMOTE_ALLOWED", "selected"); + std::env::set_var("BB_TEST_REMOTE_PATH", "should-not-forward"); + let values = selected_remote_environment(vec!["BB_TEST_REMOTE_ALLOWED".into(), "PATH".into(), "BB_TEST_REMOTE_PATH".into()]); + assert_eq!(values, vec![("BB_TEST_REMOTE_ALLOWED".into(), "selected".into()), ("BB_TEST_REMOTE_PATH".into(), "should-not-forward".into())]); + std::env::remove_var("BB_TEST_REMOTE_ALLOWED"); + std::env::remove_var("BB_TEST_REMOTE_PATH"); +} diff --git a/src/remote_ut.rs b/src/remote_ut.rs index 8e061f5..b977ba2 100644 --- a/src/remote_ut.rs +++ b/src/remote_ut.rs @@ -9,18 +9,32 @@ fn request(tool: &str) -> RemoteRequest { RemoteTool::new(tool).unwrap(), vec!["build".into()], vec![("CC".into(), "cc".into())], + RemoteSnapshotId::from_bytes([9; 16]), ) .unwrap(), ) } +struct TestSnapshotAuthority; + +impl RemoteSnapshotAuthority for TestSnapshotAuthority { + fn snapshot_available(&self, _session: WorkspaceSessionId, _snapshot_id: RemoteSnapshotId) -> bool { + true + } +} + +fn policy(tools: Vec) -> RemoteAuthorizationPolicy { + RemoteAuthorizationPolicy::new(RemoteTargetId([3; 16]), WorkspaceSessionId([2; 16]), tools) + .with_snapshot_authority(std::sync::Arc::new(TestSnapshotAuthority)) +} + fn context() -> RemoteExecutionContext { RemoteExecutionContext { target: RemoteTargetId([3; 16]), workspace_session_id: WorkspaceSessionId([2; 16]) } } #[test] fn policy_authorizes_typed_request() { - let policy = RemoteAuthorizationPolicy::new(RemoteTargetId([3; 16]), WorkspaceSessionId([2; 16]), vec!["make".into()]); + let policy = policy(vec!["make".into()]); let authorized = policy.authorize(&context(), request("make")).unwrap(); assert_eq!(authorized.request_id(), RequestId([1; 16])); @@ -30,11 +44,17 @@ fn policy_authorizes_typed_request() { #[test] fn policy_rejects_unapproved_tool() { - let policy = RemoteAuthorizationPolicy::new(RemoteTargetId([3; 16]), WorkspaceSessionId([2; 16]), vec!["cargo".into()]); + let policy = policy(vec!["cargo".into()]); assert_eq!(policy.authorize(&context(), request("make")), Err(RemoteAuthorizationError::ToolNotAllowed("make".into()))); } +#[test] +fn policy_requires_snapshot_authority_for_builds() { + let policy = RemoteAuthorizationPolicy::new(RemoteTargetId([3; 16]), WorkspaceSessionId([2; 16]), vec!["make".into()]); + assert_eq!(policy.authorize(&context(), request("make")), Err(RemoteAuthorizationError::SnapshotNotAllowed)); +} + #[test] fn backend_errors_have_typed_events() { assert_eq!(RemoteBackendError::Spawn("could not start".into()).event(), RemoteBackendEvent::Error { message: "could not start".into() }); @@ -44,7 +64,7 @@ fn backend_errors_have_typed_events() { #[test] fn environment_policy_preserves_allowed_entries() { - let policy = RemoteAuthorizationPolicy::new(RemoteTargetId([3; 16]), WorkspaceSessionId([2; 16]), vec!["make".into()]); + let policy = policy(vec!["make".into()]); let authorized = policy.authorize(&context(), request("make")).unwrap(); let RemoteOperation::Build(build) = authorized.request().operation() else { panic!("expected build") }; assert_eq!(build.env(), [("CC".into(), "cc".into())]); @@ -61,10 +81,11 @@ fn environment_policy_rejects_forbidden_and_unlisted_entries() { RemoteTool::new("make").unwrap(), Vec::new(), vec![(name.into(), "value".into())], + RemoteSnapshotId::from_bytes([9; 16]), ) .unwrap(); let request = RemoteRequest::build(RequestId([1; 16]), WorkspaceSessionId([2; 16]), build); - let policy = RemoteAuthorizationPolicy::new(RemoteTargetId([3; 16]), WorkspaceSessionId([2; 16]), vec!["make".into()]); + let policy = policy(vec!["make".into()]); assert_eq!(policy.authorize(&context(), request), Err(expected)); } } @@ -76,9 +97,10 @@ fn environment_policy_rejects_duplicates_and_control_data() { RemoteTool::new("make").unwrap(), Vec::new(), vec![("CC".into(), "one".into()), ("CC".into(), "two".into())], + RemoteSnapshotId::from_bytes([9; 16]), ) .unwrap(); - let policy = RemoteAuthorizationPolicy::new(RemoteTargetId([3; 16]), WorkspaceSessionId([2; 16]), vec!["make".into()]); + let policy = policy(vec!["make".into()]); let request = RemoteRequest::build(RequestId([1; 16]), WorkspaceSessionId([2; 16]), duplicate); assert_eq!(policy.authorize(&context(), request), Err(RemoteAuthorizationError::DuplicateEnvironment("CC".into()))); @@ -87,6 +109,7 @@ fn environment_policy_rejects_duplicates_and_control_data() { RemoteTool::new("make").unwrap(), Vec::new(), vec![("CC".into(), "bad\nvalue".into())], + RemoteSnapshotId::from_bytes([9; 16]), ) .is_err()); } @@ -99,6 +122,7 @@ fn command_policy_rejects_unapproved_arguments() { [("make".into(), RemoteToolPolicy::new(false))], RemoteEnvironmentPolicy::default(), ) - .unwrap(); + .unwrap() + .with_snapshot_authority(std::sync::Arc::new(TestSnapshotAuthority)); assert_eq!(policy.authorize(&context(), request("make")), Err(RemoteAuthorizationError::ToolArgumentsNotAllowed("make".into()))); } diff --git a/src/vscomm/mod_ut.rs b/src/vscomm/mod_ut.rs index e7e021e..a38d760 100644 --- a/src/vscomm/mod_ut.rs +++ b/src/vscomm/mod_ut.rs @@ -19,7 +19,7 @@ fn workspace_session_hex_rejects_non_ascii_without_panicking() { } fn build(argv: Vec, env: Vec<(String, String)>) -> Result { - RemoteBuild::new(WorkspaceRelativePath::new("src").unwrap(), RemoteTool::new("make").unwrap(), argv, env) + RemoteBuild::new(WorkspaceRelativePath::new("src").unwrap(), RemoteTool::new("make").unwrap(), argv, env, RemoteSnapshotId([9; 16])) } fn raw_build_frame(argv_count: u16, arg: Option<&str>, env_count: u16, env: Option<(&str, &str)>) -> Frame { @@ -99,6 +99,8 @@ fn remote_build_round_trips_structured_arguments_and_environment() { ); let decoded = RemoteRequest::from_frame(request.to_frame().unwrap()).unwrap(); assert_eq!(decoded, request); + let RemoteOperation::Build(build) = decoded.operation else { panic!("expected build") }; + assert_eq!(build.snapshot_id, RemoteSnapshotId([9; 16])); } #[test] @@ -106,6 +108,7 @@ fn every_remote_event_round_trips() { let request_id = ids().0; let events = vec![ RemoteEventKind::SyncProgress { completed_bytes: 4, total_bytes: Some(9) }, + RemoteEventKind::SyncCompleted { snapshot_id: RemoteSnapshotId([9; 16]) }, RemoteEventKind::Stdout(b"out".to_vec()), RemoteEventKind::Stderr(b"err".to_vec()), RemoteEventKind::Error { code: RemoteErrorCode::Failed, message: "failed".into() }, @@ -193,6 +196,24 @@ fn oversized_environment_key_and_value_are_rejected() { assert!(RemoteRequest::from_frame(raw_build_frame(0, None, 1, Some(("KEY", &"V".repeat(MAX_REMOTE_ENV_VALUE_BYTES + 1))))).is_err()); } +#[test] +fn zero_remote_snapshot_id_is_rejected() { + assert!(RemoteBuild::new( + WorkspaceRelativePath::new("src").unwrap(), + RemoteTool::new("make").unwrap(), + Vec::new(), + Vec::new(), + RemoteSnapshotId([0; 16]), + ) + .is_err()); +} + +#[test] +fn zero_sync_completion_snapshot_id_is_rejected() { + let event = RemoteEvent { request_id: ids().0, kind: RemoteEventKind::SyncCompleted { snapshot_id: RemoteSnapshotId([0; 16]) } }; + assert!(event.to_frame().is_err()); +} + #[test] fn control_data_in_remote_environment_value_is_rejected() { assert!(build(Vec::new(), vec![("CC".into(), "bad\nvalue".into())]).is_err()); @@ -231,6 +252,7 @@ fn protocol_request_converts_to_transport_independent_domain_request() { assert_eq!(build.tool().as_str(), "make"); assert_eq!(build.argv(), ["--release"]); assert_eq!(build.env(), [("MODE".into(), "debug".into())]); + assert_eq!(build.snapshot_id(), crate::remote::RemoteSnapshotId::from_bytes([9; 16])); } #[test] From 7321c3c7a6a8e85a45d87b96ad646e06143dcb45 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 3 Aug 2026 11:04:15 +0200 Subject: [PATCH 23/25] Implement shared remote-make ownership and diagnostic sync cleanup with bounded capabilities --- src/bin/bunkerbox-remote.rs | 50 +++++++--------------- src/bin/bunkerbox-vscomm.rs | 46 ++------------------ src/guest_install.rs | 85 +++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/loopback.rs | 49 ++++++++++++++++++--- src/loopback_ut.rs | 68 +++++++++++++++++++++++++++++ src/remote.rs | 25 +++++++++-- src/remote_client.rs | 4 ++ src/vscomm/mod.rs | 37 +++++++++++++--- 9 files changed, 274 insertions(+), 91 deletions(-) create mode 100644 src/guest_install.rs diff --git a/src/bin/bunkerbox-remote.rs b/src/bin/bunkerbox-remote.rs index d3cd19e..da8ff45 100644 --- a/src/bin/bunkerbox-remote.rs +++ b/src/bin/bunkerbox-remote.rs @@ -1,7 +1,9 @@ +use bunkerbox::guest_install::install_remote_make_link; +#[cfg(test)] use bunkerbox::remote::RemoteSnapshotId; use bunkerbox::remote_client::{ - execute_remote_request_to, logical_workspace_cwd, new_request_id, remote_build_request, remote_environment_names, remote_session_from_env, - remote_sync_request, remote_tool_enabled, selected_remote_environment, RemoteCompletion, + execute_remote_request_to, logical_workspace_cwd, new_request_id, remote_build_request, remote_diagnostic_sync_request, remote_environment_names, + remote_session_from_env, remote_sync_request, remote_tool_enabled, selected_remote_environment, RemoteCompletion, }; #[cfg(test)] use bunkerbox::vscomm::RequestId; @@ -10,7 +12,6 @@ use std::env; use std::fs; use std::io::{self, Read, Write}; use std::mem; -use std::os::unix::fs::symlink; use std::path::Path; const HOST_CID: u32 = 2; @@ -101,10 +102,18 @@ fn execute_request_over_vsock(request: RemoteRequest) -> Result Result { - match execute_request_over_vsock(remote_sync_request(new_request_id(), session))? { - RemoteCompletion::Synced(snapshot_id) => Ok(snapshot_id), - RemoteCompletion::Completed(_) => Err("remote sync returned a build completion".to_string()), +fn sync_snapshot(session: WorkspaceSessionId) -> Result<(), String> { + sync_snapshot_using(session, execute_request_over_vsock) +} + +fn sync_snapshot_using(session: WorkspaceSessionId, mut execute: F) -> Result<(), String> +where + F: FnMut(RemoteRequest) -> Result, +{ + match execute(remote_diagnostic_sync_request(new_request_id(), session))? { + RemoteCompletion::Synced(_) => Err("diagnostic sync returned a retained capability".to_string()), + RemoteCompletion::Completed(0) => Ok(()), + RemoteCompletion::Completed(code) => Err(format!("remote sync returned exit code {code}")), } } @@ -134,33 +143,6 @@ fn install_remote_links() -> Result<(), String> { install_remote_make_link(Path::new(VSCOMM_BIN_DIR), &executable, remote_tool_enabled("make")) } -fn install_remote_make_link(bin_dir: &Path, executable: &Path, enabled: bool) -> Result<(), String> { - let target = bin_dir.join("make"); - let managed = is_managed_link(&target, executable); - - if enabled { - if target.exists() || fs::symlink_metadata(&target).is_ok() { - if !managed { - return Err(format!("cannot install remote make wrapper over existing {}", target.display())); - } - fs::remove_file(&target).map_err(|error| format!("remove existing remote make wrapper: {error}"))?; - } - symlink(executable, &target).map_err(|error| format!("symlink remote make wrapper: {error}"))?; - } else if managed { - fs::remove_file(&target).map_err(|error| format!("remove disabled remote make wrapper: {error}"))?; - } - Ok(()) -} - -fn is_managed_link(target: &Path, executable: &Path) -> bool { - let Ok(metadata) = fs::symlink_metadata(target) else { return false }; - if !metadata.file_type().is_symlink() { - return false; - } - let Ok(link) = fs::read_link(target) else { return false }; - link == executable -} - fn connect_toolchain() -> Result { vsock_connect(HOST_CID, TOOLCHAIN_PORT).map_err(|error| format!("toolchain vsock connect: {error}")) } diff --git a/src/bin/bunkerbox-vscomm.rs b/src/bin/bunkerbox-vscomm.rs index aea40b5..ea68edd 100644 --- a/src/bin/bunkerbox-vscomm.rs +++ b/src/bin/bunkerbox-vscomm.rs @@ -7,9 +7,9 @@ use std::env; use std::fs; use std::io::{self, Read, Write}; use std::mem; -use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; +use bunkerbox::guest_install::install_vscomm_links; pub use bunkerbox::remote_client::{execute_remote_request_to, remote_build_request, remote_sync_request}; use vscomm::{encode_ui_payload, validate_exec_request, ExecRequest, Frame, FrameType, TOOLCHAIN_PORT, TUI_STATUS_PORT, VSCOMM_BIN_DIR}; @@ -101,27 +101,10 @@ fn install_symlinks() -> Result<(), String> { let config_path = find_config().ok_or_else(|| "no whitelist config found".to_string())?; let entries = read_whitelist_entries(&config_path)?; - - fs::create_dir_all(VSCOMM_BIN_DIR).map_err(|e| format!("mkdir {VSCOMM_BIN_DIR}: {e}"))?; - let vscomm_path = env::current_exe().map_err(|e| format!("failed to locate vscomm binary: {e}"))?; - - for entry in &entries { - let cmd = extract_command_name(entry); - if cmd.is_empty() { - continue; - } - if command_exists_in_path_except(&cmd, &vscomm_path) { - continue; - } - let target = PathBuf::from(VSCOMM_BIN_DIR).join(&cmd); - if target.exists() { - let _ = fs::remove_file(&target); - } - std::os::unix::fs::symlink(&vscomm_path, &target).map_err(|e| format!("symlink {cmd}: {e}"))?; - } - - Ok(()) + let commands = entries.iter().map(|entry| extract_command_name(entry)); + let path = env::var("PATH").unwrap_or_default(); + install_vscomm_links(commands, Path::new(VSCOMM_BIN_DIR), &vscomm_path, &path) } fn find_config() -> Option { @@ -180,27 +163,6 @@ fn extract_command_name(entry: &str) -> String { } } -fn command_exists_in_path_except(cmd: &str, except: &Path) -> bool { - if let Ok(path) = env::var("PATH") { - for dir in path.split(':') { - let candidate = Path::new(dir).join(cmd); - if candidate == except { - continue; - } - if candidate.is_file() { - let metadata = match fs::metadata(&candidate) { - Ok(m) => m, - Err(_) => continue, - }; - if metadata.permissions().mode() & 0o111 != 0 { - return true; - } - } - } - } - false -} - fn vsock_connect(cid: u32, port: u32) -> io::Result { unsafe { let fd = libc::socket(libc::AF_VSOCK, libc::SOCK_STREAM, 0); diff --git a/src/guest_install.rs b/src/guest_install.rs new file mode 100644 index 0000000..35d2807 --- /dev/null +++ b/src/guest_install.rs @@ -0,0 +1,85 @@ +use std::ffi::OsStr; +use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; +use std::path::{Path, PathBuf}; + +const REMOTE_MAKE_OWNER: &str = "bunkerbox-remote"; + +pub fn install_remote_make_link(bin_dir: &Path, executable: &Path, enabled: bool) -> Result<(), String> { + let target = bin_dir.join("make"); + let managed = is_managed_remote_make_link(&target); + + if enabled { + if let Ok(link) = fs::read_link(&target) { + if link == executable { + return Ok(()); + } + } + if fs::symlink_metadata(&target).is_ok() { + if !managed { + return Err(format!("cannot install remote make wrapper over existing {}", target.display())); + } + fs::remove_file(&target).map_err(|error| format!("remove existing remote make wrapper: {error}"))?; + } + symlink(executable, &target).map_err(|error| format!("symlink remote make wrapper: {error}"))?; + } else if managed { + fs::remove_file(&target).map_err(|error| format!("remove disabled remote make wrapper: {error}"))?; + } + Ok(()) +} + +pub fn install_vscomm_links(commands: impl IntoIterator, bin_dir: &Path, vscomm_path: &Path, path: &str) -> Result<(), String> { + fs::create_dir_all(bin_dir).map_err(|error| format!("mkdir {}: {error}", bin_dir.display()))?; + + for command in commands { + if command.is_empty() { + continue; + } + let target = bin_dir.join(&command); + if command == "make" && is_managed_remote_make_link(&target) { + continue; + } + if command_exists_in_path_except(&command, vscomm_path, path) { + continue; + } + if let Ok(link) = fs::read_link(&target) { + if link == vscomm_path { + continue; + } + } + if fs::symlink_metadata(&target).is_ok() { + fs::remove_file(&target).map_err(|error| format!("remove existing {command} link: {error}"))?; + } + symlink(vscomm_path, &target).map_err(|error| format!("symlink {command}: {error}"))?; + } + + Ok(()) +} + +fn is_managed_remote_make_link(target: &Path) -> bool { + let Ok(metadata) = fs::symlink_metadata(target) else { return false }; + if !metadata.file_type().is_symlink() { + return false; + } + fs::read_link(target).ok().and_then(|link| link.file_name().map(OsStr::to_owned)).is_some_and(|name| name == REMOTE_MAKE_OWNER) +} + +fn command_exists_in_path_except(command: &str, except: &Path, path: &str) -> bool { + for directory in path.split(':') { + let candidate = PathBuf::from(directory).join(command); + if candidate == except { + continue; + } + if candidate.is_file() { + let Ok(metadata) = fs::metadata(&candidate) else { continue }; + if metadata.permissions().mode() & 0o111 != 0 { + return true; + } + } + } + false +} + +#[cfg(test)] +#[path = "guest_install_ut.rs"] +mod tests; diff --git a/src/lib.rs b/src/lib.rs index c7b0059..e4edf99 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod cfgsetup; pub mod clidef; pub mod cmdrun; pub mod daemon; +pub mod guest_install; pub mod kata; pub mod logging; pub mod netrelay; diff --git a/src/loopback.rs b/src/loopback.rs index 9c3fb62..8fd9fab 100644 --- a/src/loopback.rs +++ b/src/loopback.rs @@ -18,6 +18,7 @@ use tokio::sync::mpsc; use tokio::time::sleep; pub const LOOPBACK_BUILD_TIMEOUT: Duration = crate::remote::DEFAULT_REMOTE_BUILD_TIMEOUT; +pub const MAX_OUTSTANDING_SNAPSHOT_CAPABILITIES: usize = 16; const LOOPBACK_PATH: &str = "/usr/local/bin:/usr/bin:/bin"; const POST_CHILD_EXIT_DRAIN_TIMEOUT: Duration = Duration::from_millis(100); const POST_CHILD_EXIT_FINAL_DRAIN_TIMEOUT: Duration = Duration::from_millis(100); @@ -80,13 +81,35 @@ impl RunRemoteSession { } pub fn sync_snapshot(&self) -> Result { + self.sync_snapshot_for_request(true)?.ok_or_else(|| "retained snapshot capability was not created".to_string()) + } + + fn sync_snapshot_for_request(&self, retain_capability: bool) -> Result, String> { let _operation = self.snapshot_operation.lock().map_err(|_| "remote session operation lock poisoned".to_string())?; let snapshot = self.snapshot_builder.build_root(&self.workspace_root, self.session_id)?; - self.register_snapshot(snapshot.handle().clone()) + let handle = snapshot.handle().clone(); + if !retain_capability { + self.discard_unclaimed_snapshot(&handle)?; + return Ok(None); + } + + match self.register_snapshot(handle.clone()) { + Ok(snapshot_id) => Ok(Some(snapshot_id)), + Err(error) => { + let cleanup = self.discard_unclaimed_snapshot(&handle); + if let Err(cleanup_error) = cleanup { + return Err(format!("{error}; snapshot cleanup failed: {cleanup_error}")); + } + Err(error) + } + } } fn register_snapshot(&self, handle: SnapshotHandle) -> Result { let mut registry = self.snapshot_capabilities.lock().map_err(|_| "remote snapshot registry lock poisoned".to_string())?; + if registry.capabilities.len() >= MAX_OUTSTANDING_SNAPSHOT_CAPABILITIES { + return Err(format!("remote snapshot capability limit reached ({MAX_OUTSTANDING_SNAPSHOT_CAPABILITIES})")); + } let snapshot_id = loop { let mut bytes = [0u8; 16]; rand::thread_rng().fill_bytes(&mut bytes); @@ -100,6 +123,16 @@ impl RunRemoteSession { Ok(snapshot_id) } + fn discard_unclaimed_snapshot(&self, handle: &SnapshotHandle) -> Result<(), String> { + let referenced = + self.snapshot_capabilities.lock().map_err(|_| "remote snapshot registry lock poisoned".to_string())?.references.contains_key(handle); + if referenced { + Ok(()) + } else { + self.snapshot_store.remove(handle) + } + } + fn claim_snapshot(self: &Arc, snapshot_id: RemoteSnapshotId) -> Result { let mut registry = self.snapshot_capabilities.lock().map_err(|_| "remote snapshot registry lock poisoned".to_string())?; let handle = registry.capabilities.remove(&snapshot_id).ok_or_else(|| "remote snapshot capability is unavailable".to_string())?; @@ -243,20 +276,24 @@ impl RemoteBackend for LoopbackBackend { let resources = self.resources; Box::pin(async move { match request.request().operation() { - RemoteOperation::Sync => execute_sync(session, events).await, + RemoteOperation::Sync(sync) => execute_sync(session, sync.retain_capability(), events).await, RemoteOperation::Build(build) => execute_build(session, tools, target_environment, resources, build, events).await, } }) } } -async fn execute_sync(session: Arc, events: mpsc::Sender) -> Result<(), RemoteBackendError> { +async fn execute_sync( + session: Arc, retain_capability: bool, events: mpsc::Sender, +) -> Result<(), RemoteBackendError> { send_event(&events, RemoteBackendEvent::SyncProgress { completed_bytes: 0, total_bytes: None }).await?; - let sync = tokio::task::spawn_blocking(move || session.sync_snapshot()) + let sync = tokio::task::spawn_blocking(move || session.sync_snapshot_for_request(retain_capability)) .await .map_err(|error| RemoteBackendError::Failed(format!("snapshot worker failed: {error}")))?; - let snapshot_id = sync.map_err(RemoteBackendError::Failed)?; - send_event(&events, RemoteBackendEvent::SyncCompleted { snapshot_id }).await + match sync.map_err(RemoteBackendError::Failed)? { + Some(snapshot_id) => send_event(&events, RemoteBackendEvent::SyncCompleted { snapshot_id }).await, + None => send_event(&events, RemoteBackendEvent::Completed { exit_code: 0 }).await, + } } async fn execute_build( diff --git a/src/loopback_ut.rs b/src/loopback_ut.rs index 89d6390..cf280ab 100644 --- a/src/loopback_ut.rs +++ b/src/loopback_ut.rs @@ -61,6 +61,24 @@ async fn sync_capability(backend: &LoopbackBackend, target: RemoteTargetId, sess .unwrap() } +async fn diagnostic_sync(backend: &LoopbackBackend, target: RemoteTargetId, session: WorkspaceSessionId) -> Vec { + let request = RemoteRequest::diagnostic_sync(RequestId([5; 16]), session); + let policy = RemoteAuthorizationPolicy::new(target, session, Vec::new()); + let authorized = policy.authorize(&RemoteExecutionContext { target, workspace_session_id: session }, request).unwrap(); + let (events, receiver) = mpsc::channel(8); + assert_eq!(backend.execute(authorized, events).await, Ok(())); + collect_events(receiver).await +} + +fn published_snapshot_count(session: &RunRemoteSession) -> usize { + fs::read_dir(session.snapshot_store_root()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name() != ".staging") + .map(|entry| fs::read_dir(entry.path()).map(|entries| entries.filter_map(Result::ok).count()).unwrap_or_default()) + .sum() +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sync_and_build_materialize_a_bound_snapshot() { let (_temp, session, target, session_id) = fixture(); @@ -95,6 +113,56 @@ async fn sync_and_build_materialize_a_bound_snapshot() { assert!(fs::read_dir(&session_state.jobs_root).unwrap().next().is_none()); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn diagnostic_sync_releases_capability_and_snapshot_storage() { + let (_temp, session, target, session_id) = fixture(); + let backend = LoopbackBackend::new(session.clone(), BTreeMap::new()); + + for _ in 0..(MAX_OUTSTANDING_SNAPSHOT_CAPABILITIES * 2) { + let events = diagnostic_sync(&backend, target, session_id).await; + assert!(events.iter().any(|event| matches!(event, RemoteBackendEvent::Completed { exit_code: 0 }))); + assert!(!events.iter().any(|event| matches!(event, RemoteBackendEvent::SyncCompleted { .. }))); + assert!(session.snapshot_capabilities.lock().unwrap().capabilities.is_empty()); + assert_eq!(published_snapshot_count(&session), 0); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn failed_diagnostic_sync_registers_nothing() { + let (_temp, session, target, session_id) = fixture(); + fs::remove_dir_all(session.workspace_root()).unwrap(); + let backend = LoopbackBackend::new(session.clone(), BTreeMap::new()); + let request = RemoteRequest::diagnostic_sync(RequestId([5; 16]), session_id); + let policy = RemoteAuthorizationPolicy::new(target, session_id, Vec::new()); + let authorized = policy.authorize(&RemoteExecutionContext { target, workspace_session_id: session_id }, request).unwrap(); + let (events, _receiver) = mpsc::channel(8); + + assert!(matches!(backend.execute(authorized, events).await, Err(RemoteBackendError::Failed(_)))); + assert!(session.snapshot_capabilities.lock().unwrap().capabilities.is_empty()); + assert_eq!(published_snapshot_count(&session), 0); +} + +#[test] +fn outstanding_snapshot_capabilities_are_bounded_and_consumption_frees_a_slot() { + let (temp, session, _target, _session_id) = fixture(); + let mut capabilities = Vec::new(); + for _ in 0..MAX_OUTSTANDING_SNAPSHOT_CAPABILITIES { + capabilities.push(session.sync_snapshot().unwrap()); + } + assert_eq!(session.snapshot_capabilities.lock().unwrap().capabilities.len(), MAX_OUTSTANDING_SNAPSHOT_CAPABILITIES); + assert!(session.sync_snapshot().unwrap_err().contains("capability limit reached")); + + let claim = session.claim_snapshot(capabilities.pop().unwrap()).unwrap(); + drop(claim); + assert_eq!(session.snapshot_capabilities.lock().unwrap().capabilities.len(), MAX_OUTSTANDING_SNAPSHOT_CAPABILITIES - 1); + session.sync_snapshot().unwrap(); + + let snapshot_root = session.snapshot_store_root(); + drop(session); + assert!(!snapshot_root.exists()); + drop(temp); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn interleaved_syncs_build_their_own_snapshot_capabilities() { let (_temp, session, target, session_id) = fixture(); diff --git a/src/remote.rs b/src/remote.rs index 0dbc7da..5f7e3f9 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -220,9 +220,20 @@ impl RemoteToolPolicy { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteSync { + retain_capability: bool, +} + +impl RemoteSync { + pub fn retain_capability(self) -> bool { + self.retain_capability + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum RemoteOperation { - Sync, + Sync(RemoteSync), Build(RemoteBuild), } @@ -235,7 +246,15 @@ pub struct RemoteRequest { impl RemoteRequest { pub fn sync(request_id: RequestId, workspace_session_id: WorkspaceSessionId) -> Self { - Self { request_id, workspace_session_id, operation: RemoteOperation::Sync } + Self::sync_with_capability(request_id, workspace_session_id, true) + } + + pub fn diagnostic_sync(request_id: RequestId, workspace_session_id: WorkspaceSessionId) -> Self { + Self::sync_with_capability(request_id, workspace_session_id, false) + } + + fn sync_with_capability(request_id: RequestId, workspace_session_id: WorkspaceSessionId, retain_capability: bool) -> Self { + Self { request_id, workspace_session_id, operation: RemoteOperation::Sync(RemoteSync { retain_capability }) } } pub fn build(request_id: RequestId, workspace_session_id: WorkspaceSessionId, build: RemoteBuild) -> Self { @@ -351,7 +370,7 @@ impl RemoteAuthorizationPolicy { } let request = match request.operation() { - RemoteOperation::Sync => request, + RemoteOperation::Sync(_) => request, RemoteOperation::Build(build) => { if self.snapshot_authority.as_ref().is_none_or(|authority| !authority.snapshot_available(self.allowed_session, build.snapshot_id())) { return Err(RemoteAuthorizationError::SnapshotNotAllowed); diff --git a/src/remote_client.rs b/src/remote_client.rs index 77912cc..c67a281 100644 --- a/src/remote_client.rs +++ b/src/remote_client.rs @@ -9,6 +9,10 @@ pub fn remote_sync_request(request_id: RequestId, session_id: WorkspaceSessionId RemoteRequest::sync(request_id, session_id) } +pub fn remote_diagnostic_sync_request(request_id: RequestId, session_id: WorkspaceSessionId) -> RemoteRequest { + RemoteRequest::diagnostic_sync(request_id, session_id) +} + pub fn remote_session_from_env() -> Result { env::var("BUNKERBOX_REMOTE_SESSION") .map_err(|_| "BUNKERBOX_REMOTE_SESSION is missing".to_string()) diff --git a/src/vscomm/mod.rs b/src/vscomm/mod.rs index 8dc7041..d444dfd 100644 --- a/src/vscomm/mod.rs +++ b/src/vscomm/mod.rs @@ -75,6 +75,11 @@ impl RemoteSnapshotId { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteSync { + pub retain_capability: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct WorkspaceSessionId(pub [u8; 16]); @@ -182,9 +187,6 @@ pub enum RemoteOperation { Build(RemoteBuild), } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RemoteSync; - #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemoteRequest { pub request_id: RequestId, @@ -194,7 +196,15 @@ pub struct RemoteRequest { impl RemoteRequest { pub fn sync(request_id: RequestId, workspace_session_id: WorkspaceSessionId) -> Self { - Self { request_id, workspace_session_id, operation: RemoteOperation::Sync(RemoteSync) } + Self::sync_with_capability(request_id, workspace_session_id, true) + } + + pub fn diagnostic_sync(request_id: RequestId, workspace_session_id: WorkspaceSessionId) -> Self { + Self::sync_with_capability(request_id, workspace_session_id, false) + } + + fn sync_with_capability(request_id: RequestId, workspace_session_id: WorkspaceSessionId, retain_capability: bool) -> Self { + Self { request_id, workspace_session_id, operation: RemoteOperation::Sync(RemoteSync { retain_capability }) } } pub fn build(request_id: RequestId, workspace_session_id: WorkspaceSessionId, build: RemoteBuild) -> Self { @@ -214,6 +224,8 @@ impl RemoteRequest { if let RemoteOperation::Build(build) = &self.operation { encode_remote_build(&mut writer, build)?; + } else if let RemoteOperation::Sync(sync) = &self.operation { + writer.u8(u8::from(sync.retain_capability)); } writer.into_frame(FrameType::RemoteRequest) @@ -232,7 +244,14 @@ impl RemoteRequest { let request_id = RequestId(reader.array16()?); let workspace_session_id = WorkspaceSessionId(reader.array16()?); let operation = match operation_kind { - 1 => RemoteOperation::Sync(RemoteSync), + 1 => { + let retain_capability = match reader.u8()? { + 0 => false, + 1 => true, + value => return Err(format!("invalid remote sync capability flag: {value}")), + }; + RemoteOperation::Sync(RemoteSync { retain_capability }) + } 2 => RemoteOperation::Build(decode_remote_build(&mut reader)?), value => return Err(format!("unknown remote operation: {value}")), }; @@ -245,7 +264,13 @@ impl RemoteRequest { let request_id = remote_domain::RequestId(self.request_id.0); let session_id = remote_domain::WorkspaceSessionId(self.workspace_session_id.0); match self.operation { - RemoteOperation::Sync(_) => Ok(remote_domain::RemoteRequest::sync(request_id, session_id)), + RemoteOperation::Sync(sync) => { + if sync.retain_capability { + Ok(remote_domain::RemoteRequest::sync(request_id, session_id)) + } else { + Ok(remote_domain::RemoteRequest::diagnostic_sync(request_id, session_id)) + } + } RemoteOperation::Build(build) => { let cwd = remote_domain::WorkspaceRelativePath::new(build.cwd.as_str())?; let tool = remote_domain::RemoteTool::new(build.tool.as_str())?; From 4e2d7ae1cbffa76d97259b64884755fedc78a76b Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 3 Aug 2026 11:04:31 +0200 Subject: [PATCH 24/25] Add regression tests for installers, cleanup, bounds, and preserved binding --- src/bunkerbox-remote_ut.rs | 11 + src/daemon_ut.rs | 483 +++++++++++++++++++++++++------------ src/guest_install_ut.rs | 82 +++++++ src/vscomm/mod_ut.rs | 19 ++ 4 files changed, 437 insertions(+), 158 deletions(-) create mode 100644 src/guest_install_ut.rs diff --git a/src/bunkerbox-remote_ut.rs b/src/bunkerbox-remote_ut.rs index 7409e69..b5fbacf 100644 --- a/src/bunkerbox-remote_ut.rs +++ b/src/bunkerbox-remote_ut.rs @@ -86,6 +86,17 @@ fn sync_success_uses_existing_remote_helper_and_returns_status() { assert!(Frame::read(&mut io::Cursor::new(stream.output)).is_ok()); } +#[test] +fn standalone_sync_uses_diagnostic_non_retaining_request() { + let session = WorkspaceSessionId([2; 16]); + sync_snapshot_using(session, |request| { + let bunkerbox::vscomm::RemoteOperation::Sync(sync) = request.operation else { panic!("expected sync") }; + assert!(!sync.retain_capability); + Ok(RemoteCompletion::Completed(0)) + }) + .unwrap(); +} + #[test] fn build_success_preserves_output_bytes_and_nonzero_exit_code() { let request_id = RequestId([6; 16]); diff --git a/src/daemon_ut.rs b/src/daemon_ut.rs index 83ea583..367332f 100644 --- a/src/daemon_ut.rs +++ b/src/daemon_ut.rs @@ -1,45 +1,20 @@ -use super::{build_command, find_netrelay_binary, make_proxy_runtime_dir, monitor_bwrap_status, ChildEvent, SandboxProxyConfig, VsockSession}; +use super::{dispatch_remote_frame, is_allowed, RemoteBroker, RemoteDispatchError}; +use super::{monitor_bwrap_status, ChildEvent}; use crate::cfg::EnvMode; -use crate::sandbox::{MergedProfile, NetworkMode}; -use crate::vscomm::{validate_exec_request, ExecRequest}; -use std::ffi::OsStr; +use crate::remote::{ + AuthorizedRemoteRequest, RemoteAuthorizationPolicy, RemoteBackend, RemoteBackendError, RemoteBackendEvent, RemoteExecutionContext, RemoteFuture, + RemoteRequest, RemoteSnapshotAuthority, RemoteSnapshotId, RemoteTargetId, RequestId, WorkspaceRelativePath, WorkspaceSessionId, +}; +use crate::vscomm::{ + Frame, RemoteBuild as WireRemoteBuild, RemoteRequest as WireRemoteRequest, RemoteTool as WireRemoteTool, RequestId as WireRequestId, + WorkspaceRelativePath as WireWorkspaceRelativePath, WorkspaceSessionId as WireWorkspaceSessionId, +}; use std::io::Write; -use std::os::unix::fs::PermissionsExt; -use std::path::PathBuf; -use std::sync::Arc; - -fn session_no_proxy() -> VsockSession { - VsockSession { - passthrough: Arc::new(vec!["cargo *".into()]), - env_mode: EnvMode::Relaxed, - workspace: PathBuf::from("/tmp/ws"), - merged_profile: Some(Arc::new(MergedProfile { name: "test".into(), network: NetworkMode::None, ..Default::default() })), - proxy_config: None, - } -} - -fn session_with_proxy() -> VsockSession { - VsockSession { - passthrough: Arc::new(vec!["cargo *".into()]), - env_mode: EnvMode::Relaxed, - workspace: PathBuf::from("/tmp/ws"), - merged_profile: Some(Arc::new(MergedProfile { name: "test".into(), network: NetworkMode::None, ..Default::default() })), - proxy_config: Some(Arc::new(SandboxProxyConfig { - socket_path: PathBuf::from("/tmp/proxy.sock"), - netrelay_path: PathBuf::from("/tmp/bunkerbox-netrelay"), - })), - } -} - -fn session_no_profile() -> VsockSession { - VsockSession { - passthrough: Arc::new(vec!["cargo *".into()]), - env_mode: EnvMode::Relaxed, - workspace: PathBuf::from("/tmp/ws"), - merged_profile: None, - proxy_config: None, - } -} +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use tokio::io::AsyncWrite; +use tokio::sync::mpsc; #[test] fn bwrap_status_reports_command_start() { @@ -65,147 +40,339 @@ fn bwrap_status_reports_setup_failure_without_child() { assert!(matches!(rx.try_recv().unwrap(), ChildEvent::LauncherFailed(_))); } -#[test] -fn a_profile_no_allowlist_has_unshare_net_no_proxy() { - let req = ExecRequest { cwd: "/workspace".into(), command: "cargo".into(), args: vec!["build".into()], env: vec![] }; - validate_exec_request(&req).unwrap(); - let session = session_no_proxy(); - let cmd = build_command(&session, &req, &PathBuf::from("/tmp/ws"), "/workspace").unwrap(); - let cmd = cmd.as_std(); - let args: Vec<_> = cmd.get_args().collect(); - let args_str: Vec = args.iter().map(|a| a.to_string_lossy().to_string()).collect(); - assert!(args_str.contains(&"--unshare-net".to_string())); - assert!(!args_str.contains(&"/run/bunkerbox/netrelay".to_string())); - assert!(!args_str.contains(&"/run/bunkerbox/proxy.sock".to_string())); - assert!(!args_str.contains(&"--setenv".to_string()) || !args_str.iter().any(|a| a.contains("HTTP_PROXY"))); +struct RecordingBackend { + calls: Mutex>, + emit: Vec, + result: Option, } -#[test] -fn b_profile_allowlist_has_unshare_net_and_relay() { - let req = ExecRequest { cwd: "/workspace".into(), command: "cargo".into(), args: vec!["build".into()], env: vec![] }; - validate_exec_request(&req).unwrap(); - let session = session_with_proxy(); - let cmd = build_command(&session, &req, &PathBuf::from("/tmp/ws"), "/workspace").unwrap(); - let cmd = cmd.as_std(); - let args: Vec<_> = cmd.get_args().collect(); - let args_str: Vec = args.iter().map(|a| a.to_string_lossy().to_string()).collect(); - assert!(args_str.contains(&"--unshare-net".to_string())); - assert!(args_str.contains(&"/run/bunkerbox/netrelay".to_string())); - assert!(args_str.contains(&"/run/bunkerbox/proxy.sock".to_string())); - assert!(args_str.contains(&"--socket".to_string())); - assert!(args_str.iter().any(|a| a.contains("HTTP_PROXY"))); +struct TestSnapshotAuthority; + +impl RemoteSnapshotAuthority for TestSnapshotAuthority { + fn snapshot_available(&self, _session: WorkspaceSessionId, _snapshot_id: RemoteSnapshotId) -> bool { + true + } } -#[test] -fn c_no_profile_allowlist_direct_host_unchanged() { - let req = ExecRequest { cwd: "/workspace".into(), command: "cargo".into(), args: vec!["build".into()], env: vec![] }; - validate_exec_request(&req).unwrap(); - let mut session = session_no_profile(); - session.proxy_config = - Some(Arc::new(SandboxProxyConfig { socket_path: PathBuf::from("/tmp/proxy.sock"), netrelay_path: PathBuf::from("/tmp/bunkerbox-netrelay") })); - let cmd = build_command(&session, &req, &PathBuf::from("/tmp/ws"), "/workspace").unwrap(); - let cmd = cmd.as_std(); - let args: Vec<_> = cmd.get_args().collect(); - let args_str: Vec = args.iter().map(|a| a.to_string_lossy().to_string()).collect(); - assert_eq!(cmd.get_program(), "cargo"); - assert!(args_str.contains(&"build".to_string())); - assert!(!args_str.contains(&"--unshare-net".to_string())); - assert!(!args_str.iter().any(|a| a.contains("HTTP_PROXY"))); +struct RejectSnapshotAuthority; + +impl RemoteSnapshotAuthority for RejectSnapshotAuthority { + fn snapshot_available(&self, _session: WorkspaceSessionId, _snapshot_id: RemoteSnapshotId) -> bool { + false + } } -#[test] -fn d_critical_regression_no_proxy_with_unshare_net() { - let req = ExecRequest { cwd: "/workspace".into(), command: "cargo".into(), args: vec!["build".into()], env: vec![] }; - validate_exec_request(&req).unwrap(); +impl RemoteBackend for RecordingBackend { + fn execute<'a>( + &'a self, request: AuthorizedRemoteRequest, events: mpsc::Sender, + ) -> RemoteFuture<'a, Result<(), RemoteBackendError>> { + self.calls.lock().unwrap().push(request); + let emit = self.emit.clone(); + let result = self.result.clone(); + Box::pin(async move { + for event in emit { + events.send(event).await.map_err(|_| RemoteBackendError::Cancelled)?; + } + result.map_or(Ok(()), Err) + }) + } +} - // No proxy -> --unshare-net present - let session = session_no_proxy(); - let cmd = build_command(&session, &req, &PathBuf::from("/tmp/ws"), "/workspace").unwrap(); - let args: Vec<_> = cmd.as_std().get_args().collect(); - let args_str: Vec = args.iter().map(|a| a.to_string_lossy().to_string()).collect(); - assert!(args_str.contains(&"--unshare-net".to_string())); +struct StreamingBackend { + event_count: usize, + release: Arc, +} - // Proxy -> --unshare-net STILL present - let session = session_with_proxy(); - let cmd = build_command(&session, &req, &PathBuf::from("/tmp/ws"), "/workspace").unwrap(); - let args: Vec<_> = cmd.as_std().get_args().collect(); - let args_str: Vec = args.iter().map(|a| a.to_string_lossy().to_string()).collect(); - assert!(args_str.contains(&"--unshare-net".to_string())); +impl RemoteBackend for StreamingBackend { + fn execute<'a>( + &'a self, _request: AuthorizedRemoteRequest, events: mpsc::Sender, + ) -> RemoteFuture<'a, Result<(), RemoteBackendError>> { + let event_count = self.event_count; + let release = self.release.clone(); + Box::pin(async move { + for index in 0..event_count { + events.send(RemoteBackendEvent::Stdout(vec![index as u8])).await.map_err(|_| RemoteBackendError::Cancelled)?; + } + release.notified().await; + events.send(RemoteBackendEvent::Completed { exit_code: 0 }).await.map_err(|_| RemoteBackendError::Cancelled) + }) + } } -#[test] -fn e_runtime_dir_exclusive_and_private() { - let dir = make_proxy_runtime_dir().unwrap(); - assert!(dir.exists()); - let meta = std::fs::symlink_metadata(&dir).unwrap(); - assert!(meta.is_dir()); - let mode = meta.permissions().mode(); - assert_eq!(mode & 0o777, 0o700); - std::fs::remove_dir(&dir).unwrap(); +struct HangingBackend; + +impl RemoteBackend for HangingBackend { + fn execute<'a>( + &'a self, _request: AuthorizedRemoteRequest, events: mpsc::Sender, + ) -> RemoteFuture<'a, Result<(), RemoteBackendError>> { + Box::pin(async move { + events.send(RemoteBackendEvent::Stdout(b"first".to_vec())).await.map_err(|_| RemoteBackendError::Cancelled)?; + std::future::pending::>().await + }) + } } -#[test] -fn e_runtime_dir_rejects_existing() { - let dir = make_proxy_runtime_dir().unwrap(); - let result = make_proxy_runtime_dir(); - // dir still exists from first call -> create fails (not the same name but - // proves the function works when path is available) - std::fs::remove_dir(&dir).unwrap(); - assert!(result.is_ok()); +struct FailingWriter; + +impl AsyncWrite for FailingWriter { + fn poll_write(self: std::pin::Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &[u8]) -> Poll> { + Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "writer closed"))) + } + + fn poll_flush(self: std::pin::Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: std::pin::Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } } -#[test] -fn e_runtime_dir_rejects_existing_file() { - let tmp = std::env::temp_dir().join(format!("bunkerbox-daemon-test-file-{}", std::process::id())); - std::fs::write(&tmp, "data").unwrap(); - let meta = std::fs::symlink_metadata(&tmp).unwrap(); - assert!(meta.is_file()); - let _ = std::fs::remove_file(&tmp); +fn remote_request(tool: &str) -> RemoteRequest { + RemoteRequest::build( + RequestId([1; 16]), + WorkspaceSessionId([2; 16]), + crate::remote::RemoteBuild::new( + WorkspaceRelativePath::new("src").unwrap(), + crate::remote::RemoteTool::new(tool).unwrap(), + vec!["build".into(), "--release".into()], + vec![("CC".into(), "cc".into())], + RemoteSnapshotId::from_bytes([9; 16]), + ) + .unwrap(), + ) } -#[test] -fn f_missing_netrelay_fails_closed() { - let exe = std::env::current_exe().unwrap(); - let dir = exe.parent().unwrap().join("nonexistent-dir-for-test"); - let path = dir.join("bunkerbox-netrelay"); - assert!(!path.is_file()); - // find_netrelay_binary looks for sibling -> succeeds if sibling exists, - // fails if not. This test proves a missing sibling returns Err. - // We can't test missing_from_nonexistent_dir without modifying the - // function, but the code path is: sibling doesn't exist -> Err. - // This is a structural test: assert the function returns Err when sibling absent. - // Since the sibling may actually exist (if built), we just verify the function - // name and error message pattern. - assert!(!path.exists()); +fn remote_broker(backend: Arc) -> RemoteBroker { + let target = RemoteTargetId([3; 16]); + let session = WorkspaceSessionId([2; 16]); + RemoteBroker::new( + RemoteAuthorizationPolicy::new(target, session, vec!["make".into()]).with_snapshot_authority(Arc::new(TestSnapshotAuthority)), + RemoteExecutionContext { target, workspace_session_id: session }, + backend, + ) } -#[test] -fn g_make_proxy_runtime_dir_rejects_existing_path() { - let existing = std::env::temp_dir().join(format!("bunkerbox-daemon-test-{}", std::process::id())); - std::fs::create_dir(&existing).unwrap(); - let exists = existing.exists(); - assert!(exists); +#[tokio::test] +async fn authorized_remote_request_reaches_typed_backend() { + let backend = Arc::new(RecordingBackend { + calls: Mutex::new(Vec::new()), + emit: vec![ + RemoteBackendEvent::Stdout(b"out".to_vec()), + RemoteBackendEvent::Stderr(b"err".to_vec()), + RemoteBackendEvent::Completed { exit_code: 7 }, + ], + result: None, + }); + let broker = remote_broker(backend.clone()); + let (tx, mut rx) = mpsc::channel(8); + + broker.dispatch(remote_request("make"), tx).await.unwrap(); + + assert_eq!(backend.calls.lock().unwrap().len(), 1); + let request = backend.calls.lock().unwrap()[0].request().clone(); + let crate::remote::RemoteOperation::Build(build) = request.operation() else { panic!("expected build") }; + assert_eq!(build.tool().as_str(), "make"); + assert_eq!(build.cwd().as_str(), "src"); + assert_eq!(build.argv(), ["build", "--release"]); + assert_eq!(build.env(), [("CC".into(), "cc".into())]); + assert_eq!(rx.recv().await, Some(RemoteBackendEvent::Stdout(b"out".to_vec()))); + assert_eq!(rx.recv().await, Some(RemoteBackendEvent::Stderr(b"err".to_vec()))); + assert_eq!(rx.recv().await, Some(RemoteBackendEvent::Completed { exit_code: 7 })); +} + +#[tokio::test] +async fn rejected_remote_request_never_calls_backend() { + let backend = Arc::new(RecordingBackend { calls: Mutex::new(Vec::new()), emit: Vec::new(), result: None }); + let broker = remote_broker(backend.clone()); + let (tx, mut rx) = mpsc::channel(8); + + let error = broker.dispatch(remote_request("cargo"), tx).await.unwrap_err(); + + assert!(matches!(error, RemoteDispatchError::Unauthorized(_))); + assert!(backend.calls.lock().unwrap().is_empty()); + assert!(matches!(rx.recv().await, Some(RemoteBackendEvent::Error { message }) if message.contains("authorization rejected"))); +} + +#[tokio::test] +async fn rejected_snapshot_capability_never_calls_backend() { + let backend = Arc::new(RecordingBackend { calls: Mutex::new(Vec::new()), emit: Vec::new(), result: None }); + let target = RemoteTargetId([3; 16]); + let session = WorkspaceSessionId([2; 16]); + let policy = RemoteAuthorizationPolicy::new(target, session, vec!["make".into()]).with_snapshot_authority(Arc::new(RejectSnapshotAuthority)); + let broker = RemoteBroker::new(policy, RemoteExecutionContext { target, workspace_session_id: session }, backend.clone()); + let (tx, mut rx) = mpsc::channel(8); + + let error = broker.dispatch(remote_request("make"), tx).await.unwrap_err(); + assert_eq!(error, RemoteDispatchError::Unauthorized(crate::remote::RemoteAuthorizationError::SnapshotNotAllowed)); + assert!(backend.calls.lock().unwrap().is_empty()); + assert!(matches!(rx.recv().await, Some(RemoteBackendEvent::Error { message }) if message.contains("SnapshotNotAllowed"))); +} + +#[tokio::test] +async fn backend_failure_is_reported_as_typed_error() { + let backend = + Arc::new(RecordingBackend { calls: Mutex::new(Vec::new()), emit: Vec::new(), result: Some(RemoteBackendError::Spawn("not found".into())) }); + let broker = remote_broker(backend); + let (tx, mut rx) = mpsc::channel(8); + + assert!(matches!(broker.dispatch(remote_request("make"), tx).await, Err(RemoteDispatchError::Backend(RemoteBackendError::Spawn(_))))); + assert_eq!(rx.recv().await, Some(RemoteBackendEvent::Error { message: "not found".into() })); +} + +#[tokio::test] +async fn fake_backend_can_report_timeout_and_cancellation_states() { + for (failure, expected) in [ + (RemoteBackendError::Timeout, RemoteBackendEvent::Error { message: "remote backend timed out".into() }), + (RemoteBackendError::Cancelled, RemoteBackendEvent::Cancelled), + ] { + let backend = Arc::new(RecordingBackend { calls: Mutex::new(Vec::new()), emit: Vec::new(), result: Some(failure.clone()) }); + let broker = remote_broker(backend); + let (tx, mut rx) = mpsc::channel(8); + + assert!(matches!(broker.dispatch(remote_request("make"), tx).await, Err(RemoteDispatchError::Backend(error)) if error == failure)); + assert_eq!(rx.recv().await, Some(expected)); + } +} + +#[tokio::test] +async fn framed_remote_sync_runs_full_dispatch_and_event_conversion_chain() { + let backend = Arc::new(RecordingBackend { + calls: Mutex::new(Vec::new()), + emit: vec![RemoteBackendEvent::SyncCompleted { snapshot_id: RemoteSnapshotId::from_bytes([9; 16]) }], + result: None, + }); + let broker = remote_broker(backend.clone()); + let request = WireRemoteRequest::sync(WireRequestId([6; 16]), WireWorkspaceSessionId([2; 16])); + let (mut guest, mut host) = tokio::io::duplex(4096); + + dispatch_remote_frame(request.to_frame().unwrap(), &broker, &mut host).await.unwrap(); + + let event = crate::vscomm::RemoteEvent::from_frame(Frame::read_async(&mut guest).await.unwrap()).unwrap(); + assert_eq!(event.request_id, crate::vscomm::RequestId([6; 16])); + assert_eq!(event.kind, crate::vscomm::RemoteEventKind::SyncCompleted { snapshot_id: crate::vscomm::RemoteSnapshotId([9; 16]) }); + let calls = backend.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert!(matches!(calls[0].request().operation(), crate::remote::RemoteOperation::Sync(_))); +} + +#[tokio::test] +async fn framed_remote_build_preserves_typed_fields_and_output_order() { + let backend = Arc::new(RecordingBackend { + calls: Mutex::new(Vec::new()), + emit: vec![ + RemoteBackendEvent::Stdout(b"out".to_vec()), + RemoteBackendEvent::Stderr(b"err".to_vec()), + RemoteBackendEvent::Completed { exit_code: 23 }, + ], + result: None, + }); + let broker = remote_broker(backend.clone()); + let request = WireRemoteRequest::build( + WireRequestId([7; 16]), + WireWorkspaceSessionId([2; 16]), + WireRemoteBuild::new( + WireWorkspaceRelativePath::new("src").unwrap(), + WireRemoteTool::new("make").unwrap(), + vec!["release mode".into(), "$(literal)".into()], + vec![("CC".into(), "cc".into())], + crate::vscomm::RemoteSnapshotId([9; 16]), + ) + .unwrap(), + ); + let (mut guest, mut host) = tokio::io::duplex(4096); + + dispatch_remote_frame(request.to_frame().unwrap(), &broker, &mut host).await.unwrap(); + + let stdout = crate::vscomm::RemoteEvent::from_frame(Frame::read_async(&mut guest).await.unwrap()).unwrap(); + let stderr = crate::vscomm::RemoteEvent::from_frame(Frame::read_async(&mut guest).await.unwrap()).unwrap(); + let completed = crate::vscomm::RemoteEvent::from_frame(Frame::read_async(&mut guest).await.unwrap()).unwrap(); + assert_eq!(stdout.kind, crate::vscomm::RemoteEventKind::Stdout(b"out".to_vec())); + assert_eq!(stderr.kind, crate::vscomm::RemoteEventKind::Stderr(b"err".to_vec())); + assert_eq!(completed.kind, crate::vscomm::RemoteEventKind::Completed { exit_code: 23 }); + + let calls = backend.calls.lock().unwrap(); + let crate::remote::RemoteOperation::Build(build) = calls[0].request().operation() else { panic!("expected build") }; + assert_eq!(calls[0].request_id(), crate::remote::RequestId([7; 16])); + assert_eq!(build.cwd().as_str(), "src"); + assert_eq!(build.tool().as_str(), "make"); + assert_eq!(build.argv(), ["release mode", "$(literal)"]); + assert_eq!(build.env(), [("CC".into(), "cc".into())]); +} + +#[tokio::test] +async fn malformed_remote_frame_fails_before_backend_dispatch() { + let backend = Arc::new(RecordingBackend { calls: Mutex::new(Vec::new()), emit: Vec::new(), result: None }); + let broker = remote_broker(backend.clone()); + let mut frame = WireRemoteRequest::sync(WireRequestId([6; 16]), WireWorkspaceSessionId([2; 16])).to_frame().unwrap(); + frame.payload[6] = 99; + let (_, mut host) = tokio::io::duplex(128); + + assert!(dispatch_remote_frame(frame, &broker, &mut host).await.is_err()); + assert!(backend.calls.lock().unwrap().is_empty()); +} - std::fs::remove_dir(&existing).unwrap(); +#[tokio::test] +async fn remote_events_stream_past_bounded_channel_capacity_before_completion() { + let release = Arc::new(tokio::sync::Notify::new()); + let backend = Arc::new(StreamingBackend { event_count: 65, release: release.clone() }); + let broker = remote_broker(backend); + let request = WireRemoteRequest::sync(WireRequestId([6; 16]), WireWorkspaceSessionId([2; 16])); + let (mut guest, mut host) = tokio::io::duplex(8192); + let dispatch = tokio::spawn(async move { dispatch_remote_frame(request.to_frame().unwrap(), &broker, &mut host).await }); + + let first = tokio::time::timeout(std::time::Duration::from_secs(1), Frame::read_async(&mut guest)).await.unwrap().unwrap(); + let first = crate::vscomm::RemoteEvent::from_frame(first).unwrap(); + assert_eq!(first.kind, crate::vscomm::RemoteEventKind::Stdout(vec![0])); + release.notify_one(); + + let result = tokio::time::timeout(std::time::Duration::from_secs(1), dispatch).await.unwrap().unwrap(); + result.unwrap(); + for index in 1..65 { + let event = crate::vscomm::RemoteEvent::from_frame( + tokio::time::timeout(std::time::Duration::from_secs(1), Frame::read_async(&mut guest)).await.unwrap().unwrap(), + ) + .unwrap(); + assert_eq!(event.kind, crate::vscomm::RemoteEventKind::Stdout(vec![index as u8])); + } + let completed = crate::vscomm::RemoteEvent::from_frame( + tokio::time::timeout(std::time::Duration::from_secs(1), Frame::read_async(&mut guest)).await.unwrap().unwrap(), + ) + .unwrap(); + assert_eq!(completed.kind, crate::vscomm::RemoteEventKind::Completed { exit_code: 0 }); +} + +#[tokio::test] +async fn writer_failure_cancels_hanging_backend_without_waiting_forever() { + let broker = remote_broker(Arc::new(HangingBackend)); + let request = WireRemoteRequest::sync(WireRequestId([6; 16]), WireWorkspaceSessionId([2; 16])); + let result = + tokio::time::timeout(std::time::Duration::from_secs(1), dispatch_remote_frame(request.to_frame().unwrap(), &broker, &mut FailingWriter)) + .await + .unwrap(); + + assert!(result.is_err()); } #[test] -fn h_literal_argv_preserved() { - let req = ExecRequest { cwd: "/workspace".into(), command: "make".into(), args: vec!["A=a b".into(), "$HOME".into(), "x;y".into()], env: vec![] }; - validate_exec_request(&req).unwrap(); - let session = session_with_proxy(); - let cmd = build_command(&session, &req, &PathBuf::from("/tmp/ws"), "/workspace").unwrap(); - let args: Vec<_> = cmd.as_std().get_args().collect(); - assert!(args.iter().any(|a| *a == OsStr::new("A=a b"))); - assert!(args.iter().any(|a| *a == OsStr::new("$HOME"))); - assert!(args.iter().any(|a| *a == OsStr::new("x;y"))); +fn local_passthrough_authorization_remains_separate() { + assert!(is_allowed(&["make *".into()], "make", &["--release".into()])); + assert!(!is_allowed(&["make *".into()], "cargo", &["build".into()])); } #[test] -fn i_static_netrelay_smoke() { - // find_netrelay_binary returns Ok if sibling exists - let result = find_netrelay_binary(); - if let Ok(path) = &result { - assert!(path.is_file()); - } +fn local_exec_request_still_builds_on_the_local_path() { + let workspace = tempfile::tempdir().unwrap(); + let cwd = crate::workspace::WorkspaceCwd::resolve(workspace.path(), Path::new("/workspace")).unwrap(); + let session = super::VsockSession { + passthrough: Arc::new(vec!["true *".into()]), + env_mode: EnvMode::Paranoid, + workspace: workspace.path().to_path_buf(), + merged_profile: None, + has_proxy: false, + remote_broker: Arc::new(remote_broker(Arc::new(RecordingBackend { calls: Mutex::new(Vec::new()), emit: Vec::new(), result: None }))), + }; + let request = crate::vscomm::ExecRequest { cwd: "/workspace".into(), command: "true".into(), args: Vec::new(), env: Vec::new() }; + + assert!(super::build_command(&session, &request, &cwd).is_ok()); } diff --git a/src/guest_install_ut.rs b/src/guest_install_ut.rs new file mode 100644 index 0000000..a3ab65e --- /dev/null +++ b/src/guest_install_ut.rs @@ -0,0 +1,82 @@ +use super::*; +use std::os::unix::fs::PermissionsExt; + +fn executable(path: &Path) { + fs::write(path, b"binary").unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); +} + +#[test] +fn remote_make_ownership_survives_local_passthrough_install() { + let root = tempfile::tempdir().unwrap(); + let remote = root.path().join("bunkerbox-remote"); + let vscomm = root.path().join("bunkerbox-vscomm"); + executable(&remote); + executable(&vscomm); + + install_remote_make_link(root.path(), &remote, true).unwrap(); + install_vscomm_links(vec!["make".into(), "cargo".into()], root.path(), &vscomm, "").unwrap(); + + assert_eq!(fs::read_link(root.path().join("make")).unwrap(), remote); + assert_eq!(fs::read_link(root.path().join("cargo")).unwrap(), vscomm); +} + +#[test] +fn disabled_remote_make_preserves_native_and_vscomm_behavior() { + let root = tempfile::tempdir().unwrap(); + let native = root.path().join("native"); + fs::create_dir(&native).unwrap(); + let native_make = native.join("make"); + executable(&native_make); + let remote = root.path().join("bunkerbox-remote"); + let vscomm = root.path().join("bunkerbox-vscomm"); + executable(&remote); + executable(&vscomm); + + install_remote_make_link(root.path(), &remote, false).unwrap(); + install_vscomm_links(vec!["make".into(), "cargo".into()], root.path(), &vscomm, native.to_str().unwrap()).unwrap(); + + assert_eq!(fs::read_link(root.path().join("make")).unwrap_err().kind(), std::io::ErrorKind::NotFound); + assert_eq!(fs::read_link(root.path().join("cargo")).unwrap(), vscomm); +} + +#[test] +fn remote_make_wins_when_native_make_is_present() { + let root = tempfile::tempdir().unwrap(); + let native = root.path().join("native"); + fs::create_dir(&native).unwrap(); + executable(&native.join("make")); + let remote = root.path().join("bunkerbox-remote"); + let vscomm = root.path().join("bunkerbox-vscomm"); + executable(&remote); + executable(&vscomm); + + install_remote_make_link(root.path(), &remote, true).unwrap(); + install_vscomm_links(vec!["make".into()], root.path(), &vscomm, native.to_str().unwrap()).unwrap(); + + assert_eq!(fs::read_link(root.path().join("make")).unwrap(), remote); +} + +#[test] +fn repeated_install_is_idempotent_and_stale_managed_links_are_replaced() { + let root = tempfile::tempdir().unwrap(); + let remote = root.path().join("bunkerbox-remote"); + let vscomm = root.path().join("bunkerbox-vscomm"); + executable(&remote); + executable(&vscomm); + + install_remote_make_link(root.path(), &remote, true).unwrap(); + install_remote_make_link(root.path(), &remote, true).unwrap(); + install_vscomm_links(vec!["make".into(), "cargo".into()], root.path(), &vscomm, "").unwrap(); + install_vscomm_links(vec!["make".into(), "cargo".into()], root.path(), &vscomm, "").unwrap(); + assert_eq!(fs::read_link(root.path().join("make")).unwrap(), remote); + assert_eq!(fs::read_link(root.path().join("cargo")).unwrap(), vscomm); + + fs::remove_file(root.path().join("make")).unwrap(); + symlink(root.path().join("old/bunkerbox-remote"), root.path().join("make")).unwrap(); + install_remote_make_link(root.path(), &remote, true).unwrap(); + assert_eq!(fs::read_link(root.path().join("make")).unwrap(), remote); + + install_remote_make_link(root.path(), &remote, false).unwrap(); + assert!(fs::symlink_metadata(root.path().join("make")).is_err()); +} diff --git a/src/vscomm/mod_ut.rs b/src/vscomm/mod_ut.rs index a38d760..2712a11 100644 --- a/src/vscomm/mod_ut.rs +++ b/src/vscomm/mod_ut.rs @@ -87,6 +87,25 @@ fn remote_sync_round_trips() { let request = RemoteRequest::sync(request_id, session_id); let decoded = RemoteRequest::from_frame(request.to_frame().unwrap()).unwrap(); assert_eq!(decoded, request); + let RemoteOperation::Sync(sync) = decoded.operation else { panic!("expected sync") }; + assert!(sync.retain_capability); +} + +#[test] +fn diagnostic_remote_sync_does_not_retain_capability() { + let (request_id, session_id) = ids(); + let request = RemoteRequest::diagnostic_sync(request_id, session_id); + let decoded = RemoteRequest::from_frame(request.to_frame().unwrap()).unwrap(); + let RemoteOperation::Sync(sync) = decoded.operation else { panic!("expected sync") }; + assert!(!sync.retain_capability); +} + +#[test] +fn invalid_remote_sync_capability_flag_is_rejected() { + let (request_id, session_id) = ids(); + let mut frame = RemoteRequest::diagnostic_sync(request_id, session_id).to_frame().unwrap(); + frame.payload[40] = 2; + assert!(RemoteRequest::from_frame(frame).unwrap_err().contains("invalid remote sync capability flag")); } #[test] From 842f4202e33b47a88581961927c71ac5b70ad4a8 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 4 Aug 2026 23:07:00 +0200 Subject: [PATCH 25/25] Linters --- src/daemon_ut.rs | 2 +- src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/daemon_ut.rs b/src/daemon_ut.rs index 367332f..07b0e0f 100644 --- a/src/daemon_ut.rs +++ b/src/daemon_ut.rs @@ -369,7 +369,7 @@ fn local_exec_request_still_builds_on_the_local_path() { env_mode: EnvMode::Paranoid, workspace: workspace.path().to_path_buf(), merged_profile: None, - has_proxy: false, + proxy_config: None, remote_broker: Arc::new(remote_broker(Arc::new(RecordingBackend { calls: Mutex::new(Vec::new()), emit: Vec::new(), result: None }))), }; let request = crate::vscomm::ExecRequest { cwd: "/workspace".into(), command: "true".into(), args: Vec::new(), env: Vec::new() }; diff --git a/src/lib.rs b/src/lib.rs index e4edf99..8706a2d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,8 +6,8 @@ pub mod daemon; pub mod guest_install; pub mod kata; pub mod logging; -pub mod netrelay; pub mod loopback; +pub mod netrelay; pub mod overlay; pub mod proxy; pub mod remote;