🛰️ export from upstream (18069bd)

This commit is contained in:
2026-09-21 14:31:12 +02:00
commit 8262b5f39d
52 changed files with 16288 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "thumper"
version = "0.1.0"
edition = "2024"
description = "In-container forwarding client for sandcage"
license = "MIT"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies]
tempfile = "3"
+311
View File
@@ -0,0 +1,311 @@
#[cfg(unix)]
mod imp {
use std::io::{self, BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use crate::protocol::{ForwardRequest, ResponseFrame, StreamKind};
#[derive(Debug)]
pub enum ClientError {
Connect(io::Error),
Send(io::Error),
Receive(io::Error),
Parse(serde_json::Error),
UnexpectedEof,
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClientError::Connect(e) => write!(f, "failed to connect to forwarding socket: {e}"),
ClientError::Send(e) => write!(f, "failed to send request: {e}"),
ClientError::Receive(e) => write!(f, "failed to read response: {e}"),
ClientError::Parse(e) => write!(f, "malformed response frame: {e}"),
ClientError::UnexpectedEof => write!(f, "connection closed without exit frame"),
}
}
}
impl std::error::Error for ClientError {}
pub fn forward(socket_path: &Path, request: &ForwardRequest) -> Result<i32, ClientError> {
let stream = UnixStream::connect(socket_path).map_err(ClientError::Connect)?;
// Send request as a single JSON line.
let mut writer = io::BufWriter::new(&stream);
serde_json::to_writer(&mut writer, request).map_err(|e| ClientError::Send(e.into()))?;
writer.write_all(b"\n").map_err(ClientError::Send)?;
writer.flush().map_err(ClientError::Send)?;
// Read response frames line by line.
let reader = BufReader::new(&stream);
for line_result in reader.lines() {
let line = line_result.map_err(ClientError::Receive)?;
if line.is_empty() {
continue;
}
let frame: ResponseFrame = serde_json::from_str(&line).map_err(ClientError::Parse)?;
match frame {
ResponseFrame::Output { stream: StreamKind::Stdout, data } => {
print!("{data}");
}
ResponseFrame::Output { stream: StreamKind::Stderr, data } => {
eprint!("{data}");
}
ResponseFrame::Exit { exit } => {
return Ok(exit);
}
}
}
Err(ClientError::UnexpectedEof)
}
}
#[cfg(unix)]
pub use imp::*;
#[cfg(not(unix))]
mod imp {
use std::path::Path;
use crate::protocol::ForwardRequest;
#[derive(Debug)]
pub enum ClientError {
Unsupported,
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "thumper is only supported on Unix platforms")
}
}
impl std::error::Error for ClientError {}
pub fn forward(_socket_path: &Path, _request: &ForwardRequest) -> Result<i32, ClientError> {
Err(ClientError::Unsupported)
}
}
#[cfg(not(unix))]
pub use imp::*;
#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::*;
use crate::protocol::RequestKind;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
use std::thread;
fn mock_server(
socket_path: &Path,
handler: impl FnOnce(UnixStream) + Send + 'static,
) -> thread::JoinHandle<()> {
let listener = UnixListener::bind(socket_path).unwrap();
thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
handler(stream);
})
}
#[test]
fn sends_request_as_single_json_line() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("cmd.sock");
let handle = mock_server(&sock, |stream| {
let mut reader = BufReader::new(&stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(parsed["kind"], "task");
assert_eq!(parsed["name"], "build");
assert_eq!(parsed["args"][0], "--release");
assert_eq!(parsed["cwd"], "/workspace/app");
// Send exit frame so forward() returns
let mut writer = reader.into_inner();
writeln!(writer, r#"{{"exit":0}}"#).unwrap();
});
let req = crate::protocol::ForwardRequest {
kind: RequestKind::Task,
name: "build".to_string(),
args: vec!["--release".to_string()],
cwd: "/workspace/app".to_string(),
};
let exit = forward(&sock, &req).unwrap();
assert_eq!(exit, 0);
handle.join().unwrap();
}
#[test]
fn routes_stdout_and_stderr_separately() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("cmd.sock");
let handle = mock_server(&sock, |stream| {
let mut reader = BufReader::new(&stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap(); // consume request
let mut writer = reader.into_inner();
writeln!(writer, r#"{{"stream":"stdout","data":"out1\n"}}"#).unwrap();
writeln!(writer, r#"{{"stream":"stderr","data":"err1\n"}}"#).unwrap();
writeln!(writer, r#"{{"stream":"stdout","data":"out2\n"}}"#).unwrap();
writeln!(writer, r#"{{"exit":0}}"#).unwrap();
});
let req = crate::protocol::ForwardRequest {
kind: RequestKind::Task,
name: "test".to_string(),
args: vec![],
cwd: "/workspace".to_string(),
};
let exit = forward(&sock, &req).unwrap();
assert_eq!(exit, 0);
handle.join().unwrap();
}
#[test]
fn nonzero_exit_is_relayed() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("cmd.sock");
let handle = mock_server(&sock, |stream| {
let mut reader = BufReader::new(&stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let mut writer = reader.into_inner();
writeln!(writer, r#"{{"stream":"stderr","data":"error: build failed\n"}}"#).unwrap();
writeln!(writer, r#"{{"exit":1}}"#).unwrap();
});
let req = crate::protocol::ForwardRequest {
kind: RequestKind::Task,
name: "build".to_string(),
args: vec![],
cwd: "/workspace".to_string(),
};
let exit = forward(&sock, &req).unwrap();
assert_eq!(exit, 1);
handle.join().unwrap();
}
#[test]
fn unexpected_eof_when_no_exit_frame() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("cmd.sock");
let handle = mock_server(&sock, |stream| {
let mut reader = BufReader::new(&stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let mut writer = reader.into_inner();
writeln!(writer, r#"{{"stream":"stdout","data":"partial"}}"#).unwrap();
let _ = writer; // close without exit frame
});
let req = crate::protocol::ForwardRequest {
kind: RequestKind::Task,
name: "test".to_string(),
args: vec![],
cwd: "/workspace".to_string(),
};
let err = forward(&sock, &req).unwrap_err();
assert!(
matches!(err, ClientError::UnexpectedEof),
"expected UnexpectedEof, got {err}"
);
handle.join().unwrap();
}
#[test]
fn full_roundtrip_simulating_cargo_build() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("cmd.sock");
let handle = mock_server(&sock, |stream| {
let mut reader = BufReader::new(&stream);
let mut req_line = String::new();
reader.read_line(&mut req_line).unwrap();
// Validate the request
let req: serde_json::Value = serde_json::from_str(&req_line).unwrap();
assert_eq!(req["kind"], "task");
assert_eq!(req["name"], "build");
assert_eq!(req["args"], serde_json::json!(["--release"]));
// Simulate cargo build output
let writer = reader.into_inner();
writeln!(&*writer, r#"{{"stream":"stderr","data":" Compiling sandcage v0.1.0\n"}}"#).unwrap();
writeln!(&*writer, r#"{{"stream":"stderr","data":" Compiling thumper v0.1.0\n"}}"#).unwrap();
writeln!(&*writer, r#"{{"stream":"stderr","data":" Finished release [optimized]\n"}}"#).unwrap();
writeln!(&*writer, r#"{{"exit":0}}"#).unwrap();
});
let req = crate::protocol::ForwardRequest {
kind: RequestKind::Task,
name: "build".to_string(),
args: vec!["--release".to_string()],
cwd: "/workspace/app".to_string(),
};
let exit = forward(&sock, &req).unwrap();
assert_eq!(exit, 0);
handle.join().unwrap();
}
#[test]
fn tool_request_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("cmd.sock");
let handle = mock_server(&sock, |stream| {
let mut reader = BufReader::new(&stream);
let mut req_line = String::new();
reader.read_line(&mut req_line).unwrap();
let req: serde_json::Value = serde_json::from_str(&req_line).unwrap();
assert_eq!(req["kind"], "tool");
assert_eq!(req["name"], "docker");
let writer = reader.into_inner();
writeln!(&*writer, r#"{{"stream":"stdout","data":"container-id-abc123\n"}}"#).unwrap();
writeln!(&*writer, r#"{{"exit":0}}"#).unwrap();
});
let req = crate::protocol::ForwardRequest {
kind: RequestKind::Tool,
name: "docker".to_string(),
args: vec!["compose".to_string(), "up".to_string(), "-d".to_string()],
cwd: "/workspace/app".to_string(),
};
let exit = forward(&sock, &req).unwrap();
assert_eq!(exit, 0);
handle.join().unwrap();
}
#[test]
fn connect_error_on_missing_socket() {
let err = forward(
Path::new("/tmp/sandcage-nonexistent-test.sock"),
&crate::protocol::ForwardRequest {
kind: RequestKind::Task,
name: "x".to_string(),
args: vec![],
cwd: "/workspace".to_string(),
},
)
.unwrap_err();
assert!(matches!(err, ClientError::Connect(_)), "expected Connect, got {err}");
}
}
+44
View File
@@ -0,0 +1,44 @@
mod client;
mod protocol;
use std::path::Path;
use std::process::ExitCode;
use protocol::{ForwardRequest, RequestKind};
const DEFAULT_SOCKET: &str = "/run/sandcage/cmd.sock";
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 || args[1] == "--help" || args[1] == "-h" {
eprintln!("usage: thumper <task-name> [args...]");
eprintln!();
eprintln!("Forwards a named task to the sandcage host daemon.");
eprintln!("The task is executed on the host and output is streamed back.");
return ExitCode::from(2);
}
let socket_path = std::env::var("SANDCAGE_SOCKET")
.map(|s| std::path::PathBuf::from(s))
.unwrap_or_else(|_| Path::new(DEFAULT_SOCKET).to_path_buf());
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "/workspace".to_string());
let request = ForwardRequest {
kind: RequestKind::Task,
name: args[1].clone(),
args: args[2..].to_vec(),
cwd,
};
match client::forward(&socket_path, &request) {
Ok(code) => ExitCode::from(code as u8),
Err(e) => {
eprintln!("thumper: {e}");
ExitCode::from(1)
}
}
}
+113
View File
@@ -0,0 +1,113 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)]
pub struct ForwardRequest {
pub kind: RequestKind,
pub name: String,
pub args: Vec<String>,
pub cwd: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub enum RequestKind {
Task,
Tool,
}
/// Response frames from the host-side daemon.
///
/// IMPORTANT: `Exit` MUST come before `Output`. Serde's `untagged` tries variants in
/// declaration order — `{"exit":0}` would match `Output` if `Output` were first.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
#[allow(dead_code)]
pub enum ResponseFrame {
Exit { exit: i32 },
Output { stream: StreamKind, data: String },
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub enum StreamKind {
Stdout,
Stderr,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_serializes_to_expected_json() {
let req = ForwardRequest {
kind: RequestKind::Task,
name: "build".to_string(),
args: vec!["--release".to_string()],
cwd: "/workspace".to_string(),
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["kind"], "task");
assert_eq!(json["name"], "build");
assert_eq!(json["args"][0], "--release");
assert_eq!(json["cwd"], "/workspace");
}
#[test]
fn tool_request_kind_serializes() {
let req = ForwardRequest {
kind: RequestKind::Tool,
name: "grep".to_string(),
args: vec![],
cwd: "/".to_string(),
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["kind"], "tool");
}
#[test]
fn stdout_frame_deserializes() {
let frame: ResponseFrame =
serde_json::from_str(r#"{"stream":"stdout","data":"hello\n"}"#).unwrap();
match frame {
ResponseFrame::Output { stream: StreamKind::Stdout, data } => {
assert_eq!(data, "hello\n");
}
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn stderr_frame_deserializes() {
let frame: ResponseFrame =
serde_json::from_str(r#"{"stream":"stderr","data":"warning: unused var"}"#).unwrap();
match frame {
ResponseFrame::Output { stream: StreamKind::Stderr, data } => {
assert_eq!(data, "warning: unused var");
}
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn exit_frame_deserializes() {
let frame: ResponseFrame = serde_json::from_str(r#"{"exit":42}"#).unwrap();
match frame {
ResponseFrame::Exit { exit } => assert_eq!(exit, 42),
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn exit_zero_frame() {
// Regression guard: serde untagged ordering — Exit must come before Output
// so {"exit":0} does not accidentally match the Output variant.
let frame: ResponseFrame = serde_json::from_str(r#"{"exit":0}"#).unwrap();
match frame {
ResponseFrame::Exit { exit } => assert_eq!(exit, 0),
other => panic!("unexpected variant: {:?}", other),
}
}
}