297 lines
11 KiB
Rust
297 lines
11 KiB
Rust
//! End-to-end observation tests: run the real nash binary against a fake
|
|
//! Nucleic control-socket sink and assert on the shell-batch events it posts
|
|
//! (docs/NASH.md §6.1, §10).
|
|
|
|
use std::io::{Read, Write};
|
|
use std::os::unix::net::UnixListener;
|
|
use std::process::Command;
|
|
use std::sync::mpsc;
|
|
use std::time::Duration;
|
|
|
|
struct Sink {
|
|
dir: std::path::PathBuf,
|
|
rx: mpsc::Receiver<(Option<String>, serde_json::Value)>,
|
|
}
|
|
|
|
impl Sink {
|
|
/// Starts a unix-socket HTTP sink; each posted batch is parsed and sent
|
|
/// through the channel along with its Authorization header.
|
|
fn start() -> Self {
|
|
let dir = std::env::temp_dir().join(format!("nash-test-{}-{:?}", std::process::id(), std::time::Instant::now()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let socket_path = dir.join("control.sock");
|
|
let listener = UnixListener::bind(&socket_path).unwrap();
|
|
let (tx, rx) = mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
for stream in listener.incoming() {
|
|
let Ok(mut stream) = stream else { break };
|
|
let tx = tx.clone();
|
|
std::thread::spawn(move || {
|
|
let mut buf = Vec::new();
|
|
let mut chunk = [0u8; 4096];
|
|
// Read until headers + declared body length are complete.
|
|
loop {
|
|
match stream.read(&mut chunk) {
|
|
Ok(0) => break,
|
|
Ok(n) => {
|
|
buf.extend_from_slice(&chunk[..n]);
|
|
if let Some(body) = full_body(&buf) {
|
|
let auth = header(&buf, "authorization");
|
|
if let Ok(json) = serde_json::from_slice(body) {
|
|
let _ = tx.send((auth, json));
|
|
}
|
|
let _ = stream.write_all(
|
|
b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n",
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
Self { dir, rx }
|
|
}
|
|
|
|
fn socket(&self) -> std::path::PathBuf {
|
|
self.dir.join("control.sock")
|
|
}
|
|
|
|
/// Collects events from batches until `pred` is satisfied or ~5s elapse.
|
|
fn events_until(
|
|
&self,
|
|
pred: impl Fn(&[serde_json::Value]) -> bool,
|
|
) -> Vec<serde_json::Value> {
|
|
let mut events = Vec::new();
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
|
while std::time::Instant::now() < deadline {
|
|
match self.rx.recv_timeout(Duration::from_millis(250)) {
|
|
Ok((auth, batch)) => {
|
|
assert_eq!(
|
|
auth.as_deref(),
|
|
Some("Bearer test-token"),
|
|
"batch must carry the bearer token"
|
|
);
|
|
assert_eq!(batch["type"], "shell-batch");
|
|
assert_eq!(batch["source"], "nash");
|
|
assert_eq!(batch["sessionId"], "sess-1");
|
|
assert!(batch["shellId"].is_string());
|
|
events.extend(batch["events"].as_array().cloned().unwrap_or_default());
|
|
if pred(&events) {
|
|
break;
|
|
}
|
|
}
|
|
Err(_) => {
|
|
if pred(&events) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
events
|
|
}
|
|
}
|
|
|
|
fn full_body(buf: &[u8]) -> Option<&[u8]> {
|
|
let headers_end = buf.windows(4).position(|w| w == b"\r\n\r\n")? + 4;
|
|
let len: usize = header(buf, "content-length")?.parse().ok()?;
|
|
let body = &buf[headers_end..];
|
|
(body.len() >= len).then(|| &body[..len])
|
|
}
|
|
|
|
fn header(buf: &[u8], name: &str) -> Option<String> {
|
|
let text = String::from_utf8_lossy(buf);
|
|
text.lines()
|
|
.take_while(|l| !l.is_empty())
|
|
.find_map(|l| {
|
|
let (k, v) = l.split_once(':')?;
|
|
k.trim().eq_ignore_ascii_case(name).then(|| v.trim().to_string())
|
|
})
|
|
}
|
|
|
|
fn run_nash(sink: &Sink, script: &str) -> std::process::ExitStatus {
|
|
Command::new(env!("CARGO_BIN_EXE_nash"))
|
|
.args(["-c", script])
|
|
.env("NUCLEIC_SHELL_SOCKET", sink.socket())
|
|
.env("NUCLEIC_HOOK_TOKEN", "test-token")
|
|
.env("NUCLEIC_SESSION_ID", "sess-1")
|
|
.env_remove("NUCLEIC_SHELL_PARENT")
|
|
.status()
|
|
.unwrap()
|
|
}
|
|
|
|
fn find<'a>(
|
|
events: &'a [serde_json::Value],
|
|
kind: &str,
|
|
pred: impl Fn(&serde_json::Value) -> bool,
|
|
) -> Option<&'a serde_json::Value> {
|
|
events
|
|
.iter()
|
|
.find(|e| e["kind"] == kind && pred(e))
|
|
}
|
|
|
|
#[test]
|
|
fn exec_events_flow() {
|
|
let sink = Sink::start();
|
|
let status = run_nash(&sink, "echo observed; false");
|
|
assert_eq!(status.code(), Some(1));
|
|
|
|
let events = sink.events_until(|evs| {
|
|
find(evs, "exec", |e| e["argv"][0] == "echo").is_some()
|
|
&& find(evs, "exec", |e| e["argv"][0] == "false").is_some()
|
|
});
|
|
|
|
let echo = find(&events, "exec", |e| e["argv"][0] == "echo").expect("echo exec event");
|
|
assert_eq!(echo["argv"][1], "observed");
|
|
assert_eq!(echo["exitCode"], 0);
|
|
assert!(echo["cwd"].is_string());
|
|
assert!(echo["durationMs"].is_u64());
|
|
|
|
let false_ev = find(&events, "exec", |e| e["argv"][0] == "false").expect("false exec event");
|
|
assert_eq!(false_ev["exitCode"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn external_command_exit_codes() {
|
|
let sink = Sink::start();
|
|
let status = run_nash(&sink, "/bin/echo ext; /usr/bin/env true; sh -c 'exit 7'");
|
|
assert_eq!(status.code(), Some(7));
|
|
|
|
let events = sink.events_until(|evs| {
|
|
find(evs, "exec", |e| e["argv"][0] == "sh").is_some()
|
|
});
|
|
let ext = find(&events, "exec", |e| e["argv"][0] == "/bin/echo").expect("external exec event");
|
|
assert_eq!(ext["exitCode"], 0);
|
|
let sh = find(&events, "exec", |e| e["argv"][0] == "sh").expect("sh exec event");
|
|
assert_eq!(sh["exitCode"], 7);
|
|
}
|
|
|
|
#[test]
|
|
fn cd_and_export_events() {
|
|
let sink = Sink::start();
|
|
let status = run_nash(&sink, "cd /tmp && export NASH_E2E=1 OTHER=2 && unset OTHER");
|
|
assert_eq!(status.code(), Some(0));
|
|
|
|
let events = sink.events_until(|evs| {
|
|
find(evs, "cd", |_| true).is_some()
|
|
&& find(evs, "export", |e| e["op"] == "unset").is_some()
|
|
});
|
|
|
|
let cd = find(&events, "cd", |_| true).expect("cd event");
|
|
assert_eq!(cd["to"], "/tmp");
|
|
assert!(cd["from"].is_string());
|
|
|
|
let export = find(&events, "export", |e| e["op"] == "export").expect("export event");
|
|
let names: Vec<_> = export["names"].as_array().unwrap().iter().collect();
|
|
assert_eq!(names.len(), 2);
|
|
assert_eq!(export["names"][0], "NASH_E2E");
|
|
assert_eq!(export["names"][1], "OTHER");
|
|
// Names only — values must never appear anywhere in the event.
|
|
assert!(!serde_json::to_string(export).unwrap().contains('='));
|
|
|
|
let unset = find(&events, "export", |e| e["op"] == "unset").expect("unset event");
|
|
assert_eq!(unset["names"][0], "OTHER");
|
|
}
|
|
|
|
#[test]
|
|
fn pipeline_members_each_reported() {
|
|
let sink = Sink::start();
|
|
let status = run_nash(&sink, "printf 'a\\nb\\n' | wc -l | cat > /dev/null");
|
|
assert_eq!(status.code(), Some(0));
|
|
|
|
let events = sink.events_until(|evs| {
|
|
["printf", "wc", "cat"]
|
|
.iter()
|
|
.all(|c| find(evs, "exec", |e| &e["argv"][0] == c).is_some())
|
|
});
|
|
for cmd in ["printf", "wc", "cat"] {
|
|
let ev = find(&events, "exec", |e| &e["argv"][0] == cmd)
|
|
.unwrap_or_else(|| panic!("{cmd} exec event missing"));
|
|
assert_eq!(ev["exitCode"], 0, "{cmd} should exit 0");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn command_not_found_reported() {
|
|
let sink = Sink::start();
|
|
let status = run_nash(&sink, "definitely-not-a-command-xyz 2>/dev/null; true");
|
|
assert_eq!(status.code(), Some(0));
|
|
|
|
let events = sink.events_until(|evs| {
|
|
find(evs, "exec", |e| e["argv"][0] == "definitely-not-a-command-xyz").is_some()
|
|
});
|
|
let ev = find(&events, "exec", |e| e["argv"][0] == "definitely-not-a-command-xyz")
|
|
.expect("not-found exec event");
|
|
assert_eq!(ev["exitCode"], 127);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_failure_falls_back_to_bash() {
|
|
let sink = Sink::start();
|
|
let status = Command::new(env!("CARGO_BIN_EXE_nash"))
|
|
.args(["-c", "if then fi"])
|
|
.env("NUCLEIC_SHELL_SOCKET", sink.socket())
|
|
.env("NUCLEIC_HOOK_TOKEN", "test-token")
|
|
.env("NUCLEIC_SESSION_ID", "sess-1")
|
|
.env("NUCLEIC_REAL_BASH", "/bin/bash")
|
|
.env_remove("NUCLEIC_SHELL_PARENT")
|
|
.status()
|
|
.unwrap();
|
|
// bash also rejects this input; what matters is bash's diagnostic + code.
|
|
assert_eq!(status.code(), Some(2));
|
|
|
|
let events = sink.events_until(|evs| find(evs, "fallback", |_| true).is_some());
|
|
let fb = find(&events, "fallback", |_| true).expect("fallback event");
|
|
assert_eq!(fb["input"], "if then fi");
|
|
assert!(fb["reason"].as_str().unwrap().starts_with("parse-error"));
|
|
}
|
|
|
|
#[test]
|
|
fn silent_without_config() {
|
|
// No NUCLEIC_SHELL_* config: nash must run normally and post nothing.
|
|
let out = Command::new(env!("CARGO_BIN_EXE_nash"))
|
|
.args(["-c", "echo quiet"])
|
|
.env_remove("NUCLEIC_SHELL_SOCKET")
|
|
.env_remove("NUCLEIC_SHELL_HOOK_URL")
|
|
.env_remove("NUCLEIC_SHELL_SPOOL")
|
|
.output()
|
|
.unwrap();
|
|
assert_eq!(out.status.code(), Some(0));
|
|
assert_eq!(String::from_utf8_lossy(&out.stdout), "quiet\n");
|
|
}
|
|
|
|
#[test]
|
|
fn spool_fallback_when_socket_absent() {
|
|
let dir = std::env::temp_dir().join(format!("nash-spool-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
let status = Command::new(env!("CARGO_BIN_EXE_nash"))
|
|
.args(["-c", "echo spooled"])
|
|
.env("NUCLEIC_SHELL_SOCKET", "/nonexistent/control.sock")
|
|
.env("NUCLEIC_SHELL_SPOOL", &dir)
|
|
.env("NUCLEIC_SESSION_ID", "sess-1")
|
|
.env_remove("NUCLEIC_SHELL_PARENT")
|
|
.status()
|
|
.unwrap();
|
|
assert_eq!(status.code(), Some(0));
|
|
|
|
let mut found = false;
|
|
for entry in std::fs::read_dir(&dir).expect("spool dir created") {
|
|
let content = std::fs::read_to_string(entry.unwrap().path()).unwrap();
|
|
for line in content.lines() {
|
|
let batch: serde_json::Value = serde_json::from_str(line).unwrap();
|
|
if batch["events"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|e| e["kind"] == "exec" && e["argv"][0] == "echo")
|
|
{
|
|
found = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(found, "exec event spooled to disk");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|