From 5378311cdf4e6f1870daafa3654d23351eaac5bd Mon Sep 17 00:00:00 2001 From: Mavdol Date: Thu, 17 Sep 2026 13:19:23 +0200 Subject: [PATCH 01/11] add trace syteme --- crates/machine/src/trace/http.rs | 271 ++++++++++++++++++++++++++++++ crates/machine/src/trace/mod.rs | 274 +++++++++++++++++++++++++++++++ 2 files changed, 545 insertions(+) create mode 100644 crates/machine/src/trace/http.rs create mode 100644 crates/machine/src/trace/mod.rs diff --git a/crates/machine/src/trace/http.rs b/crates/machine/src/trace/http.rs new file mode 100644 index 00000000..66049867 --- /dev/null +++ b/crates/machine/src/trace/http.rs @@ -0,0 +1,271 @@ +use serde_json::Value; + +use super::Tracer; + +const MAX_HEAD_BYTES: usize = 16 * 1024; + +#[derive(Debug, PartialEq, Eq)] +pub struct HttpRequest { + pub method: String, + pub url: String, +} + +pub struct HttpRequests { + scheme: &'static str, + default_host: Option, + head: Vec, + body_bytes_left: u64, + stopped: bool, +} + +impl HttpRequests { + pub fn new(scheme: &'static str, default_host: Option) -> Self { + Self { + scheme, + default_host, + head: Vec::new(), + body_bytes_left: 0, + stopped: false, + } + } + + pub fn set_default_host(&mut self, host: &str) { + if self.default_host.is_none() { + self.default_host = Some(host.to_string()); + } + } + + pub fn observe(&mut self, bytes: &[u8]) -> Vec { + let mut requests = Vec::new(); + if self.stopped { + return requests; + } + + let skipped = self.body_bytes_left.min(bytes.len() as u64); + self.body_bytes_left -= skipped; + let bytes = &bytes[skipped as usize..]; + + let mut searched_to = self.head.len().saturating_sub(3); + self.head.extend_from_slice(bytes); + + while !self.stopped { + let Some(end) = find_head_end(&self.head, searched_to) else { + if self.head.len() > MAX_HEAD_BYTES { + self.stop(); + } + break; + }; + + let parsed = self.parse_head(&self.head[..end]); + self.head.drain(..end); + searched_to = 0; + + match parsed { + Some((request, Body::Length(length))) => { + requests.push(request); + let skipped = length.min(self.head.len() as u64); + self.head.drain(..skipped as usize); + self.body_bytes_left = length - skipped; + } + Some((request, Body::Undelimited)) => { + requests.push(request); + self.stop(); + } + None => self.stop(), + } + } + + requests + } + + fn stop(&mut self) { + self.stopped = true; + self.head = Vec::new(); + } + + fn parse_head(&self, head: &[u8]) -> Option<(HttpRequest, Body)> { + let text = std::str::from_utf8(head).ok()?; + let mut lines = text.split("\r\n"); + + let mut request_line = lines.next()?.split(' '); + let method = request_line.next()?; + let target = request_line.next()?; + let version = request_line.next()?; + if request_line.next().is_some() + || !version.starts_with("HTTP/1.") + || method.is_empty() + || !method.bytes().all(|byte| byte.is_ascii_uppercase()) + { + return None; + } + + let mut host = None; + let mut body = Body::Length(0); + for line in lines { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim(); + if name.eq_ignore_ascii_case("host") { + host = Some(value.to_string()); + } else if name.eq_ignore_ascii_case("content-length") { + body = Body::Length(value.parse().ok()?); + } else if name.eq_ignore_ascii_case("transfer-encoding") + || (name.eq_ignore_ascii_case("upgrade") && !value.is_empty()) + { + body = Body::Undelimited; + } + } + if method == "CONNECT" { + body = Body::Undelimited; + } + + let url = if target.starts_with("http://") || target.starts_with("https://") { + target.to_string() + } else { + let host = host.or_else(|| self.default_host.clone())?; + format!("{}://{host}{target}", self.scheme) + }; + + Some(( + HttpRequest { + method: method.to_string(), + url, + }, + body, + )) + } +} + +enum Body { + Length(u64), + Undelimited, +} + +fn find_head_end(buffer: &[u8], from: usize) -> Option { + buffer[from..] + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| from + position + 4) +} +pub struct HttpObserver { + tracer: Tracer, + requests: HttpRequests, + address: [u8; 4], + port: u16, +} + +impl HttpObserver { + pub fn new(tracer: Tracer, scheme: &'static str, address: [u8; 4], port: u16) -> Self { + Self { + tracer, + requests: HttpRequests::new(scheme, None), + address, + port, + } + } + + pub fn set_default_host(&mut self, host: &str) { + self.requests.set_default_host(host); + } + + pub fn observe(&mut self, bytes: &[u8]) { + for request in self.requests.observe(bytes) { + self.tracer.record( + "net.http", + &[ + ("protocol", Value::from(self.requests.scheme)), + ("method", request.method.into()), + ("url", request.url.into()), + ("address", super::format_address(self.address).into()), + ("port", self.port.into()), + ], + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn urls(requests: Vec) -> Vec { + requests + .into_iter() + .map(|request| format!("{} {}", request.method, request.url)) + .collect() + } + + #[test] + fn a_request_split_across_writes_is_seen_once() { + let mut requests = HttpRequests::new("https", None); + let wire = b"GET /simple/requests/ HTTP/1.1\r\nHost: pypi.org\r\nAccept: */*\r\n\r\n"; + + let mut seen = Vec::new(); + for byte in wire { + seen.extend(requests.observe(std::slice::from_ref(byte))); + } + + assert_eq!(urls(seen), ["GET https://pypi.org/simple/requests/"]); + } + + #[test] + fn keep_alive_requests_are_followed_past_their_bodies() { + let mut requests = HttpRequests::new("http", None); + let wire = b"POST /upload HTTP/1.1\r\nHost: example.com\r\nContent-Length: 11\r\n\r\n\ + hello worldGET /second HTTP/1.1\r\nHost: example.com\r\n\r\n"; + + assert_eq!( + urls(requests.observe(wire)), + [ + "POST http://example.com/upload", + "GET http://example.com/second" + ] + ); + } + + #[test] + fn a_chunked_body_ends_the_following() { + let mut requests = HttpRequests::new("http", None); + let wire = b"POST /a HTTP/1.1\r\nHost: h\r\nTransfer-Encoding: chunked\r\n\r\n\ + b\r\nGET /b HTTP/1.1\r\n\r\n0\r\n\r\n"; + + assert_eq!(urls(requests.observe(wire)), ["POST http://h/a"]); + assert!( + requests + .observe(b"GET /c HTTP/1.1\r\nHost: h\r\n\r\n") + .is_empty() + ); + } + + #[test] + fn the_default_host_fills_in_a_missing_host_header() { + let mut requests = HttpRequests::new("https", None); + requests.set_default_host("files.pythonhosted.org"); + + assert_eq!( + urls(requests.observe(b"GET /x.whl HTTP/1.0\r\n\r\n")), + ["GET https://files.pythonhosted.org/x.whl"] + ); + } + + #[test] + fn a_proxy_form_target_is_kept_as_given() { + let mut requests = HttpRequests::new("http", None); + assert_eq!( + urls(requests.observe(b"GET http://example.com/a HTTP/1.1\r\n\r\n")), + ["GET http://example.com/a"] + ); + } + + #[test] + fn non_http_bytes_stop_the_following() { + let mut requests = HttpRequests::new("http", None); + assert!(requests.observe(b"SSH-2.0-OpenSSH_9.6\r\n\r\n").is_empty()); + assert!( + requests + .observe(b"GET / HTTP/1.1\r\nHost: h\r\n\r\n") + .is_empty() + ); + } +} diff --git a/crates/machine/src/trace/mod.rs b/crates/machine/src/trace/mod.rs new file mode 100644 index 00000000..38d2092b --- /dev/null +++ b/crates/machine/src/trace/mod.rs @@ -0,0 +1,274 @@ +mod http; + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::Value; + +pub use http::{HttpObserver, HttpRequest, HttpRequests}; + +pub const SCHEMA_VERSION: u32 = 1; +pub const DEFAULT_BUFFER_BYTES: usize = 64 * 1024 * 1024; + +const MAX_REMEMBERED_NAMES: usize = 4096; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TraceOptions { + pub network: bool, + pub mounts: bool, + pub buffer_bytes: usize, +} + +impl Default for TraceOptions { + fn default() -> Self { + Self { + network: true, + mounts: true, + buffer_bytes: DEFAULT_BUFFER_BYTES, + } + } +} + +#[derive(Clone)] +pub struct Tracer { + options: TraceOptions, + recorder: Arc>, +} + +struct Recorder { + next_seq: u64, + guest_ns: u64, + lines: VecDeque>, + buffered_bytes: usize, + dropped: u64, + names_by_address: HashMap<[u8; 4], String>, +} + +impl Tracer { + pub fn new(options: TraceOptions) -> Self { + Self { + options, + recorder: Arc::new(Mutex::new(Recorder { + next_seq: 0, + guest_ns: 0, + lines: VecDeque::new(), + buffered_bytes: 0, + dropped: 0, + names_by_address: HashMap::new(), + })), + } + } + + pub fn options(&self) -> TraceOptions { + self.options + } + + pub fn traces_network(&self) -> bool { + self.options.network + } + + pub fn traces_mounts(&self) -> bool { + self.options.mounts + } + + fn recorder(&self) -> MutexGuard<'_, Recorder> { + self.recorder + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + pub fn set_guest_ns(&self, guest_ns: u64) { + self.recorder().guest_ns = guest_ns; + } + + pub fn guest_ns(&self) -> u64 { + self.recorder().guest_ns + } + + pub fn record(&self, kind: &str, fields: &[(&str, Value)]) { + let wall_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0); + + let mut recorder = self.recorder(); + let capacity = self.options.buffer_bytes; + + if recorder.dropped > 0 { + let count = recorder.dropped; + let marker = recorder.line("trace.dropped", wall_ms, &[("count", count.into())]); + + if recorder.buffered_bytes + marker.len() > capacity { + recorder.dropped += 1; + return; + } + recorder.dropped = 0; + recorder.push(marker); + } + + let line = recorder.line(kind, wall_ms, fields); + if recorder.buffered_bytes + line.len() > capacity { + recorder.dropped += 1; + return; + } + recorder.push(line); + } + + pub fn drain(&self, max_bytes: usize) -> Vec { + let mut recorder = self.recorder(); + let mut drained = Vec::new(); + + while let Some(line) = recorder.lines.front() { + if !drained.is_empty() && drained.len() + line.len() > max_bytes { + break; + } + let line = recorder.lines.pop_front().unwrap(); + recorder.buffered_bytes -= line.len(); + drained.extend_from_slice(&line); + } + + drained + } + + pub fn remember_name(&self, address: [u8; 4], name: &str) { + let mut recorder = self.recorder(); + if recorder.names_by_address.len() >= MAX_REMEMBERED_NAMES + && !recorder.names_by_address.contains_key(&address) + { + recorder.names_by_address.clear(); + } + recorder.names_by_address.insert(address, name.to_string()); + } + + pub fn name_of(&self, address: [u8; 4]) -> Option { + self.recorder().names_by_address.get(&address).cloned() + } +} + +impl Recorder { + fn line(&mut self, kind: &str, wall_ms: u64, fields: &[(&str, Value)]) -> Vec { + let seq = self.next_seq; + self.next_seq += 1; + + let mut line = format!( + "{{\"v\":{SCHEMA_VERSION},\"seq\":{seq},\"guest_ns\":{},\"wall_ms\":{wall_ms},\"kind\":{}", + self.guest_ns, + Value::from(kind), + ); + for (name, value) in fields { + line.push(','); + line.push_str(&Value::from(*name).to_string()); + line.push(':'); + line.push_str(&value.to_string()); + } + line.push_str("}\n"); + + line.into_bytes() + } + + fn push(&mut self, line: Vec) { + self.buffered_bytes += line.len(); + self.lines.push_back(line); + } +} + +pub fn format_address(address: [u8; 4]) -> String { + std::net::Ipv4Addr::from(address).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tracer_with_buffer(buffer_bytes: usize) -> Tracer { + Tracer::new(TraceOptions { + buffer_bytes, + ..TraceOptions::default() + }) + } + + fn drained_lines(tracer: &Tracer) -> Vec { + String::from_utf8(tracer.drain(usize::MAX)) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + + #[test] + fn a_line_carries_the_common_fields_then_its_own_in_order() { + let tracer = tracer_with_buffer(4096); + tracer.set_guest_ns(1_500); + tracer.record( + "net.dns", + &[("name", "pypi.org".into()), ("type", "A".into())], + ); + + let text = String::from_utf8(tracer.drain(usize::MAX)).unwrap(); + assert!( + text.starts_with("{\"v\":1,\"seq\":0,\"guest_ns\":1500,\"wall_ms\":"), + "{text}" + ); + assert!( + text.ends_with(",\"kind\":\"net.dns\",\"name\":\"pypi.org\",\"type\":\"A\"}\n"), + "{text}" + ); + } + + #[test] + fn drain_returns_whole_lines_and_keeps_the_rest() { + let tracer = tracer_with_buffer(4096); + for index in 0..3 { + tracer.record("mount.open", &[("index", index.into())]); + } + + let first_line_len = tracer.recorder().lines[0].len(); + let first = tracer.drain(first_line_len + 1); + assert_eq!(first.iter().filter(|&&byte| byte == b'\n').count(), 1); + + let rest = drained_lines(&tracer); + assert_eq!(rest.len(), 2); + assert_eq!(rest[0]["seq"], 1); + assert!(tracer.drain(usize::MAX).is_empty()); + } + + #[test] + fn a_line_longer_than_the_limit_is_still_drained() { + let tracer = tracer_with_buffer(4096); + tracer.record("net.http", &[("url", "x".repeat(200).into())]); + assert!(!tracer.drain(10).is_empty()); + } + + #[test] + fn a_full_buffer_counts_drops_and_says_so_once_there_is_room() { + let tracer = tracer_with_buffer(300); + for _ in 0..10 { + tracer.record("mount.open", &[("path", "/data/file".into())]); + } + let kept = drained_lines(&tracer); + assert!(kept.len() < 10); + + tracer.record("mount.open", &[("path", "/data/after".into())]); + let after = drained_lines(&tracer); + + assert_eq!(after[0]["kind"], "trace.dropped"); + assert_eq!(after[0]["count"], 10 - kept.len() as u64); + assert_eq!(after[1]["path"], "/data/after"); + assert_eq!( + after[1]["seq"].as_u64().unwrap(), + after[0]["seq"].as_u64().unwrap() + 1 + ); + } + + #[test] + fn names_are_remembered_by_address() { + let tracer = tracer_with_buffer(4096); + tracer.remember_name([151, 101, 0, 223], "pypi.org"); + assert_eq!( + tracer.name_of([151, 101, 0, 223]).as_deref(), + Some("pypi.org") + ); + assert_eq!(tracer.name_of([1, 1, 1, 1]), None); + } +} From bdba171efb920f7cf260cbd349bb7b25cc742e43 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Thu, 17 Sep 2026 14:23:42 +0200 Subject: [PATCH 02/11] add tracing support for sandbox sessions and virtual network components --- crates/machine/src/lib.rs | 1 + crates/machine/src/machine_bus.rs | 41 ++++ crates/machine/src/virtio/fs.rs | 261 ++++++++++++++++++++- crates/machine/src/virtio/https_gateway.rs | 34 +++ crates/machine/src/virtio/net.rs | 4 + crates/machine/src/virtio/slirp/dns.rs | 130 ++++++++++ crates/machine/src/virtio/slirp/mod.rs | 65 ++++- crates/machine/src/virtio/slirp/tcp.rs | 121 ++++++++-- crates/machine/src/virtio/slirp/udp.rs | 16 ++ crates/machine/src/virtio/tls_proxy/mod.rs | 19 ++ crates/wasi-component/src/api/executor.rs | 14 +- crates/wasi-component/src/api/session.rs | 48 +++- crates/wasi-component/src/vm.rs | 2 + crates/wasi-component/vpod.wit | 12 + 14 files changed, 746 insertions(+), 22 deletions(-) diff --git a/crates/machine/src/lib.rs b/crates/machine/src/lib.rs index 67a27b9b..e32f0b35 100644 --- a/crates/machine/src/lib.rs +++ b/crates/machine/src/lib.rs @@ -5,6 +5,7 @@ pub mod instance; pub mod machine_bus; pub mod plic; pub mod snapshot; +pub mod trace; pub mod uart; pub mod virtio; diff --git a/crates/machine/src/machine_bus.rs b/crates/machine/src/machine_bus.rs index 881e24b3..90de3639 100644 --- a/crates/machine/src/machine_bus.rs +++ b/crates/machine/src/machine_bus.rs @@ -4,6 +4,7 @@ use crate::clint::{CLINT_BASE, CLINT_SIZE, Clint, TIMER_FREQUENCY}; use crate::cow_ram::CowRam; use crate::dtb; use crate::plic::{PLIC_BASE, PLIC_SIZE, Plic}; +use crate::trace::{TraceOptions, Tracer}; use crate::uart::Uart; use crate::virtio::RamView; use crate::virtio::blk::VirtioBlk; @@ -36,6 +37,7 @@ pub struct MachineBus { pub console: VirtioConsole, pub net: Option>, pub fs_devices: Vec, + tracer: Option, } impl MachineBus { @@ -59,6 +61,7 @@ impl MachineBus { console: VirtioConsole::new(), net: None, fs_devices: Vec::new(), + tracer: None, } } @@ -92,6 +95,40 @@ impl MachineBus { .collect(); } + pub fn start_trace(&mut self, options: TraceOptions) { + let tracer = Tracer::new(options); + tracer.set_guest_ns(self.guest_ns()); + self.attach_tracer(Some(tracer)); + } + + pub fn stop_trace(&mut self) { + self.attach_tracer(None); + } + + pub fn tracer(&self) -> Option<&Tracer> { + self.tracer.as_ref() + } + + fn attach_tracer(&mut self, tracer: Option) { + if let Some(network_device) = &mut self.net { + network_device.backend_mut().set_tracer(tracer.clone()); + } + for fs_device in &mut self.fs_devices { + fs_device.set_tracer(tracer.clone()); + } + self.tracer = tracer; + } + + fn guest_ns(&self) -> u64 { + self.clint.mtime() * (1_000_000_000 / TIMER_FREQUENCY) + } + + fn sync_trace_clock(&self) { + if let Some(tracer) = &self.tracer { + tracer.set_guest_ns(self.guest_ns()); + } + } + pub fn ram_size(&self) -> u64 { self.ram.len() as u64 } @@ -127,6 +164,8 @@ impl MachineBus { hart.csr.mip &= !MIP_MSIP; } + self.sync_trace_clock(); + if let Some(network_device) = &mut self.net { let mask = self.ram_mask; let mut ram = RamView::new(&mut self.ram, mask); @@ -438,6 +477,8 @@ impl SystemBus for MachineBus { }; if let Some(queue_index) = notify_queue_index { + self.sync_trace_clock(); + let mask = self.ram_mask; let mut ram = RamView::new(&mut self.ram, mask); diff --git a/crates/machine/src/virtio/fs.rs b/crates/machine/src/virtio/fs.rs index 2532d31b..148fe724 100644 --- a/crates/machine/src/virtio/fs.rs +++ b/crates/machine/src/virtio/fs.rs @@ -5,7 +5,10 @@ use std::fs; use std::io::{Read, Seek, Write}; use std::path::{Path, PathBuf}; +use serde_json::Value; + use super::{RAM_BASE, RamView, VRING_DESC_F_NEXT, VRING_DESC_F_WRITE, VirtioMmio}; +use crate::trace::Tracer; const DEVICE_ID: u32 = 26; // VIRTIO_DEVICE_ID_FS const VIRTIO_F_VERSION_1: u64 = 1u64 << 32; @@ -53,6 +56,11 @@ const FATTR_SIZE: u32 = 1 << 3; const FUSE_ROOT_ID: u64 = 1; +const O_ACCMODE: u32 = 0o3; +const O_WRONLY: u32 = 0o1; +const O_RDWR: u32 = 0o2; +const O_TRUNC: u32 = 0o1000; + // #[repr(C)] struct FuseInHeader { _len: u32, @@ -61,7 +69,7 @@ struct FuseInHeader { nodeid: u64, _uid: u32, _gid: u32, - _pid: u32, + pid: u32, _padding: u32, } @@ -89,6 +97,27 @@ pub struct VirtioFs { file_handles: HashMap, next_fh: u64, mounts: Vec, + tracer: Option, + guest_root: String, + traced_handles: HashMap, +} + +enum MountRequest { + Open { path: String, flags: u32 }, + Read { handle: u64 }, + Write { handle: u64 }, + Release { handle: u64 }, + Create { path: String }, + Mkdir { path: String }, + Delete { path: String, directory: bool }, + Rename { from: String, to: String }, + Truncate { path: String, size: u64 }, +} + +struct TracedHandle { + path: String, + bytes_read: u64, + bytes_written: u64, } impl VirtioFs { @@ -100,6 +129,9 @@ impl VirtioFs { file_handles: HashMap::new(), next_fh: 1, mounts, + tracer: None, + guest_root: String::new(), + traced_handles: HashMap::new(), }; let tag = b"virtiofs"; @@ -117,6 +149,9 @@ impl VirtioFs { file_handles: HashMap::new(), next_fh: 1, mounts: vec![mount], + tracer: None, + guest_root: String::new(), + traced_handles: HashMap::new(), }; let tag_bytes = tag.as_bytes(); @@ -131,6 +166,15 @@ impl VirtioFs { self.mounts = mounts; } + pub fn set_guest_root(&mut self, guest_root: &str) { + self.guest_root = guest_root.trim_end_matches('/').to_string(); + } + + pub fn set_tracer(&mut self, tracer: Option) { + self.tracer = tracer.filter(|tracer| tracer.traces_mounts()); + self.traced_handles.clear(); + } + fn root_path(&self) -> Option<&Path> { self.mounts.first().and_then(|m| { if m.host_path.as_os_str().is_empty() { @@ -191,7 +235,7 @@ impl VirtioFs { nodeid: ram.read_u64(header_addr + 16), _uid: ram.read_u32(header_addr + 24), _gid: ram.read_u32(header_addr + 28), - _pid: ram.read_u32(header_addr + 32), + pid: ram.read_u32(header_addr + 32), _padding: ram.read_u32(header_addr + 36), }; @@ -216,6 +260,11 @@ impl VirtioFs { } }; + let traced_request = match self.tracer { + Some(_) => self.describe_request(&header, ram, in_body_addr, in_body_len), + None => None, + }; + let used_len = match header.opcode { FUSE_INIT => self.init(&header, out_addr, out_len, ram), FUSE_LOOKUP => self.lookup(&header, ram, in_body_addr, in_body_len, out_addr, out_len), @@ -242,6 +291,12 @@ impl VirtioFs { _ => self.reply_error(&header, ENOSYS, out_addr, ram), }; + if let Some(request) = traced_request + && used_len >= 16 + { + self.record_request(request, &header, ram, out_addr, used_len); + } + if needs_scatter && used_len > 0 { let mut src_offset = 0u64; for &(buf_addr, buf_len) in &write_bufs { @@ -263,6 +318,208 @@ impl VirtioFs { used_len } + fn guest_path_of_node(&self, nodeid: u64) -> Option { + if nodeid == FUSE_ROOT_ID { + return Some(if self.guest_root.is_empty() { + "/".to_string() + } else { + self.guest_root.clone() + }); + } + + let host_path = &self.inodes.get(&nodeid)?.path; + let relative = host_path.strip_prefix(self.root_path()?).ok()?; + Some(format!( + "{}/{}", + self.guest_root, + relative.to_string_lossy() + )) + } + + fn guest_path_of_child(&self, parent: u64, name: &str) -> Option { + let parent = self.guest_path_of_node(parent)?; + Some(format!("{}/{name}", parent.trim_end_matches('/'))) + } + + fn describe_request( + &self, + header: &FuseInHeader, + ram: &RamView, + in_body_addr: u64, + in_body_len: u32, + ) -> Option { + let name_at = |offset: u32| { + self.read_cstring( + ram, + in_body_addr + offset as u64, + in_body_len.saturating_sub(offset), + ) + }; + + Some(match header.opcode { + FUSE_OPEN => MountRequest::Open { + path: self.guest_path_of_node(header.nodeid)?, + flags: ram.read_u32(in_body_addr), + }, + FUSE_READ => MountRequest::Read { + handle: ram.read_u64(in_body_addr), + }, + FUSE_WRITE => MountRequest::Write { + handle: ram.read_u64(in_body_addr), + }, + FUSE_RELEASE => MountRequest::Release { + handle: ram.read_u64(in_body_addr), + }, + FUSE_CREATE => MountRequest::Create { + path: self.guest_path_of_child(header.nodeid, &name_at(16))?, + }, + FUSE_MKDIR => MountRequest::Mkdir { + path: self.guest_path_of_child(header.nodeid, &name_at(8))?, + }, + FUSE_UNLINK | FUSE_RMDIR => MountRequest::Delete { + path: self.guest_path_of_child(header.nodeid, &name_at(0))?, + directory: header.opcode == FUSE_RMDIR, + }, + FUSE_RENAME | FUSE_RENAME2 => { + let names_at = if header.opcode == FUSE_RENAME2 { 12 } else { 8 }; + let from_name = name_at(names_at); + let to_name = name_at(names_at + from_name.len() as u32 + 1); + MountRequest::Rename { + from: self.guest_path_of_child(header.nodeid, &from_name)?, + to: self.guest_path_of_child(ram.read_u64(in_body_addr), &to_name)?, + } + } + FUSE_SETATTR if ram.read_u32(in_body_addr) & FATTR_SIZE != 0 => { + MountRequest::Truncate { + path: self.guest_path_of_node(header.nodeid)?, + size: ram.read_u64(in_body_addr + 16), + } + } + _ => return None, + }) + } + + fn record_request( + &mut self, + request: MountRequest, + header: &FuseInHeader, + ram: &RamView, + out_addr: u64, + used_len: u32, + ) { + let Some(tracer) = self.tracer.clone() else { + return; + }; + let result = ram.read_u32(out_addr + 4) as i32; + let succeeded = result == 0; + let pid = Value::from(header.pid); + + match request { + MountRequest::Read { handle } => { + if succeeded && let Some(traced) = self.traced_handles.get_mut(&handle) { + traced.bytes_read += (used_len - 16) as u64; + } + } + MountRequest::Write { handle } => { + if succeeded && let Some(traced) = self.traced_handles.get_mut(&handle) { + traced.bytes_written += ram.read_u32(out_addr + 16) as u64; + } + } + MountRequest::Release { handle } => { + if let Some(traced) = self.traced_handles.remove(&handle) { + tracer.record( + "mount.close", + &[ + ("pid", pid), + ("path", traced.path.into()), + ("bytes_read", traced.bytes_read.into()), + ("bytes_written", traced.bytes_written.into()), + ], + ); + } + } + MountRequest::Open { path, flags } => { + let access = match flags & O_ACCMODE { + O_WRONLY => "write", + O_RDWR => "read-write", + _ => "read", + }; + if succeeded { + self.track_handle(ram.read_u64(out_addr + 16), &path); + } + tracer.record( + "mount.open", + &[ + ("pid", pid), + ("path", path.into()), + ("access", access.into()), + ("truncate", (flags & O_TRUNC != 0).into()), + ("result", result.into()), + ], + ); + } + MountRequest::Create { path } => { + if succeeded { + self.track_handle(ram.read_u64(out_addr + 16 + 128), &path); + } + tracer.record( + "mount.create", + &[ + ("pid", pid), + ("path", path.into()), + ("result", result.into()), + ], + ); + } + MountRequest::Mkdir { path } => tracer.record( + "mount.mkdir", + &[ + ("pid", pid), + ("path", path.into()), + ("result", result.into()), + ], + ), + MountRequest::Delete { path, directory } => tracer.record( + "mount.delete", + &[ + ("pid", pid), + ("path", path.into()), + ("directory", directory.into()), + ("result", result.into()), + ], + ), + MountRequest::Rename { from, to } => tracer.record( + "mount.rename", + &[ + ("pid", pid), + ("from", from.into()), + ("to", to.into()), + ("result", result.into()), + ], + ), + MountRequest::Truncate { path, size } => tracer.record( + "mount.truncate", + &[ + ("pid", pid), + ("path", path.into()), + ("size", size.into()), + ("result", result.into()), + ], + ), + } + } + + fn track_handle(&mut self, handle: u64, path: &str) { + self.traced_handles.insert( + handle, + TracedHandle { + path: path.to_string(), + bytes_read: 0, + bytes_written: 0, + }, + ); + } + fn init(&self, header: &FuseInHeader, out_addr: u64, _out_len: u32, ram: &mut RamView) -> u32 { let out_header_size = 16u32; let init_out_size = 64u32; diff --git a/crates/machine/src/virtio/https_gateway.rs b/crates/machine/src/virtio/https_gateway.rs index daaa4be7..03443215 100644 --- a/crates/machine/src/virtio/https_gateway.rs +++ b/crates/machine/src/virtio/https_gateway.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use super::tls_proxy::{Timing, TlsContext, TlsProxy}; use super::upstream::{PREAMBLE_PREFIX, Upstream, UpstreamMode, UpstreamStatus}; +use crate::trace::{HttpObserver, Tracer}; const PREAMBLE_MAX: usize = 280; @@ -23,6 +24,7 @@ pub struct HttpsGateway { upstream_config: Arc, dst_ip: [u8; 4], timing: Option, + tracer: Option, } impl HttpsGateway { @@ -33,6 +35,19 @@ impl HttpsGateway { upstream_config: ctx.upstream_config(), dst_ip, timing: Timing::new(), + tracer: None, + } + } + + pub fn observe_http(&mut self, tracer: Tracer) { + self.tracer = Some(tracer); + } + + pub fn server_name(&self) -> Option<&str> { + match &self.state { + GatewayState::Tls(proxy) => proxy.server_name(), + GatewayState::Plain(bridge) => Some(&bridge.host), + _ => None, } } @@ -44,6 +59,7 @@ impl HttpsGateway { upstream_config, dst_ip, timing: None, + tracer: None, } } @@ -115,6 +131,9 @@ impl HttpsGateway { match TlsProxy::with_timing(&self.ctx, self.dst_ip, self.timing.take()) { Ok(mut proxy) => { + if let Some(tracer) = self.tracer.take() { + proxy.observe_http(HttpObserver::new(tracer, "https", self.dst_ip, 443)); + } proxy.push_from_guest(&buffered); self.state = GatewayState::Tls(Box::new(proxy)); } @@ -162,9 +181,16 @@ impl HttpsGateway { self.timing.take(), ) { Ok(mut bridge) => { + if let Some(tracer) = self.tracer.take() { + let mut http = HttpObserver::new(tracer, "https", self.dst_ip, port); + http.set_default_host(&host); + bridge.http = Some(http); + } + if !remainder.is_empty() { bridge.push_from_guest(remainder); } + self.state = GatewayState::Plain(Box::new(bridge)); } Err(e) => { @@ -188,6 +214,8 @@ fn parse_preamble(line: &[u8]) -> Option<(String, u16)> { struct PlainBridge { upstream: Upstream, + host: String, + http: Option, to_guest: VecDeque, failed: bool, upstream_closed: bool, @@ -210,6 +238,8 @@ impl PlainBridge { let bridge = Self { upstream, + host: host.to_string(), + http: None, to_guest: VecDeque::new(), failed: false, upstream_closed: false, @@ -225,6 +255,10 @@ impl PlainBridge { } fn push_from_guest(&mut self, bytes: &[u8]) { + if let Some(http) = &mut self.http { + http.observe(bytes); + } + if self.upstream.send_plaintext(bytes).is_err() { self.failed = true; return; diff --git a/crates/machine/src/virtio/net.rs b/crates/machine/src/virtio/net.rs index be5891c2..bcc16427 100644 --- a/crates/machine/src/virtio/net.rs +++ b/crates/machine/src/virtio/net.rs @@ -37,6 +37,10 @@ impl VirtioNet { } } + pub fn backend_mut(&mut self) -> &mut B { + &mut self.backend + } + pub fn rx_pending(&self) -> bool { !self.rx_hold.is_empty() || self.backend.has_rx() } diff --git a/crates/machine/src/virtio/slirp/dns.rs b/crates/machine/src/virtio/slirp/dns.rs index 07a6038c..b0a8a2b4 100644 --- a/crates/machine/src/virtio/slirp/dns.rs +++ b/crates/machine/src/virtio/slirp/dns.rs @@ -4,14 +4,18 @@ use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, ToSocketAddrs, UdpSocket}; use std::time::{Duration, Instant}; +use serde_json::Value; + use super::SlirpBackend; use super::frames::{GW_IP, IP_PROTO_UDP, make_ip_frame, make_udp_payload}; +use crate::trace::{Tracer, format_address}; const RELAY_TIMEOUT: Duration = Duration::from_secs(5); const QTYPE_A: u16 = 1; const QTYPE_AAAA: u16 = 28; const RCODE_NOERROR: u16 = 0; const RCODE_SERVFAIL: u16 = 2; +const RCODE_NXDOMAIN: u16 = 3; const MAX_ANSWERS: usize = 4; const UPSTREAM_SERVERS: [[u8; 4]; 3] = [ @@ -44,6 +48,7 @@ impl SlirpBackend { src_port: u16, ) { if let Some(reply) = answer_from_host(query) { + trace_reply(self.tracer.as_ref(), &reply); self.reply_to_guest(&guest_mac, &src_ip, src_port, &reply); return; } @@ -54,6 +59,7 @@ impl SlirpBackend { if let Some(question) = parse_question(query) { let servfail = build_reply(query, &question, &[], RCODE_SERVFAIL); + trace_reply(self.tracer.as_ref(), &servfail); self.reply_to_guest(&guest_mac, &src_ip, src_port, &servfail); } } @@ -65,6 +71,7 @@ impl SlirpBackend { let mut buf = [0u8; 2048]; if let Ok((n, _)) = request.sock.recv_from(&mut buf) { + trace_reply(self.tracer.as_ref(), &buf[..n]); let reply = make_udp_payload(53, request.src_port, &buf[..n]); self.rx_pending.push_back(make_ip_frame( &request.guest_mac, @@ -80,6 +87,7 @@ impl SlirpBackend { if request.created.elapsed() > RELAY_TIMEOUT { if let Some(question) = parse_question(&request.query) { let servfail = build_reply(&request.query, &question, &[], RCODE_SERVFAIL); + trace_reply(self.tracer.as_ref(), &servfail); let reply = make_udp_payload(53, request.src_port, &servfail); self.rx_pending.push_back(make_ip_frame( &request.guest_mac, @@ -193,6 +201,85 @@ fn parse_question(query: &[u8]) -> Option { }) } +fn ipv4_answers(reply: &[u8], question_end: usize) -> Vec<[u8; 4]> { + let answer_count = u16::from_be_bytes([reply[6], reply[7]]); + let mut position = question_end; + let mut answers = Vec::new(); + + for _ in 0..answer_count { + loop { + let Some(&label_len) = reply.get(position) else { + return answers; + }; + if label_len & 0xC0 == 0xC0 { + position += 2; + break; + } + position += 1 + label_len as usize; + if label_len == 0 { + break; + } + } + + let Some(fixed) = reply.get(position..position + 10) else { + return answers; + }; + let record_type = u16::from_be_bytes([fixed[0], fixed[1]]); + let data_len = u16::from_be_bytes([fixed[8], fixed[9]]) as usize; + position += 10; + + let Some(data) = reply.get(position..position + data_len) else { + return answers; + }; + if record_type == QTYPE_A && data_len == 4 { + answers.push(data.try_into().unwrap()); + } + position += data_len; + } + + answers +} + +fn trace_reply(tracer: Option<&Tracer>, reply: &[u8]) { + let Some(tracer) = tracer.filter(|tracer| tracer.traces_network()) else { + return; + }; + let Some(question) = parse_question(reply) else { + return; + }; + + let answers = ipv4_answers(reply, question.question_end); + for address in &answers { + tracer.remember_name(*address, &question.hostname); + } + + let record_type = match question.qtype { + QTYPE_A => Value::from("A"), + QTYPE_AAAA => Value::from("AAAA"), + other => Value::from(other), + }; + let mut fields = vec![ + ("name", Value::from(question.hostname)), + ("type", record_type), + ( + "answers", + answers + .into_iter() + .map(format_address) + .collect::>() + .into(), + ), + ]; + match u16::from_be_bytes([reply[2], reply[3]]) & 0x000F { + RCODE_NOERROR => {} + RCODE_NXDOMAIN => fields.push(("error", "nxdomain".into())), + RCODE_SERVFAIL => fields.push(("error", "servfail".into())), + other => fields.push(("error", format!("rcode {other}").into())), + } + + tracer.record("net.dns", &fields); +} + fn answer_from_host(query: &[u8]) -> Option> { let question = parse_question(query)?; @@ -304,6 +391,49 @@ mod tests { assert_eq!(u16::from_be_bytes([reply[6], reply[7]]), 0); } + #[test] + fn answers_are_read_back_from_a_reply_and_named_in_the_trace() { + let query = example_com_query(QTYPE_A); + let question = parse_question(&query).unwrap(); + let reply = build_reply( + &query, + &question, + &[[93, 184, 215, 14], [93, 184, 215, 15]], + RCODE_NOERROR, + ); + assert_eq!( + ipv4_answers(&reply, question.question_end), + [[93, 184, 215, 14], [93, 184, 215, 15]] + ); + + let tracer = Tracer::new(crate::trace::TraceOptions::default()); + trace_reply(Some(&tracer), &reply); + + let line: Value = serde_json::from_slice(&tracer.drain(usize::MAX)).unwrap(); + assert_eq!(line["kind"], "net.dns"); + assert_eq!(line["name"], "example.com"); + assert_eq!(line["answers"][1], "93.184.215.15"); + assert!(line.get("error").is_none()); + assert_eq!( + tracer.name_of([93, 184, 215, 14]).as_deref(), + Some("example.com") + ); + } + + #[test] + fn a_failed_lookup_is_traced_with_its_error() { + let query = example_com_query(QTYPE_A); + let question = parse_question(&query).unwrap(); + let servfail = build_reply(&query, &question, &[], RCODE_SERVFAIL); + + let tracer = Tracer::new(crate::trace::TraceOptions::default()); + trace_reply(Some(&tracer), &servfail); + + let line: Value = serde_json::from_slice(&tracer.drain(usize::MAX)).unwrap(); + assert_eq!(line["error"], "servfail"); + assert_eq!(line["answers"], serde_json::json!([])); + } + #[test] fn aaaa_query_gets_empty_noerror_without_touching_resolver() { let query = example_com_query(QTYPE_AAAA); diff --git a/crates/machine/src/virtio/slirp/mod.rs b/crates/machine/src/virtio/slirp/mod.rs index d4bce266..ecede456 100644 --- a/crates/machine/src/virtio/slirp/mod.rs +++ b/crates/machine/src/virtio/slirp/mod.rs @@ -18,6 +18,7 @@ use udp::{UdpConn, UdpKey}; use super::net::NetworkBackend; use super::tls_proxy::TlsContext; +use crate::trace::Tracer; pub struct SlirpBackend { guest_mac: [u8; 6], @@ -27,6 +28,7 @@ pub struct SlirpBackend { dns_pending: Vec, dhcp_xid: u32, tls: Option, + tracer: Option, } impl SlirpBackend { @@ -47,9 +49,15 @@ impl SlirpBackend { dns_pending: Vec::new(), dhcp_xid: 0, tls, + tracer: None, } } + /// Connections opened from here on are traced; open ones are not. + pub fn set_tracer(&mut self, tracer: Option) { + self.tracer = tracer; + } + fn handle_ip(&mut self, frame: &[u8]) { if frame.len() < 14 + 20 { return; @@ -172,7 +180,7 @@ impl NetworkBackend for SlirpBackend { #[cfg(test)] mod tests { - use super::frames::{ACK, Endpoints, FIN, GUEST_IP, SYN, make_tcp_frame}; + use super::frames::{ACK, Endpoints, FIN, GUEST_IP, RST, SYN, make_tcp_frame}; use super::*; use std::io::{Read, Write}; use std::time::{Duration, Instant}; @@ -299,6 +307,61 @@ mod tests { ); } + #[test] + fn a_traced_connection_records_its_request_and_its_flow() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + + let guest_mac = [0x02, 0, 0, 0, 0, 0x19]; + let mut slirp = SlirpBackend::new(guest_mac); + let tracer = crate::trace::Tracer::new(crate::trace::TraceOptions::default()); + tracer.remember_name([127, 0, 0, 1], "local.test"); + slirp.set_tracer(Some(tracer.clone())); + + let ends = from_guest(guest_mac, [127, 0, 0, 1], 45005, port); + let guest_isn = 5000u32; + let (mut upstream, ack) = connect(&mut slirp, &listener, &ends, guest_isn); + + let request = b"GET /simple/ HTTP/1.1\r\nHost: local.test\r\n\r\n"; + slirp.send(&make_tcp_frame( + &ends, + guest_isn.wrapping_add(1), + ack, + ACK, + request, + )); + assert_eq!( + read_upstream(&mut upstream, &mut slirp, request.len()), + request + ); + + slirp.send(&make_tcp_frame( + &ends, + guest_isn.wrapping_add(1), + ack, + RST, + &[], + )); + + let events: Vec = String::from_utf8(tracer.drain(usize::MAX)) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let kinds: Vec<&str> = events + .iter() + .map(|event| event["kind"].as_str().unwrap()) + .collect(); + assert_eq!(kinds, ["net.http", "net.flow"]); + + assert_eq!(events[0]["method"], "GET"); + assert_eq!(events[0]["url"], "http://local.test/simple/"); + assert_eq!(events[1]["host"], "local.test"); + assert_eq!(events[1]["port"], port); + assert_eq!(events[1]["bytes_out"], request.len()); + assert_eq!(events[1]["failed"], false); + } + #[test] fn a_guest_fin_becomes_an_upstream_eof() { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/crates/machine/src/virtio/slirp/tcp.rs b/crates/machine/src/virtio/slirp/tcp.rs index 503879fa..3ccb15a0 100644 --- a/crates/machine/src/virtio/slirp/tcp.rs +++ b/crates/machine/src/virtio/slirp/tcp.rs @@ -12,6 +12,7 @@ use super::frames::{ ACK, Endpoints, FIN, PSH, RST, SYN, eth_src, ip_dst, ip_payload, ip_src, make_tcp_frame, u16be, would_block, }; +use crate::trace::{HttpObserver, Tracer, format_address}; use crate::virtio::https_gateway::HttpsGateway; const HTTPS_PORT: u16 = 443; @@ -79,6 +80,53 @@ pub(super) enum TcpState { Closed, } +pub(super) struct FlowTrace { + tracer: Tracer, + address: [u8; 4], + port: u16, + host: Option, + opened_guest_ns: u64, + bytes_out: u64, + bytes_in: u64, + failed: bool, + http: Option, +} + +impl FlowTrace { + fn new(tracer: &Tracer, address: [u8; 4], port: u16, plaintext: bool) -> Self { + Self { + tracer: tracer.clone(), + address, + port, + host: tracer.name_of(address), + opened_guest_ns: tracer.guest_ns(), + bytes_out: 0, + bytes_in: 0, + failed: false, + http: plaintext.then(|| HttpObserver::new(tracer.clone(), "http", address, port)), + } + } +} + +impl Drop for FlowTrace { + fn drop(&mut self) { + let duration_ns = self.tracer.guest_ns().saturating_sub(self.opened_guest_ns); + self.tracer.record( + "net.flow", + &[ + ("protocol", "tcp".into()), + ("address", format_address(self.address).into()), + ("port", self.port.into()), + ("host", self.host.clone().into()), + ("bytes_out", self.bytes_out.into()), + ("bytes_in", self.bytes_in.into()), + ("duration_ns", duration_ns.into()), + ("failed", self.failed.into()), + ], + ); + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(super) struct TcpKey { pub src_port: u16, @@ -110,6 +158,8 @@ pub(super) struct TcpConn { wnd_shift: u8, ooo_buf: BTreeMap>, + + trace: Option, } impl TcpConn { @@ -125,9 +175,14 @@ impl TcpConn { self.snd_nxt.wrapping_sub(self.snd_una) } - /// Both directions, plus whatever the guest is owed. `Finished` once the - /// connection can be dropped. pub(super) fn service(&mut self, frames: &mut Vec>) -> Serviced { + if let (Some(trace), Transport::Https(gateway)) = (&mut self.trace, &self.transport) + && let Some(server_name) = gateway.server_name() + && trace.host.as_deref() != Some(server_name) + { + trace.host = Some(server_name.to_string()); + } + match self.state { TcpState::Established => { let mut serviced = Serviced::Alive; @@ -156,7 +211,6 @@ impl TcpConn { } } - /// False once the upstream can no longer be written to. fn forward_guest_writes(&mut self) -> bool { while !self.write_buf.is_empty() { let (front, back) = self.write_buf.as_slices(); @@ -164,18 +218,25 @@ impl TcpConn { match self.transport.write(pending) { Ok(written) => { + if let Some(trace) = &mut self.trace { + trace.bytes_out += written as u64; + if let Some(http) = &mut trace.http { + http.observe(&pending[..written]); + } + } self.write_buf.drain(..written); } Err(e) if would_block(&e) => return true, - Err(_) => return false, + Err(_) => { + self.mark_failed(); + return false; + } } } true } - /// Held until the guest's own writes have drained, because shutting the - /// write side down discards whatever is still queued. fn forward_half_close(&mut self) { if self.guest_finished_writing && self.write_buf.is_empty() { self.guest_finished_writing = false; @@ -183,7 +244,6 @@ impl TcpConn { } } - /// False once the upstream failed, after telling the guest. fn read_upstream(&mut self, frames: &mut Vec>) -> bool { let mut buf = [0u8; 16384]; @@ -197,6 +257,9 @@ impl TcpConn { return true; } Ok(n) => { + if let Some(trace) = &mut self.trace { + trace.bytes_in += n as u64; + } self.snd_buf.extend(&buf[..n]); if self.snd_buf.len() >= MAX_SND_BUF { return true; @@ -204,6 +267,7 @@ impl TcpConn { } Err(e) if would_block(&e) => return true, Err(_) => { + self.mark_failed(); frames.push(self.frame(RST | ACK, &[])); return false; } @@ -211,6 +275,12 @@ impl TcpConn { } } + fn mark_failed(&mut self) { + if let Some(trace) = &mut self.trace { + trace.failed = true; + } + } + fn drain_snd_buf(&mut self, frames: &mut Vec>) { loop { let can_send = self.rcv_wnd.saturating_sub(self.in_flight()) as usize; @@ -232,8 +302,6 @@ impl TcpConn { self.fin_sent = true; } - /// The FIN owed to the guest, once the reply ahead of it has been sent and - /// acknowledged. fn send_fin_if_due(&mut self, frames: &mut Vec>) { if self.fin_sent || !self.snd_buf.is_empty() || self.in_flight() != 0 { return; @@ -247,9 +315,6 @@ impl TcpConn { self.rcv_wnd = (window as u32) << self.wnd_shift; } - /// Only our own FIN being acknowledged closes the connection. A guest - /// acknowledging the last of the reply must not, or the FIN it is still - /// owed never goes out. fn on_guest_ack(&mut self, ack: u32) { if (ack.wrapping_sub(self.snd_una) as i32) > 0 { self.snd_una = ack; @@ -281,8 +346,6 @@ impl TcpConn { } } - /// The guest will not write again. This says nothing about the FIN we owe - /// it, which may still be queued behind a reply. fn on_guest_fin(&mut self) { self.rcv_nxt = self.rcv_nxt.wrapping_add(1); @@ -424,6 +487,14 @@ impl SlirpBackend { } let Some(transport) = self.connect(&to_guest) else { + if let Some(tracer) = self + .tracer + .as_ref() + .filter(|tracer| tracer.traces_network()) + { + let mut refused = FlowTrace::new(tracer, to_guest.src_ip, to_guest.src_port, false); + refused.failed = true; + } self.rx_pending.push_back(make_tcp_frame( &to_guest, 0, @@ -434,6 +505,15 @@ impl SlirpBackend { return; }; + let trace = self + .tracer + .as_ref() + .filter(|tracer| tracer.traces_network()) + .map(|tracer| { + let plaintext = matches!(transport, Transport::Raw(_)); + FlowTrace::new(tracer, to_guest.src_ip, to_guest.src_port, plaintext) + }); + let wnd_shift = parse_wnd_scale(payload, tcp_hlen); let isn_host = generate_isn(&to_guest); @@ -461,18 +541,25 @@ impl SlirpBackend { rcv_wnd: (window as u32) << wnd_shift, wnd_shift, ooo_buf: BTreeMap::new(), + trace, }, ); } - /// `:443` goes through the gateway, which terminates the guest's TLS. - /// Everything else is an ordinary outbound socket. fn connect(&self, to_guest: &Endpoints) -> Option { let host_ip = to_guest.src_ip; let host_port = to_guest.src_port; if let Some(ctx) = self.tls.as_ref().filter(|_| host_port == HTTPS_PORT) { - return Some(Transport::Https(Box::new(HttpsGateway::new(ctx, host_ip)))); + let mut gateway = HttpsGateway::new(ctx, host_ip); + if let Some(tracer) = self + .tracer + .as_ref() + .filter(|tracer| tracer.traces_network()) + { + gateway.observe_http(tracer.clone()); + } + return Some(Transport::Https(Box::new(gateway))); } let address = SocketAddrV4::new(Ipv4Addr::from(host_ip), host_port); diff --git a/crates/machine/src/virtio/slirp/udp.rs b/crates/machine/src/virtio/slirp/udp.rs index 64eb7a7f..49f60687 100644 --- a/crates/machine/src/virtio/slirp/udp.rs +++ b/crates/machine/src/virtio/slirp/udp.rs @@ -9,6 +9,7 @@ use super::frames::{ GW_IP, IP_PROTO_UDP, eth_src, ip_dst, ip_payload, ip_src, make_ip_frame, make_udp_payload, u16be, }; +use crate::trace::format_address; const DHCP_SERVER_PORT: u16 = 67; const DHCP_CLIENT_PORT: u16 = 68; @@ -83,6 +84,21 @@ impl SlirpBackend { }; sock.set_nonblocking(true).ok(); + if let Some(tracer) = self + .tracer + .as_ref() + .filter(|tracer| tracer.traces_network()) + { + tracer.record( + "net.udp", + &[ + ("address", format_address(dst_ip).into()), + ("port", dst_port.into()), + ("host", tracer.name_of(dst_ip).into()), + ], + ); + } + slot.insert(UdpConn { sock, guest_mac, diff --git a/crates/machine/src/virtio/tls_proxy/mod.rs b/crates/machine/src/virtio/tls_proxy/mod.rs index 2ba4d24a..2bbd0489 100644 --- a/crates/machine/src/virtio/tls_proxy/mod.rs +++ b/crates/machine/src/virtio/tls_proxy/mod.rs @@ -28,6 +28,7 @@ use x509_cert::spki::SubjectPublicKeyInfoOwned; use x509_cert::time::{Time, Validity}; use super::upstream::{Upstream, UpstreamMode, UpstreamStatus}; +use crate::trace::HttpObserver; #[cfg(test)] mod testutil; @@ -238,6 +239,7 @@ pub struct TlsProxy { upstream_closed: bool, close_notified: bool, timing: Option, + http: Option, } pub(crate) struct Timing { @@ -308,9 +310,18 @@ impl TlsProxy { upstream_closed: false, close_notified: false, timing, + http: None, }) } + pub(crate) fn observe_http(&mut self, http: HttpObserver) { + self.http = Some(http); + } + + pub fn server_name(&self) -> Option<&str> { + self.server.server_name() + } + pub fn failed(&self) -> bool { self.failed } @@ -375,6 +386,9 @@ impl TlsProxy { && !self.server.is_handshaking() && let Some(sni) = self.server.server_name().map(|s| s.to_string()) { + if let Some(http) = &mut self.http { + http.set_default_host(&sni); + } self.connect_upstream(&sni); if let (Some(t), true) = (&mut self.timing, self.upstream.is_some()) @@ -392,6 +406,10 @@ impl TlsProxy { match self.server.reader().read(&mut buf) { Ok(0) => break, Ok(n) => { + if let Some(http) = &mut self.http { + http.observe(&buf[..n]); + } + if upstream.send_plaintext(&buf[..n]).is_err() { self.failed = true; return; @@ -548,6 +566,7 @@ impl TlsProxy { upstream_closed: false, close_notified: false, timing: None, + http: None, } } } diff --git a/crates/wasi-component/src/api/executor.rs b/crates/wasi-component/src/api/executor.rs index d12bef5e..ca88e6ea 100644 --- a/crates/wasi-component/src/api/executor.rs +++ b/crates/wasi-component/src/api/executor.rs @@ -1,6 +1,6 @@ use crate::api::session::SESSION_MANAGER; use crate::exports::vpod::sandbox::executor::{ - ExecMode, ExecutionResult, Guest, MountEntry, SliceOutput, + ExecMode, ExecutionResult, Guest, MountEntry, SliceOutput, TraceOptions, }; use crate::vm; @@ -84,4 +84,16 @@ impl Guest for Executor { SESSION_MANAGER.resume_session(snapshot_path, delta, command, prompt, mount_args) } + + fn session_trace_start(handle: u64, options: TraceOptions) -> Result<(), String> { + SESSION_MANAGER.trace_start(handle, options) + } + + fn session_trace_drain(handle: u64, max_bytes: u32) -> Result, String> { + SESSION_MANAGER.trace_drain(handle, max_bytes) + } + + fn session_trace_stop(handle: u64) -> Result<(), String> { + SESSION_MANAGER.trace_stop(handle) + } } diff --git a/crates/wasi-component/src/api/session.rs b/crates/wasi-component/src/api/session.rs index 0c939d0d..a57bc1ff 100644 --- a/crates/wasi-component/src/api/session.rs +++ b/crates/wasi-component/src/api/session.rs @@ -3,11 +3,14 @@ use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::sync::LazyLock; -use crate::exports::vpod::sandbox::executor::{ExecMode, ExecutionResult, SliceOutput}; +use crate::exports::vpod::sandbox::executor::{ + ExecMode, ExecutionResult, SliceOutput, TraceOptions as WitTraceOptions, +}; use crate::repl; use crate::vm; use machine::machine_bus::MachineBus; +use machine::trace::{DEFAULT_BUFFER_BYTES, TraceOptions}; use riscv_core::Hart; const PYRUNNER_SENTINEL: &str = "---VPOD_DONE---"; @@ -819,6 +822,49 @@ impl SessionManager { Ok(id) } + + pub fn trace_start(&self, handle: u64, options: WitTraceOptions) -> Result<(), String> { + let mut sessions = self.sessions.borrow_mut(); + let session = sessions + .get_mut(&handle) + .ok_or_else(|| format!("invalid session handle: {handle}"))?; + + session.bus.start_trace(TraceOptions { + network: options.network, + mounts: options.mounts, + buffer_bytes: if options.buffer_bytes == 0 { + DEFAULT_BUFFER_BYTES + } else { + options.buffer_bytes as usize + }, + }); + + Ok(()) + } + + pub fn trace_drain(&self, handle: u64, max_bytes: u32) -> Result, String> { + let sessions = self.sessions.borrow(); + let session = sessions + .get(&handle) + .ok_or_else(|| format!("invalid session handle: {handle}"))?; + + let tracer = session + .bus + .tracer() + .ok_or_else(|| "tracing is not enabled for this session".to_string())?; + + Ok(tracer.drain(max_bytes as usize)) + } + + pub fn trace_stop(&self, handle: u64) -> Result<(), String> { + let mut sessions = self.sessions.borrow_mut(); + let session = sessions + .get_mut(&handle) + .ok_or_else(|| format!("invalid session handle: {handle}"))?; + + session.bus.stop_trace(); + Ok(()) + } } const PYRUNNER_RESEED_CODE: &str = "\ diff --git a/crates/wasi-component/src/vm.rs b/crates/wasi-component/src/vm.rs index 5b90a84b..f3bf772b 100644 --- a/crates/wasi-component/src/vm.rs +++ b/crates/wasi-component/src/vm.rs @@ -140,6 +140,7 @@ pub fn _bus_from_base( tag: format!("vfs{}", i), writable: m.writable, }]); + fs.set_guest_root(&m.guest_path); } } @@ -198,6 +199,7 @@ pub fn _load(config: _VmConfig) -> Result<(MachineBus, Hart, u8), String> { tag: format!("vfs{}", i), writable: m.writable, }]); + fs.set_guest_root(&m.guest_path); } } diff --git a/crates/wasi-component/vpod.wit b/crates/wasi-component/vpod.wit index 23715879..669e2744 100644 --- a/crates/wasi-component/vpod.wit +++ b/crates/wasi-component/vpod.wit @@ -25,6 +25,14 @@ interface executor { enum exec-mode { closed, piped, terminal } + record trace-options { + processes: bool, + files: bool, + network: bool, + mounts: bool, + buffer-bytes: u32, + } + session-start: func(snapshot-path: string, command: string, prompt: string, mounts: list) -> result; session-exec: func(handle: u64, code: string, timeout: option) -> result; session-close: func(handle: u64); @@ -36,4 +44,8 @@ interface executor { session-suspend: func(handle: u64, delta-path: string) -> result; session-resume: func(snapshot-path: string, delta-path: string, command: string, prompt: string, mounts: list) -> result; + + session-trace-start: func(handle: u64, options: trace-options) -> result<_, string>; + session-trace-drain: func(handle: u64, max-bytes: u32) -> result, string>; + session-trace-stop: func(handle: u64) -> result<_, string>; } From e55034e60a4ff2a8c32cbea96647801f414ad2ff Mon Sep 17 00:00:00 2001 From: Mavdol Date: Thu, 17 Sep 2026 15:15:30 +0200 Subject: [PATCH 03/11] add syscall tracing support for processes and files --- crates/machine/src/machine_bus.rs | 29 +- crates/machine/src/trace/mod.rs | 14 + crates/machine/src/trace/syscalls.rs | 598 +++++++++++++++++++++++ crates/riscv-core/src/execute.rs | 15 + crates/riscv-core/src/lib.rs | 2 + crates/riscv-core/src/syscall_trace.rs | 541 ++++++++++++++++++++ crates/riscv-core/src/system_bus.rs | 14 + crates/wasi-component/src/api/session.rs | 2 + 8 files changed, 1214 insertions(+), 1 deletion(-) create mode 100644 crates/machine/src/trace/syscalls.rs create mode 100644 crates/riscv-core/src/syscall_trace.rs diff --git a/crates/machine/src/machine_bus.rs b/crates/machine/src/machine_bus.rs index 90de3639..29e742d3 100644 --- a/crates/machine/src/machine_bus.rs +++ b/crates/machine/src/machine_bus.rs @@ -4,7 +4,7 @@ use crate::clint::{CLINT_BASE, CLINT_SIZE, Clint, TIMER_FREQUENCY}; use crate::cow_ram::CowRam; use crate::dtb; use crate::plic::{PLIC_BASE, PLIC_SIZE, Plic}; -use crate::trace::{TraceOptions, Tracer}; +use crate::trace::{SyscallTracer, TraceOptions, Tracer}; use crate::uart::Uart; use crate::virtio::RamView; use crate::virtio::blk::VirtioBlk; @@ -38,6 +38,7 @@ pub struct MachineBus { pub net: Option>, pub fs_devices: Vec, tracer: Option, + syscall_tracer: Option, } impl MachineBus { @@ -62,6 +63,7 @@ impl MachineBus { net: None, fs_devices: Vec::new(), tracer: None, + syscall_tracer: None, } } @@ -113,9 +115,17 @@ impl MachineBus { if let Some(network_device) = &mut self.net { network_device.backend_mut().set_tracer(tracer.clone()); } + for fs_device in &mut self.fs_devices { fs_device.set_tracer(tracer.clone()); } + + self.syscall_tracer = tracer + .clone() + .filter(|tracer| { + tracer.traces_processes() || tracer.traces_files() || tracer.traces_network() + }) + .map(SyscallTracer::new); self.tracer = tracer; } @@ -565,6 +575,23 @@ impl SystemBus for MachineBus { fn external_interrupt_pending(&mut self) -> Option { Some(self.refresh_external_interrupt()) } + + #[inline] + fn syscall_trace_enabled(&self) -> bool { + self.syscall_tracer.is_some() + } + + fn on_syscall_entry(&mut self, entry: riscv_core::SyscallEntry) { + if let Some(syscall_tracer) = &mut self.syscall_tracer { + syscall_tracer.on_entry(entry); + } + } + + fn on_syscall_return(&mut self, task: u64, return_pc: u64, value: i64) { + if let Some(syscall_tracer) = &mut self.syscall_tracer { + syscall_tracer.on_return(task, return_pc, value); + } + } } fn kernel_entry_and_offset(kernel: &[u8]) -> (u64, u64) { diff --git a/crates/machine/src/trace/mod.rs b/crates/machine/src/trace/mod.rs index 38d2092b..414b05ae 100644 --- a/crates/machine/src/trace/mod.rs +++ b/crates/machine/src/trace/mod.rs @@ -1,4 +1,5 @@ mod http; +mod syscalls; use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex, MutexGuard}; @@ -7,6 +8,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::Value; pub use http::{HttpObserver, HttpRequest, HttpRequests}; +pub use syscalls::SyscallTracer; pub const SCHEMA_VERSION: u32 = 1; pub const DEFAULT_BUFFER_BYTES: usize = 64 * 1024 * 1024; @@ -15,6 +17,8 @@ const MAX_REMEMBERED_NAMES: usize = 4096; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TraceOptions { + pub processes: bool, + pub files: bool, pub network: bool, pub mounts: bool, pub buffer_bytes: usize, @@ -23,6 +27,8 @@ pub struct TraceOptions { impl Default for TraceOptions { fn default() -> Self { Self { + processes: true, + files: true, network: true, mounts: true, buffer_bytes: DEFAULT_BUFFER_BYTES, @@ -64,6 +70,14 @@ impl Tracer { self.options } + pub fn traces_processes(&self) -> bool { + self.options.processes + } + + pub fn traces_files(&self) -> bool { + self.options.files + } + pub fn traces_network(&self) -> bool { self.options.network } diff --git a/crates/machine/src/trace/syscalls.rs b/crates/machine/src/trace/syscalls.rs new file mode 100644 index 00000000..6ea88ddf --- /dev/null +++ b/crates/machine/src/trace/syscalls.rs @@ -0,0 +1,598 @@ +use std::collections::HashMap; + +use riscv_core::{GuestString, SyscallEntry, SyscallKind}; +use serde_json::Value; + +use super::Tracer; + +const VPOD_HELPER_PREFIX: &str = "/usr/lib/vpod/"; + +pub struct SyscallTracer { + tracer: Tracer, + trace_processes: bool, + trace_files: bool, + trace_network: bool, + pending: HashMap, + internal_tasks: HashMap, + bound_sockets: HashMap<(u64, u64), ([u8; 4], u16)>, +} + +impl SyscallTracer { + pub fn new(tracer: Tracer) -> Self { + Self { + trace_processes: tracer.traces_processes(), + trace_files: tracer.traces_files(), + trace_network: tracer.traces_network(), + tracer, + pending: HashMap::new(), + internal_tasks: HashMap::new(), + bound_sockets: HashMap::new(), + } + } + + pub fn on_entry(&mut self, entry: SyscallEntry) { + match entry.kind { + SyscallKind::Exec { path, argv } => self.emit_exec(entry.task, path, argv), + SyscallKind::Exit { code } => self.emit_exit(entry.task, code), + kind => { + self.pending + .insert(entry.task, (entry.pc.wrapping_add(4), kind)); + } + } + } + + pub fn on_return(&mut self, task: u64, return_pc: u64, value: i64) { + let Some((expected_pc, kind)) = self.pending.remove(&task) else { + return; + }; + + if expected_pc != return_pc { + return; + } + + self.emit_return(task, kind, value); + } + + fn is_internal(&self, task: u64) -> bool { + self.internal_tasks.get(&task).copied().unwrap_or(false) + } + + fn emit_exec(&mut self, task: u64, path: GuestString, argv: Vec) { + let is_internal = + matches!(&path, GuestString::Value(text) if text.starts_with(VPOD_HELPER_PREFIX)); + self.internal_tasks.insert(task, is_internal); + + if !self.trace_processes { + return; + } + + let mut fields = Vec::new(); + push_guest_string( + &mut fields, + "path", + "path_truncated", + "path_unreadable", + path, + ); + fields.push(( + "argv", + Value::Array(argv.into_iter().map(guest_string_into_value).collect()), + )); + if is_internal { + fields.push(("internal", true.into())); + } + self.tracer.record("process.exec", &fields); + } + + fn emit_exit(&mut self, task: u64, code: i32) { + let internal = self.internal_tasks.remove(&task).unwrap_or(false); + if !self.trace_processes { + return; + } + + let mut fields = vec![("code", Value::from(code))]; + if internal { + fields.push(("internal", true.into())); + } + self.tracer.record("process.exit", &fields); + } + + fn emit_return(&mut self, task: u64, kind: SyscallKind, value: i64) { + let internal = self.is_internal(task); + + match kind { + SyscallKind::Clone { thread } => { + if self.trace_processes && value > 0 { + let mut fields = vec![ + ("child_pid", Value::from(value as u64)), + ("thread", Value::from(thread)), + ]; + if internal { + fields.push(("internal", true.into())); + } + self.tracer.record("process.fork", &fields); + } + } + SyscallKind::Open { + path, + write, + read_write, + create, + truncate, + } => { + if !self.trace_files { + return; + } + let access = if read_write { + "read-write" + } else if write { + "write" + } else { + "read" + }; + let mut fields = Vec::new(); + push_guest_string( + &mut fields, + "path", + "path_truncated", + "path_unreadable", + path, + ); + fields.push(("access", access.into())); + fields.push(("create", create.into())); + fields.push(("truncate", truncate.into())); + fields.push(("result", Value::from(value as i32))); + if internal { + fields.push(("internal", true.into())); + } + self.tracer.record("file.open", &fields); + } + SyscallKind::Rename { from, to } => { + if !self.trace_files { + return; + } + let mut fields = Vec::new(); + push_guest_string( + &mut fields, + "from", + "from_truncated", + "from_unreadable", + from, + ); + push_guest_string(&mut fields, "to", "to_truncated", "to_unreadable", to); + fields.push(("result", Value::from(value as i32))); + if internal { + fields.push(("internal", true.into())); + } + self.tracer.record("file.rename", &fields); + } + SyscallKind::Unlink { path, directory } => { + if !self.trace_files { + return; + } + let mut fields = Vec::new(); + push_guest_string( + &mut fields, + "path", + "path_truncated", + "path_unreadable", + path, + ); + fields.push(("directory", directory.into())); + fields.push(("result", Value::from(value as i32))); + if internal { + fields.push(("internal", true.into())); + } + self.tracer.record("file.delete", &fields); + } + SyscallKind::Mkdir { path } => { + if !self.trace_files { + return; + } + let mut fields = Vec::new(); + push_guest_string( + &mut fields, + "path", + "path_truncated", + "path_unreadable", + path, + ); + fields.push(("result", Value::from(value as i32))); + if internal { + fields.push(("internal", true.into())); + } + self.tracer.record("dir.create", &fields); + } + SyscallKind::Truncate { path, size } => { + if !self.trace_files { + return; + } + let mut fields = Vec::new(); + push_guest_string( + &mut fields, + "path", + "path_truncated", + "path_unreadable", + path, + ); + fields.push(("size", size.into())); + fields.push(("result", Value::from(value as i32))); + if internal { + fields.push(("internal", true.into())); + } + self.tracer.record("file.truncate", &fields); + } + SyscallKind::Connect { + fd: _, + address, + port, + } => { + if !self.trace_network { + return; + } + let host = address.and_then(|address| self.tracer.name_of(address)); + let mut fields = vec![ + ("protocol", Value::from("tcp")), + ("address", address.map(super::format_address).into()), + ("port", port.into()), + ("host", host.into()), + ("result", Value::from(value as i32)), + ]; + if internal { + fields.push(("internal", true.into())); + } + self.tracer.record("net.connect", &fields); + } + SyscallKind::Bind { fd, address, port } => { + if value == 0 + && let Some(address) = address + { + self.bound_sockets.insert((task, fd), (address, port)); + } + } + SyscallKind::Listen { fd } => { + if value != 0 { + return; + } + let Some((address, port)) = self.bound_sockets.remove(&(task, fd)) else { + return; + }; + if !self.trace_network { + return; + } + let host = self.tracer.name_of(address); + let mut fields = vec![ + ("protocol", Value::from("tcp")), + ("address", Value::from(super::format_address(address))), + ("port", port.into()), + ("host", host.into()), + ]; + if internal { + fields.push(("internal", true.into())); + } + self.tracer.record("net.listen", &fields); + } + SyscallKind::Exec { .. } | SyscallKind::Exit { .. } => { + unreachable!("exec and exit are emitted at entry, never pending") + } + } + } +} + +fn push_guest_string( + fields: &mut Vec<(&'static str, Value)>, + key: &'static str, + truncated_key: &'static str, + unreadable_key: &'static str, + value: GuestString, +) { + match value { + GuestString::Value(text) => fields.push((key, text.into())), + GuestString::Truncated(text) => { + fields.push((key, text.into())); + fields.push((truncated_key, true.into())); + } + GuestString::Unreadable => { + fields.push((key, Value::Null)); + fields.push((unreadable_key, true.into())); + } + } +} + +fn guest_string_into_value(value: GuestString) -> Value { + match value { + GuestString::Value(text) | GuestString::Truncated(text) => Value::from(text), + GuestString::Unreadable => Value::Null, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::trace::TraceOptions; + + fn guest_string(text: &str) -> GuestString { + GuestString::Value(text.to_string()) + } + + fn entry(task: u64, pc: u64, kind: SyscallKind) -> SyscallEntry { + SyscallEntry { + task, + number: 0, + pc, + kind, + } + } + + fn drained(tracer: &Tracer) -> Vec { + String::from_utf8(tracer.drain(usize::MAX)) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + + #[test] + fn an_open_is_only_emitted_once_its_matching_return_arrives() { + let tracer = Tracer::new(TraceOptions::default()); + let mut syscalls = SyscallTracer::new(tracer.clone()); + + syscalls.on_entry(entry( + 0x1000, + 0x4000, + SyscallKind::Open { + path: guest_string("/tmp/trace-demo.txt"), + write: true, + read_write: false, + create: true, + truncate: true, + }, + )); + assert!(drained(&tracer).is_empty()); + + syscalls.on_return(0x1000, 0x4004, 3); + + let events = drained(&tracer); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["kind"], "file.open"); + assert_eq!(events[0]["path"], "/tmp/trace-demo.txt"); + assert_eq!(events[0]["access"], "write"); + assert_eq!(events[0]["create"], true); + assert_eq!(events[0]["result"], 3); + assert!(events[0].get("internal").is_none()); + } + + #[test] + fn a_return_at_the_wrong_pc_is_dropped_not_misattributed() { + let tracer = Tracer::new(TraceOptions::default()); + let mut syscalls = SyscallTracer::new(tracer.clone()); + + syscalls.on_entry(entry( + 0x1000, + 0x4000, + SyscallKind::Mkdir { + path: guest_string("/tmp/new-dir"), + }, + )); + // A signal or interrupt landed the hart back in U mode somewhere + // else; this must not be read as sys_mkdirat's own return. + syscalls.on_return(0x1000, 0x9999, 0); + + assert!(drained(&tracer).is_empty()); + } + + #[test] + fn a_failed_call_is_still_recorded_with_its_negative_result() { + let tracer = Tracer::new(TraceOptions::default()); + let mut syscalls = SyscallTracer::new(tracer.clone()); + + syscalls.on_entry(entry( + 0x1000, + 0x4000, + SyscallKind::Open { + path: guest_string("/etc/shadow"), + write: false, + read_write: false, + create: false, + truncate: false, + }, + )); + syscalls.on_return(0x1000, 0x4004, -13); // EACCES + + let events = drained(&tracer); + assert_eq!(events[0]["result"], -13); + } + + #[test] + fn a_process_run_from_the_vpod_helper_directory_is_marked_internal() { + let tracer = Tracer::new(TraceOptions::default()); + let mut syscalls = SyscallTracer::new(tracer.clone()); + + syscalls.on_entry(entry( + 0x1000, + 0x4000, + SyscallKind::Exec { + path: guest_string("/usr/lib/vpod/pyrunner.py"), + argv: vec![guest_string("pyrunner.py")], + }, + )); + syscalls.on_entry(entry( + 0x1000, + 0x5000, + SyscallKind::Mkdir { + path: guest_string("/tmp/scratch"), + }, + )); + syscalls.on_return(0x1000, 0x5004, 0); + + let events = drained(&tracer); + assert_eq!(events[0]["kind"], "process.exec"); + assert_eq!(events[0]["internal"], true); + assert_eq!(events[1]["kind"], "dir.create"); + assert_eq!(events[1]["internal"], true); + } + + #[test] + fn a_user_command_is_not_marked_internal() { + let tracer = Tracer::new(TraceOptions::default()); + let mut syscalls = SyscallTracer::new(tracer.clone()); + + syscalls.on_entry(entry( + 0x1000, + 0x4000, + SyscallKind::Exec { + path: guest_string("/usr/bin/wget"), + argv: vec![guest_string("wget")], + }, + )); + + let events = drained(&tracer); + assert!(events[0].get("internal").is_none()); + } + + #[test] + fn a_process_exit_clears_its_task_so_the_pointer_can_be_reused() { + let tracer = Tracer::new(TraceOptions::default()); + let mut syscalls = SyscallTracer::new(tracer.clone()); + + syscalls.on_entry(entry( + 0x1000, + 0x4000, + SyscallKind::Exec { + path: guest_string("/usr/lib/vpod/pyrunner.py"), + argv: vec![], + }, + )); + syscalls.on_entry(entry(0x1000, 0x5000, SyscallKind::Exit { code: 0 })); + + syscalls.on_entry(entry( + 0x1000, + 0x6000, + SyscallKind::Exec { + path: guest_string("/usr/bin/python3"), + argv: vec![], + }, + )); + + let events = drained(&tracer); + assert_eq!(events.len(), 3); + assert!(events[2].get("internal").is_none()); + } + + #[test] + fn a_listen_is_paired_with_its_bind_address() { + let tracer = Tracer::new(TraceOptions::default()); + let mut syscalls = SyscallTracer::new(tracer.clone()); + + syscalls.on_entry(entry( + 0x1000, + 0x4000, + SyscallKind::Bind { + fd: 5, + address: Some([0, 0, 0, 0]), + port: 8080, + }, + )); + syscalls.on_return(0x1000, 0x4004, 0); + assert!(drained(&tracer).is_empty(), "bind alone emits nothing"); + + syscalls.on_entry(entry(0x1000, 0x5000, SyscallKind::Listen { fd: 5 })); + syscalls.on_return(0x1000, 0x5004, 0); + + let events = drained(&tracer); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["kind"], "net.listen"); + assert_eq!(events[0]["address"], "0.0.0.0"); + assert_eq!(events[0]["port"], 8080); + } + + #[test] + fn listen_on_a_socket_that_was_never_seen_bound_emits_nothing() { + let tracer = Tracer::new(TraceOptions::default()); + let mut syscalls = SyscallTracer::new(tracer.clone()); + + syscalls.on_entry(entry(0x1000, 0x4000, SyscallKind::Listen { fd: 5 })); + syscalls.on_return(0x1000, 0x4004, 0); + + assert!(drained(&tracer).is_empty()); + } + + #[test] + fn connect_looks_up_the_host_the_same_dns_answers_named() { + let tracer = Tracer::new(TraceOptions::default()); + tracer.remember_name([151, 101, 0, 223], "pypi.org"); + let mut syscalls = SyscallTracer::new(tracer.clone()); + + syscalls.on_entry(entry( + 0x1000, + 0x4000, + SyscallKind::Connect { + fd: 4, + address: Some([151, 101, 0, 223]), + port: 443, + }, + )); + syscalls.on_return(0x1000, 0x4004, 0); + + let events = drained(&tracer); + assert_eq!(events[0]["kind"], "net.connect"); + assert_eq!(events[0]["host"], "pypi.org"); + assert_eq!(events[0]["address"], "151.101.0.223"); + } + + #[test] + fn a_successful_fork_reports_the_child_pid() { + let tracer = Tracer::new(TraceOptions::default()); + let mut syscalls = SyscallTracer::new(tracer.clone()); + + syscalls.on_entry(entry(0x1000, 0x4000, SyscallKind::Clone { thread: false })); + syscalls.on_return(0x1000, 0x4004, 4242); + + let events = drained(&tracer); + assert_eq!(events[0]["kind"], "process.fork"); + assert_eq!(events[0]["child_pid"], 4242); + assert_eq!(events[0]["thread"], false); + } + + #[test] + fn a_failed_fork_is_not_reported() { + let tracer = Tracer::new(TraceOptions::default()); + let mut syscalls = SyscallTracer::new(tracer.clone()); + + syscalls.on_entry(entry(0x1000, 0x4000, SyscallKind::Clone { thread: false })); + syscalls.on_return(0x1000, 0x4004, -11); // EAGAIN + + assert!(drained(&tracer).is_empty()); + } + + #[test] + fn disabling_files_still_traces_network() { + let tracer = Tracer::new(TraceOptions { + files: false, + ..TraceOptions::default() + }); + let mut syscalls = SyscallTracer::new(tracer.clone()); + + syscalls.on_entry(entry( + 0x1000, + 0x4000, + SyscallKind::Mkdir { + path: guest_string("/tmp/x"), + }, + )); + syscalls.on_return(0x1000, 0x4004, 0); + assert!(drained(&tracer).is_empty()); + + syscalls.on_entry(entry( + 0x1000, + 0x5000, + SyscallKind::Connect { + fd: 4, + address: None, + port: 443, + }, + )); + syscalls.on_return(0x1000, 0x5004, 0); + assert_eq!(drained(&tracer)[0]["kind"], "net.connect"); + } +} diff --git a/crates/riscv-core/src/execute.rs b/crates/riscv-core/src/execute.rs index 974c558d..74bfc318 100644 --- a/crates/riscv-core/src/execute.rs +++ b/crates/riscv-core/src/execute.rs @@ -972,6 +972,13 @@ fn exec_system(ctx: &mut ExecContext, inst: Instruction, raw: u *ctx.shutdown_requested = true; } + if matches!(ctx.priv_mode, PrivMode::U) + && ctx.bus.syscall_trace_enabled() + && let Some(entry) = crate::syscall_trace::decode_entry(ctx, pc) + { + ctx.bus.on_syscall_entry(entry); + } + take_exception(ctx, cause.mcause_code(), 0); return StepResult::Ok; } @@ -994,6 +1001,14 @@ fn exec_system(ctx: &mut ExecContext, inst: Instruction, raw: u ctx.csr.mstatus |= MSTATUS_SPIE; *ctx.priv_mode = PrivMode::from_bits(spp); + + if spp == 0 && ctx.bus.syscall_trace_enabled() { + let task = ctx.csr.sscratch; + let return_pc = ctx.csr.sepc; + let value = ctx.regs.read(10) as i64; // a0 + ctx.bus.on_syscall_return(task, return_pc, value); + } + ctx.regs.pc = ctx.csr.sepc; return StepResult::Ok; } diff --git a/crates/riscv-core/src/lib.rs b/crates/riscv-core/src/lib.rs index bdb7a678..e34edc68 100644 --- a/crates/riscv-core/src/lib.rs +++ b/crates/riscv-core/src/lib.rs @@ -10,12 +10,14 @@ pub mod gpr; pub mod hart; pub mod mmu; pub mod perf; +pub mod syscall_trace; pub mod system_bus; pub mod trap; pub use csr::{Csr, PrivMode}; pub use hart::Hart; pub use mmu::Mmu; +pub use syscall_trace::{GuestString, SyscallEntry, SyscallKind}; pub use system_bus::{FlatMemory, SystemBus}; pub use trap::{StepResult, TrapCause}; diff --git a/crates/riscv-core/src/syscall_trace.rs b/crates/riscv-core/src/syscall_trace.rs new file mode 100644 index 00000000..641342b6 --- /dev/null +++ b/crates/riscv-core/src/syscall_trace.rs @@ -0,0 +1,541 @@ +use crate::execute::ExecContext; +use crate::system_bus::SystemBus; + +const SYS_UNLINKAT: u64 = 35; +const SYS_MKDIRAT: u64 = 34; +const SYS_RENAMEAT: u64 = 38; +const SYS_TRUNCATE: u64 = 45; +const SYS_OPENAT: u64 = 56; +const SYS_CLONE: u64 = 220; +const SYS_EXECVE: u64 = 221; +const SYS_BIND: u64 = 200; +const SYS_LISTEN: u64 = 201; +const SYS_CONNECT: u64 = 203; +const SYS_EXIT_GROUP: u64 = 94; +const SYS_RENAMEAT2: u64 = 276; +const SYS_EXECVEAT: u64 = 281; +const SYS_OPENAT2: u64 = 437; +const SYS_CLONE3: u64 = 435; + +const CLONE_THREAD: u64 = 0x0001_0000; +const AT_REMOVEDIR: u64 = 0x200; +const AF_INET: u16 = 2; + +const MAX_STRING_BYTES: usize = 256; +const MAX_ARGV_ENTRIES: usize = 64; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GuestString { + Value(String), + Truncated(String), + Unreadable, +} + +pub struct SyscallEntry { + pub task: u64, + pub number: u64, + pub pc: u64, + pub kind: SyscallKind, +} + +pub enum SyscallKind { + Exec { + path: GuestString, + argv: Vec, + }, + Exit { + code: i32, + }, + Clone { + thread: bool, + }, + Open { + path: GuestString, + write: bool, + read_write: bool, + create: bool, + truncate: bool, + }, + Rename { + from: GuestString, + to: GuestString, + }, + Unlink { + path: GuestString, + directory: bool, + }, + Mkdir { + path: GuestString, + }, + Truncate { + path: GuestString, + size: u64, + }, + Connect { + fd: u64, + address: Option<[u8; 4]>, + port: u16, + }, + Bind { + fd: u64, + address: Option<[u8; 4]>, + port: u16, + }, + Listen { + fd: u64, + }, +} + +pub fn decode_entry(ctx: &mut ExecContext, pc: u64) -> Option { + let task = ctx.csr.sscratch; + let number = ctx.regs.read(17); + let satp = crate::block::effective_satp(*ctx.priv_mode, ctx.csr.satp); + let args: [u64; 6] = std::array::from_fn(|n| ctx.regs.read(10 + n)); // a0..a5 + + let kind = match number { + SYS_EXECVE | SYS_EXECVEAT => { + let path_arg = if number == SYS_EXECVEAT { + args[1] + } else { + args[0] + }; + let argv_arg = if number == SYS_EXECVEAT { + args[2] + } else { + args[1] + }; + SyscallKind::Exec { + path: read_cstring(ctx, satp, path_arg), + argv: read_argv(ctx, satp, argv_arg), + } + } + SYS_EXIT_GROUP => SyscallKind::Exit { + code: args[0] as i32, + }, + SYS_CLONE => SyscallKind::Clone { + thread: args[0] & CLONE_THREAD != 0, + }, + SYS_CLONE3 => { + let flags = read_u64(ctx, satp, args[0]).unwrap_or(0); + SyscallKind::Clone { + thread: flags & CLONE_THREAD != 0, + } + } + SYS_OPENAT => { + let flags = args[2] as u32; + SyscallKind::Open { + path: read_cstring(ctx, satp, args[1]), + write: flags & O_ACCMODE == O_WRONLY, + read_write: flags & O_ACCMODE == O_RDWR, + create: flags & O_CREAT != 0, + truncate: flags & O_TRUNC != 0, + } + } + SYS_OPENAT2 => { + let how_flags = read_u64(ctx, satp, args[2]).unwrap_or(0) as u32; + SyscallKind::Open { + path: read_cstring(ctx, satp, args[1]), + write: how_flags & O_ACCMODE == O_WRONLY, + read_write: how_flags & O_ACCMODE == O_RDWR, + create: how_flags & O_CREAT != 0, + truncate: how_flags & O_TRUNC != 0, + } + } + SYS_RENAMEAT | SYS_RENAMEAT2 => SyscallKind::Rename { + from: read_cstring(ctx, satp, args[1]), + to: read_cstring(ctx, satp, args[3]), + }, + SYS_UNLINKAT => SyscallKind::Unlink { + path: read_cstring(ctx, satp, args[1]), + directory: args[2] & AT_REMOVEDIR != 0, + }, + SYS_MKDIRAT => SyscallKind::Mkdir { + path: read_cstring(ctx, satp, args[1]), + }, + SYS_TRUNCATE => SyscallKind::Truncate { + path: read_cstring(ctx, satp, args[0]), + size: args[1], + }, + SYS_CONNECT => { + let (address, port) = read_sockaddr_in(ctx, satp, args[1]); + SyscallKind::Connect { + fd: args[0], + address, + port, + } + } + SYS_BIND => { + let (address, port) = read_sockaddr_in(ctx, satp, args[1]); + SyscallKind::Bind { + fd: args[0], + address, + port, + } + } + SYS_LISTEN => SyscallKind::Listen { fd: args[0] }, + _ => return None, + }; + + Some(SyscallEntry { + task, + number, + pc, + kind, + }) +} + +const O_ACCMODE: u32 = 0o3; +const O_WRONLY: u32 = 0o1; +const O_RDWR: u32 = 0o2; +const O_CREAT: u32 = 0o100; +const O_TRUNC: u32 = 0o1000; + +fn read_u64(ctx: &mut ExecContext, satp: u64, va: u64) -> Option { + let pa = ctx.mmu.translate_load(va, satp, ctx.bus).ok()?; + Some(ctx.bus.read_doubleword(pa)) +} + +fn read_cstring(ctx: &mut ExecContext, satp: u64, va: u64) -> GuestString { + if va == 0 { + return GuestString::Unreadable; + } + let mut bytes = Vec::new(); + let mut cursor = va; + + loop { + if bytes.len() >= MAX_STRING_BYTES { + return GuestString::Truncated(String::from_utf8_lossy(&bytes).into_owned()); + } + + let Ok(pa) = ctx.mmu.translate_load(cursor, satp, ctx.bus) else { + return if bytes.is_empty() { + GuestString::Unreadable + } else { + GuestString::Truncated(String::from_utf8_lossy(&bytes).into_owned()) + }; + }; + + let byte = ctx.bus.read_byte(pa); + if byte == 0 { + return GuestString::Value(String::from_utf8_lossy(&bytes).into_owned()); + } + bytes.push(byte); + cursor = cursor.wrapping_add(1); + } +} + +fn read_argv(ctx: &mut ExecContext, satp: u64, mut va: u64) -> Vec { + if va == 0 { + return Vec::new(); + } + + let mut argv = Vec::new(); + for _ in 0..MAX_ARGV_ENTRIES { + let Some(pointer) = read_u64(ctx, satp, va) else { + break; + }; + if pointer == 0 { + break; + } + argv.push(read_cstring(ctx, satp, pointer)); + va = va.wrapping_add(8); + } + argv +} + +fn read_sockaddr_in( + ctx: &mut ExecContext, + satp: u64, + va: u64, +) -> (Option<[u8; 4]>, u16) { + if va == 0 { + return (None, 0); + } + + let Ok(pa) = ctx.mmu.translate_load(va, satp, ctx.bus) else { + return (None, 0); + }; + + let family = ctx.bus.read_halfword(pa); + if family != AF_INET { + return (None, 0); + } + + let port = u16::from_be_bytes([ctx.bus.read_byte(pa + 2), ctx.bus.read_byte(pa + 3)]); + let address = [ + ctx.bus.read_byte(pa + 4), + ctx.bus.read_byte(pa + 5), + ctx.bus.read_byte(pa + 6), + ctx.bus.read_byte(pa + 7), + ]; + + (Some(address), port) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::csr::PrivMode; + use crate::hart::Hart; + use crate::system_bus::FlatMemory; + + const ECALL: u32 = 0x0000_0073; + const SRET: u32 = 0x1020_0073; + + struct RecordingBus { + memory: FlatMemory, + entries: Vec<(u64, SyscallKind)>, + returns: Vec<(u64, u64, i64)>, + } + + impl RecordingBus { + fn new() -> Self { + Self { + memory: FlatMemory::new(1024 * 1024), + entries: Vec::new(), + returns: Vec::new(), + } + } + + fn write_cstring(&mut self, address: u64, text: &str) { + self.memory.load_at(address as usize, text.as_bytes()); + self.memory.load_at(address as usize + text.len(), &[0]); + } + } + + impl SystemBus for RecordingBus { + fn read_byte(&mut self, address: u64) -> u8 { + self.memory.read_byte(address) + } + fn read_halfword(&mut self, address: u64) -> u16 { + self.memory.read_halfword(address) + } + fn read_word(&mut self, address: u64) -> u32 { + self.memory.read_word(address) + } + fn read_doubleword(&mut self, address: u64) -> u64 { + self.memory.read_doubleword(address) + } + fn write_byte(&mut self, address: u64, value: u8) { + self.memory.write_byte(address, value) + } + fn write_halfword(&mut self, address: u64, value: u16) { + self.memory.write_halfword(address, value) + } + fn write_word(&mut self, address: u64, value: u32) { + self.memory.write_word(address, value) + } + fn write_doubleword(&mut self, address: u64, value: u64) { + self.memory.write_doubleword(address, value) + } + + fn syscall_trace_enabled(&self) -> bool { + true + } + + fn on_syscall_entry(&mut self, entry: SyscallEntry) { + self.entries.push((entry.number, entry.kind)); + } + + fn on_syscall_return(&mut self, task: u64, return_pc: u64, value: i64) { + self.returns.push((task, return_pc, value)); + } + } + + fn user_ecall(setup: impl FnOnce(&mut Hart, &mut RecordingBus)) -> RecordingBus { + let mut bus = RecordingBus::new(); + bus.memory.load_at(0, &ECALL.to_le_bytes()); + + let mut cpu = Hart::new(0); + cpu.priv_mode = PrivMode::U; + cpu.csr.stvec = 0x8000; // somewhere with no instructions; we stop before fetching there + setup(&mut cpu, &mut bus); + + cpu.run(&mut bus, 1); + bus + } + + #[test] + fn openat_is_decoded_at_entry_with_its_path_and_access_mode() { + const O_WRONLY: u64 = 0o1; + const O_CREAT: u64 = 0o100; + const O_TRUNC: u64 = 0o1000; + + let bus = user_ecall(|cpu, bus| { + bus.write_cstring(0x2000, "/tmp/trace-demo.txt"); + cpu.regs.write(17, SYS_OPENAT); // a7 + cpu.regs.write(10, u64::MAX); // a0: dirfd, unused by the decoder + cpu.regs.write(11, 0x2000); // a1: path + cpu.regs.write(12, O_WRONLY | O_CREAT | O_TRUNC); // a2: flags + }); + + assert_eq!(bus.entries.len(), 1); + let (number, kind) = &bus.entries[0]; + assert_eq!(*number, SYS_OPENAT); + match kind { + SyscallKind::Open { + path, + write, + create, + truncate, + .. + } => { + assert_eq!(path, &GuestString::Value("/tmp/trace-demo.txt".to_string())); + assert!(write); + assert!(create); + assert!(truncate); + } + other => panic!( + "expected Open, got a different kind: {other:?}", + other = std::mem::discriminant(other) + ), + } + } + + #[test] + fn execve_reads_the_path_and_the_whole_argv_array() { + let bus = user_ecall(|cpu, bus| { + bus.write_cstring(0x3000, "/usr/bin/wget"); + bus.write_cstring(0x3100, "wget"); + bus.write_cstring(0x3110, "-q"); + + bus.memory.load_at(0x3200, &0x3100u64.to_le_bytes()); // argv[0] = "wget" + bus.memory.load_at(0x3208, &0x3110u64.to_le_bytes()); // argv[1] = "-q" + bus.memory.load_at(0x3210, &0u64.to_le_bytes()); // argv[2] = NULL + + cpu.regs.write(17, SYS_EXECVE); + cpu.regs.write(10, 0x3000); // a0: path + cpu.regs.write(11, 0x3200); // a1: argv + }); + + assert_eq!(bus.entries.len(), 1); + match &bus.entries[0].1 { + SyscallKind::Exec { path, argv } => { + assert_eq!(path, &GuestString::Value("/usr/bin/wget".to_string())); + assert_eq!( + argv, + &vec![ + GuestString::Value("wget".to_string()), + GuestString::Value("-q".to_string()), + ] + ); + } + _ => panic!("expected Exec"), + } + } + + #[test] + fn a_string_longer_than_the_cap_comes_back_truncated() { + let bus = user_ecall(|cpu, bus| { + let long_name = "a".repeat(MAX_STRING_BYTES + 50); + bus.write_cstring(0x4000, &long_name); + + cpu.regs.write(17, SYS_MKDIRAT); + cpu.regs.write(11, 0x4000); // a1: path + }); + + match &bus.entries[0].1 { + SyscallKind::Mkdir { path } => match path { + GuestString::Truncated(text) => assert_eq!(text.len(), MAX_STRING_BYTES), + other => panic!("expected Truncated, got {other:?}"), + }, + _ => panic!("expected Mkdir"), + } + } + + #[test] + fn a_null_path_pointer_is_unreadable_not_a_guest_fault() { + let bus = user_ecall(|cpu, _bus| { + cpu.regs.write(17, SYS_MKDIRAT); + cpu.regs.write(11, 0); // a1: path, NULL + }); + + match &bus.entries[0].1 { + SyscallKind::Mkdir { path } => assert_eq!(path, &GuestString::Unreadable), + _ => panic!("expected Mkdir"), + } + } + + #[test] + fn connect_decodes_an_ipv4_sockaddr() { + let bus = user_ecall(|cpu, bus| { + bus.memory.write_halfword(0x5000, 2); // AF_INET + bus.memory.write_byte(0x5002, 0x01); // port 0x0150 = 336, big-endian + bus.memory.write_byte(0x5003, 0x50); + bus.memory.write_byte(0x5004, 151); + bus.memory.write_byte(0x5005, 101); + bus.memory.write_byte(0x5006, 0); + bus.memory.write_byte(0x5007, 223); + + cpu.regs.write(17, SYS_CONNECT); + cpu.regs.write(10, 7); // a0: fd + cpu.regs.write(11, 0x5000); // a1: sockaddr + }); + + match &bus.entries[0].1 { + SyscallKind::Connect { fd, address, port } => { + assert_eq!(*fd, 7); + assert_eq!(*address, Some([151, 101, 0, 223])); + assert_eq!(*port, 336); + } + _ => panic!("expected Connect"), + } + } + + #[test] + fn an_untraced_syscall_number_is_not_decoded_at_all() { + let bus = user_ecall(|cpu, _bus| { + cpu.regs.write(17, 64); // sys_write, not one we trace + }); + + assert!(bus.entries.is_empty()); + } + + #[test] + fn a_kernel_mode_ecall_is_never_treated_as_a_user_syscall() { + let mut bus = RecordingBus::new(); + bus.memory.load_at(0, &ECALL.to_le_bytes()); + + let mut cpu = Hart::new(0); + cpu.priv_mode = PrivMode::S; + cpu.regs.write(17, SYS_OPENAT); + cpu.regs.write(16, 0); + + cpu.run(&mut bus, 1); + + assert!(bus.entries.is_empty()); + } + + #[test] + fn sret_to_user_mode_reports_the_task_pc_and_return_value() { + let mut bus = RecordingBus::new(); + bus.memory.load_at(0, &SRET.to_le_bytes()); + + let mut cpu = Hart::new(0); + cpu.priv_mode = PrivMode::S; + cpu.csr.sscratch = 0x88_e600; + cpu.csr.sepc = 0x1000; + cpu.regs.write(10, 3); + + cpu.run(&mut bus, 1); + + assert_eq!(bus.returns, vec![(0x88_e600, 0x1000, 3)]); + assert_eq!(cpu.regs.pc, 0x1000); + assert_eq!(cpu.priv_mode, PrivMode::U); + } + + #[test] + fn sret_back_to_supervisor_mode_is_not_reported_as_a_syscall_return() { + let mut bus = RecordingBus::new(); + bus.memory.load_at(0, &SRET.to_le_bytes()); + + let mut cpu = Hart::new(0); + cpu.priv_mode = PrivMode::S; + cpu.csr.mstatus |= 1 << 8; // SPP = 1: sret goes back to S, not U + cpu.csr.sscratch = 0x1234; + + cpu.run(&mut bus, 1); + + assert!(bus.returns.is_empty()); + } +} diff --git a/crates/riscv-core/src/system_bus.rs b/crates/riscv-core/src/system_bus.rs index de878c97..00e2dd87 100644 --- a/crates/riscv-core/src/system_bus.rs +++ b/crates/riscv-core/src/system_bus.rs @@ -1,6 +1,8 @@ // External communication linking the hart to RAM and peripherals (disk, network). use std::sync::atomic::{AtomicU64, Ordering}; +use crate::syscall_trace::SyscallEntry; + pub trait SystemBus { fn read_byte(&mut self, address: u64) -> u8; fn read_halfword(&mut self, address: u64) -> u16; @@ -33,6 +35,18 @@ pub trait SystemBus { fn external_interrupt_pending(&mut self) -> Option { None } + + fn syscall_trace_enabled(&self) -> bool { + false + } + + fn on_syscall_entry(&mut self, entry: SyscallEntry) { + let _ = entry; + } + + fn on_syscall_return(&mut self, task: u64, return_pc: u64, value: i64) { + let _ = (task, return_pc, value); + } } static FLAT_EPOCH_SOURCE: AtomicU64 = AtomicU64::new(1); diff --git a/crates/wasi-component/src/api/session.rs b/crates/wasi-component/src/api/session.rs index a57bc1ff..8f91c73a 100644 --- a/crates/wasi-component/src/api/session.rs +++ b/crates/wasi-component/src/api/session.rs @@ -830,6 +830,8 @@ impl SessionManager { .ok_or_else(|| format!("invalid session handle: {handle}"))?; session.bus.start_trace(TraceOptions { + processes: options.processes, + files: options.files, network: options.network, mounts: options.mounts, buffer_bytes: if options.buffer_bytes == 0 { From 779b834627cd91f2ce24038322d5ccad99e52d93 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Thu, 17 Sep 2026 16:25:06 +0200 Subject: [PATCH 04/11] box SyscallTracer and improve syscall tracing and tracking logic --- crates/machine/src/machine_bus.rs | 4 +- crates/machine/src/trace/syscalls.rs | 800 +++++++++++++++---------- crates/riscv-core/src/execute.rs | 28 +- crates/riscv-core/src/syscall_trace.rs | 613 ++++++++++++------- 4 files changed, 882 insertions(+), 563 deletions(-) diff --git a/crates/machine/src/machine_bus.rs b/crates/machine/src/machine_bus.rs index 29e742d3..4b7960cb 100644 --- a/crates/machine/src/machine_bus.rs +++ b/crates/machine/src/machine_bus.rs @@ -38,7 +38,7 @@ pub struct MachineBus { pub net: Option>, pub fs_devices: Vec, tracer: Option, - syscall_tracer: Option, + syscall_tracer: Option>, } impl MachineBus { @@ -125,7 +125,7 @@ impl MachineBus { .filter(|tracer| { tracer.traces_processes() || tracer.traces_files() || tracer.traces_network() }) - .map(SyscallTracer::new); + .map(|tracer| Box::new(SyscallTracer::new(tracer))); self.tracer = tracer; } diff --git a/crates/machine/src/trace/syscalls.rs b/crates/machine/src/trace/syscalls.rs index 6ea88ddf..4d66fdea 100644 --- a/crates/machine/src/trace/syscalls.rs +++ b/crates/machine/src/trace/syscalls.rs @@ -1,4 +1,5 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::net::{IpAddr, SocketAddr}; use riscv_core::{GuestString, SyscallEntry, SyscallKind}; use serde_json::Value; @@ -6,15 +7,39 @@ use serde_json::Value; use super::Tracer; const VPOD_HELPER_PREFIX: &str = "/usr/lib/vpod/"; +const VPOD_STAGING_PREFIX: &str = "/tmp/.vpod_"; +const VPOD_DEVICES: [&str; 3] = ["/dev/ttyS1", "/dev/ttyS2", "/dev/ttyS3"]; + +const AF_INET: u32 = 2; +const AF_INET6: u32 = 10; +const SOCK_TYPE_MASK: u32 = 0xf; +const SOCK_STREAM: u32 = 1; +const SOCK_DGRAM: u32 = 2; + +const PATH_KEYS: [&str; 3] = ["path", "path_truncated", "path_unreadable"]; +const FROM_KEYS: [&str; 3] = ["from", "from_truncated", "from_unreadable"]; +const TO_KEYS: [&str; 3] = ["to", "to_truncated", "to_unreadable"]; pub struct SyscallTracer { tracer: Tracer, trace_processes: bool, trace_files: bool, trace_network: bool, - pending: HashMap, - internal_tasks: HashMap, - bound_sockets: HashMap<(u64, u64), ([u8; 4], u16)>, + pending: HashMap, + internal_tasks: HashSet, + socket_protocols: HashMap<(u64, u64), &'static str>, + bound_sockets: HashMap<(u64, u64), SocketAddr>, +} + +struct Pending { + return_pc: u64, + kind: SyscallKind, +} + +enum ExecOutcome { + Succeeded, + Failed(i64), + Unknown, } impl SyscallTracer { @@ -25,93 +50,115 @@ impl SyscallTracer { trace_network: tracer.traces_network(), tracer, pending: HashMap::new(), - internal_tasks: HashMap::new(), + internal_tasks: HashSet::new(), + socket_protocols: HashMap::new(), bound_sockets: HashMap::new(), } } pub fn on_entry(&mut self, entry: SyscallEntry) { - match entry.kind { - SyscallKind::Exec { path, argv } => self.emit_exec(entry.task, path, argv), - SyscallKind::Exit { code } => self.emit_exit(entry.task, code), - kind => { - self.pending - .insert(entry.task, (entry.pc.wrapping_add(4), kind)); - } + if let SyscallKind::Exit { code } = entry.kind { + self.emit_exit(entry.task, code); + return; } + + self.pending.insert( + entry.task, + Pending { + return_pc: entry.pc.wrapping_add(4), + kind: entry.kind, + }, + ); } pub fn on_return(&mut self, task: u64, return_pc: u64, value: i64) { - let Some((expected_pc, kind)) = self.pending.remove(&task) else { + let Some(pending) = self.pending.remove(&task) else { return; }; + let returned_to_caller = return_pc == pending.return_pc; - if expected_pc != return_pc { - return; + match pending.kind { + SyscallKind::Exec { + path, + argv, + argv_truncated, + } => { + let outcome = if returned_to_caller { + ExecOutcome::Failed(value) + } else if value == 0 { + ExecOutcome::Succeeded + } else { + ExecOutcome::Unknown + }; + self.emit_exec(task, path, argv, argv_truncated, outcome); + } + kind if returned_to_caller => self.emit_return(task, kind, value), + _ => {} } - - self.emit_return(task, kind, value); } - fn is_internal(&self, task: u64) -> bool { - self.internal_tasks.get(&task).copied().unwrap_or(false) - } - - fn emit_exec(&mut self, task: u64, path: GuestString, argv: Vec) { - let is_internal = - matches!(&path, GuestString::Value(text) if text.starts_with(VPOD_HELPER_PREFIX)); - self.internal_tasks.insert(task, is_internal); + fn emit_exec( + &mut self, + task: u64, + path: GuestString, + argv: Vec, + argv_truncated: bool, + outcome: ExecOutcome, + ) { + if let ExecOutcome::Succeeded = outcome { + if matches!(&path, GuestString::Value(text) if text.starts_with(VPOD_HELPER_PREFIX)) { + self.internal_tasks.insert(task); + } else { + self.internal_tasks.remove(&task); + } + } if !self.trace_processes { return; } - let mut fields = Vec::new(); - push_guest_string( - &mut fields, - "path", - "path_truncated", - "path_unreadable", - path, - ); - fields.push(( - "argv", - Value::Array(argv.into_iter().map(guest_string_into_value).collect()), - )); - if is_internal { - fields.push(("internal", true.into())); + let mut fields = task_fields(task); + push_guest_string(&mut fields, PATH_KEYS, path); + fields.push(("argv", argv.into())); + if argv_truncated { + fields.push(("argv_truncated", true.into())); } - self.tracer.record("process.exec", &fields); + match outcome { + ExecOutcome::Succeeded => {} + ExecOutcome::Failed(value) => fields.push(("result", Value::from(value as i32))), + ExecOutcome::Unknown => fields.push(("result", Value::Null)), + } + self.record("process.exec", fields, self.internal_tasks.contains(&task)); } fn emit_exit(&mut self, task: u64, code: i32) { - let internal = self.internal_tasks.remove(&task).unwrap_or(false); + self.pending.remove(&task); + self.socket_protocols.retain(|(owner, _), _| *owner != task); + self.bound_sockets.retain(|(owner, _), _| *owner != task); + let internal = self.internal_tasks.remove(&task); + if !self.trace_processes { return; } - let mut fields = vec![("code", Value::from(code))]; - if internal { - fields.push(("internal", true.into())); - } - self.tracer.record("process.exit", &fields); + let mut fields = task_fields(task); + fields.push(("code", Value::from(code & 0xff))); + self.record("process.exit", fields, internal); } fn emit_return(&mut self, task: u64, kind: SyscallKind, value: i64) { - let internal = self.is_internal(task); + let internal_task = self.internal_tasks.contains(&task); + let result = Value::from(value as i32); match kind { SyscallKind::Clone { thread } => { - if self.trace_processes && value > 0 { - let mut fields = vec![ - ("child_pid", Value::from(value as u64)), - ("thread", Value::from(thread)), - ]; - if internal { - fields.push(("internal", true.into())); - } - self.tracer.record("process.fork", &fields); + if !self.trace_processes || value <= 0 { + return; } + let mut fields = task_fields(task); + fields.push(("child_pid", Value::from(value))); + fields.push(("thread", thread.into())); + self.record("process.fork", fields, internal_task); } SyscallKind::Open { path, @@ -130,160 +177,146 @@ impl SyscallTracer { } else { "read" }; - let mut fields = Vec::new(); - push_guest_string( - &mut fields, - "path", - "path_truncated", - "path_unreadable", - path, - ); + let internal = internal_task || is_vpod_plumbing(&path); + let mut fields = task_fields(task); + push_guest_string(&mut fields, PATH_KEYS, path); fields.push(("access", access.into())); fields.push(("create", create.into())); fields.push(("truncate", truncate.into())); - fields.push(("result", Value::from(value as i32))); - if internal { - fields.push(("internal", true.into())); - } - self.tracer.record("file.open", &fields); + fields.push(("result", result)); + self.record("file.open", fields, internal); } SyscallKind::Rename { from, to } => { if !self.trace_files { return; } - let mut fields = Vec::new(); - push_guest_string( - &mut fields, - "from", - "from_truncated", - "from_unreadable", - from, - ); - push_guest_string(&mut fields, "to", "to_truncated", "to_unreadable", to); - fields.push(("result", Value::from(value as i32))); - if internal { - fields.push(("internal", true.into())); - } - self.tracer.record("file.rename", &fields); + let internal = internal_task || is_vpod_plumbing(&from) || is_vpod_plumbing(&to); + let mut fields = task_fields(task); + push_guest_string(&mut fields, FROM_KEYS, from); + push_guest_string(&mut fields, TO_KEYS, to); + fields.push(("result", result)); + self.record("file.rename", fields, internal); } SyscallKind::Unlink { path, directory } => { if !self.trace_files { return; } - let mut fields = Vec::new(); - push_guest_string( - &mut fields, - "path", - "path_truncated", - "path_unreadable", - path, - ); + let internal = internal_task || is_vpod_plumbing(&path); + let mut fields = task_fields(task); + push_guest_string(&mut fields, PATH_KEYS, path); fields.push(("directory", directory.into())); - fields.push(("result", Value::from(value as i32))); - if internal { - fields.push(("internal", true.into())); - } - self.tracer.record("file.delete", &fields); + fields.push(("result", result)); + self.record("file.delete", fields, internal); } SyscallKind::Mkdir { path } => { if !self.trace_files { return; } - let mut fields = Vec::new(); - push_guest_string( - &mut fields, - "path", - "path_truncated", - "path_unreadable", - path, - ); - fields.push(("result", Value::from(value as i32))); - if internal { - fields.push(("internal", true.into())); - } - self.tracer.record("dir.create", &fields); + let internal = internal_task || is_vpod_plumbing(&path); + let mut fields = task_fields(task); + push_guest_string(&mut fields, PATH_KEYS, path); + fields.push(("result", result)); + self.record("dir.create", fields, internal); } SyscallKind::Truncate { path, size } => { if !self.trace_files { return; } - let mut fields = Vec::new(); - push_guest_string( - &mut fields, - "path", - "path_truncated", - "path_unreadable", - path, - ); + let internal = internal_task || is_vpod_plumbing(&path); + let mut fields = task_fields(task); + push_guest_string(&mut fields, PATH_KEYS, path); fields.push(("size", size.into())); - fields.push(("result", Value::from(value as i32))); - if internal { - fields.push(("internal", true.into())); - } - self.tracer.record("file.truncate", &fields); + fields.push(("result", result)); + self.record("file.truncate", fields, internal); } - SyscallKind::Connect { - fd: _, - address, - port, + SyscallKind::Socket { + domain, + socket_type, } => { + let protocol = match socket_type & SOCK_TYPE_MASK { + SOCK_STREAM => "tcp", + SOCK_DGRAM => "udp", + _ => return, + }; + if value >= 0 && matches!(domain, AF_INET | AF_INET6) { + self.socket_protocols.insert((task, value as u64), protocol); + } + } + SyscallKind::Connect { fd, address } => { + let Some(address) = address else { + return; + }; if !self.trace_network { return; } - let host = address.and_then(|address| self.tracer.name_of(address)); - let mut fields = vec![ - ("protocol", Value::from("tcp")), - ("address", address.map(super::format_address).into()), - ("port", port.into()), - ("host", host.into()), - ("result", Value::from(value as i32)), - ]; - if internal { - fields.push(("internal", true.into())); - } - self.tracer.record("net.connect", &fields); + let mut fields = task_fields(task); + fields.push(( + "protocol", + self.socket_protocols.get(&(task, fd)).copied().into(), + )); + fields.push(("address", address.ip().to_string().into())); + fields.push(("port", address.port().into())); + fields.push(("host", self.name_of(address.ip()).into())); + fields.push(("result", result)); + self.record("net.connect", fields, internal_task); } - SyscallKind::Bind { fd, address, port } => { + SyscallKind::Bind { fd, address } => { if value == 0 && let Some(address) = address { - self.bound_sockets.insert((task, fd), (address, port)); + self.bound_sockets.insert((task, fd), address); } } SyscallKind::Listen { fd } => { if value != 0 { return; } - let Some((address, port)) = self.bound_sockets.remove(&(task, fd)) else { + let Some(address) = self.bound_sockets.remove(&(task, fd)) else { return; }; if !self.trace_network { return; } - let host = self.tracer.name_of(address); - let mut fields = vec![ - ("protocol", Value::from("tcp")), - ("address", Value::from(super::format_address(address))), - ("port", port.into()), - ("host", host.into()), - ]; - if internal { - fields.push(("internal", true.into())); - } - self.tracer.record("net.listen", &fields); - } - SyscallKind::Exec { .. } | SyscallKind::Exit { .. } => { - unreachable!("exec and exit are emitted at entry, never pending") + let mut fields = task_fields(task); + fields.push(( + "protocol", + self.socket_protocols.get(&(task, fd)).copied().into(), + )); + fields.push(("address", address.ip().to_string().into())); + fields.push(("port", address.port().into())); + self.record("net.listen", fields, internal_task); } + SyscallKind::Exec { .. } | SyscallKind::Exit { .. } => {} + } + } + + fn name_of(&self, address: IpAddr) -> Option { + match address { + IpAddr::V4(address) => self.tracer.name_of(address.octets()), + IpAddr::V6(_) => None, } } + + fn record(&self, kind: &str, mut fields: Vec<(&'static str, Value)>, internal: bool) { + if internal { + fields.push(("internal", true.into())); + } + self.tracer.record(kind, &fields); + } +} + +fn task_fields(task: u64) -> Vec<(&'static str, Value)> { + vec![("task", Value::from(format!("{task:x}")))] +} + +fn is_vpod_plumbing(path: &GuestString) -> bool { + matches!(path, GuestString::Value(text) + if VPOD_DEVICES.contains(&text.as_str()) || text.starts_with(VPOD_STAGING_PREFIX)) } fn push_guest_string( fields: &mut Vec<(&'static str, Value)>, - key: &'static str, - truncated_key: &'static str, - unreadable_key: &'static str, + [key, truncated_key, unreadable_key]: [&'static str; 3], value: GuestString, ) { match value { @@ -299,31 +332,66 @@ fn push_guest_string( } } -fn guest_string_into_value(value: GuestString) -> Value { - match value { - GuestString::Value(text) | GuestString::Truncated(text) => Value::from(text), - GuestString::Unreadable => Value::Null, - } -} - #[cfg(test)] mod tests { use super::*; use crate::trace::TraceOptions; + const TASK: u64 = 0xffff_ffd8_0088_e600; + const ECALL_PC: u64 = 0x4000; + const AFTER_ECALL: u64 = ECALL_PC + 4; + fn guest_string(text: &str) -> GuestString { GuestString::Value(text.to_string()) } - fn entry(task: u64, pc: u64, kind: SyscallKind) -> SyscallEntry { + fn entry(kind: SyscallKind) -> SyscallEntry { SyscallEntry { - task, + task: TASK, number: 0, - pc, + pc: ECALL_PC, kind, } } + fn exec(path: &str, argv: &[&str]) -> SyscallKind { + SyscallKind::Exec { + path: guest_string(path), + argv: argv.iter().map(|argument| argument.to_string()).collect(), + argv_truncated: false, + } + } + + fn open(path: &str) -> SyscallKind { + SyscallKind::Open { + path: guest_string(path), + write: true, + read_write: false, + create: true, + truncate: true, + } + } + + fn traced() -> (Tracer, SyscallTracer) { + traced_with(TraceOptions::default()) + } + + fn traced_with(options: TraceOptions) -> (Tracer, SyscallTracer) { + let tracer = Tracer::new(options); + let syscalls = SyscallTracer::new(tracer.clone()); + (tracer, syscalls) + } + + fn call(syscalls: &mut SyscallTracer, kind: SyscallKind, value: i64) { + syscalls.on_entry(entry(kind)); + syscalls.on_return(TASK, AFTER_ECALL, value); + } + + fn succeed_exec(syscalls: &mut SyscallTracer, path: &str, argv: &[&str]) { + syscalls.on_entry(entry(exec(path, argv))); + syscalls.on_return(TASK, 0x1_0000, 0); + } + fn drained(tracer: &Tracer) -> Vec { String::from_utf8(tracer.drain(usize::MAX)) .unwrap() @@ -334,219 +402,289 @@ mod tests { #[test] fn an_open_is_only_emitted_once_its_matching_return_arrives() { - let tracer = Tracer::new(TraceOptions::default()); - let mut syscalls = SyscallTracer::new(tracer.clone()); + let (tracer, mut syscalls) = traced(); - syscalls.on_entry(entry( - 0x1000, - 0x4000, - SyscallKind::Open { - path: guest_string("/tmp/trace-demo.txt"), - write: true, - read_write: false, - create: true, - truncate: true, - }, - )); + syscalls.on_entry(entry(open("/tmp/trace-demo.txt"))); assert!(drained(&tracer).is_empty()); - syscalls.on_return(0x1000, 0x4004, 3); + syscalls.on_return(TASK, AFTER_ECALL, 3); let events = drained(&tracer); assert_eq!(events.len(), 1); assert_eq!(events[0]["kind"], "file.open"); + assert_eq!(events[0]["task"], "ffffffd80088e600"); assert_eq!(events[0]["path"], "/tmp/trace-demo.txt"); assert_eq!(events[0]["access"], "write"); - assert_eq!(events[0]["create"], true); assert_eq!(events[0]["result"], 3); assert!(events[0].get("internal").is_none()); } #[test] fn a_return_at_the_wrong_pc_is_dropped_not_misattributed() { - let tracer = Tracer::new(TraceOptions::default()); - let mut syscalls = SyscallTracer::new(tracer.clone()); + let (tracer, mut syscalls) = traced(); - syscalls.on_entry(entry( - 0x1000, - 0x4000, - SyscallKind::Mkdir { - path: guest_string("/tmp/new-dir"), - }, - )); - // A signal or interrupt landed the hart back in U mode somewhere - // else; this must not be read as sys_mkdirat's own return. - syscalls.on_return(0x1000, 0x9999, 0); + syscalls.on_entry(entry(SyscallKind::Mkdir { + path: guest_string("/tmp/new-dir"), + })); + syscalls.on_return(TASK, 0x9999, 0); assert!(drained(&tracer).is_empty()); } #[test] fn a_failed_call_is_still_recorded_with_its_negative_result() { - let tracer = Tracer::new(TraceOptions::default()); - let mut syscalls = SyscallTracer::new(tracer.clone()); + let (tracer, mut syscalls) = traced(); - syscalls.on_entry(entry( - 0x1000, - 0x4000, - SyscallKind::Open { - path: guest_string("/etc/shadow"), - write: false, - read_write: false, - create: false, - truncate: false, - }, - )); - syscalls.on_return(0x1000, 0x4004, -13); // EACCES + call(&mut syscalls, open("/etc/shadow"), -13); // EACCES - let events = drained(&tracer); - assert_eq!(events[0]["result"], -13); + assert_eq!(drained(&tracer)[0]["result"], -13); } #[test] - fn a_process_run_from_the_vpod_helper_directory_is_marked_internal() { - let tracer = Tracer::new(TraceOptions::default()); - let mut syscalls = SyscallTracer::new(tracer.clone()); + fn a_successful_exec_is_recorded_when_the_task_resumes_in_the_new_program() { + let (tracer, mut syscalls) = traced(); - syscalls.on_entry(entry( - 0x1000, - 0x4000, - SyscallKind::Exec { - path: guest_string("/usr/lib/vpod/pyrunner.py"), - argv: vec![guest_string("pyrunner.py")], - }, - )); - syscalls.on_entry(entry( - 0x1000, - 0x5000, - SyscallKind::Mkdir { - path: guest_string("/tmp/scratch"), - }, - )); - syscalls.on_return(0x1000, 0x5004, 0); + succeed_exec(&mut syscalls, "/bin/sh", &["sh", "-c", "cd /app && make"]); let events = drained(&tracer); + assert_eq!(events.len(), 1); assert_eq!(events[0]["kind"], "process.exec"); + assert_eq!(events[0]["path"], "/bin/sh"); + assert_eq!( + events[0]["argv"], + serde_json::json!(["sh", "-c", "cd /app && make"]) + ); + assert!(events[0].get("result").is_none()); + assert!(events[0].get("argv_truncated").is_none()); + } + + #[test] + fn a_path_search_probe_that_fails_is_marked_with_its_error() { + let (tracer, mut syscalls) = traced(); + + call( + &mut syscalls, + exec("/usr/local/bin/git", &["git", "status"]), + -2, + ); // ENOENT + succeed_exec(&mut syscalls, "/usr/bin/git", &["git", "status"]); + + let events = drained(&tracer); + assert_eq!(events[0]["path"], "/usr/local/bin/git"); + assert_eq!(events[0]["result"], -2); + assert_eq!(events[1]["path"], "/usr/bin/git"); + assert!(events[1].get("result").is_none()); + } + + #[test] + fn an_exec_whose_return_is_redirected_to_a_signal_handler_has_an_unknown_result() { + let (tracer, mut syscalls) = traced(); + + syscalls.on_entry(entry(exec("/usr/bin/missing", &["missing"]))); + syscalls.on_return(TASK, 0x7777, 10); // SIGUSR1 handler, a0 = signal number + + let events = drained(&tracer); + assert_eq!(events[0]["result"], Value::Null); + } + + #[test] + fn a_truncated_command_line_says_so() { + let (tracer, mut syscalls) = traced(); + + syscalls.on_entry(entry(SyscallKind::Exec { + path: guest_string("/bin/sh"), + argv: vec!["sh".into(), "-c".into(), "x".repeat(10)], + argv_truncated: true, + })); + syscalls.on_return(TASK, 0x1_0000, 0); + + assert_eq!(drained(&tracer)[0]["argv_truncated"], true); + } + + #[test] + fn a_vpod_helper_is_internal_until_its_task_execs_something_else() { + let (tracer, mut syscalls) = traced(); + + succeed_exec( + &mut syscalls, + "/usr/lib/vpod/vpod-seed-entropy", + &["vpod-seed-entropy"], + ); + call(&mut syscalls, open("/tmp/seed"), 3); + succeed_exec(&mut syscalls, "/usr/bin/wget", &["wget"]); + call(&mut syscalls, open("/tmp/page.html"), 4); + + let events = drained(&tracer); assert_eq!(events[0]["internal"], true); - assert_eq!(events[1]["kind"], "dir.create"); assert_eq!(events[1]["internal"], true); + assert!(events[2].get("internal").is_none()); + assert!(events[3].get("internal").is_none()); } #[test] - fn a_user_command_is_not_marked_internal() { - let tracer = Tracer::new(TraceOptions::default()); - let mut syscalls = SyscallTracer::new(tracer.clone()); + fn pyrunner_runs_user_code_so_its_activity_is_not_internal() { + let (tracer, mut syscalls) = traced(); - syscalls.on_entry(entry( - 0x1000, - 0x4000, - SyscallKind::Exec { - path: guest_string("/usr/bin/wget"), - argv: vec![guest_string("wget")], - }, - )); + succeed_exec( + &mut syscalls, + "/usr/bin/python3.real", + &["/usr/bin/python3.real", "/usr/lib/vpod/pyrunner.py"], + ); + call(&mut syscalls, open("/data/results.csv"), 3); let events = drained(&tracer); - assert!(events[0].get("internal").is_none()); + assert!(events.iter().all(|event| event.get("internal").is_none())); } #[test] - fn a_process_exit_clears_its_task_so_the_pointer_can_be_reused() { - let tracer = Tracer::new(TraceOptions::default()); - let mut syscalls = SyscallTracer::new(tracer.clone()); + fn opening_a_vpod_device_or_staging_file_is_internal_but_the_console_is_not() { + let (tracer, mut syscalls) = traced(); - syscalls.on_entry(entry( - 0x1000, - 0x4000, - SyscallKind::Exec { - path: guest_string("/usr/lib/vpod/pyrunner.py"), - argv: vec![], + call(&mut syscalls, open("/dev/ttyS1"), 3); + call(&mut syscalls, open("/tmp/.vpod_cmd.b64"), 3); + call(&mut syscalls, open("/dev/ttyS0"), 3); + + let events = drained(&tracer); + assert_eq!(events[0]["internal"], true); + assert_eq!(events[1]["internal"], true); + assert!(events[2].get("internal").is_none()); + } + + #[test] + fn an_exit_reports_the_status_the_shell_would_see_and_forgets_the_task() { + let (tracer, mut syscalls) = traced(); + + succeed_exec( + &mut syscalls, + "/usr/lib/vpod/vpod-seed-entropy", + &["vpod-seed-entropy"], + ); + syscalls.on_entry(entry(SyscallKind::Exit { code: 256 + 3 })); + succeed_exec(&mut syscalls, "/usr/bin/python3", &["python3"]); + + let events = drained(&tracer); + assert_eq!(events[1]["kind"], "process.exit"); + assert_eq!(events[1]["code"], 3); + assert_eq!(events[1]["internal"], true); + assert!(events[2].get("internal").is_none()); + } + + #[test] + fn connect_reports_the_protocol_its_socket_was_created_with_and_the_dns_name() { + let (tracer, mut syscalls) = traced(); + tracer.remember_name([151, 101, 0, 223], "pypi.org"); + + call( + &mut syscalls, + SyscallKind::Socket { + domain: AF_INET, + socket_type: SOCK_STREAM | 0o4000, // SOCK_NONBLOCK + }, + 4, + ); + call( + &mut syscalls, + SyscallKind::Connect { + fd: 4, + address: Some("151.101.0.223:443".parse().unwrap()), }, - )); - syscalls.on_entry(entry(0x1000, 0x5000, SyscallKind::Exit { code: 0 })); + -115, // EINPROGRESS + ); - syscalls.on_entry(entry( - 0x1000, - 0x6000, - SyscallKind::Exec { - path: guest_string("/usr/bin/python3"), - argv: vec![], + let events = drained(&tracer); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["kind"], "net.connect"); + assert_eq!(events[0]["protocol"], "tcp"); + assert_eq!(events[0]["address"], "151.101.0.223"); + assert_eq!(events[0]["port"], 443); + assert_eq!(events[0]["host"], "pypi.org"); + assert_eq!(events[0]["result"], -115); + } + + #[test] + fn a_connect_on_a_socket_we_never_saw_created_has_no_protocol() { + let (tracer, mut syscalls) = traced(); + + call( + &mut syscalls, + SyscallKind::Connect { + fd: 9, + address: Some("[2a04:4e42::223]:443".parse().unwrap()), }, - )); + 0, + ); let events = drained(&tracer); - assert_eq!(events.len(), 3); - assert!(events[2].get("internal").is_none()); + assert_eq!(events[0]["protocol"], Value::Null); + assert_eq!(events[0]["address"], "2a04:4e42::223"); + assert_eq!(events[0]["host"], Value::Null); + } + + #[test] + fn a_unix_socket_connect_is_not_a_network_event() { + let (tracer, mut syscalls) = traced(); + + call( + &mut syscalls, + SyscallKind::Connect { + fd: 3, + address: None, + }, + 0, + ); + + assert!(drained(&tracer).is_empty()); } #[test] fn a_listen_is_paired_with_its_bind_address() { - let tracer = Tracer::new(TraceOptions::default()); - let mut syscalls = SyscallTracer::new(tracer.clone()); + let (tracer, mut syscalls) = traced(); - syscalls.on_entry(entry( - 0x1000, - 0x4000, + call( + &mut syscalls, + SyscallKind::Socket { + domain: AF_INET, + socket_type: SOCK_STREAM, + }, + 5, + ); + call( + &mut syscalls, SyscallKind::Bind { fd: 5, - address: Some([0, 0, 0, 0]), - port: 8080, + address: Some("0.0.0.0:8080".parse().unwrap()), }, - )); - syscalls.on_return(0x1000, 0x4004, 0); - assert!(drained(&tracer).is_empty(), "bind alone emits nothing"); + 0, + ); + assert!( + drained(&tracer).is_empty(), + "socket and bind alone emit nothing" + ); - syscalls.on_entry(entry(0x1000, 0x5000, SyscallKind::Listen { fd: 5 })); - syscalls.on_return(0x1000, 0x5004, 0); + call(&mut syscalls, SyscallKind::Listen { fd: 5 }, 0); let events = drained(&tracer); assert_eq!(events.len(), 1); assert_eq!(events[0]["kind"], "net.listen"); + assert_eq!(events[0]["protocol"], "tcp"); assert_eq!(events[0]["address"], "0.0.0.0"); assert_eq!(events[0]["port"], 8080); } #[test] fn listen_on_a_socket_that_was_never_seen_bound_emits_nothing() { - let tracer = Tracer::new(TraceOptions::default()); - let mut syscalls = SyscallTracer::new(tracer.clone()); + let (tracer, mut syscalls) = traced(); - syscalls.on_entry(entry(0x1000, 0x4000, SyscallKind::Listen { fd: 5 })); - syscalls.on_return(0x1000, 0x4004, 0); + call(&mut syscalls, SyscallKind::Listen { fd: 5 }, 0); assert!(drained(&tracer).is_empty()); } - #[test] - fn connect_looks_up_the_host_the_same_dns_answers_named() { - let tracer = Tracer::new(TraceOptions::default()); - tracer.remember_name([151, 101, 0, 223], "pypi.org"); - let mut syscalls = SyscallTracer::new(tracer.clone()); - - syscalls.on_entry(entry( - 0x1000, - 0x4000, - SyscallKind::Connect { - fd: 4, - address: Some([151, 101, 0, 223]), - port: 443, - }, - )); - syscalls.on_return(0x1000, 0x4004, 0); - - let events = drained(&tracer); - assert_eq!(events[0]["kind"], "net.connect"); - assert_eq!(events[0]["host"], "pypi.org"); - assert_eq!(events[0]["address"], "151.101.0.223"); - } - #[test] fn a_successful_fork_reports_the_child_pid() { - let tracer = Tracer::new(TraceOptions::default()); - let mut syscalls = SyscallTracer::new(tracer.clone()); + let (tracer, mut syscalls) = traced(); - syscalls.on_entry(entry(0x1000, 0x4000, SyscallKind::Clone { thread: false })); - syscalls.on_return(0x1000, 0x4004, 4242); + call(&mut syscalls, SyscallKind::Clone { thread: false }, 4242); let events = drained(&tracer); assert_eq!(events[0]["kind"], "process.fork"); @@ -556,43 +694,57 @@ mod tests { #[test] fn a_failed_fork_is_not_reported() { - let tracer = Tracer::new(TraceOptions::default()); - let mut syscalls = SyscallTracer::new(tracer.clone()); + let (tracer, mut syscalls) = traced(); - syscalls.on_entry(entry(0x1000, 0x4000, SyscallKind::Clone { thread: false })); - syscalls.on_return(0x1000, 0x4004, -11); // EAGAIN + call(&mut syscalls, SyscallKind::Clone { thread: false }, -11); // EAGAIN assert!(drained(&tracer).is_empty()); } #[test] fn disabling_files_still_traces_network() { - let tracer = Tracer::new(TraceOptions { + let (tracer, mut syscalls) = traced_with(TraceOptions { files: false, ..TraceOptions::default() }); - let mut syscalls = SyscallTracer::new(tracer.clone()); - syscalls.on_entry(entry( - 0x1000, - 0x4000, + call( + &mut syscalls, SyscallKind::Mkdir { path: guest_string("/tmp/x"), }, - )); - syscalls.on_return(0x1000, 0x4004, 0); + 0, + ); assert!(drained(&tracer).is_empty()); - syscalls.on_entry(entry( - 0x1000, - 0x5000, + call( + &mut syscalls, SyscallKind::Connect { fd: 4, - address: None, - port: 443, + address: Some("10.0.2.2:443".parse().unwrap()), }, - )); - syscalls.on_return(0x1000, 0x5004, 0); + 0, + ); assert_eq!(drained(&tracer)[0]["kind"], "net.connect"); } + + #[test] + fn an_unreadable_path_is_null_and_flagged() { + let (tracer, mut syscalls) = traced(); + + call( + &mut syscalls, + SyscallKind::Rename { + from: GuestString::Truncated("/tmp/aaaa".into()), + to: GuestString::Unreadable, + }, + -14, // EFAULT + ); + + let events = drained(&tracer); + assert_eq!(events[0]["from"], "/tmp/aaaa"); + assert_eq!(events[0]["from_truncated"], true); + assert_eq!(events[0]["to"], Value::Null); + assert_eq!(events[0]["to_unreadable"], true); + } } diff --git a/crates/riscv-core/src/execute.rs b/crates/riscv-core/src/execute.rs index 74bfc318..a50c69bf 100644 --- a/crates/riscv-core/src/execute.rs +++ b/crates/riscv-core/src/execute.rs @@ -954,6 +954,22 @@ fn exec_amo(ctx: &mut ExecContext, inst: Instruction, raw: u32) StepResult::Ok } +#[cold] +#[inline(never)] +fn trace_syscall_entry(ctx: &mut ExecContext, pc: u64) { + if let Some(entry) = crate::syscall_trace::decode_entry(ctx, pc) { + ctx.bus.on_syscall_entry(entry); + } +} + +#[cold] +#[inline(never)] +fn trace_syscall_return(ctx: &mut ExecContext) { + let value = ctx.regs.read(10) as i64; // a0 + ctx.bus + .on_syscall_return(ctx.csr.sscratch, ctx.csr.sepc, value); +} + fn exec_system(ctx: &mut ExecContext, inst: Instruction, raw: u32) -> StepResult { let pc = ctx.regs.pc; @@ -972,11 +988,8 @@ fn exec_system(ctx: &mut ExecContext, inst: Instruction, raw: u *ctx.shutdown_requested = true; } - if matches!(ctx.priv_mode, PrivMode::U) - && ctx.bus.syscall_trace_enabled() - && let Some(entry) = crate::syscall_trace::decode_entry(ctx, pc) - { - ctx.bus.on_syscall_entry(entry); + if matches!(ctx.priv_mode, PrivMode::U) && ctx.bus.syscall_trace_enabled() { + trace_syscall_entry(ctx, pc); } take_exception(ctx, cause.mcause_code(), 0); @@ -1003,10 +1016,7 @@ fn exec_system(ctx: &mut ExecContext, inst: Instruction, raw: u *ctx.priv_mode = PrivMode::from_bits(spp); if spp == 0 && ctx.bus.syscall_trace_enabled() { - let task = ctx.csr.sscratch; - let return_pc = ctx.csr.sepc; - let value = ctx.regs.read(10) as i64; // a0 - ctx.bus.on_syscall_return(task, return_pc, value); + trace_syscall_return(ctx); } ctx.regs.pc = ctx.csr.sepc; diff --git a/crates/riscv-core/src/syscall_trace.rs b/crates/riscv-core/src/syscall_trace.rs index 641342b6..6f4dba5a 100644 --- a/crates/riscv-core/src/syscall_trace.rs +++ b/crates/riscv-core/src/syscall_trace.rs @@ -1,28 +1,38 @@ +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; + use crate::execute::ExecContext; use crate::system_bus::SystemBus; -const SYS_UNLINKAT: u64 = 35; const SYS_MKDIRAT: u64 = 34; -const SYS_RENAMEAT: u64 = 38; +const SYS_UNLINKAT: u64 = 35; const SYS_TRUNCATE: u64 = 45; const SYS_OPENAT: u64 = 56; -const SYS_CLONE: u64 = 220; -const SYS_EXECVE: u64 = 221; +const SYS_EXIT_GROUP: u64 = 94; +const SYS_SOCKET: u64 = 198; const SYS_BIND: u64 = 200; const SYS_LISTEN: u64 = 201; const SYS_CONNECT: u64 = 203; -const SYS_EXIT_GROUP: u64 = 94; +const SYS_CLONE: u64 = 220; +const SYS_EXECVE: u64 = 221; const SYS_RENAMEAT2: u64 = 276; const SYS_EXECVEAT: u64 = 281; -const SYS_OPENAT2: u64 = 437; const SYS_CLONE3: u64 = 435; +const SYS_OPENAT2: u64 = 437; const CLONE_THREAD: u64 = 0x0001_0000; const AT_REMOVEDIR: u64 = 0x200; const AF_INET: u16 = 2; +const AF_INET6: u16 = 10; -const MAX_STRING_BYTES: usize = 256; -const MAX_ARGV_ENTRIES: usize = 64; +const O_ACCMODE: u32 = 0o3; +const O_WRONLY: u32 = 0o1; +const O_RDWR: u32 = 0o2; +const O_CREAT: u32 = 0o100; +const O_TRUNC: u32 = 0o1000; + +const MAX_PATH_BYTES: usize = 4096; +const MAX_ARGV_BYTES: usize = 32 * 1024; +const MAX_ARGV_ENTRIES: usize = 1024; #[derive(Debug, Clone, PartialEq, Eq)] pub enum GuestString { @@ -41,7 +51,8 @@ pub struct SyscallEntry { pub enum SyscallKind { Exec { path: GuestString, - argv: Vec, + argv: Vec, + argv_truncated: bool, }, Exit { code: i32, @@ -71,15 +82,17 @@ pub enum SyscallKind { path: GuestString, size: u64, }, + Socket { + domain: u32, + socket_type: u32, + }, Connect { fd: u64, - address: Option<[u8; 4]>, - port: u16, + address: Option, }, Bind { fd: u64, - address: Option<[u8; 4]>, - port: u16, + address: Option, }, Listen { fd: u64, @@ -88,25 +101,23 @@ pub enum SyscallKind { pub fn decode_entry(ctx: &mut ExecContext, pc: u64) -> Option { let task = ctx.csr.sscratch; - let number = ctx.regs.read(17); - let satp = crate::block::effective_satp(*ctx.priv_mode, ctx.csr.satp); + let number = ctx.regs.read(17); // a7 let args: [u64; 6] = std::array::from_fn(|n| ctx.regs.read(10 + n)); // a0..a5 + let mut memory = GuestMemory::new(crate::block::effective_satp(*ctx.priv_mode, ctx.csr.satp)); let kind = match number { SYS_EXECVE | SYS_EXECVEAT => { - let path_arg = if number == SYS_EXECVEAT { - args[1] + let (path_address, argv_address) = if number == SYS_EXECVEAT { + (args[1], args[2]) } else { - args[0] - }; - let argv_arg = if number == SYS_EXECVEAT { - args[2] - } else { - args[1] + (args[0], args[1]) }; + let path = memory.cstring(ctx, path_address, MAX_PATH_BYTES); + let (argv, argv_truncated) = memory.argv(ctx, argv_address); SyscallKind::Exec { - path: read_cstring(ctx, satp, path_arg), - argv: read_argv(ctx, satp, argv_arg), + path, + argv, + argv_truncated, } } SYS_EXIT_GROUP => SyscallKind::Exit { @@ -115,63 +126,50 @@ pub fn decode_entry(ctx: &mut ExecContext, pc: u64) -> Option SyscallKind::Clone { thread: args[0] & CLONE_THREAD != 0, }, - SYS_CLONE3 => { - let flags = read_u64(ctx, satp, args[0]).unwrap_or(0); - SyscallKind::Clone { - thread: flags & CLONE_THREAD != 0, - } - } - SYS_OPENAT => { - let flags = args[2] as u32; + SYS_CLONE3 => SyscallKind::Clone { + thread: memory.u64(ctx, args[0]).unwrap_or(0) & CLONE_THREAD != 0, + }, + SYS_OPENAT | SYS_OPENAT2 => { + let flags = if number == SYS_OPENAT2 { + memory.u64(ctx, args[2]).unwrap_or(0) as u32 + } else { + args[2] as u32 + }; SyscallKind::Open { - path: read_cstring(ctx, satp, args[1]), + path: memory.cstring(ctx, args[1], MAX_PATH_BYTES), write: flags & O_ACCMODE == O_WRONLY, read_write: flags & O_ACCMODE == O_RDWR, create: flags & O_CREAT != 0, truncate: flags & O_TRUNC != 0, } } - SYS_OPENAT2 => { - let how_flags = read_u64(ctx, satp, args[2]).unwrap_or(0) as u32; - SyscallKind::Open { - path: read_cstring(ctx, satp, args[1]), - write: how_flags & O_ACCMODE == O_WRONLY, - read_write: how_flags & O_ACCMODE == O_RDWR, - create: how_flags & O_CREAT != 0, - truncate: how_flags & O_TRUNC != 0, - } - } - SYS_RENAMEAT | SYS_RENAMEAT2 => SyscallKind::Rename { - from: read_cstring(ctx, satp, args[1]), - to: read_cstring(ctx, satp, args[3]), + SYS_RENAMEAT2 => SyscallKind::Rename { + from: memory.cstring(ctx, args[1], MAX_PATH_BYTES), + to: memory.cstring(ctx, args[3], MAX_PATH_BYTES), }, SYS_UNLINKAT => SyscallKind::Unlink { - path: read_cstring(ctx, satp, args[1]), + path: memory.cstring(ctx, args[1], MAX_PATH_BYTES), directory: args[2] & AT_REMOVEDIR != 0, }, SYS_MKDIRAT => SyscallKind::Mkdir { - path: read_cstring(ctx, satp, args[1]), + path: memory.cstring(ctx, args[1], MAX_PATH_BYTES), }, SYS_TRUNCATE => SyscallKind::Truncate { - path: read_cstring(ctx, satp, args[0]), + path: memory.cstring(ctx, args[0], MAX_PATH_BYTES), size: args[1], }, - SYS_CONNECT => { - let (address, port) = read_sockaddr_in(ctx, satp, args[1]); - SyscallKind::Connect { - fd: args[0], - address, - port, - } - } - SYS_BIND => { - let (address, port) = read_sockaddr_in(ctx, satp, args[1]); - SyscallKind::Bind { - fd: args[0], - address, - port, - } - } + SYS_SOCKET => SyscallKind::Socket { + domain: args[0] as u32, + socket_type: args[1] as u32, + }, + SYS_CONNECT => SyscallKind::Connect { + fd: args[0], + address: memory.socket_address(ctx, args[1]), + }, + SYS_BIND => SyscallKind::Bind { + fd: args[0], + address: memory.socket_address(ctx, args[1]), + }, SYS_LISTEN => SyscallKind::Listen { fd: args[0] }, _ => return None, }; @@ -184,92 +182,143 @@ pub fn decode_entry(ctx: &mut ExecContext, pc: u64) -> Option(ctx: &mut ExecContext, satp: u64, va: u64) -> Option { - let pa = ctx.mmu.translate_load(va, satp, ctx.bus).ok()?; - Some(ctx.bus.read_doubleword(pa)) +struct GuestMemory { + satp: u64, + virtual_page: u64, + host_page: *const u8, } -fn read_cstring(ctx: &mut ExecContext, satp: u64, va: u64) -> GuestString { - if va == 0 { - return GuestString::Unreadable; +impl GuestMemory { + fn new(satp: u64) -> Self { + Self { + satp, + virtual_page: u64::MAX, + host_page: std::ptr::null(), + } } - let mut bytes = Vec::new(); - let mut cursor = va; - loop { - if bytes.len() >= MAX_STRING_BYTES { - return GuestString::Truncated(String::from_utf8_lossy(&bytes).into_owned()); + fn byte(&mut self, ctx: &mut ExecContext, virtual_address: u64) -> Option { + let virtual_page = virtual_address >> 12; + if virtual_page != self.virtual_page { + let physical_address = ctx + .mmu + .translate_load(virtual_address, self.satp, ctx.bus) + .ok()?; + self.host_page = ctx.bus.ram_load_page(physical_address)?; + self.virtual_page = virtual_page; } - let Ok(pa) = ctx.mmu.translate_load(cursor, satp, ctx.bus) else { - return if bytes.is_empty() { - GuestString::Unreadable - } else { - GuestString::Truncated(String::from_utf8_lossy(&bytes).into_owned()) - }; - }; + Some(unsafe { *self.host_page.add((virtual_address & 0xfff) as usize) }) + } - let byte = ctx.bus.read_byte(pa); - if byte == 0 { - return GuestString::Value(String::from_utf8_lossy(&bytes).into_owned()); + fn array( + &mut self, + ctx: &mut ExecContext, + virtual_address: u64, + ) -> Option<[u8; N]> { + let mut bytes = [0u8; N]; + for (offset, byte) in bytes.iter_mut().enumerate() { + *byte = self.byte(ctx, virtual_address.wrapping_add(offset as u64))?; } - bytes.push(byte); - cursor = cursor.wrapping_add(1); + Some(bytes) } -} -fn read_argv(ctx: &mut ExecContext, satp: u64, mut va: u64) -> Vec { - if va == 0 { - return Vec::new(); + fn u64(&mut self, ctx: &mut ExecContext, virtual_address: u64) -> Option { + self.array(ctx, virtual_address).map(u64::from_le_bytes) } - let mut argv = Vec::new(); - for _ in 0..MAX_ARGV_ENTRIES { - let Some(pointer) = read_u64(ctx, satp, va) else { - break; - }; - if pointer == 0 { - break; + fn cstring( + &mut self, + ctx: &mut ExecContext, + virtual_address: u64, + max_bytes: usize, + ) -> GuestString { + if virtual_address == 0 { + return GuestString::Unreadable; } - argv.push(read_cstring(ctx, satp, pointer)); - va = va.wrapping_add(8); - } - argv -} -fn read_sockaddr_in( - ctx: &mut ExecContext, - satp: u64, - va: u64, -) -> (Option<[u8; 4]>, u16) { - if va == 0 { - return (None, 0); + let mut bytes = Vec::new(); + loop { + let next = virtual_address.wrapping_add(bytes.len() as u64); + match self.byte(ctx, next) { + None if bytes.is_empty() => return GuestString::Unreadable, + None => return GuestString::Truncated(lossy(bytes)), + Some(0) => return GuestString::Value(lossy(bytes)), + Some(_) if bytes.len() == max_bytes => { + return GuestString::Truncated(lossy(bytes)); + } + Some(byte) => bytes.push(byte), + } + } } - let Ok(pa) = ctx.mmu.translate_load(va, satp, ctx.bus) else { - return (None, 0); - }; + fn argv( + &mut self, + ctx: &mut ExecContext, + virtual_address: u64, + ) -> (Vec, bool) { + let mut argv = Vec::new(); + if virtual_address == 0 { + return (argv, false); + } + + let mut budget = MAX_ARGV_BYTES; + for index in 0..MAX_ARGV_ENTRIES as u64 { + let Some(pointer) = self.u64(ctx, virtual_address.wrapping_add(index * 8)) else { + return (argv, true); + }; + if pointer == 0 { + return (argv, false); + } + if budget == 0 { + return (argv, true); + } + + match self.cstring(ctx, pointer, budget) { + GuestString::Value(argument) => { + budget = budget.saturating_sub(argument.len()); + argv.push(argument); + } + GuestString::Truncated(argument) => { + argv.push(argument); + return (argv, true); + } + GuestString::Unreadable => return (argv, true), + } + } - let family = ctx.bus.read_halfword(pa); - if family != AF_INET { - return (None, 0); + (argv, true) } - let port = u16::from_be_bytes([ctx.bus.read_byte(pa + 2), ctx.bus.read_byte(pa + 3)]); - let address = [ - ctx.bus.read_byte(pa + 4), - ctx.bus.read_byte(pa + 5), - ctx.bus.read_byte(pa + 6), - ctx.bus.read_byte(pa + 7), - ]; + fn socket_address( + &mut self, + ctx: &mut ExecContext, + virtual_address: u64, + ) -> Option { + if virtual_address == 0 { + return None; + } + + let family = u16::from_le_bytes(self.array(ctx, virtual_address)?); + let port = u16::from_be_bytes(self.array(ctx, virtual_address + 2)?); + + match family { + AF_INET => { + let octets: [u8; 4] = self.array(ctx, virtual_address + 4)?; + Some(SocketAddr::from((Ipv4Addr::from(octets), port))) + } + AF_INET6 => { + let octets: [u8; 16] = self.array(ctx, virtual_address + 8)?; + Some(SocketAddr::from((Ipv6Addr::from(octets), port))) + } + _ => None, + } + } +} - (Some(address), port) +fn lossy(bytes: Vec) -> String { + String::from_utf8(bytes) + .unwrap_or_else(|error| String::from_utf8_lossy(error.as_bytes()).into_owned()) } #[cfg(test)] @@ -281,9 +330,11 @@ mod tests { const ECALL: u32 = 0x0000_0073; const SRET: u32 = 0x1020_0073; + const DEVICE_BASE: u64 = 0x8_0000; struct RecordingBus { memory: FlatMemory, + device_reads: usize, entries: Vec<(u64, SyscallKind)>, returns: Vec<(u64, u64, i64)>, } @@ -292,6 +343,7 @@ mod tests { fn new() -> Self { Self { memory: FlatMemory::new(1024 * 1024), + device_reads: 0, entries: Vec::new(), returns: Vec::new(), } @@ -301,19 +353,33 @@ mod tests { self.memory.load_at(address as usize, text.as_bytes()); self.memory.load_at(address as usize + text.len(), &[0]); } + + fn write_u64(&mut self, address: u64, value: u64) { + self.memory.load_at(address as usize, &value.to_le_bytes()); + } + + fn count_device(&mut self, address: u64) { + if address >= DEVICE_BASE { + self.device_reads += 1; + } + } } impl SystemBus for RecordingBus { fn read_byte(&mut self, address: u64) -> u8 { + self.count_device(address); self.memory.read_byte(address) } fn read_halfword(&mut self, address: u64) -> u16 { + self.count_device(address); self.memory.read_halfword(address) } fn read_word(&mut self, address: u64) -> u32 { + self.count_device(address); self.memory.read_word(address) } fn read_doubleword(&mut self, address: u64) -> u64 { + self.count_device(address); self.memory.read_doubleword(address) } fn write_byte(&mut self, address: u64, value: u8) { @@ -329,6 +395,13 @@ mod tests { self.memory.write_doubleword(address, value) } + fn ram_load_page(&mut self, address: u64) -> Option<*const u8> { + if address >= DEVICE_BASE { + return None; + } + self.memory.ram_load_page(address) + } + fn syscall_trace_enabled(&self) -> bool { true } @@ -348,146 +421,231 @@ mod tests { let mut cpu = Hart::new(0); cpu.priv_mode = PrivMode::U; - cpu.csr.stvec = 0x8000; // somewhere with no instructions; we stop before fetching there setup(&mut cpu, &mut bus); cpu.run(&mut bus, 1); bus } + fn only_entry(bus: RecordingBus) -> SyscallKind { + let mut entries = bus.entries; + assert_eq!(entries.len(), 1, "expected exactly one decoded syscall"); + entries.remove(0).1 + } + #[test] fn openat_is_decoded_at_entry_with_its_path_and_access_mode() { - const O_WRONLY: u64 = 0o1; - const O_CREAT: u64 = 0o100; - const O_TRUNC: u64 = 0o1000; - - let bus = user_ecall(|cpu, bus| { + let kind = only_entry(user_ecall(|cpu, bus| { bus.write_cstring(0x2000, "/tmp/trace-demo.txt"); - cpu.regs.write(17, SYS_OPENAT); // a7 - cpu.regs.write(10, u64::MAX); // a0: dirfd, unused by the decoder + cpu.regs.write(17, SYS_OPENAT); cpu.regs.write(11, 0x2000); // a1: path - cpu.regs.write(12, O_WRONLY | O_CREAT | O_TRUNC); // a2: flags - }); - - assert_eq!(bus.entries.len(), 1); - let (number, kind) = &bus.entries[0]; - assert_eq!(*number, SYS_OPENAT); - match kind { - SyscallKind::Open { - path, - write, - create, - truncate, - .. - } => { - assert_eq!(path, &GuestString::Value("/tmp/trace-demo.txt".to_string())); - assert!(write); - assert!(create); - assert!(truncate); - } - other => panic!( - "expected Open, got a different kind: {other:?}", - other = std::mem::discriminant(other) - ), - } + cpu.regs.write(12, (O_WRONLY | O_CREAT | O_TRUNC) as u64); // a2: flags + })); + + let SyscallKind::Open { + path, + write, + read_write, + create, + truncate, + } = kind + else { + panic!("expected Open"); + }; + assert_eq!(path, GuestString::Value("/tmp/trace-demo.txt".into())); + assert!(write && create && truncate && !read_write); } #[test] fn execve_reads_the_path_and_the_whole_argv_array() { - let bus = user_ecall(|cpu, bus| { - bus.write_cstring(0x3000, "/usr/bin/wget"); - bus.write_cstring(0x3100, "wget"); - bus.write_cstring(0x3110, "-q"); - - bus.memory.load_at(0x3200, &0x3100u64.to_le_bytes()); // argv[0] = "wget" - bus.memory.load_at(0x3208, &0x3110u64.to_le_bytes()); // argv[1] = "-q" - bus.memory.load_at(0x3210, &0u64.to_le_bytes()); // argv[2] = NULL + let kind = only_entry(user_ecall(|cpu, bus| { + bus.write_cstring(0x3000, "/bin/sh"); + bus.write_cstring(0x3100, "sh"); + bus.write_cstring(0x3110, "-c"); + bus.write_cstring(0x3120, "cd /app && make"); + bus.write_u64(0x3200, 0x3100); + bus.write_u64(0x3208, 0x3110); + bus.write_u64(0x3210, 0x3120); + bus.write_u64(0x3218, 0); cpu.regs.write(17, SYS_EXECVE); cpu.regs.write(10, 0x3000); // a0: path cpu.regs.write(11, 0x3200); // a1: argv - }); + })); + + let SyscallKind::Exec { + path, + argv, + argv_truncated, + } = kind + else { + panic!("expected Exec"); + }; + assert_eq!(path, GuestString::Value("/bin/sh".into())); + assert_eq!(argv, ["sh", "-c", "cd /app && make"]); + assert!(!argv_truncated); + } - assert_eq!(bus.entries.len(), 1); - match &bus.entries[0].1 { - SyscallKind::Exec { path, argv } => { - assert_eq!(path, &GuestString::Value("/usr/bin/wget".to_string())); - assert_eq!( - argv, - &vec![ - GuestString::Value("wget".to_string()), - GuestString::Value("-q".to_string()), - ] - ); - } - _ => panic!("expected Exec"), - } + #[test] + fn a_command_line_past_the_argv_budget_is_kept_up_to_it_and_marked() { + let kind = only_entry(user_ecall(|cpu, bus| { + bus.write_cstring(0x3000, "/bin/sh"); + bus.write_cstring(0x3100, "sh"); + bus.write_cstring(0x3110, "-c"); + bus.write_cstring(0x4000, &"x".repeat(MAX_ARGV_BYTES + 100)); + bus.write_u64(0x3200, 0x3100); + bus.write_u64(0x3208, 0x3110); + bus.write_u64(0x3210, 0x4000); + bus.write_u64(0x3218, 0); + + cpu.regs.write(17, SYS_EXECVE); + cpu.regs.write(10, 0x3000); + cpu.regs.write(11, 0x3200); + })); + + let SyscallKind::Exec { + argv, + argv_truncated, + .. + } = kind + else { + panic!("expected Exec"); + }; + assert!(argv_truncated); + assert_eq!(argv.len(), 3); + assert_eq!(argv.iter().map(String::len).sum::(), MAX_ARGV_BYTES); } #[test] - fn a_string_longer_than_the_cap_comes_back_truncated() { - let bus = user_ecall(|cpu, bus| { - let long_name = "a".repeat(MAX_STRING_BYTES + 50); - bus.write_cstring(0x4000, &long_name); + fn a_path_longer_than_path_max_comes_back_truncated() { + let kind = only_entry(user_ecall(|cpu, bus| { + bus.write_cstring(0x4000, &"a".repeat(MAX_PATH_BYTES + 50)); + cpu.regs.write(17, SYS_MKDIRAT); + cpu.regs.write(11, 0x4000); + })); + + let SyscallKind::Mkdir { path } = kind else { + panic!("expected Mkdir"); + }; + let GuestString::Truncated(text) = path else { + panic!("expected Truncated, got {path:?}"); + }; + assert_eq!(text.len(), MAX_PATH_BYTES); + } + #[test] + fn a_path_exactly_at_path_max_is_whole() { + let kind = only_entry(user_ecall(|cpu, bus| { + bus.write_cstring(0x4000, &"a".repeat(MAX_PATH_BYTES)); cpu.regs.write(17, SYS_MKDIRAT); - cpu.regs.write(11, 0x4000); // a1: path - }); + cpu.regs.write(11, 0x4000); + })); - match &bus.entries[0].1 { - SyscallKind::Mkdir { path } => match path { - GuestString::Truncated(text) => assert_eq!(text.len(), MAX_STRING_BYTES), - other => panic!("expected Truncated, got {other:?}"), - }, - _ => panic!("expected Mkdir"), - } + let SyscallKind::Mkdir { path } = kind else { + panic!("expected Mkdir"); + }; + assert!(matches!(path, GuestString::Value(text) if text.len() == MAX_PATH_BYTES)); + } + + #[test] + fn a_null_path_pointer_is_unreadable() { + let kind = only_entry(user_ecall(|cpu, _bus| { + cpu.regs.write(17, SYS_MKDIRAT); + cpu.regs.write(11, 0); + })); + + let SyscallKind::Mkdir { path } = kind else { + panic!("expected Mkdir"); + }; + assert_eq!(path, GuestString::Unreadable); } #[test] - fn a_null_path_pointer_is_unreadable_not_a_guest_fault() { + fn a_pointer_onto_a_device_is_unreadable_and_never_touches_the_device() { let bus = user_ecall(|cpu, _bus| { cpu.regs.write(17, SYS_MKDIRAT); - cpu.regs.write(11, 0); // a1: path, NULL + cpu.regs.write(11, DEVICE_BASE + 0x10); }); - match &bus.entries[0].1 { - SyscallKind::Mkdir { path } => assert_eq!(path, &GuestString::Unreadable), - _ => panic!("expected Mkdir"), - } + assert_eq!(bus.device_reads, 0, "tracing read a device register"); + let SyscallKind::Mkdir { path } = only_entry(bus) else { + panic!("expected Mkdir"); + }; + assert_eq!(path, GuestString::Unreadable); } #[test] fn connect_decodes_an_ipv4_sockaddr() { - let bus = user_ecall(|cpu, bus| { - bus.memory.write_halfword(0x5000, 2); // AF_INET - bus.memory.write_byte(0x5002, 0x01); // port 0x0150 = 336, big-endian - bus.memory.write_byte(0x5003, 0x50); - bus.memory.write_byte(0x5004, 151); - bus.memory.write_byte(0x5005, 101); - bus.memory.write_byte(0x5006, 0); - bus.memory.write_byte(0x5007, 223); + let kind = only_entry(user_ecall(|cpu, bus| { + bus.memory.load_at(0x5000, &AF_INET.to_le_bytes()); + bus.memory.load_at(0x5002, &443u16.to_be_bytes()); + bus.memory.load_at(0x5004, &[151, 101, 0, 223]); cpu.regs.write(17, SYS_CONNECT); cpu.regs.write(10, 7); // a0: fd cpu.regs.write(11, 0x5000); // a1: sockaddr - }); + })); - match &bus.entries[0].1 { - SyscallKind::Connect { fd, address, port } => { - assert_eq!(*fd, 7); - assert_eq!(*address, Some([151, 101, 0, 223])); - assert_eq!(*port, 336); - } - _ => panic!("expected Connect"), - } + let SyscallKind::Connect { fd, address } = kind else { + panic!("expected Connect"); + }; + assert_eq!(fd, 7); + assert_eq!(address, Some("151.101.0.223:443".parse().unwrap())); + } + + #[test] + fn connect_decodes_an_ipv6_sockaddr() { + let kind = only_entry(user_ecall(|cpu, bus| { + bus.memory.load_at(0x5000, &AF_INET6.to_le_bytes()); + bus.memory.load_at(0x5002, &80u16.to_be_bytes()); + bus.memory.load_at(0x5004, &0u32.to_be_bytes()); // flowinfo + bus.memory.load_at( + 0x5008, + &"2a04:4e42::223".parse::().unwrap().octets(), + ); + + cpu.regs.write(17, SYS_CONNECT); + cpu.regs.write(11, 0x5000); + })); + + let SyscallKind::Connect { address, .. } = kind else { + panic!("expected Connect"); + }; + assert_eq!(address, Some("[2a04:4e42::223]:80".parse().unwrap())); + } + + #[test] + fn a_unix_socket_connect_has_no_ip_address() { + const AF_UNIX: u16 = 1; + let kind = only_entry(user_ecall(|cpu, bus| { + bus.memory.load_at(0x5000, &AF_UNIX.to_le_bytes()); + bus.write_cstring(0x5002, "/run/vpod-pyd.sock"); + + cpu.regs.write(17, SYS_CONNECT); + cpu.regs.write(11, 0x5000); + })); + + let SyscallKind::Connect { address, .. } = kind else { + panic!("expected Connect"); + }; + assert_eq!(address, None); } #[test] fn an_untraced_syscall_number_is_not_decoded_at_all() { let bus = user_ecall(|cpu, _bus| { - cpu.regs.write(17, 64); // sys_write, not one we trace + cpu.regs.write(17, 64); // write }); + assert!(bus.entries.is_empty()); + } + #[test] + fn renameat_is_not_part_of_the_riscv64_abi() { + let bus = user_ecall(|cpu, bus| { + bus.write_cstring(0x2000, "/tmp/a"); + cpu.regs.write(17, 38); + cpu.regs.write(11, 0x2000); + }); assert!(bus.entries.is_empty()); } @@ -499,7 +657,6 @@ mod tests { let mut cpu = Hart::new(0); cpu.priv_mode = PrivMode::S; cpu.regs.write(17, SYS_OPENAT); - cpu.regs.write(16, 0); cpu.run(&mut bus, 1); @@ -513,13 +670,13 @@ mod tests { let mut cpu = Hart::new(0); cpu.priv_mode = PrivMode::S; - cpu.csr.sscratch = 0x88_e600; + cpu.csr.sscratch = 0xffff_ffd8_0088_e600; cpu.csr.sepc = 0x1000; cpu.regs.write(10, 3); cpu.run(&mut bus, 1); - assert_eq!(bus.returns, vec![(0x88_e600, 0x1000, 3)]); + assert_eq!(bus.returns, vec![(0xffff_ffd8_0088_e600, 0x1000, 3)]); assert_eq!(cpu.regs.pc, 0x1000); assert_eq!(cpu.priv_mode, PrivMode::U); } @@ -531,7 +688,7 @@ mod tests { let mut cpu = Hart::new(0); cpu.priv_mode = PrivMode::S; - cpu.csr.mstatus |= 1 << 8; // SPP = 1: sret goes back to S, not U + cpu.csr.mstatus |= 1 << 8; // SPP = S cpu.csr.sscratch = 0x1234; cpu.run(&mut bus, 1); From 65b667b655cd1433043f6dd8c3bab8c2b98a57e2 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Thu, 17 Sep 2026 17:01:47 +0200 Subject: [PATCH 05/11] classify commands handling only staging files as internal vpod tasks --- crates/machine/src/trace/syscalls.rs | 53 ++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/machine/src/trace/syscalls.rs b/crates/machine/src/trace/syscalls.rs index 4d66fdea..e2cd7fe9 100644 --- a/crates/machine/src/trace/syscalls.rs +++ b/crates/machine/src/trace/syscalls.rs @@ -105,8 +105,11 @@ impl SyscallTracer { argv_truncated: bool, outcome: ExecOutcome, ) { + let runs_vpod_plumbing = matches!(&path, GuestString::Value(text) if text.starts_with(VPOD_HELPER_PREFIX)) + || handles_only_staging_files(&argv); + if let ExecOutcome::Succeeded = outcome { - if matches!(&path, GuestString::Value(text) if text.starts_with(VPOD_HELPER_PREFIX)) { + if runs_vpod_plumbing { self.internal_tasks.insert(task); } else { self.internal_tasks.remove(&task); @@ -117,6 +120,7 @@ impl SyscallTracer { return; } + let internal = runs_vpod_plumbing || self.internal_tasks.contains(&task); let mut fields = task_fields(task); push_guest_string(&mut fields, PATH_KEYS, path); fields.push(("argv", argv.into())); @@ -128,7 +132,7 @@ impl SyscallTracer { ExecOutcome::Failed(value) => fields.push(("result", Value::from(value as i32))), ExecOutcome::Unknown => fields.push(("result", Value::Null)), } - self.record("process.exec", fields, self.internal_tasks.contains(&task)); + self.record("process.exec", fields, internal); } fn emit_exit(&mut self, task: u64, code: i32) { @@ -309,6 +313,21 @@ fn task_fields(task: u64) -> Vec<(&'static str, Value)> { vec![("task", Value::from(format!("{task:x}")))] } +fn handles_only_staging_files(argv: &[String]) -> bool { + let Some((program, arguments)) = argv.split_first() else { + return false; + }; + let program = program.rsplit('/').next().unwrap_or(program); + let mut operands = arguments + .iter() + .filter(|argument| !argument.starts_with('-')) + .peekable(); + + matches!(program, "base64" | "rm") + && operands.peek().is_some() + && operands.all(|operand| operand.starts_with(VPOD_STAGING_PREFIX)) +} + fn is_vpod_plumbing(path: &GuestString) -> bool { matches!(path, GuestString::Value(text) if VPOD_DEVICES.contains(&text.as_str()) || text.starts_with(VPOD_STAGING_PREFIX)) @@ -550,6 +569,36 @@ mod tests { assert!(events[2].get("internal").is_none()); } + #[test] + fn staging_a_long_command_is_internal_but_running_it_is_not() { + let (tracer, mut syscalls) = traced(); + + succeed_exec( + &mut syscalls, + "/bin/base64", + &["base64", "-d", "/tmp/.vpod_cmd.b64"], + ); + succeed_exec( + &mut syscalls, + "/bin/rm", + &["rm", "-f", "/tmp/.vpod_cmd.b64"], + ); + succeed_exec(&mut syscalls, "/bin/sh", &["sh", "/tmp/.vpod_cmd.sh"]); + succeed_exec( + &mut syscalls, + "/bin/rm", + &["rm", "-f", "/tmp/.vpod_cmd.b64", "/tmp/notes.txt"], + ); + succeed_exec(&mut syscalls, "/bin/rm", &["rm", "-f"]); + + let events = drained(&tracer); + assert_eq!(events[0]["internal"], true); + assert_eq!(events[1]["internal"], true); + assert!(events[2].get("internal").is_none()); + assert!(events[3].get("internal").is_none()); + assert!(events[4].get("internal").is_none()); + } + #[test] fn an_exit_reports_the_status_the_shell_would_see_and_forgets_the_task() { let (tracer, mut syscalls) = traced(); From 3af89c63ff6879618b677a3118de0ca8cb4b8bf3 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Thu, 17 Sep 2026 18:09:20 +0200 Subject: [PATCH 06/11] add sandbox execution tracing support to TypeScript and Python SDKs --- sdks/python/tests/conftest.py | 41 ++- sdks/python/tests/test_trace.py | 156 +++++++++ sdks/python/tests/test_trace_integration.py | 66 ++++ sdks/python/vpod/__init__.py | 7 + sdks/python/vpod/_component.py | 8 +- sdks/python/vpod/code.py | 9 +- sdks/python/vpod/commands.py | 14 +- sdks/python/vpod/execution.py | 23 +- sdks/python/vpod/sandbox.py | 45 ++- sdks/python/vpod/trace.py | 364 ++++++++++++++++++++ 10 files changed, 720 insertions(+), 13 deletions(-) create mode 100644 sdks/python/tests/test_trace.py create mode 100644 sdks/python/tests/test_trace_integration.py create mode 100644 sdks/python/vpod/trace.py diff --git a/sdks/python/tests/conftest.py b/sdks/python/tests/conftest.py index fa995b6e..e5cd31ca 100644 --- a/sdks/python/tests/conftest.py +++ b/sdks/python/tests/conftest.py @@ -1,3 +1,5 @@ +import json + import pytest from pathlib import Path from unittest.mock import MagicMock @@ -26,6 +28,7 @@ def mock_component(request, monkeypatch): sessions = {} session_counter = {"id": 0} stdin_writes = [] + traced_sessions = {} def fake_execute(snapshot_path, command): import subprocess @@ -72,7 +75,40 @@ def fake_session_exec(sid, command): def fake_session_close(sid): sessions.pop(sid, None) + def record_exec(sid, command): + session = traced_sessions.get(sid) + if session is None or command is None: + return + event = { + "v": 1, + "seq": session["seq"], + "guest_ns": session["seq"], + "wall_ms": 0, + "kind": "process.exec", + "task": f"{sid:x}", + "path": "/bin/sh", + "argv": ["sh", "-c", command], + } + session["seq"] += 1 + session["pending"].append((json.dumps(event) + "\n").encode()) + + def fake_session_trace_start(sid, options): + traced_sessions[sid] = {"options": options, "pending": [], "seq": 0} + return FakeVariant(tag="ok", payload=None) + + def fake_session_trace_drain(sid, max_bytes): + session = traced_sessions.get(sid) + if session is None: + return FakeVariant(tag="err", payload="tracing is not enabled for this session") + pending, session["pending"] = session["pending"], [] + return FakeVariant(tag="ok", payload=b"".join(pending)) + + def fake_session_trace_stop(sid): + traced_sessions.pop(sid, None) + return FakeVariant(tag="ok", payload=None) + def fake_session_exec_slice(sid, command, timeout=None, slice_nanos=0, mode="closed"): + record_exec(sid, command) payload = fake_session_exec(sid, command).payload return FakeVariant( tag="ok", @@ -94,6 +130,9 @@ def fake_session_stdin(sid, data): "session-interrupt": fake_session_interrupt, "session-stdin": fake_session_stdin, "session-close": fake_session_close, + "session-trace-start": fake_session_trace_start, + "session-trace-drain": fake_session_trace_drain, + "session-trace-stop": fake_session_trace_stop, } from vpod.snapshots import PulledSnapshot @@ -114,4 +153,4 @@ def fake_session_stdin(sid, data): lambda path, snap=None, mounts=None, **kwargs: (store, exports), ) - return {"exports": exports, "stdin_writes": stdin_writes} + return {"exports": exports, "stdin_writes": stdin_writes, "traced_sessions": traced_sessions} diff --git a/sdks/python/tests/test_trace.py b/sdks/python/tests/test_trace.py new file mode 100644 index 00000000..02523683 --- /dev/null +++ b/sdks/python/tests/test_trace.py @@ -0,0 +1,156 @@ +import json +import threading +from dataclasses import asdict +from pathlib import Path + +import pytest + +from vpod import Sandbox, Trace +from vpod.trace import NOT_ENABLED, NOT_SUPPORTED, trace_options + +FIXTURES = json.loads( + (Path(__file__).resolve().parents[2] / "trace-summaries.json").read_text() +)["cases"] + + +@pytest.mark.parametrize("case", FIXTURES, ids=[case["name"] for case in FIXTURES]) +def test_summaries_match_the_shared_fixture(case): + trace = Trace(case["events"]) + + assert [asdict(entry) for entry in trace.files()] == case["files"] + assert [asdict(entry) for entry in trace.files(internal=True, noise=True)] == case[ + "files_including_internal_and_noise" + ] + assert [asdict(entry) for entry in trace.network()] == case["network"] + assert [asdict(entry) for entry in trace.network(internal=True)] == case[ + "network_including_internal" + ] + assert [asdict(entry) for entry in trace.processes()] == case["processes"] + assert [asdict(entry) for entry in trace.processes(internal=True)] == case[ + "processes_including_internal" + ] + assert trace.complete is case["complete"] + + +@pytest.mark.parametrize("case", FIXTURES, ids=[case["name"] for case in FIXTURES]) +def test_json_lines_round_trip_every_event(case): + lines = Trace(case["events"]).to_jsonl().splitlines() + assert [json.loads(line) for line in lines] == case["events"] + + +def test_a_trace_is_a_copy_the_caller_cannot_change(): + events = [{"v": 1, "seq": 0, "guest_ns": 0, "wall_ms": 0, "kind": "trace.dropped", "count": 1}] + trace = Trace(events) + events.clear() + trace.events.clear() + assert len(trace.events) == 1 + + +def test_true_turns_every_source_on(): + assert trace_options(True) == { + "processes": True, + "files": True, + "network": True, + "mounts": True, + "buffer_bytes": 0, + } + + +def test_a_dict_turns_on_only_the_sources_it_names(): + assert trace_options({"network": True}) == { + "processes": False, + "files": False, + "network": True, + "mounts": False, + "buffer_bytes": 0, + } + + +def test_an_unknown_source_is_refused(): + with pytest.raises(ValueError, match="netwrok"): + trace_options({"netwrok": True}) + + +def test_each_command_carries_only_its_own_trace(mock_component): + with Sandbox.create(trace=True) as sbx: + first = sbx.commands.run("echo one") + second = sbx.commands.run("echo two") + + assert [node.argv for node in first.trace.processes()] == [["sh", "-c", "echo one"]] + assert [node.argv for node in second.trace.processes()] == [["sh", "-c", "echo two"]] + assert [node.argv[2] for node in sbx.trace.collect().processes()] == [ + "echo one", + "echo two", + ] + + +def test_the_trace_starts_with_the_sources_asked_for(mock_component): + with Sandbox.create(trace={"files": True, "buffer_bytes": 4096}) as sbx: + sbx.commands.run("true") + (session,) = mock_component["traced_sessions"].values() + options = session["options"] + + assert (options.processes, options.files, options.network, options.mounts) == ( + False, + True, + False, + False, + ) + assert getattr(options, "buffer-bytes") == 4096 + + +def test_the_whole_trace_survives_closing_the_sandbox(mock_component): + sbx = Sandbox.create(trace=True) + with sbx: + sbx.commands.run("echo kept") + + assert [node.argv[2] for node in sbx.trace.collect().processes()] == ["echo kept"] + + +def test_clear_forgets_what_was_recorded(mock_component): + with Sandbox.create(trace=True) as sbx: + sbx.commands.run("echo before") + sbx.trace.clear() + sbx.commands.run("echo after") + + assert [node.argv[2] for node in sbx.trace.collect().processes()] == ["echo after"] + + +def test_watch_follows_commands_as_they_run_and_ends_when_the_sandbox_closes(mock_component): + sbx = Sandbox.create(trace=True) + seen = [] + with sbx: + events = sbx.trace.watch() + follower = threading.Thread(target=lambda: seen.extend(events)) + follower.start() + sbx.commands.run("echo watched") + follower.join(timeout=5) + + assert not follower.is_alive() + assert [event["argv"][2] for event in seen] == ["echo watched"] + + +def test_without_tracing_every_trace_says_how_to_turn_it_on(mock_component): + with Sandbox.create() as sbx: + result = sbx.commands.run("echo hi") + + with pytest.raises(RuntimeError, match="trace=True"): + result.trace + with pytest.raises(RuntimeError, match="trace=True"): + sbx.trace.collect() + with pytest.raises(RuntimeError, match="trace=True"): + sbx.trace.watch() + + assert "trace=True" in NOT_ENABLED + + +def test_an_engine_without_trace_support_is_refused_up_front(mock_component): + for name in ("session-trace-start", "session-trace-drain", "session-trace-stop"): + del mock_component["exports"][name] + + with pytest.raises(RuntimeError, match="engine=\"default\""): + Sandbox.create(trace=True) + assert "engine=\"default\"" in NOT_SUPPORTED + + with Sandbox.create() as sbx: + assert sbx.commands.run("echo untraced").success diff --git a/sdks/python/tests/test_trace_integration.py b/sdks/python/tests/test_trace_integration.py new file mode 100644 index 00000000..c18effde --- /dev/null +++ b/sdks/python/tests/test_trace_integration.py @@ -0,0 +1,66 @@ +import pytest + +from vpod import Sandbox + +pytestmark = pytest.mark.integration + + +def test_a_shell_script_records_the_commands_it_runs_and_the_files_it_writes(): + script = "mkdir -p /tmp/traced && echo hi > /tmp/traced/out.txt && cat /tmp/traced/out.txt" + with Sandbox.create(trace=True) as sbx: + result = sbx.commands.run(f"sh -c '{script}'") + assert result.success + + commands = [node.argv for node in result.trace.processes()] + assert commands[0] == ["sh", "-c", script] + assert any(argv[0] == "cat" for argv in commands), commands + + written = [file.path for file in result.trace.files() if file.written] + assert "/tmp/traced/out.txt" in written, written + assert result.trace.complete + + +def test_code_run_activity_belongs_to_the_user_not_to_vpod(): + with Sandbox.create(trace=True) as sbx: + execution = sbx.code.run("open('/tmp/from_code.txt', 'w').write('x')") + assert execution.success, execution.error + + written = [file.path for file in execution.trace.files() if file.written] + assert "/tmp/from_code.txt" in written, written + + +def test_each_command_keeps_its_own_events_and_the_sandbox_keeps_them_all(): + with Sandbox.create(trace=True) as sbx: + first = sbx.commands.run("touch /tmp/first") + second = sbx.commands.run("touch /tmp/second") + + def paths(trace): + return [file.path for file in trace.files()] + + assert "/tmp/first" in paths(first.trace) + assert "/tmp/second" not in paths(first.trace) + assert "/tmp/second" in paths(second.trace) + assert {"/tmp/first", "/tmp/second"} <= set(paths(sbx.trace.collect())) + + +def test_vpod_plumbing_is_hidden_unless_asked_for(): + with Sandbox.create(trace=True) as sbx: + trace = sbx.commands.run("true").trace + + assert "/dev/ttyS1" not in [file.path for file in trace.files(noise=True)] + assert "/dev/ttyS1" in [file.path for file in trace.files(internal=True, noise=True)] + + +def test_a_resumed_sandbox_starts_a_fresh_trace(): + with Sandbox.create(trace=True) as sbx: + sbx.commands.run("touch /tmp/before-suspend") + instance_id = sbx.suspend() + assert "/tmp/before-suspend" in [file.path for file in sbx.trace.collect().files()] + + resumed = Sandbox.resume(instance_id, trace=True) + try: + after = resumed.commands.run("touch /tmp/after-resume") + assert "/tmp/after-resume" in [file.path for file in after.trace.files()] + assert "/tmp/before-suspend" not in [file.path for file in resumed.trace.collect().files()] + finally: + resumed.close() diff --git a/sdks/python/vpod/__init__.py b/sdks/python/vpod/__init__.py index f821f30e..c6420c91 100644 --- a/sdks/python/vpod/__init__.py +++ b/sdks/python/vpod/__init__.py @@ -1,6 +1,7 @@ from .sandbox import Sandbox, INSTANCES_DIR from .execution import CommandResult, CodeExecution from .snapshots import SnapshotAuthError +from .trace import FileActivity, HttpRequest, NetworkActivity, ProcessNode, Trace, TraceRecorder __version__ = "0.0.0" __all__ = [ @@ -9,4 +10,10 @@ "CommandResult", "CodeExecution", "SnapshotAuthError", + "Trace", + "TraceRecorder", + "FileActivity", + "NetworkActivity", + "HttpRequest", + "ProcessNode", ] diff --git a/sdks/python/vpod/_component.py b/sdks/python/vpod/_component.py index f7fd36fe..edec658f 100644 --- a/sdks/python/vpod/_component.py +++ b/sdks/python/vpod/_component.py @@ -374,7 +374,7 @@ def call(*args): return call - return { + exports = { name: get_export(name) for name in ( "session-start", @@ -388,6 +388,12 @@ def call(*args): ) } + trace_exports = ("session-trace-start", "session-trace-drain", "session-trace-stop") + if all(instance.get_export_index(store, name, iface_index) is not None for name in trace_exports): + exports.update({name: get_export(name) for name in trace_exports}) + + return exports + def _instance_key(snap_dir: str, mount_dirs: list[str] | None) -> str: parts = [snap_dir] diff --git a/sdks/python/vpod/code.py b/sdks/python/vpod/code.py index 4d3bdbba..fd21c819 100644 --- a/sdks/python/vpod/code.py +++ b/sdks/python/vpod/code.py @@ -12,10 +12,11 @@ class Code: """Code execution interface for a sandbox — persistent Python REPL.""" - def __init__(self, get_exports, snapshot_path: str, get_session_id): + def __init__(self, get_exports, snapshot_path: str, get_session_id, recorder): self._get_exports = get_exports self._snapshot_path = snapshot_path self._get_session_id = get_session_id + self._recorder = recorder def run(self, code: str, timeout: int = 120) -> CodeExecution: """Run Python code in a persistent REPL. State lives in memory across calls.""" @@ -26,7 +27,13 @@ def run(self, code: str, timeout: int = 120) -> CodeExecution: "Use 'with Sandbox.create() as sandbox:'" ) + trace_mark = self._recorder._mark() result = unwrap_result(self._get_exports()["session-exec"](session_id, "\x00" + code, timeout)) + execution = self._interpret(result, timeout) + execution._trace = self._recorder._since(trace_mark) + return execution + + def _interpret(self, result, timeout: int) -> CodeExecution: output = result.stdout if hasattr(result, 'stdout') else str(result) stderr = result.stderr if hasattr(result, 'stderr') else "" diff --git a/sdks/python/vpod/commands.py b/sdks/python/vpod/commands.py index cf3ccd61..7a2bd606 100644 --- a/sdks/python/vpod/commands.py +++ b/sdks/python/vpod/commands.py @@ -27,9 +27,10 @@ def mode_for(stdin, tty: bool) -> str: class Execution: """A command in flight. Internal: `Commands.run` is the supported entry point.""" - def __init__(self, exports, session_id, command, timeout, mode): + def __init__(self, exports, session_id, command, timeout, mode, recorder): self._exports = exports self._session_id = session_id + self._recorder = recorder self._mode = mode self._tty = mode == TERMINAL self._timeout = timeout @@ -70,6 +71,7 @@ def step(self) -> str: ) ) self._pending = None + self._recorder._drain() stdout_chunk = self._clean(slice_output.stdout) stderr_chunk = self._clean(slice_output.stderr or "") @@ -159,15 +161,16 @@ def _flush_input(self) -> None: class Commands: """Shell command execution interface for a sandbox.""" - def __init__(self, get_exports, snapshot_path: str, get_session_id): + def __init__(self, get_exports, snapshot_path: str, get_session_id, recorder): self._get_exports = get_exports self._snapshot_path = snapshot_path self._get_session_id = get_session_id + self._recorder = recorder self._running = None def _start(self, command: str, timeout: int, mode: str) -> Execution: execution = Execution( - self._get_exports(), self._get_session_id(), command, timeout, mode + self._get_exports(), self._get_session_id(), command, timeout, mode, self._recorder ) self._running = execution return execution @@ -182,6 +185,7 @@ def run( tty: bool = False, ) -> CommandResult: execution = self._start(command, timeout, mode_for(stdin, tty)) + trace_mark = self._recorder._mark() if stdin is not None: self._feed(execution, stdin) @@ -200,7 +204,9 @@ def run( if on_stderr is not None and len(execution.stderr) > before_err: on_stderr(execution.stderr[before_err:]) - return execution.result() + result = execution.result() + result._trace = self._recorder._since(trace_mark) + return result def interrupt(self) -> None: if self._running is not None: diff --git a/sdks/python/vpod/execution.py b/sdks/python/vpod/execution.py index 1d7130c7..fffea389 100644 --- a/sdks/python/vpod/execution.py +++ b/sdks/python/vpod/execution.py @@ -1,5 +1,8 @@ from dataclasses import dataclass, field -from typing import Optional +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from .trace import Trace def normalize_line_endings(value: str) -> str: @@ -11,16 +14,29 @@ def split_lines(value: str) -> list[str]: return trimmed.split("\n") if trimmed else [] +def _require_trace(trace: Optional["Trace"]) -> "Trace": + if trace is None: + from .trace import NOT_ENABLED + + raise RuntimeError(NOT_ENABLED) + return trace + + @dataclass class CommandResult: stdout: str stderr: str = "" exit_code: int = 0 + _trace: Optional["Trace"] = field(default=None, repr=False, compare=False) @property def success(self) -> bool: return self.exit_code == 0 + @property + def trace(self) -> "Trace": + return _require_trace(self._trace) + @dataclass class CodeExecution: @@ -28,7 +44,12 @@ class CodeExecution: error: Optional[str] = None logs: list[str] = field(default_factory=list) stderr: str = "" + _trace: Optional["Trace"] = field(default=None, repr=False, compare=False) @property def success(self) -> bool: return self.error is None + + @property + def trace(self) -> "Trace": + return _require_trace(self._trace) diff --git a/sdks/python/vpod/sandbox.py b/sdks/python/vpod/sandbox.py index 4eed5c01..e2c855e2 100644 --- a/sdks/python/vpod/sandbox.py +++ b/sdks/python/vpod/sandbox.py @@ -18,6 +18,7 @@ from ._result import unwrap_result as _unwrap_result from .code import Code from .commands import Commands +from .trace import TraceRecorder, trace_options INSTANCES_DIR = Path.home() / ".vpod" / "instances" @@ -54,9 +55,13 @@ def __init__( registry_url: str | None = None, api_key: str | None = None, engine: str = "auto", + trace=None, ): if engine not in engines.ENGINE_MODES: raise ValueError(f"engine must be one of {engines.ENGINE_MODES}, got {engine!r}") + self.trace = TraceRecorder( + trace_options(trace), lambda: (self._exports, self._shell_session_id) + ) pulled = snapshots._pull(snapshot, registry_url, api_key, engine_mode=engine) snapshot_path = pulled.path @@ -88,16 +93,20 @@ def __init__( ) self._tier = active_tier() + self.trace._require_support(self._exports) + self.commands = Commands( lambda: self._exports, self._snapshot_path, self._get_shell_session_id, + self.trace, ) self.code = Code( lambda: self._exports, self._snapshot_path, self._get_code_session_id, + self.trace, ) def _start_on_image_engine(self, chosen: dict, snapshot_path: Path, mount_dirs) -> bool: @@ -123,8 +132,16 @@ def create( registry_url: str | None = None, api_key: str | None = None, engine: str = "auto", + trace=None, ) -> "Sandbox": - return cls(snapshot, mounts=mounts, registry_url=registry_url, api_key=api_key, engine=engine) + return cls( + snapshot, + mounts=mounts, + registry_url=registry_url, + api_key=api_key, + engine=engine, + trace=trace, + ) @property def tier(self) -> str | None: @@ -148,6 +165,7 @@ def _get_shell_session_id(self) -> int: self._snapshot_path, _DEFAULT_SHELL, _DEFAULT_PROMPT, self._mount_entries() ) self._shell_session_id = int(_unwrap_result(result)) + self.trace._start(self._exports, self._shell_session_id) return self._shell_session_id def _maybe_upgrade_engine(self) -> None: @@ -184,6 +202,7 @@ def _resume(exports) -> None: _DEFAULT_PROMPT, self._mount_entries(), ) self._shell_session_id = int(_unwrap_result(result)) + self.trace._start(exports, self._shell_session_id) try: self._store, self._exports = load_component( @@ -215,8 +234,10 @@ def __exit__(self, *_) -> None: self.code.close() self._in_context = False if self._shell_session_id is not None: + self.trace._drain() self._exports["session-close"](self._shell_session_id) self._shell_session_id = None + self.trace._close() def close(self) -> None: self.__exit__() @@ -237,6 +258,7 @@ def suspend(self) -> str: instance_dir.mkdir(parents=True, exist_ok=True) delta_rel = f"instances/{instance_id}/delta.bin" + self.trace._drain() _unwrap_result(self._exports["session-suspend"](session_id, delta_rel)) (instance_dir / "meta.json").write_text(json.dumps({ @@ -252,7 +274,10 @@ def suspend(self) -> str: return instance_id @classmethod - def resume(cls, instance_id: str, mounts: dict[str, str] | None = None) -> "Sandbox": + def resume( + cls, instance_id: str, mounts: dict[str, str] | None = None, trace=None + ) -> "Sandbox": + options = trace_options(trace) instance_dir = INSTANCES_DIR / instance_id meta = json.loads((instance_dir / "meta.json").read_text()) delta_rel = f"instances/{instance_id}/delta.bin" @@ -299,6 +324,12 @@ def resume(cls, instance_id: str, mounts: dict[str, str] | None = None) -> "Sand store, exports = load_component(locate_wasm(), snapshot_path, mount_dirs or None) tier = active_tier() + instance = cls.__new__(cls) + instance.trace = TraceRecorder( + options, lambda: (instance._exports, instance._shell_session_id) + ) + instance.trace._require_support(exports) + mount_entries = [] for i, m in enumerate(saved_mounts): entry = object.__new__(type("MountEntry", (), {})) @@ -313,7 +344,6 @@ def resume(cls, instance_id: str, mounts: dict[str, str] | None = None) -> "Sand ) session_id = int(_unwrap_result(result)) - instance = cls.__new__(cls) instance._snapshot_path = snap_rel instance._snapshot_file = snapshot_path instance._tier = tier @@ -324,8 +354,13 @@ def resume(cls, instance_id: str, mounts: dict[str, str] | None = None) -> "Sand instance._exports = exports instance._shell_session_id = session_id instance._in_context = True - instance.commands = Commands(lambda: instance._exports, snap_rel, instance._get_shell_session_id) - instance.code = Code(lambda: instance._exports, snap_rel, instance._get_code_session_id) + instance.trace._start(exports, session_id) + instance.commands = Commands( + lambda: instance._exports, snap_rel, instance._get_shell_session_id, instance.trace + ) + instance.code = Code( + lambda: instance._exports, snap_rel, instance._get_code_session_id, instance.trace + ) Sandbox.destroy(instance_id) return instance diff --git a/sdks/python/vpod/trace.py b/sdks/python/vpod/trace.py new file mode 100644 index 00000000..460b0f8d --- /dev/null +++ b/sdks/python/vpod/trace.py @@ -0,0 +1,364 @@ +import json +import queue +import threading +from dataclasses import dataclass, field +from typing import Callable, Iterator, Optional +from urllib.parse import urlsplit + +from ._result import unwrap_result + +SOURCES = ("processes", "files", "network", "mounts") +DRAIN_ALL_BYTES = 0xFFFF_FFFF + +NOT_ENABLED = ( + "tracing is not enabled for this sandbox. Create it with " + "Sandbox.create(trace=True) to record what it does." +) +NOT_SUPPORTED = ( + "this sandbox runs on an engine without trace support, a snapshot's own " + "engine from an older vpod release. Create the sandbox with engine=\"default\" " + "to trace it." +) + +EACCES = -13 +EPERM = -1 + +NOISE_PREFIXES = ("/proc/", "/sys/", "/dev/", "/etc/ld-musl-") +NOISE_DIRECTORIES = ("/proc", "/sys", "/dev") + +_WATCH_CLOSED = object() + + +def trace_options(trace) -> Optional[dict]: + if trace is None or trace is False: + return None + if trace is True: + return {**{source: True for source in SOURCES}, "buffer_bytes": 0} + if isinstance(trace, dict): + unknown = set(trace) - set(SOURCES) - {"buffer_bytes"} + if unknown: + raise ValueError( + f"unknown trace options {sorted(unknown)}, expected {list(SOURCES) + ['buffer_bytes']}" + ) + return { + **{source: bool(trace.get(source, False)) for source in SOURCES}, + "buffer_bytes": int(trace.get("buffer_bytes", 0)), + } + raise TypeError(f"trace must be True or a dict of sources, got {trace!r}") + + +@dataclass +class FileActivity: + path: str + read: bool = False + written: bool = False + created: bool = False + deleted: bool = False + renamed_to: Optional[str] = None + renamed_from: Optional[str] = None + denied: bool = False + + +@dataclass +class HttpRequest: + method: str + url: str + + +@dataclass +class NetworkActivity: + host: Optional[str] + address: str + port: int + protocol: Optional[str] + requests: list[HttpRequest] = field(default_factory=list) + bytes_out: int = 0 + bytes_in: int = 0 + failed: bool = True + + +@dataclass +class ProcessNode: + pid: Optional[int] + path: Optional[str] + argv: list[str] + exit_code: Optional[int] + started_at: int + children: list["ProcessNode"] = field(default_factory=list) + + +class Trace: + """What a sandbox, or one command in it, did. Plain data, safe to keep.""" + + def __init__(self, events: list[dict]): + self._events = tuple(events) + + @property + def events(self) -> list[dict]: + return list(self._events) + + @property + def complete(self) -> bool: + return not any(event["kind"] == "trace.dropped" for event in self._events) + + def to_jsonl(self) -> str: + return "".join( + json.dumps(event, separators=(",", ":"), ensure_ascii=False) + "\n" + for event in self._events + ) + + def files(self, internal: bool = False, noise: bool = False) -> list[FileActivity]: + activities: dict[str, FileActivity] = {} + + def activity(path) -> Optional[FileActivity]: + if not isinstance(path, str): + return None + if path not in activities: + activities[path] = FileActivity(path) + return activities[path] + + def mark_denied(path, result) -> None: + if result in (EACCES, EPERM) and (entry := activity(path)) is not None: + entry.denied = True + + for event in self._events: + if event.get("internal") and not internal: + continue + kind = event["kind"] + + if kind in ("file.open", "mount.open"): + succeeded = event["result"] >= 0 if kind == "file.open" else event["result"] == 0 + if not succeeded: + mark_denied(event.get("path"), event["result"]) + continue + if (entry := activity(event.get("path"))) is None: + continue + + access = event.get("access") + entry.read |= access in ("read", "read-write") + entry.written |= access in ("write", "read-write") or bool(event.get("truncate")) + elif kind in ("file.rename", "mount.rename"): + if event["result"] != 0: + mark_denied(event.get("from"), event["result"]) + mark_denied(event.get("to"), event["result"]) + continue + source, destination = activity(event.get("from")), activity(event.get("to")) + if source is not None: + source.renamed_to = event.get("to") + if destination is not None: + destination.renamed_from = event.get("from") + destination.written = True + elif kind in ("file.delete", "mount.delete"): + if event["result"] != 0: + mark_denied(event.get("path"), event["result"]) + elif (entry := activity(event.get("path"))) is not None: + entry.deleted = True + elif kind in ("dir.create", "mount.mkdir", "mount.create"): + if event["result"] != 0: + mark_denied(event.get("path"), event["result"]) + elif (entry := activity(event.get("path"))) is not None: + entry.created = True + entry.written |= kind == "mount.create" + elif kind in ("file.truncate", "mount.truncate"): + if event["result"] != 0: + mark_denied(event.get("path"), event["result"]) + elif (entry := activity(event.get("path"))) is not None: + entry.written = True + elif kind == "mount.close": + bytes_read = event.get("bytes_read", 0) + bytes_written = event.get("bytes_written", 0) + if (bytes_read or bytes_written) and (entry := activity(event.get("path"))) is not None: + entry.read |= bytes_read > 0 + entry.written |= bytes_written > 0 + + return [entry for entry in activities.values() if noise or not _is_noise(entry)] + + def network(self, internal: bool = False) -> list[NetworkActivity]: + activities: dict[tuple[str, int], NetworkActivity] = {} + + def activity(event) -> Optional[NetworkActivity]: + address, port = event.get("address"), event.get("port") + if not isinstance(address, str) or not isinstance(port, int): + return None + + key = (address, port) + if key not in activities: + activities[key] = NetworkActivity( + host=None, address=address, port=port, protocol=None + ) + + entry = activities[key] + entry.host = entry.host or event.get("host") + return entry + + for event in self._events: + if event.get("internal") and not internal: + continue + kind = event["kind"] + + if kind not in ("net.connect", "net.flow", "net.udp", "net.http"): + continue + if (entry := activity(event)) is None: + continue + + if kind == "net.connect": + entry.protocol = entry.protocol or event.get("protocol") + entry.failed &= event["result"] != 0 + elif kind == "net.flow": + entry.protocol = entry.protocol or event.get("protocol") + entry.bytes_out += event.get("bytes_out", 0) + entry.bytes_in += event.get("bytes_in", 0) + entry.failed &= bool(event.get("failed")) + elif kind == "net.udp": + entry.protocol = entry.protocol or "udp" + entry.failed = False + else: + entry.host = entry.host or urlsplit(event["url"]).hostname + entry.requests.append(HttpRequest(event["method"], event["url"])) + + return list(activities.values()) + + def processes(self, internal: bool = False) -> list[ProcessNode]: + nodes: list[tuple[ProcessNode, bool]] = [] + running_by_task: dict[str, ProcessNode] = {} + + for event in self._events: + kind = event["kind"] + if kind == "process.exec" and "result" not in event: + node = ProcessNode( + pid=None, + path=event.get("path"), + argv=list(event.get("argv", [])), + exit_code=None, + started_at=event["guest_ns"], + ) + + nodes.append((node, bool(event.get("internal")))) + running_by_task[event["task"]] = node + elif kind == "process.exit" and event["task"] in running_by_task: + running_by_task.pop(event["task"]).exit_code = event["code"] + + return [node for node, is_internal in nodes if internal or not is_internal] + + +def _is_noise(entry: FileActivity) -> bool: + path = entry.path + if path in NOISE_DIRECTORIES or path.startswith(NOISE_PREFIXES): + return True + + only_read = not ( + entry.written + or entry.created + or entry.deleted + or entry.renamed_to is not None + or entry.renamed_from is not None + or entry.denied + ) + return only_read and (path.endswith(".so") or ".so." in path) + + +class TraceRecorder: + """The live record of a sandbox. `collect()` freezes what it has so far.""" + + def __init__(self, options: Optional[dict], session: Callable[[], tuple]): + self._options = options + self._session = session + self._events: list[dict] = [] + self._watchers: list[queue.Queue] = [] + self._lock = threading.Lock() + self._drain_lock = threading.Lock() + + @property + def enabled(self) -> bool: + return self._options is not None + + def collect(self) -> Trace: + self._require_enabled() + self._drain() + with self._lock: + return Trace(self._events) + + def watch(self) -> Iterator[dict]: + self._require_enabled() + feed: queue.Queue = queue.Queue() + with self._lock: + self._watchers.append(feed) + return self._follow(feed) + + def clear(self) -> None: + self._require_enabled() + self._drain() + with self._lock: + self._events.clear() + + def _follow(self, feed: queue.Queue) -> Iterator[dict]: + try: + while True: + event = feed.get() + if event is _WATCH_CLOSED: + return + yield event + finally: + with self._lock: + if feed in self._watchers: + self._watchers.remove(feed) + + def _require_enabled(self) -> None: + if not self.enabled: + raise RuntimeError(NOT_ENABLED) + + def _require_support(self, exports) -> None: + if self.enabled and "session-trace-start" not in exports: + raise RuntimeError(NOT_SUPPORTED) + + def _start(self, exports, session_id: int) -> None: + if self._options is None: + return + + record: object = object.__new__(type("TraceOptions", (), {})) + for source in SOURCES: + object.__setattr__(record, source, self._options[source]) + object.__setattr__(record, "buffer-bytes", self._options["buffer_bytes"]) + unwrap_result(exports["session-trace-start"](session_id, record)) + + def _drain(self) -> None: + if not self.enabled: + return + with self._drain_lock: + exports, session_id = self._session() + if session_id is None: + return + + drained = bytes( + unwrap_result(exports["session-trace-drain"](session_id, DRAIN_ALL_BYTES)) + ) + if not drained: + return + + events = [json.loads(line) for line in drained.decode().splitlines() if line] + with self._lock: + self._events.extend(events) + watchers = list(self._watchers) + for feed in watchers: + for event in events: + feed.put(event) + + def _mark(self) -> int: + if not self.enabled: + return 0 + self._drain() + with self._lock: + return len(self._events) + + def _since(self, mark: int) -> Optional[Trace]: + if not self.enabled: + return None + self._drain() + with self._lock: + return Trace(self._events[mark:]) + + def _close(self) -> None: + with self._lock: + watchers, self._watchers = self._watchers, [] + for feed in watchers: + feed.put(_WATCH_CLOSED) From 9b3c30f15ca555689ae529c707dfe5166206f10c Mon Sep 17 00:00:00 2001 From: Mavdol Date: Thu, 17 Sep 2026 18:29:49 +0200 Subject: [PATCH 07/11] add tracing support to typescript sdk --- sdks/trace-summaries.json | 150 ++++++ sdks/typescript/src/execution.ts | 29 +- sdks/typescript/src/index.ts | 11 + sdks/typescript/src/node/index.ts | 11 + sdks/typescript/src/node/transport.ts | 18 + sdks/typescript/src/runtime.ts | 17 + sdks/typescript/src/sandbox.ts | 81 ++- sdks/typescript/src/trace.ts | 478 ++++++++++++++++++ sdks/typescript/src/worker/dispatch.ts | 18 + sdks/typescript/src/worker/protocol.ts | 5 + .../tests/integration/trace.test.mjs | 134 +++++ sdks/typescript/tests/unit/trace.test.mjs | 73 +++ 12 files changed, 1020 insertions(+), 5 deletions(-) create mode 100644 sdks/trace-summaries.json create mode 100644 sdks/typescript/src/trace.ts create mode 100644 sdks/typescript/tests/integration/trace.test.mjs create mode 100644 sdks/typescript/tests/unit/trace.test.mjs diff --git a/sdks/trace-summaries.json b/sdks/trace-summaries.json new file mode 100644 index 00000000..41d4f5aa --- /dev/null +++ b/sdks/trace-summaries.json @@ -0,0 +1,150 @@ +{ + "cases": [ + { + "name": "an agent command that installs, downloads and edits files", + "events": [ + {"v": 1, "seq": 0, "guest_ns": 1000, "wall_ms": 1, "kind": "file.open", "task": "a1", "path": "/dev/ttyS1", "access": "write", "create": true, "truncate": true, "result": 3, "internal": true}, + {"v": 1, "seq": 1, "guest_ns": 1000, "wall_ms": 1, "kind": "process.fork", "task": "a1", "child_pid": 563, "thread": false}, + {"v": 1, "seq": 2, "guest_ns": 2000, "wall_ms": 1, "kind": "process.exec", "task": "b2", "path": "/usr/local/bin/sh", "argv": ["sh", "-c", "cd /app && make"], "result": -2}, + {"v": 1, "seq": 3, "guest_ns": 2000, "wall_ms": 1, "kind": "process.exec", "task": "b2", "path": "/bin/sh", "argv": ["sh", "-c", "cd /app && make"]}, + {"v": 1, "seq": 4, "guest_ns": 3000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "/etc/ld-musl-riscv64.path", "access": "read", "create": false, "truncate": false, "result": 3}, + {"v": 1, "seq": 5, "guest_ns": 3000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "/usr/lib/libz.so.1", "access": "read", "create": false, "truncate": false, "result": 3}, + {"v": 1, "seq": 6, "guest_ns": 3000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "/proc/self/stat", "access": "read", "create": false, "truncate": false, "result": 4}, + {"v": 1, "seq": 7, "guest_ns": 4000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "Makefile", "access": "read", "create": false, "truncate": false, "result": 3}, + {"v": 1, "seq": 8, "guest_ns": 4000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "/app/missing.h", "access": "read", "create": false, "truncate": false, "result": -2}, + {"v": 1, "seq": 9, "guest_ns": 5000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "/etc/shadow", "access": "read", "create": false, "truncate": false, "result": -13}, + {"v": 1, "seq": 10, "guest_ns": 5000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "/app/build/.out.tmp", "access": "write", "create": true, "truncate": true, "result": 5}, + {"v": 1, "seq": 11, "guest_ns": 6000, "wall_ms": 1, "kind": "file.rename", "task": "b2", "from": "/app/build/.out.tmp", "to": "/app/build/out", "result": 0}, + {"v": 1, "seq": 12, "guest_ns": 6000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "/app/build/ext.so", "access": "write", "create": true, "truncate": true, "result": 6}, + {"v": 1, "seq": 13, "guest_ns": 6000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "/app/db.sqlite", "access": "read-write", "create": false, "truncate": false, "result": 7}, + {"v": 1, "seq": 14, "guest_ns": 7000, "wall_ms": 1, "kind": "dir.create", "task": "b2", "path": "/app/cache", "result": 0}, + {"v": 1, "seq": 15, "guest_ns": 7000, "wall_ms": 1, "kind": "dir.create", "task": "b2", "path": "/app/build", "result": -17}, + {"v": 1, "seq": 16, "guest_ns": 7000, "wall_ms": 1, "kind": "file.truncate", "task": "b2", "path": "/app/log.txt", "size": 0, "result": 0}, + {"v": 1, "seq": 17, "guest_ns": 8000, "wall_ms": 1, "kind": "file.delete", "task": "b2", "path": "/app/old.o", "directory": false, "result": 0}, + {"v": 1, "seq": 18, "guest_ns": 8000, "wall_ms": 1, "kind": "file.delete", "task": "b2", "path": "/root/.ssh/authorized_keys", "directory": false, "result": -1}, + {"v": 1, "seq": 19, "guest_ns": 8000, "wall_ms": 1, "kind": "file.delete", "task": "c3", "path": "/tmp/.vpod_cmd.b64", "directory": false, "result": 0, "internal": true}, + {"v": 1, "seq": 20, "guest_ns": 9000, "wall_ms": 1, "kind": "net.dns", "name": "pypi.org", "type": "A", "answers": ["151.101.192.223"]}, + {"v": 1, "seq": 21, "guest_ns": 9000, "wall_ms": 1, "kind": "net.connect", "task": "b2", "protocol": "tcp", "address": "151.101.192.223", "port": 443, "host": "pypi.org", "result": 0}, + {"v": 1, "seq": 22, "guest_ns": 9500, "wall_ms": 1, "kind": "net.http", "protocol": "https", "method": "GET", "url": "https://pypi.org/simple/requests/", "address": "151.101.192.223", "port": 443}, + {"v": 1, "seq": 23, "guest_ns": 9600, "wall_ms": 1, "kind": "net.http", "protocol": "https", "method": "GET", "url": "https://pypi.org/simple/urllib3/", "address": "151.101.192.223", "port": 443}, + {"v": 1, "seq": 24, "guest_ns": 9700, "wall_ms": 1, "kind": "net.flow", "protocol": "tcp", "address": "151.101.192.223", "port": 443, "host": "pypi.org", "bytes_out": 252, "bytes_in": 84487, "duration_ns": 300, "failed": false}, + {"v": 1, "seq": 25, "guest_ns": 9800, "wall_ms": 1, "kind": "net.connect", "task": "b2", "protocol": "tcp", "address": "151.101.192.223", "port": 443, "host": "pypi.org", "result": -115}, + {"v": 1, "seq": 26, "guest_ns": 9900, "wall_ms": 1, "kind": "net.flow", "protocol": "tcp", "address": "151.101.192.223", "port": 443, "host": "pypi.org", "bytes_out": 100, "bytes_in": 1000, "duration_ns": 50, "failed": false}, + {"v": 1, "seq": 27, "guest_ns": 10000, "wall_ms": 1, "kind": "net.connect", "task": "b2", "protocol": null, "address": "127.0.0.1", "port": 9, "host": null, "result": -111}, + {"v": 1, "seq": 28, "guest_ns": 10000, "wall_ms": 1, "kind": "net.http", "protocol": "http", "method": "POST", "url": "http://example.com/upload", "address": "93.184.215.14", "port": 80}, + {"v": 1, "seq": 29, "guest_ns": 10000, "wall_ms": 1, "kind": "net.flow", "protocol": "tcp", "address": "93.184.215.14", "port": 80, "host": null, "bytes_out": 11, "bytes_in": 0, "duration_ns": 5, "failed": true}, + {"v": 1, "seq": 30, "guest_ns": 10500, "wall_ms": 1, "kind": "net.udp", "address": "8.8.8.8", "port": 123, "host": null}, + {"v": 1, "seq": 31, "guest_ns": 10600, "wall_ms": 1, "kind": "net.connect", "task": "c3", "protocol": "tcp", "address": "10.0.2.2", "port": 8080, "host": null, "result": 0, "internal": true}, + {"v": 1, "seq": 32, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.open", "pid": 12, "path": "/data/input.csv", "access": "read", "truncate": false, "result": 0}, + {"v": 1, "seq": 33, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.close", "pid": 12, "path": "/data/input.csv", "bytes_read": 4096, "bytes_written": 0}, + {"v": 1, "seq": 34, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.create", "pid": 12, "path": "/data/report.md", "result": 0}, + {"v": 1, "seq": 35, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.close", "pid": 12, "path": "/data/report.md", "bytes_read": 0, "bytes_written": 512}, + {"v": 1, "seq": 36, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.mkdir", "pid": 12, "path": "/data/out", "result": 0}, + {"v": 1, "seq": 37, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.rename", "pid": 12, "from": "/data/draft.md", "to": "/data/out/final.md", "result": 0}, + {"v": 1, "seq": 38, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.delete", "pid": 12, "path": "/data/stale.lock", "directory": false, "result": 0}, + {"v": 1, "seq": 39, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.truncate", "pid": 12, "path": "/data/log.txt", "size": 0, "result": 0}, + {"v": 1, "seq": 40, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.open", "pid": 12, "path": "/data/secret.key", "access": "read", "truncate": false, "result": -13}, + {"v": 1, "seq": 41, "guest_ns": 12000, "wall_ms": 1, "kind": "process.exec", "task": "d4", "path": "/usr/lib/vpod/vpod-seed-entropy", "argv": ["vpod-seed-entropy", "ab12"], "internal": true}, + {"v": 1, "seq": 42, "guest_ns": 12000, "wall_ms": 1, "kind": "process.exit", "task": "d4", "code": 0, "internal": true}, + {"v": 1, "seq": 43, "guest_ns": 13000, "wall_ms": 1, "kind": "process.exec", "task": "b2", "path": "/usr/bin/make", "argv": ["make"]}, + {"v": 1, "seq": 44, "guest_ns": 14000, "wall_ms": 1, "kind": "process.exec", "task": "e5", "path": "/usr/bin/cc", "argv": ["cc", "-o", "build/out", "main.c"]}, + {"v": 1, "seq": 45, "guest_ns": 15000, "wall_ms": 1, "kind": "process.exit", "task": "e5", "code": 1}, + {"v": 1, "seq": 46, "guest_ns": 16000, "wall_ms": 1, "kind": "process.exit", "task": "b2", "code": 2}, + {"v": 1, "seq": 47, "guest_ns": 17000, "wall_ms": 1, "kind": "process.exec", "task": "b2", "path": "/usr/bin/python3", "argv": ["python3", "-c", "print(1)"], "argv_truncated": true} + ], + "complete": true, + "files": [ + {"path": "Makefile", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/etc/shadow", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true}, + {"path": "/app/build/.out.tmp", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": "/app/build/out", "renamed_from": null, "denied": false}, + {"path": "/app/build/out", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": "/app/build/.out.tmp", "denied": false}, + {"path": "/app/build/ext.so", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/app/db.sqlite", "read": true, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/app/cache", "read": false, "written": false, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/app/log.txt", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/app/old.o", "read": false, "written": false, "created": false, "deleted": true, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/root/.ssh/authorized_keys", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true}, + {"path": "/data/input.csv", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/data/report.md", "read": false, "written": true, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/data/out", "read": false, "written": false, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/data/draft.md", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": "/data/out/final.md", "renamed_from": null, "denied": false}, + {"path": "/data/out/final.md", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": "/data/draft.md", "denied": false}, + {"path": "/data/stale.lock", "read": false, "written": false, "created": false, "deleted": true, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/data/log.txt", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/data/secret.key", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true} + ], + "files_including_internal_and_noise": [ + {"path": "/dev/ttyS1", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/etc/ld-musl-riscv64.path", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/usr/lib/libz.so.1", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/proc/self/stat", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "Makefile", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/etc/shadow", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true}, + {"path": "/app/build/.out.tmp", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": "/app/build/out", "renamed_from": null, "denied": false}, + {"path": "/app/build/out", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": "/app/build/.out.tmp", "denied": false}, + {"path": "/app/build/ext.so", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/app/db.sqlite", "read": true, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/app/cache", "read": false, "written": false, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/app/log.txt", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/app/old.o", "read": false, "written": false, "created": false, "deleted": true, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/root/.ssh/authorized_keys", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true}, + {"path": "/tmp/.vpod_cmd.b64", "read": false, "written": false, "created": false, "deleted": true, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/data/input.csv", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/data/report.md", "read": false, "written": true, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/data/out", "read": false, "written": false, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/data/draft.md", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": "/data/out/final.md", "renamed_from": null, "denied": false}, + {"path": "/data/out/final.md", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": "/data/draft.md", "denied": false}, + {"path": "/data/stale.lock", "read": false, "written": false, "created": false, "deleted": true, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/data/log.txt", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/data/secret.key", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true} + ], + "network": [ + {"host": "pypi.org", "address": "151.101.192.223", "port": 443, "protocol": "tcp", "requests": [{"method": "GET", "url": "https://pypi.org/simple/requests/"}, {"method": "GET", "url": "https://pypi.org/simple/urllib3/"}], "bytes_out": 352, "bytes_in": 85487, "failed": false}, + {"host": null, "address": "127.0.0.1", "port": 9, "protocol": null, "requests": [], "bytes_out": 0, "bytes_in": 0, "failed": true}, + {"host": "example.com", "address": "93.184.215.14", "port": 80, "protocol": "tcp", "requests": [{"method": "POST", "url": "http://example.com/upload"}], "bytes_out": 11, "bytes_in": 0, "failed": true}, + {"host": null, "address": "8.8.8.8", "port": 123, "protocol": "udp", "requests": [], "bytes_out": 0, "bytes_in": 0, "failed": false} + ], + "network_including_internal": [ + {"host": "pypi.org", "address": "151.101.192.223", "port": 443, "protocol": "tcp", "requests": [{"method": "GET", "url": "https://pypi.org/simple/requests/"}, {"method": "GET", "url": "https://pypi.org/simple/urllib3/"}], "bytes_out": 352, "bytes_in": 85487, "failed": false}, + {"host": null, "address": "127.0.0.1", "port": 9, "protocol": null, "requests": [], "bytes_out": 0, "bytes_in": 0, "failed": true}, + {"host": "example.com", "address": "93.184.215.14", "port": 80, "protocol": "tcp", "requests": [{"method": "POST", "url": "http://example.com/upload"}], "bytes_out": 11, "bytes_in": 0, "failed": true}, + {"host": null, "address": "8.8.8.8", "port": 123, "protocol": "udp", "requests": [], "bytes_out": 0, "bytes_in": 0, "failed": false}, + {"host": null, "address": "10.0.2.2", "port": 8080, "protocol": "tcp", "requests": [], "bytes_out": 0, "bytes_in": 0, "failed": false} + ], + "processes": [ + {"pid": null, "path": "/bin/sh", "argv": ["sh", "-c", "cd /app && make"], "exit_code": null, "started_at": 2000, "children": []}, + {"pid": null, "path": "/usr/bin/make", "argv": ["make"], "exit_code": 2, "started_at": 13000, "children": []}, + {"pid": null, "path": "/usr/bin/cc", "argv": ["cc", "-o", "build/out", "main.c"], "exit_code": 1, "started_at": 14000, "children": []}, + {"pid": null, "path": "/usr/bin/python3", "argv": ["python3", "-c", "print(1)"], "exit_code": null, "started_at": 17000, "children": []} + ], + "processes_including_internal": [ + {"pid": null, "path": "/bin/sh", "argv": ["sh", "-c", "cd /app && make"], "exit_code": null, "started_at": 2000, "children": []}, + {"pid": null, "path": "/usr/lib/vpod/vpod-seed-entropy", "argv": ["vpod-seed-entropy", "ab12"], "exit_code": 0, "started_at": 12000, "children": []}, + {"pid": null, "path": "/usr/bin/make", "argv": ["make"], "exit_code": 2, "started_at": 13000, "children": []}, + {"pid": null, "path": "/usr/bin/cc", "argv": ["cc", "-o", "build/out", "main.c"], "exit_code": 1, "started_at": 14000, "children": []}, + {"pid": null, "path": "/usr/bin/python3", "argv": ["python3", "-c", "print(1)"], "exit_code": null, "started_at": 17000, "children": []} + ] + }, + { + "name": "a buffer that overflowed", + "events": [ + {"v": 1, "seq": 0, "guest_ns": 1, "wall_ms": 1, "kind": "file.open", "task": "a1", "path": "/tmp/a", "access": "write", "create": true, "truncate": true, "result": 3}, + {"v": 1, "seq": 1, "guest_ns": 2, "wall_ms": 1, "kind": "trace.dropped", "count": 912}, + {"v": 1, "seq": 2, "guest_ns": 3, "wall_ms": 1, "kind": "file.open", "task": "a1", "path": "/tmp/b", "access": "read", "create": false, "truncate": false, "result": 3} + ], + "complete": false, + "files": [ + {"path": "/tmp/a", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/tmp/b", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false} + ], + "files_including_internal_and_noise": [ + {"path": "/tmp/a", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, + {"path": "/tmp/b", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false} + ], + "network": [], + "network_including_internal": [], + "processes": [], + "processes_including_internal": [] + } + ] +} diff --git a/sdks/typescript/src/execution.ts b/sdks/typescript/src/execution.ts index 8dfa7f78..7d274e16 100644 --- a/sdks/typescript/src/execution.ts +++ b/sdks/typescript/src/execution.ts @@ -1,17 +1,32 @@ +import { TRACE_NOT_ENABLED, type Trace } from "./trace.js"; + +function requireTrace(trace: Trace | null): Trace { + if (trace === null) { + throw new Error(TRACE_NOT_ENABLED); + } + return trace; +} + export class CommandResult { readonly stdout: string; readonly stderr: string; readonly exitCode: number; + readonly #trace: Trace | null; - constructor(stdout: string, stderr = "", exitCode = 0) { + constructor(stdout: string, stderr = "", exitCode = 0, trace: Trace | null = null) { this.stdout = stdout; this.stderr = stderr; this.exitCode = exitCode; + this.#trace = trace; } get success(): boolean { return this.exitCode === 0; } + + get trace(): Trace { + return requireTrace(this.#trace); + } } export class CodeExecution { @@ -19,22 +34,34 @@ export class CodeExecution { readonly error: string | null; readonly logs: string[]; readonly stderr: string; + readonly #trace: Trace | null; constructor( text: string, error: string | null = null, logs: string[] = [], stderr = "", + trace: Trace | null = null, ) { this.text = text; this.error = error; this.logs = logs; this.stderr = stderr; + this.#trace = trace; } get success(): boolean { return this.error === null; } + + get trace(): Trace { + return requireTrace(this.#trace); + } + + /** @internal */ + _withTrace(trace: Trace | null): CodeExecution { + return new CodeExecution(this.text, this.error, this.logs, this.stderr, trace); + } } export function normalizeLineEndings(value: string): string { diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index ae2f52cc..7c7a1f4d 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -13,6 +13,17 @@ export { normalizeLineEndings, } from "./execution.js"; +export { Trace, TraceRecorder } from "./trace.js"; +export type { + FileActivity, + HttpRequest, + NetworkActivity, + ProcessNode, + TraceEvent, + TraceSetting, + TraceSources, +} from "./trace.js"; + export { SandboxRuntime } from "./runtime.js"; export type { SandboxRuntimeOptions, StorageQuota } from "./runtime.js"; diff --git a/sdks/typescript/src/node/index.ts b/sdks/typescript/src/node/index.ts index 116df4b1..60b2d6f0 100644 --- a/sdks/typescript/src/node/index.ts +++ b/sdks/typescript/src/node/index.ts @@ -26,6 +26,17 @@ export { normalizeLineEndings, } from "../execution.js"; +export { Trace, TraceRecorder } from "../trace.js"; +export type { + FileActivity, + HttpRequest, + NetworkActivity, + ProcessNode, + TraceEvent, + TraceSetting, + TraceSources, +} from "../trace.js"; + export { SandboxRuntime } from "../runtime.js"; export type { SandboxRuntimeOptions, StorageQuota } from "../runtime.js"; diff --git a/sdks/typescript/src/node/transport.ts b/sdks/typescript/src/node/transport.ts index 5ad8d92b..cfe3fbee 100644 --- a/sdks/typescript/src/node/transport.ts +++ b/sdks/typescript/src/node/transport.ts @@ -13,6 +13,7 @@ import { FileSnapshotStore } from "./store.js"; import type { ComponentModule } from "../worker/component-imports.js"; import type { ExecutorTransport } from "../transport/types.js"; import type { ExecutionResult, WorkerCall } from "../worker/protocol.js"; +import type { WireTraceOptions } from "../trace.js"; interface Executor { sessionStart(snapshotPath: string, command: string, prompt: string, mounts: never[]): bigint; @@ -35,6 +36,9 @@ interface Executor { prompt: string, mounts: never[], ): bigint; + sessionTraceStart?(handle: bigint, options: WireTraceOptions): void; + sessionTraceDrain?(handle: bigint, maxBytes: number): Uint8Array; + sessionTraceStop?(handle: bigint): void; } export interface NodeTransportOptions { @@ -188,6 +192,20 @@ export class NodeDispatcher { } } + case "trace-supported": + return typeof this.#executor.sessionTraceStart === "function"; + + case "session-trace-start": + this.#executor.sessionTraceStart!(call.handle, call.options); + return null; + + case "session-trace-drain": + return this.#executor.sessionTraceDrain!(call.handle, call.maxBytes); + + case "session-trace-stop": + this.#executor.sessionTraceStop!(call.handle); + return null; + case "poll-stats": return { spinCount: 0, spinNanoseconds: 0 }; diff --git a/sdks/typescript/src/runtime.ts b/sdks/typescript/src/runtime.ts index de42dca1..c4570aef 100644 --- a/sdks/typescript/src/runtime.ts +++ b/sdks/typescript/src/runtime.ts @@ -7,6 +7,7 @@ import { import { capabilitiesOf } from "./net/capabilities.js"; import type { NetworkBackendName, NetworkCapabilities } from "./net/capabilities.js"; import type { ExecutorTransport } from "./transport/types.js"; +import type { WireTraceOptions } from "./trace.js"; import type { ExecutionResult, PullResult, @@ -152,6 +153,22 @@ export class SandboxRuntime { ); } + traceSupported(): Promise { + return this.#transport.call({ kind: "trace-supported" }); + } + + sessionTraceStart(handle: bigint, options: WireTraceOptions): Promise { + return this.#transport.call({ kind: "session-trace-start", handle, options }); + } + + sessionTraceDrain(handle: bigint, maxBytes: number): Promise { + return this.#transport.call({ kind: "session-trace-drain", handle, maxBytes }); + } + + sessionTraceStop(handle: bigint): Promise { + return this.#transport.call({ kind: "session-trace-stop", handle }); + } + pollStats(): Promise<{ spinCount: number; spinNanoseconds: number }> { return this.#transport.call({ kind: "poll-stats" }); } diff --git a/sdks/typescript/src/sandbox.ts b/sdks/typescript/src/sandbox.ts index 17d5ed99..e15af9d4 100644 --- a/sdks/typescript/src/sandbox.ts +++ b/sdks/typescript/src/sandbox.ts @@ -22,6 +22,13 @@ import { } from "./snapshots/engine.js"; import { defaultStore } from "./snapshots/index.js"; import { resolveRegistryUrl } from "./snapshots/registry.js"; +import { + TRACE_NOT_SUPPORTED, + TraceRecorder, + traceOptions, + type TraceSetting, + type WireTraceOptions, +} from "./trace.js"; const DEFAULT_SHELL = "/bin/sh"; const DEFAULT_PROMPT = "# "; @@ -54,6 +61,7 @@ export interface SandboxOptions extends SandboxRuntimeOptions { * and it is already cached; "default" always uses the bundled engine. */ engine?: EngineMode; + trace?: TraceSetting; } interface ImageEngine { @@ -110,6 +118,7 @@ export class Execution { #timeout: bigint; #mode: ExecMode; #tty: boolean; + #recorder: TraceRecorder | null; #outbox: Uint8Array[] = []; #eofPending = false; @@ -128,6 +137,7 @@ export class Execution { command: string, timeoutSeconds: number, mode: ExecMode, + recorder: TraceRecorder | null = null, ) { this.#runtime = runtime; this.#handle = handle; @@ -135,6 +145,7 @@ export class Execution { this.#timeout = BigInt(timeoutSeconds); this.#mode = mode; this.#tty = mode === "terminal"; + this.#recorder = recorder; } get done(): boolean { @@ -178,6 +189,7 @@ export class Execution { this.#mode, ); this.#pending = null; + await this.#recorder?._drain(); const stdoutChunk = this.#clean(slice.stdout); const stderrChunk = this.#clean(slice.stderr ?? ""); @@ -267,6 +279,7 @@ export class Commands { async run(command: string, options: RunOptions = {}): Promise { if (options.stdin === undefined && !options.tty && !options.onStdout && !options.onStderr) { + const traceMark = await this.#sandbox.trace._mark(); const result = await this.#sandbox._execSliced( command, options.timeout, @@ -277,11 +290,13 @@ export class Commands { normalizeLineEndings(result.stdout), normalizeLineEndings(result.stderr ?? ""), result.exitCode, + await this.#sandbox.trace._since(traceMark), ); } options.signal?.throwIfAborted(); const execution = await this.#start(command, options); + const traceMark = await this.#sandbox.trace._mark(); const feeding = options.stdin === undefined ? null : feedStdin(execution, options.stdin); @@ -312,7 +327,13 @@ export class Commands { } options.signal?.throwIfAborted(); - return execution.result(); + const result = execution.result(); + return new CommandResult( + result.stdout, + result.stderr, + result.exitCode, + await this.#sandbox.trace._since(traceMark), + ); } async interrupt(): Promise { @@ -380,6 +401,12 @@ export class Code { } async run(code: string, options: RunOptions = {}): Promise { + const traceMark = await this.#sandbox.trace._mark(); + const execution = await this.#run(code, options); + return execution._withTrace(await this.#sandbox.trace._since(traceMark)); + } + + async #run(code: string, options: RunOptions): Promise { const timeout = options.timeout ?? DEFAULT_TIMEOUT_SECONDS; const result = await this.#sandbox._exec(PYTHON_PREFIX + code, timeout); @@ -410,6 +437,7 @@ export class Code { export class Sandbox { readonly commands: Commands; readonly code: Code; + readonly trace: TraceRecorder; readonly #runtime: SandboxRuntime; readonly #snapshotPath: string; @@ -422,6 +450,7 @@ export class Sandbox { snapshotPath: string, snapshotId: string, imageEngineSha256: string | null, + trace: WireTraceOptions | null, ) { this.#runtime = runtime; this.#snapshotPath = snapshotPath; @@ -429,6 +458,11 @@ export class Sandbox { this.#imageEngineSha256 = imageEngineSha256; this.commands = new Commands(this); this.code = new Code(this); + this.trace = new TraceRecorder(trace, async (maxBytes) => + this.#sessionHandle === null + ? null + : this.#runtime.sessionTraceDrain(this.#sessionHandle, maxBytes), + ); } static async #withTransport( @@ -557,6 +591,16 @@ export class Sandbox { }; } + static async #requireTraceSupport( + runtime: SandboxRuntime, + trace: WireTraceOptions | null, + ): Promise { + if (trace !== null && !(await runtime.traceSupported())) { + runtime.terminate(); + throw new Error(TRACE_NOT_SUPPORTED); + } + } + static async #connectNetwork( runtime: SandboxRuntime, requested: boolean | undefined, @@ -585,10 +629,12 @@ export class Sandbox { throw new Error(`vpod: engine must be "auto" or "default", got ${JSON.stringify(options.engine)}`); } + const trace = traceOptions(options.trace); const snapshot = options.snapshot ?? DEFAULT_SNAPSHOT; const cachedEngine = typeof snapshot === "string" ? await Sandbox.#cachedImageEngine(options, snapshot) : null; const { runtime, imageEngine } = await Sandbox.#startRuntime(options, cachedEngine); + await Sandbox.#requireTraceSupport(runtime, trace); await Sandbox.#connectNetwork(runtime, options.network, options.corsProxy); @@ -598,7 +644,13 @@ export class Sandbox { options.registryUrl, options.apiKey, ); - return new Sandbox(runtime, mounted.snapshotPath, mounted.snapshotId, imageEngine?.sha256 ?? null); + return new Sandbox( + runtime, + mounted.snapshotPath, + mounted.snapshotId, + imageEngine?.sha256 ?? null, + trace, + ); } get snapshotId(): string { @@ -627,7 +679,7 @@ export class Sandbox { /** @internal */ async _start(command: string, timeoutSeconds: number, mode: ExecMode): Promise { const handle = await this.#ensureSession(); - return new Execution(this.#runtime, handle, command, timeoutSeconds, mode); + return new Execution(this.#runtime, handle, command, timeoutSeconds, mode, this.trace); } async _execSliced( @@ -655,6 +707,7 @@ export class Sandbox { SLICE_NANOS, ); code = null; + await this.trace._drain(); const stdoutChunk = normalizeLineEndings(slice.stdout); const stderrChunk = normalizeLineEndings(slice.stderr); @@ -702,12 +755,21 @@ export class Sandbox { DEFAULT_SHELL, DEFAULT_PROMPT, ); + await this.#startTrace(this.#sessionHandle); } return this.#sessionHandle; } + async #startTrace(handle: bigint): Promise { + const options = this.trace._options; + if (options !== null) { + await this.#runtime.sessionTraceStart(handle, options); + } + } + async suspend(): Promise { const handle = await this.#ensureSession(); + await this.trace._drain(); const suspended = await this.#runtime.sessionSuspend(handle); this.#sessionHandle = null; return new Uint8Array(suspended.deltaBytes); @@ -728,6 +790,7 @@ export class Sandbox { ? await (await InstanceStore.open()).load(instance) : instance; + const trace = traceOptions(options.trace); const snapshot = options.snapshot ?? resolved.snapshotId; let wanted: ImageEngine | null; if (resolved.engineSha256 !== undefined) { @@ -747,6 +810,7 @@ export class Sandbox { ); } + await Sandbox.#requireTraceSupport(runtime, trace); await Sandbox.#connectNetwork(runtime, options.network, options.corsProxy); const mounted = await Sandbox.#mount( @@ -756,7 +820,13 @@ export class Sandbox { options.apiKey, ); - const sandbox = new Sandbox(runtime, mounted.snapshotPath, mounted.snapshotId, imageEngine?.sha256 ?? null); + const sandbox = new Sandbox( + runtime, + mounted.snapshotPath, + mounted.snapshotId, + imageEngine?.sha256 ?? null, + trace, + ); const delta = resolved.delta.slice(); sandbox.#sessionHandle = await runtime.sessionResume( mounted.snapshotPath, @@ -764,6 +834,7 @@ export class Sandbox { DEFAULT_SHELL, DEFAULT_PROMPT, ); + await sandbox.#startTrace(sandbox.#sessionHandle); if (typeof instance === "string") { await Sandbox.destroy(instance); @@ -783,9 +854,11 @@ export class Sandbox { async close(): Promise { if (this.#sessionHandle !== null) { + await this.trace._drain(); await this.#runtime.sessionClose(this.#sessionHandle); this.#sessionHandle = null; } + this.trace._close(); this.#runtime.terminate(); } diff --git a/sdks/typescript/src/trace.ts b/sdks/typescript/src/trace.ts new file mode 100644 index 00000000..a501c5c7 --- /dev/null +++ b/sdks/typescript/src/trace.ts @@ -0,0 +1,478 @@ +export interface TraceSources { + processes?: boolean; + files?: boolean; + network?: boolean; + mounts?: boolean; + bufferBytes?: number; +} + +export type TraceSetting = boolean | TraceSources; + +export interface WireTraceOptions { + processes: boolean; + files: boolean; + network: boolean; + mounts: boolean; + bufferBytes: number; +} + +export interface TraceEvent { + v: number; + seq: number; + guest_ns: number; + wall_ms: number; + kind: string; + [field: string]: unknown; +} + +export interface FileActivity { + path: string; + read: boolean; + written: boolean; + created: boolean; + deleted: boolean; + renamedTo: string | null; + renamedFrom: string | null; + denied: boolean; +} + +export interface HttpRequest { + method: string; + url: string; +} + +export interface NetworkActivity { + host: string | null; + address: string; + port: number; + protocol: string | null; + requests: HttpRequest[]; + bytesOut: number; + bytesIn: number; + failed: boolean; +} + +export interface ProcessNode { + pid: number | null; + path: string | null; + argv: string[]; + exitCode: number | null; + startedAt: number; + children: ProcessNode[]; +} + +export const TRACE_NOT_ENABLED = + "vpod: tracing is not enabled for this sandbox. Create it with " + + "Sandbox.create({ trace: true }) to record what it does."; + +export const TRACE_NOT_SUPPORTED = + "vpod: this sandbox runs on an engine without trace support, a snapshot's own " + + 'engine from an older vpod release. Create the sandbox with engine: "default" ' + + "to trace it."; + +const SOURCES = ["processes", "files", "network", "mounts"] as const; +const DRAIN_ALL_BYTES = 0xffff_ffff; + +const EACCES = -13; +const EPERM = -1; + +const NOISE_PREFIXES = ["/proc/", "/sys/", "/dev/", "/etc/ld-musl-"]; +const NOISE_DIRECTORIES = ["/proc", "/sys", "/dev"]; + +export function traceOptions(setting: TraceSetting | undefined): WireTraceOptions | null { + if (setting === undefined || setting === false) { + return null; + } + if (setting === true) { + return { processes: true, files: true, network: true, mounts: true, bufferBytes: 0 }; + } + if (typeof setting !== "object" || setting === null) { + throw new Error(`vpod: trace must be true or an object of sources, got ${JSON.stringify(setting)}`); + } + + const known = new Set([...SOURCES, "bufferBytes"]); + const unknown = Object.keys(setting).filter((key) => !known.has(key)); + if (unknown.length > 0) { + throw new Error( + `vpod: unknown trace options ${JSON.stringify(unknown)}, expected ${JSON.stringify([...known])}`, + ); + } + + return { + processes: setting.processes === true, + files: setting.files === true, + network: setting.network === true, + mounts: setting.mounts === true, + bufferBytes: setting.bufferBytes ?? 0, + }; +} + +const text = (value: unknown): string | null => (typeof value === "string" ? value : null); +const count = (value: unknown): number => (typeof value === "number" ? value : 0); + +function hostOf(url: string): string | null { + try { + return new URL(url).hostname.replace(/^\[(.*)\]$/, "$1") || null; + } catch { + return null; + } +} + +function isNoise(entry: FileActivity): boolean { + const path = entry.path; + if (NOISE_DIRECTORIES.includes(path) || NOISE_PREFIXES.some((prefix) => path.startsWith(prefix))) { + return true; + } + const onlyRead = !( + entry.written || + entry.created || + entry.deleted || + entry.renamedTo !== null || + entry.renamedFrom !== null || + entry.denied + ); + return onlyRead && (path.endsWith(".so") || path.includes(".so.")); +} + +export class Trace { + readonly #events: readonly TraceEvent[]; + + constructor(events: readonly TraceEvent[]) { + this.#events = Object.freeze([...events]); + } + + get events(): readonly TraceEvent[] { + return this.#events; + } + + get complete(): boolean { + return !this.#events.some((event) => event.kind === "trace.dropped"); + } + + toJSONL(): string { + return this.#events.map((event) => `${JSON.stringify(event)}\n`).join(""); + } + + files(options: { internal?: boolean; noise?: boolean } = {}): FileActivity[] { + const activities = new Map(); + + const activity = (path: unknown): FileActivity | null => { + if (typeof path !== "string") return null; + let entry = activities.get(path); + if (entry === undefined) { + entry = { + path, + read: false, + written: false, + created: false, + deleted: false, + renamedTo: null, + renamedFrom: null, + denied: false, + }; + activities.set(path, entry); + } + return entry; + }; + + const markDenied = (path: unknown, result: number) => { + if (result !== EACCES && result !== EPERM) return; + const entry = activity(path); + if (entry !== null) entry.denied = true; + }; + + for (const event of this.#events) { + if (event.internal === true && !options.internal) continue; + const result = event.result as number; + + switch (event.kind) { + case "file.open": + case "mount.open": { + const succeeded = event.kind === "file.open" ? result >= 0 : result === 0; + if (!succeeded) { + markDenied(event.path, result); + break; + } + const entry = activity(event.path); + if (entry === null) break; + entry.read ||= event.access === "read" || event.access === "read-write"; + entry.written ||= + event.access === "write" || event.access === "read-write" || event.truncate === true; + break; + } + case "file.rename": + case "mount.rename": { + if (result !== 0) { + markDenied(event.from, result); + markDenied(event.to, result); + break; + } + const source = activity(event.from); + const destination = activity(event.to); + if (source !== null) source.renamedTo = text(event.to); + if (destination !== null) { + destination.renamedFrom = text(event.from); + destination.written = true; + } + break; + } + case "file.delete": + case "mount.delete": { + if (result !== 0) { + markDenied(event.path, result); + break; + } + const entry = activity(event.path); + if (entry !== null) entry.deleted = true; + break; + } + case "dir.create": + case "mount.mkdir": + case "mount.create": { + if (result !== 0) { + markDenied(event.path, result); + break; + } + const entry = activity(event.path); + if (entry !== null) { + entry.created = true; + entry.written ||= event.kind === "mount.create"; + } + break; + } + case "file.truncate": + case "mount.truncate": { + if (result !== 0) { + markDenied(event.path, result); + break; + } + const entry = activity(event.path); + if (entry !== null) entry.written = true; + break; + } + case "mount.close": { + const bytesRead = count(event.bytes_read); + const bytesWritten = count(event.bytes_written); + if (bytesRead === 0 && bytesWritten === 0) break; + const entry = activity(event.path); + if (entry === null) break; + entry.read ||= bytesRead > 0; + entry.written ||= bytesWritten > 0; + break; + } + } + } + + return [...activities.values()].filter((entry) => options.noise || !isNoise(entry)); + } + + network(options: { internal?: boolean } = {}): NetworkActivity[] { + const activities = new Map(); + + for (const event of this.#events) { + if (event.internal === true && !options.internal) continue; + if (!["net.connect", "net.flow", "net.udp", "net.http"].includes(event.kind)) continue; + if (typeof event.address !== "string" || typeof event.port !== "number") continue; + + const key = `${event.address}${event.port}`; + let entry = activities.get(key); + if (entry === undefined) { + entry = { + host: null, + address: event.address, + port: event.port, + protocol: null, + requests: [], + bytesOut: 0, + bytesIn: 0, + failed: true, + }; + activities.set(key, entry); + } + entry.host ??= text(event.host); + + switch (event.kind) { + case "net.connect": + entry.protocol ??= text(event.protocol); + entry.failed &&= event.result !== 0; + break; + case "net.flow": + entry.protocol ??= text(event.protocol); + entry.bytesOut += count(event.bytes_out); + entry.bytesIn += count(event.bytes_in); + entry.failed &&= event.failed === true; + break; + case "net.udp": + entry.protocol ??= "udp"; + entry.failed = false; + break; + case "net.http": + entry.host ??= hostOf(String(event.url)); + entry.requests.push({ method: String(event.method), url: String(event.url) }); + break; + } + } + + return [...activities.values()]; + } + + processes(options: { internal?: boolean } = {}): ProcessNode[] { + const nodes: { node: ProcessNode; internal: boolean }[] = []; + const runningByTask = new Map(); + + for (const event of this.#events) { + if (event.kind === "process.exec" && !("result" in event)) { + const node: ProcessNode = { + pid: null, + path: text(event.path), + argv: Array.isArray(event.argv) ? event.argv.map(String) : [], + exitCode: null, + startedAt: event.guest_ns, + children: [], + }; + nodes.push({ node, internal: event.internal === true }); + runningByTask.set(String(event.task), node); + } else if (event.kind === "process.exit") { + const task = String(event.task); + const node = runningByTask.get(task); + if (node !== undefined) { + node.exitCode = event.code as number; + runningByTask.delete(task); + } + } + } + + return nodes.filter((entry) => options.internal || !entry.internal).map((entry) => entry.node); + } +} + +const WATCH_CLOSED = Symbol("watch closed"); + +class Watcher { + #queue: (TraceEvent | typeof WATCH_CLOSED)[] = []; + #wake: (() => void) | null = null; + + push(item: TraceEvent | typeof WATCH_CLOSED): void { + this.#queue.push(item); + this.#wake?.(); + this.#wake = null; + } + + async next(): Promise { + while (this.#queue.length === 0) { + await new Promise((resolve) => { + this.#wake = resolve; + }); + } + return this.#queue.shift()!; + } +} + +export class TraceRecorder { + readonly #options: WireTraceOptions | null; + readonly #drainSession: (maxBytes: number) => Promise; + readonly #decoder = new TextDecoder(); + #events: TraceEvent[] = []; + #watchers = new Set(); + #closed = false; + + /** @internal */ + constructor( + options: WireTraceOptions | null, + drainSession: (maxBytes: number) => Promise, + ) { + this.#options = options; + this.#drainSession = drainSession; + } + + get enabled(): boolean { + return this.#options !== null; + } + + async collect(): Promise { + this.#requireEnabled(); + await this._drain(); + return new Trace(this.#events); + } + + watch(): AsyncIterable { + this.#requireEnabled(); + const watcher = new Watcher(); + if (this.#closed) { + watcher.push(WATCH_CLOSED); + } else { + this.#watchers.add(watcher); + } + const watchers = this.#watchers; + + return { + async *[Symbol.asyncIterator]() { + try { + for (;;) { + const next = await watcher.next(); + if (next === WATCH_CLOSED) return; + yield next; + } + } finally { + watchers.delete(watcher); + } + }, + }; + } + + async clear(): Promise { + this.#requireEnabled(); + await this._drain(); + this.#events = []; + } + + #requireEnabled(): void { + if (!this.enabled) { + throw new Error(TRACE_NOT_ENABLED); + } + } + + /** @internal */ + get _options(): WireTraceOptions | null { + return this.#options; + } + + /** @internal */ + async _drain(): Promise { + if (!this.enabled) return; + const drained = await this.#drainSession(DRAIN_ALL_BYTES); + if (drained === null || drained.byteLength === 0) return; + + const events = this.#decoder + .decode(drained) + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as TraceEvent); + + this.#events.push(...events); + for (const watcher of this.#watchers) { + for (const event of events) watcher.push(event); + } + } + + /** @internal */ + async _mark(): Promise { + if (!this.enabled) return 0; + await this._drain(); + return this.#events.length; + } + + /** @internal */ + async _since(mark: number): Promise { + if (!this.enabled) return null; + await this._drain(); + return new Trace(this.#events.slice(mark)); + } + + /** @internal */ + _close(): void { + this.#closed = true; + for (const watcher of this.#watchers) watcher.push(WATCH_CLOSED); + this.#watchers.clear(); + } +} diff --git a/sdks/typescript/src/worker/dispatch.ts b/sdks/typescript/src/worker/dispatch.ts index 40fa5086..0e2b3b66 100644 --- a/sdks/typescript/src/worker/dispatch.ts +++ b/sdks/typescript/src/worker/dispatch.ts @@ -14,6 +14,7 @@ import { componentImports } from "./component-imports.js"; import type { ComponentModule, CoreModuleLoader } from "./component-imports.js"; import type { DriverCommand } from "../net/driver-protocol.js"; import type { ExecutionResult, WorkerCall } from "./protocol.js"; +import type { WireTraceOptions } from "../trace.js"; async function announceHostTerminatedTls(): Promise { const cli = (await import("../shims/cli.js")) as unknown as { @@ -52,6 +53,9 @@ export interface Executor { prompt: string, mounts: never[], ): bigint; + sessionTraceStart?(handle: bigint, options: WireTraceOptions): void; + sessionTraceDrain?(handle: bigint, maxBytes: number): Uint8Array; + sessionTraceStop?(handle: bigint): void; } const sharedComponents = new Map>(); @@ -252,6 +256,20 @@ export class Dispatcher { } } + case "trace-supported": + return typeof this.#requireExecutor().sessionTraceStart === "function"; + + case "session-trace-start": + this.#requireExecutor().sessionTraceStart!(call.handle, call.options); + return null; + + case "session-trace-drain": + return this.#requireExecutor().sessionTraceDrain!(call.handle, call.maxBytes); + + case "session-trace-stop": + this.#requireExecutor().sessionTraceStop!(call.handle); + return null; + case "enable-network": { await announceHostTerminatedTls(); diff --git a/sdks/typescript/src/worker/protocol.ts b/sdks/typescript/src/worker/protocol.ts index 0f540f4e..3e0928c6 100644 --- a/sdks/typescript/src/worker/protocol.ts +++ b/sdks/typescript/src/worker/protocol.ts @@ -1,6 +1,7 @@ import type { ExecMode } from "../sandbox.js"; import type { CoreModuleBytes } from "./component-imports.js"; +import type { WireTraceOptions } from "../trace.js"; export interface WorkerInit { kind: "init"; @@ -78,6 +79,10 @@ export type WorkerCall = command: string; prompt: string; } + | { kind: "trace-supported" } + | { kind: "session-trace-start"; handle: bigint; options: WireTraceOptions } + | { kind: "session-trace-drain"; handle: bigint; maxBytes: number } + | { kind: "session-trace-stop"; handle: bigint } | { kind: "poll-stats" } | { kind: "component-load-milliseconds" } | { kind: "enable-network"; port: MessagePort; allowedPorts?: number[] }; diff --git a/sdks/typescript/tests/integration/trace.test.mjs b/sdks/typescript/tests/integration/trace.test.mjs new file mode 100644 index 00000000..d43feaeb --- /dev/null +++ b/sdks/typescript/tests/integration/trace.test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; +import { describe, it } from "node:test"; + +import { createTestSandbox, loadSdk, locateSnapshot, skipReason } from "../helpers.mjs"; + +async function withTracedSandbox(body) { + const sandbox = await createTestSandbox({ trace: true }); + try { + return await body(sandbox); + } finally { + await sandbox.close(); + } +} + +describe("trace", { skip: skipReason() ?? false }, () => { + it("records the commands a shell script runs and the files it writes", async () => { + await withTracedSandbox(async (sandbox) => { + const result = await sandbox.commands.run( + "sh -c 'mkdir -p /tmp/traced && echo hi > /tmp/traced/out.txt && cat /tmp/traced/out.txt'", + ); + assert.equal(result.exitCode, 0); + + const commands = result.trace.processes().map((process) => process.argv); + assert.deepEqual(commands[0], [ + "sh", + "-c", + "mkdir -p /tmp/traced && echo hi > /tmp/traced/out.txt && cat /tmp/traced/out.txt", + ]); + assert.ok(commands.some((argv) => argv[0] === "cat"), JSON.stringify(commands)); + + const written = result.trace.files().filter((file) => file.written).map((file) => file.path); + assert.ok(written.includes("/tmp/traced/out.txt"), JSON.stringify(written)); + assert.equal(result.trace.complete, true); + }); + }); + + it("keeps each command's events to that command and the whole run on the sandbox", async () => { + await withTracedSandbox(async (sandbox) => { + const first = await sandbox.commands.run("touch /tmp/first"); + const second = await sandbox.commands.run("touch /tmp/second"); + + const pathsOf = (trace) => trace.files().map((file) => file.path); + assert.ok(pathsOf(first.trace).includes("/tmp/first")); + assert.ok(!pathsOf(first.trace).includes("/tmp/second")); + assert.ok(pathsOf(second.trace).includes("/tmp/second")); + + const everything = pathsOf(await sandbox.trace.collect()); + assert.ok(everything.includes("/tmp/first") && everything.includes("/tmp/second")); + }); + }); + + it("hides vpod's own plumbing unless asked for it", async () => { + await withTracedSandbox(async (sandbox) => { + const result = await sandbox.commands.run("true"); + + const visible = result.trace.files({ noise: true }).map((file) => file.path); + assert.ok(!visible.includes("/dev/ttyS1"), JSON.stringify(visible)); + + const everything = result.trace.files({ internal: true, noise: true }).map((file) => file.path); + assert.ok(everything.includes("/dev/ttyS1"), JSON.stringify(everything)); + }); + }); + + it("follows commands live and stops when the sandbox closes", async () => { + const sandbox = await createTestSandbox({ trace: true }); + const seen = []; + const following = (async () => { + for await (const event of sandbox.trace.watch()) seen.push(event); + })(); + + await sandbox.commands.run("touch /tmp/watched"); + await sandbox.close(); + await following; + + assert.ok( + seen.some((event) => event.kind === "file.open" && event.path === "/tmp/watched"), + JSON.stringify(seen.map((event) => event.kind)), + ); + }); + + it("forgets what clear drained", async () => { + await withTracedSandbox(async (sandbox) => { + await sandbox.commands.run("touch /tmp/cleared"); + await sandbox.trace.clear(); + await sandbox.commands.run("touch /tmp/kept"); + + const paths = (await sandbox.trace.collect()).files().map((file) => file.path); + assert.ok(paths.includes("/tmp/kept"), JSON.stringify(paths)); + assert.ok(!paths.includes("/tmp/cleared"), JSON.stringify(paths)); + }); + }); + + it("starts a fresh trace on a resumed sandbox", async () => { + const sandbox = await createTestSandbox({ trace: true }); + await sandbox.commands.run("touch /tmp/before-suspend"); + const delta = await sandbox.suspend(); + const before = (await sandbox.trace.collect()).files().map((file) => file.path); + await sandbox.close(); + assert.ok(before.includes("/tmp/before-suspend"), JSON.stringify(before)); + + const { Sandbox, createInlineTransport } = await loadSdk(); + const snapshotPath = locateSnapshot(); + const resumed = await Sandbox.resume( + { id: "test", snapshotId: basename(snapshotPath), delta }, + { + transport: await createInlineTransport(), + snapshot: { bytes: readFileSync(snapshotPath), name: basename(snapshotPath) }, + trace: true, + }, + ); + try { + const after = await resumed.commands.run("touch /tmp/after-resume"); + assert.ok(after.trace.files().some((file) => file.path === "/tmp/after-resume")); + + const everything = (await resumed.trace.collect()).files().map((file) => file.path); + assert.ok(!everything.includes("/tmp/before-suspend"), JSON.stringify(everything)); + } finally { + await resumed.close(); + } + }); + + it("says how to turn tracing on when it is off", async () => { + const sandbox = await createTestSandbox(); + try { + const result = await sandbox.commands.run("true"); + assert.throws(() => result.trace, /trace: true/); + await assert.rejects(sandbox.trace.collect(), /trace: true/); + } finally { + await sandbox.close(); + } + }); +}); diff --git a/sdks/typescript/tests/unit/trace.test.mjs b/sdks/typescript/tests/unit/trace.test.mjs new file mode 100644 index 00000000..f7c8df9f --- /dev/null +++ b/sdks/typescript/tests/unit/trace.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { distPath } from "../helpers.mjs"; + +const { Sandbox, Trace } = await import(distPath("index.js")); + +const { cases } = JSON.parse( + readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "trace-summaries.json"), + "utf8", + ), +); + +const camelCase = (key) => key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); + +function withCamelCaseKeys(value) { + if (Array.isArray(value)) return value.map(withCamelCaseKeys); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, inner]) => [camelCase(key), withCamelCaseKeys(inner)]), + ); + } + return value; +} + +describe("trace summaries match the fixture the Python SDK checks", () => { + for (const fixture of cases) { + it(fixture.name, () => { + const trace = new Trace(fixture.events); + + assert.deepEqual(trace.files(), withCamelCaseKeys(fixture.files)); + assert.deepEqual( + trace.files({ internal: true, noise: true }), + withCamelCaseKeys(fixture.files_including_internal_and_noise), + ); + assert.deepEqual(trace.network(), withCamelCaseKeys(fixture.network)); + assert.deepEqual( + trace.network({ internal: true }), + withCamelCaseKeys(fixture.network_including_internal), + ); + assert.deepEqual(trace.processes(), withCamelCaseKeys(fixture.processes)); + assert.deepEqual( + trace.processes({ internal: true }), + withCamelCaseKeys(fixture.processes_including_internal), + ); + assert.equal(trace.complete, fixture.complete); + }); + + it(`${fixture.name}: JSON lines carry every event`, () => { + const lines = new Trace(fixture.events).toJSONL().split("\n").filter(Boolean); + assert.deepEqual(lines.map((line) => JSON.parse(line)), fixture.events); + }); + } + + it("keeps its own copy of the events", () => { + const events = [{ v: 1, seq: 0, guest_ns: 0, wall_ms: 0, kind: "trace.dropped", count: 1 }]; + const trace = new Trace(events); + events.length = 0; + + assert.equal(trace.events.length, 1); + assert.throws(() => trace.events.push({}), TypeError); + }); +}); + +describe("trace options", () => { + it("refuses an unknown source before anything starts", async () => { + await assert.rejects(Sandbox.create({ trace: { netwrok: true } }), /netwrok/); + }); +}); From 83509a401e158621c752d83f6db011dcb1c8dc77 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Thu, 17 Sep 2026 23:10:12 +0200 Subject: [PATCH 08/11] add process identity and tree relationship tracking to runtime trace engine --- crates/machine/src/machine_bus.rs | 30 +- crates/machine/src/trace/identity.rs | 311 +++++ crates/machine/src/trace/mod.rs | 8 + crates/machine/src/trace/processes.rs | 298 +++++ crates/machine/src/trace/syscalls.rs | 1338 ++++++++++++++++----- crates/riscv-core/src/execute.rs | 8 +- crates/riscv-core/src/lib.rs | 2 +- crates/riscv-core/src/mmu.rs | 13 + crates/riscv-core/src/syscall_trace.rs | 352 +++++- crates/riscv-core/src/system_bus.rs | 8 +- crates/wasi-component/src/api/executor.rs | 2 +- crates/wasi-component/src/api/session.rs | 57 +- 12 files changed, 2069 insertions(+), 358 deletions(-) create mode 100644 crates/machine/src/trace/identity.rs create mode 100644 crates/machine/src/trace/processes.rs diff --git a/crates/machine/src/machine_bus.rs b/crates/machine/src/machine_bus.rs index 4b7960cb..ed6c7284 100644 --- a/crates/machine/src/machine_bus.rs +++ b/crates/machine/src/machine_bus.rs @@ -107,6 +107,22 @@ impl MachineBus { self.attach_tracer(None); } + pub fn traces_syscalls(&self) -> bool { + self.syscall_tracer.is_some() + } + + pub fn set_trace_quiet(&mut self, quiet: bool) { + if let Some(syscall_tracer) = &mut self.syscall_tracer { + syscall_tracer.set_quiet(quiet); + } + } + + pub fn seed_trace_working_directory(&mut self, process_id: u32, path: String) { + if let Some(syscall_tracer) = &mut self.syscall_tracer { + syscall_tracer.seed_working_directory(process_id, path); + } + } + pub fn tracer(&self) -> Option<&Tracer> { self.tracer.as_ref() } @@ -581,15 +597,17 @@ impl SystemBus for MachineBus { self.syscall_tracer.is_some() } - fn on_syscall_entry(&mut self, entry: riscv_core::SyscallEntry) { - if let Some(syscall_tracer) = &mut self.syscall_tracer { - syscall_tracer.on_entry(entry); + fn on_syscall_entry(&mut self, entry: riscv_core::SyscallEntry, satp: u64) { + if let Some(mut syscall_tracer) = self.syscall_tracer.take() { + syscall_tracer.on_entry(entry, self, satp); + self.syscall_tracer = Some(syscall_tracer); } } - fn on_syscall_return(&mut self, task: u64, return_pc: u64, value: i64) { - if let Some(syscall_tracer) = &mut self.syscall_tracer { - syscall_tracer.on_return(task, return_pc, value); + fn on_syscall_return(&mut self, task: u64, return_pc: u64, value: i64, satp: u64) { + if let Some(mut syscall_tracer) = self.syscall_tracer.take() { + syscall_tracer.on_return(task, return_pc, value, self, satp); + self.syscall_tracer = Some(syscall_tracer); } } } diff --git a/crates/machine/src/trace/identity.rs b/crates/machine/src/trace/identity.rs new file mode 100644 index 00000000..9567507e --- /dev/null +++ b/crates/machine/src/trace/identity.rs @@ -0,0 +1,311 @@ +use std::collections::HashMap; + +use riscv_core::{GuestMemory, SystemBus}; + +const IDENTITY_SCAN_BYTES: u64 = 8192; +const PARENT_SCAN_BYTES: u64 = 256; +const REQUIRED_SUPPORT: usize = 2; +const MAX_CANDIDATES: usize = 512; +const PID_MAX_LIMIT: u32 = 4 * 1024 * 1024; + +pub struct TaskLayout { + pub thread_id: u64, + pub process_id: u64, + pub real_parent: Option, +} + +#[derive(Default)] +pub struct Identities { + layout: Option, + identity_support: HashMap>, + parent_support: HashMap>, +} + +impl Identities { + pub fn calibrated(&self) -> bool { + self.layout.is_some() + } + + pub fn knows_parents(&self) -> bool { + matches!(&self.layout, Some(layout) if layout.real_parent.is_some()) + } + + pub fn observe_identity( + &mut self, + task: u64, + value: u32, + bus: &mut B, + satp: u64, + ) { + if self.layout.is_some() || value == 0 || value > PID_MAX_LIMIT { + return; + } + + let mut memory = GuestMemory::new(satp); + let mut previous = None; + let mut found = Vec::new(); + + for offset in (0..IDENTITY_SCAN_BYTES).step_by(4) { + let Some(word) = memory.u32(bus, task.wrapping_add(offset)) else { + break; + }; + if word == value && previous == Some(value) { + found.push(offset - 4); + } + previous = Some(word); + } + + for offset in found { + support(&mut self.identity_support, offset, value); + } + + if let Some(offset) = settled(&self.identity_support) { + self.layout = Some(TaskLayout { + thread_id: offset, + process_id: offset + 4, + real_parent: None, + }); + self.identity_support = HashMap::new(); + } + } + + pub fn observe_parent( + &mut self, + child_task: u64, + parent_task: u64, + bus: &mut B, + satp: u64, + ) { + let Some(layout) = &self.layout else { + return; + }; + if layout.real_parent.is_some() || !is_kernel_pointer(parent_task) { + return; + } + + let start = (layout.process_id + 4).next_multiple_of(8); + let mut memory = GuestMemory::new(satp); + let mut found = Vec::new(); + + for offset in (start..start + PARENT_SCAN_BYTES).step_by(8) { + let Some(word) = memory.u64(bus, child_task.wrapping_add(offset)) else { + break; + }; + if word == parent_task { + found.push(offset); + } + } + + for offset in found { + support(&mut self.parent_support, offset, parent_task); + } + + if let Some(offset) = lowest_settled(&self.parent_support) + && let Some(layout) = &mut self.layout + { + layout.real_parent = Some(offset); + self.parent_support = HashMap::new(); + } + } + + pub fn process_of(&self, task: u64, bus: &mut B, satp: u64) -> Option { + let layout = self.layout.as_ref()?; + let value = GuestMemory::new(satp).u32(bus, task.wrapping_add(layout.process_id))?; + (value > 0 && value <= PID_MAX_LIMIT).then_some(value) + } + + pub fn thread_of(&self, task: u64, bus: &mut B, satp: u64) -> Option { + let layout = self.layout.as_ref()?; + let value = GuestMemory::new(satp).u32(bus, task.wrapping_add(layout.thread_id))?; + (value > 0 && value <= PID_MAX_LIMIT).then_some(value) + } + + pub fn parent_task_of(&self, task: u64, bus: &mut B, satp: u64) -> Option { + let layout = self.layout.as_ref()?; + let pointer = GuestMemory::new(satp).u64(bus, task.wrapping_add(layout.real_parent?))?; + is_kernel_pointer(pointer).then_some(pointer) + } +} + +fn support(table: &mut HashMap>, offset: u64, value: T) { + if table.len() >= MAX_CANDIDATES && !table.contains_key(&offset) { + return; + } + let seen = table.entry(offset).or_default(); + if !seen.contains(&value) { + seen.push(value); + } +} + +fn settled(table: &HashMap>) -> Option { + let best = table.values().map(Vec::len).max()?; + if best < REQUIRED_SUPPORT { + return None; + } + let mut winners = table + .iter() + .filter(|(_, values)| values.len() == best) + .map(|(offset, _)| *offset); + + let first = winners.next()?; + winners.next().is_none().then_some(first) +} + +fn lowest_settled(table: &HashMap>) -> Option { + let best = table.values().map(Vec::len).max()?; + (best >= REQUIRED_SUPPORT) + .then(|| { + table + .iter() + .filter(|(_, values)| values.len() == best) + .map(|(offset, _)| *offset) + .min() + }) + .flatten() +} + +fn is_kernel_pointer(pointer: u64) -> bool { + pointer >> 56 == 0xff && pointer % 8 == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use riscv_core::FlatMemory; + + const PID_OFFSET: u64 = 1296; + const REAL_PARENT_OFFSET: u64 = 1312; + + struct Guest { + memory: FlatMemory, + } + + impl Guest { + fn new() -> Self { + Self { + memory: FlatMemory::new(1024 * 1024), + } + } + + fn task(&mut self, address: u64, thread_id: u32, process_id: u32, parent: u64) { + self.memory + .load_at((address + PID_OFFSET) as usize, &thread_id.to_le_bytes()); + self.memory.load_at( + (address + PID_OFFSET + 4) as usize, + &process_id.to_le_bytes(), + ); + self.memory.load_at( + (address + REAL_PARENT_OFFSET) as usize, + &parent.to_le_bytes(), + ); + } + } + + #[test] + fn two_tasks_naming_themselves_pin_the_pid_and_tgid_offsets() { + let mut guest = Guest::new(); + let mut identities = Identities::default(); + guest.task(0x10000, 556, 556, 0); + guest.task(0x20000, 557, 557, 0); + + identities.observe_identity(0x10000, 556, &mut guest.memory, 0); + assert!(!identities.calibrated(), "one task cannot settle an offset"); + + identities.observe_identity(0x20000, 557, &mut guest.memory, 0); + + assert!(identities.calibrated()); + assert_eq!( + identities.process_of(0x10000, &mut guest.memory, 0), + Some(556) + ); + assert_eq!( + identities.process_of(0x20000, &mut guest.memory, 0), + Some(557) + ); + } + + #[test] + fn a_thread_naming_itself_does_not_settle_anything_on_its_own() { + let mut guest = Guest::new(); + let mut identities = Identities::default(); + guest.task(0x10000, 566, 565, 0); + guest.task(0x20000, 570, 569, 0); + + identities.observe_identity(0x10000, 566, &mut guest.memory, 0); + identities.observe_identity(0x20000, 570, &mut guest.memory, 0); + + assert!(!identities.calibrated()); + } + + #[test] + fn a_process_that_is_a_thread_group_reports_the_group_not_the_thread() { + let mut guest = Guest::new(); + let mut identities = Identities::default(); + guest.task(0x10000, 556, 556, 0); + guest.task(0x20000, 557, 557, 0); + identities.observe_identity(0x10000, 556, &mut guest.memory, 0); + identities.observe_identity(0x20000, 557, &mut guest.memory, 0); + + guest.task(0x30000, 566, 565, 0); + + assert_eq!( + identities.process_of(0x30000, &mut guest.memory, 0), + Some(565) + ); + assert_eq!( + identities.thread_of(0x30000, &mut guest.memory, 0), + Some(566) + ); + } + + #[test] + fn two_children_pointing_at_their_parents_pin_the_parent_offset() { + let mut guest = Guest::new(); + let mut identities = Identities::default(); + let first_parent = 0xffff_ffd6_0000_1000; + let second_parent = 0xffff_ffd6_0000_2000; + + guest.task(0x10000, 556, 556, first_parent); + guest.task(0x20000, 557, 557, second_parent); + identities.observe_identity(0x10000, 556, &mut guest.memory, 0); + identities.observe_identity(0x20000, 557, &mut guest.memory, 0); + + identities.observe_parent(0x10000, first_parent, &mut guest.memory, 0); + assert!(!identities.knows_parents()); + + identities.observe_parent(0x20000, second_parent, &mut guest.memory, 0); + + assert!(identities.knows_parents()); + assert_eq!( + identities.parent_task_of(0x10000, &mut guest.memory, 0), + Some(first_parent) + ); + } + + #[test] + fn an_implausible_process_id_is_not_reported() { + let mut guest = Guest::new(); + let mut identities = Identities::default(); + guest.task(0x10000, 556, 556, 0); + guest.task(0x20000, 557, 557, 0); + identities.observe_identity(0x10000, 556, &mut guest.memory, 0); + identities.observe_identity(0x20000, 557, &mut guest.memory, 0); + + guest.task(0x30000, 0, 0, 0); + assert_eq!(identities.process_of(0x30000, &mut guest.memory, 0), None); + + guest.task(0x40000, 9_000_000, 9_000_000, 0); + assert_eq!(identities.process_of(0x40000, &mut guest.memory, 0), None); + } + + #[test] + fn a_task_whose_memory_holds_no_matching_pair_never_calibrates() { + let mut guest = Guest::new(); + let mut identities = Identities::default(); + + identities.observe_identity(0x10000, 556, &mut guest.memory, 0); + identities.observe_identity(0x20000, 557, &mut guest.memory, 0); + + assert!(!identities.calibrated()); + } +} diff --git a/crates/machine/src/trace/mod.rs b/crates/machine/src/trace/mod.rs index 414b05ae..48861ad7 100644 --- a/crates/machine/src/trace/mod.rs +++ b/crates/machine/src/trace/mod.rs @@ -1,4 +1,6 @@ mod http; +mod identity; +mod processes; mod syscalls; use std::collections::{HashMap, VecDeque}; @@ -145,6 +147,12 @@ impl Tracer { drained } + pub fn drain_text(&self, max_bytes: usize) -> String { + let drained = self.drain(max_bytes); + String::from_utf8(drained) + .unwrap_or_else(|error| String::from_utf8_lossy(error.as_bytes()).into_owned()) + } + pub fn remember_name(&self, address: [u8; 4], name: &str) { let mut recorder = self.recorder(); if recorder.names_by_address.len() >= MAX_REMEMBERED_NAMES diff --git a/crates/machine/src/trace/processes.rs b/crates/machine/src/trace/processes.rs new file mode 100644 index 00000000..ca381caa --- /dev/null +++ b/crates/machine/src/trace/processes.rs @@ -0,0 +1,298 @@ +use std::collections::{HashMap, VecDeque}; + +use riscv_core::AT_FDCWD; + +const MAX_PROCESSES: usize = 4096; +const MAX_DESCRIPTORS: usize = 1024; +const MAX_PENDING_FORKS: usize = 1024; + +#[derive(Clone, Default)] +pub struct Process { + pub parent: Option, + pub working_directory: Option, + pub descriptors: HashMap, +} + +#[derive(Clone)] +pub struct Descriptor { + pub path: String, + pub close_on_exec: bool, +} + +pub struct Fork { + pub state: Process, + pub parent_task: u64, +} + +#[derive(Default)] +pub struct Processes { + live: HashMap, + arrival: VecDeque, + forks: HashMap, + fork_arrival: VecDeque, +} + +impl Processes { + pub fn contains(&self, process_id: u32) -> bool { + self.live.contains_key(&process_id) + } + + pub fn get(&self, process_id: u32) -> Option<&Process> { + self.live.get(&process_id) + } + + pub fn get_mut(&mut self, process_id: u32) -> Option<&mut Process> { + self.live.get_mut(&process_id) + } + + pub fn insert(&mut self, process_id: u32, state: Process) { + if self.live.insert(process_id, state).is_none() { + self.arrival.push_back(process_id); + } + while self.arrival.len() > MAX_PROCESSES { + if let Some(oldest) = self.arrival.pop_front() { + self.live.remove(&oldest); + } + } + } + + pub fn remove(&mut self, process_id: u32) { + self.live.remove(&process_id); + self.forks.remove(&process_id); + } + + pub fn record_fork(&mut self, child_id: u32, state: Process, parent_task: u64) { + self.forks.insert(child_id, Fork { state, parent_task }); + self.fork_arrival.push_back(child_id); + while self.fork_arrival.len() > MAX_PENDING_FORKS { + if let Some(oldest) = self.fork_arrival.pop_front() { + self.forks.remove(&oldest); + } + } + } + + pub fn take_fork(&mut self, child_id: u32) -> Option { + self.forks.remove(&child_id) + } + + pub fn parent_task_of_fork(&self, child_id: u32) -> Option { + self.forks.get(&child_id).map(|fork| fork.parent_task) + } + + pub fn set_working_directory(&mut self, process_id: u32, path: String) { + self.live.entry(process_id).or_default().working_directory = Some(path); + if !self.arrival.contains(&process_id) { + self.arrival.push_back(process_id); + } + } + + pub fn resolve( + &self, + process_id: Option, + directory_fd: i32, + path: &str, + ) -> Option { + if path.starts_with('/') { + return Some(normalize(path)); + } + + let process = self.live.get(&process_id?)?; + let base = if directory_fd == AT_FDCWD { + process.working_directory.as_deref()? + } else { + process.descriptors.get(&directory_fd)?.path.as_str() + }; + + if path.is_empty() { + return Some(normalize(base)); + } + Some(normalize(&format!("{base}/{path}"))) + } + + pub fn path_of_descriptor(&self, process_id: Option, fd: i32) -> Option { + let process = self.live.get(&process_id?)?; + Some(process.descriptors.get(&fd)?.path.clone()) + } +} + +impl Process { + pub fn open(&mut self, fd: i32, path: String, close_on_exec: bool) { + if self.descriptors.len() >= MAX_DESCRIPTORS && !self.descriptors.contains_key(&fd) { + return; + } + self.descriptors.insert( + fd, + Descriptor { + path, + close_on_exec, + }, + ); + } + + pub fn duplicate(&mut self, from_fd: i32, to_fd: i32, close_on_exec: bool) { + if let Some(source) = self.descriptors.get(&from_fd) { + let path = source.path.clone(); + self.open(to_fd, path, close_on_exec); + } else { + self.descriptors.remove(&to_fd); + } + } + + pub fn close(&mut self, fd: i32) { + self.descriptors.remove(&fd); + } + + pub fn close_range(&mut self, first: u32, last: u32) { + self.descriptors + .retain(|fd, _| *fd < first as i32 || *fd > last.min(i32::MAX as u32) as i32); + } + + pub fn keep_across_exec(&mut self) { + self.descriptors + .retain(|_, descriptor| !descriptor.close_on_exec); + } +} + +pub fn normalize(path: &str) -> String { + let mut parts: Vec<&str> = Vec::new(); + for part in path.split('/') { + match part { + "" | "." => {} + ".." => { + parts.pop(); + } + name => parts.push(name), + } + } + + let mut normalized = String::with_capacity(path.len()); + for part in parts { + normalized.push('/'); + normalized.push_str(part); + } + if normalized.is_empty() { + normalized.push('/'); + } + normalized +} + +#[cfg(test)] +mod tests { + use super::*; + + fn process_at(working_directory: &str) -> Process { + Process { + working_directory: Some(working_directory.to_string()), + ..Process::default() + } + } + + #[test] + fn a_relative_path_lands_under_the_working_directory() { + let mut processes = Processes::default(); + processes.insert(600, process_at("/app")); + + assert_eq!( + processes + .resolve(Some(600), AT_FDCWD, "src/main.rs") + .as_deref(), + Some("/app/src/main.rs") + ); + } + + #[test] + fn dot_and_dot_dot_are_folded_without_touching_the_guest() { + assert_eq!(normalize("/app/./src/../build//out"), "/app/build/out"); + assert_eq!(normalize("/.."), "/"); + assert_eq!(normalize("/"), "/"); + } + + #[test] + fn a_relative_path_under_a_directory_descriptor_uses_that_directory() { + let mut processes = Processes::default(); + let mut process = process_at("/app"); + process.open(7, "/etc/ssl".to_string(), false); + processes.insert(600, process); + + assert_eq!( + processes.resolve(Some(600), 7, "certs/ca.pem").as_deref(), + Some("/etc/ssl/certs/ca.pem") + ); + assert_eq!(processes.resolve(Some(600), 9, "certs/ca.pem"), None); + } + + #[test] + fn an_absolute_path_needs_no_process_at_all() { + let processes = Processes::default(); + + assert_eq!( + processes.resolve(None, AT_FDCWD, "/etc/passwd").as_deref(), + Some("/etc/passwd") + ); + assert_eq!(processes.resolve(None, AT_FDCWD, "notes.txt"), None); + } + + #[test] + fn an_empty_path_names_the_descriptor_itself() { + let mut processes = Processes::default(); + let mut process = process_at("/app"); + process.open(3, "/usr/bin/python3".to_string(), false); + processes.insert(600, process); + + assert_eq!( + processes.resolve(Some(600), 3, "").as_deref(), + Some("/usr/bin/python3") + ); + } + + #[test] + fn descriptors_that_close_on_exec_do_not_survive_it() { + let mut process = process_at("/app"); + process.open(3, "/tmp/kept".to_string(), false); + process.open(4, "/tmp/dropped".to_string(), true); + + process.keep_across_exec(); + + assert!(process.descriptors.contains_key(&3)); + assert!(!process.descriptors.contains_key(&4)); + } + + #[test] + fn duplicating_a_descriptor_copies_its_path_and_clears_a_stale_target() { + let mut process = process_at("/app"); + process.open(3, "/tmp/data".to_string(), false); + process.open(9, "/tmp/old".to_string(), false); + + process.duplicate(3, 4, true); + process.duplicate(5, 9, false); + + assert_eq!(process.descriptors[&4].path, "/tmp/data"); + assert!(process.descriptors[&4].close_on_exec); + assert!(!process.descriptors.contains_key(&9)); + } + + #[test] + fn closing_a_range_forgets_every_descriptor_in_it() { + let mut process = process_at("/app"); + process.open(3, "/tmp/a".to_string(), false); + process.open(4, "/tmp/b".to_string(), false); + process.open(9, "/tmp/c".to_string(), false); + + process.close_range(3, 5); + + assert!(!process.descriptors.contains_key(&3)); + assert!(!process.descriptors.contains_key(&4)); + assert!(process.descriptors.contains_key(&9)); + } + + #[test] + fn the_oldest_process_gives_way_once_the_table_is_full() { + let mut processes = Processes::default(); + for pid in 0..MAX_PROCESSES as u32 + 10 { + processes.insert(pid, process_at("/app")); + } + + assert!(!processes.contains(0)); + assert!(processes.contains(MAX_PROCESSES as u32 + 9)); + } +} diff --git a/crates/machine/src/trace/syscalls.rs b/crates/machine/src/trace/syscalls.rs index e2cd7fe9..8ea70b09 100644 --- a/crates/machine/src/trace/syscalls.rs +++ b/crates/machine/src/trace/syscalls.rs @@ -1,10 +1,12 @@ use std::collections::{HashMap, HashSet}; use std::net::{IpAddr, SocketAddr}; -use riscv_core::{GuestString, SyscallEntry, SyscallKind}; +use riscv_core::{AT_FDCWD, GuestString, SyscallEntry, SyscallKind, SystemBus}; use serde_json::Value; use super::Tracer; +use super::identity::Identities; +use super::processes::{Process, Processes}; const VPOD_HELPER_PREFIX: &str = "/usr/lib/vpod/"; const VPOD_STAGING_PREFIX: &str = "/tmp/.vpod_"; @@ -16,19 +18,97 @@ const SOCK_TYPE_MASK: u32 = 0xf; const SOCK_STREAM: u32 = 1; const SOCK_DGRAM: u32 = 2; -const PATH_KEYS: [&str; 3] = ["path", "path_truncated", "path_unreadable"]; -const FROM_KEYS: [&str; 3] = ["from", "from_truncated", "from_unreadable"]; -const TO_KEYS: [&str; 3] = ["to", "to_truncated", "to_unreadable"]; +const MAX_ADOPTION_DEPTH: usize = 8; +const MAX_TASKS_BY_ID: usize = 1024; + +const PATH_KEYS: PathKeys = PathKeys { + value: "path", + truncated: "path_truncated", + unreadable: "path_unreadable", + unresolved: "path_unresolved", +}; +const FROM_KEYS: PathKeys = PathKeys { + value: "from", + truncated: "from_truncated", + unreadable: "from_unreadable", + unresolved: "from_unresolved", +}; +const TO_KEYS: PathKeys = PathKeys { + value: "to", + truncated: "to_truncated", + unreadable: "to_unreadable", + unresolved: "to_unresolved", +}; + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Owner { + Process(u32), + Task(u64), +} + +impl Owner { + fn process(self) -> Option { + match self { + Owner::Process(process_id) => Some(process_id), + Owner::Task(_) => None, + } + } +} + +struct PathKeys { + value: &'static str, + truncated: &'static str, + unreadable: &'static str, + unresolved: &'static str, +} + +struct PathField { + text: Option, + truncated: bool, + unreadable: bool, + unresolved: bool, +} + +impl PathField { + fn unknown() -> Self { + Self { + text: None, + truncated: false, + unreadable: false, + unresolved: true, + } + } + + fn known(path: String) -> Self { + Self { + text: Some(path), + truncated: false, + unreadable: false, + unresolved: false, + } + } + + fn resolved(&self) -> Option<&str> { + match (&self.text, self.unresolved || self.truncated) { + (Some(text), false) => Some(text), + _ => None, + } + } +} pub struct SyscallTracer { tracer: Tracer, trace_processes: bool, trace_files: bool, trace_network: bool, + quiet: bool, + identities: Identities, + processes: Processes, pending: HashMap, - internal_tasks: HashSet, - socket_protocols: HashMap<(u64, u64), &'static str>, - bound_sockets: HashMap<(u64, u64), SocketAddr>, + tasks_by_id: HashMap, + internal_owners: HashSet, + socket_protocols: HashMap<(Owner, i32), &'static str>, + bound_sockets: HashMap<(Owner, i32), SocketAddr>, } struct Pending { @@ -49,17 +129,37 @@ impl SyscallTracer { trace_files: tracer.traces_files(), trace_network: tracer.traces_network(), tracer, + quiet: false, + identities: Identities::default(), + processes: Processes::default(), pending: HashMap::new(), - internal_tasks: HashSet::new(), + tasks_by_id: HashMap::new(), + internal_owners: HashSet::new(), socket_protocols: HashMap::new(), bound_sockets: HashMap::new(), } } - pub fn on_entry(&mut self, entry: SyscallEntry) { - if let SyscallKind::Exit { code } = entry.kind { - self.emit_exit(entry.task, code); - return; + pub fn set_quiet(&mut self, quiet: bool) { + self.quiet = quiet; + } + + pub fn knows_process_ids(&self) -> bool { + self.identities.calibrated() + } + + pub fn seed_working_directory(&mut self, process_id: u32, path: String) { + self.processes.set_working_directory(process_id, path); + } + + pub fn on_entry(&mut self, entry: SyscallEntry, bus: &mut B, satp: u64) { + match entry.kind { + SyscallKind::Exit { code } => { + self.emit_exit(entry.task, code, bus, satp); + return; + } + SyscallKind::Identity { .. } if self.identities.knows_parents() => return, + _ => {} } self.pending.insert( @@ -71,7 +171,14 @@ impl SyscallTracer { ); } - pub fn on_return(&mut self, task: u64, return_pc: u64, value: i64) { + pub fn on_return( + &mut self, + task: u64, + return_pc: u64, + value: i64, + bus: &mut B, + satp: u64, + ) { let Some(pending) = self.pending.remove(&task) else { return; }; @@ -79,6 +186,7 @@ impl SyscallTracer { match pending.kind { SyscallKind::Exec { + directory_fd, path, argv, argv_truncated, @@ -90,87 +198,79 @@ impl SyscallTracer { } else { ExecOutcome::Unknown }; - self.emit_exec(task, path, argv, argv_truncated, outcome); + self.finish_exec( + task, + directory_fd, + path, + argv, + argv_truncated, + outcome, + bus, + satp, + ); } - kind if returned_to_caller => self.emit_return(task, kind, value), + kind if returned_to_caller => self.finish(task, kind, value, bus, satp), _ => {} } } - fn emit_exec( + fn finish( &mut self, task: u64, - path: GuestString, - argv: Vec, - argv_truncated: bool, - outcome: ExecOutcome, + kind: SyscallKind, + value: i64, + bus: &mut B, + satp: u64, ) { - let runs_vpod_plumbing = matches!(&path, GuestString::Value(text) if text.starts_with(VPOD_HELPER_PREFIX)) - || handles_only_staging_files(&argv); - - if let ExecOutcome::Succeeded = outcome { - if runs_vpod_plumbing { - self.internal_tasks.insert(task); - } else { - self.internal_tasks.remove(&task); - } - } - - if !self.trace_processes { - return; - } - - let internal = runs_vpod_plumbing || self.internal_tasks.contains(&task); - let mut fields = task_fields(task); - push_guest_string(&mut fields, PATH_KEYS, path); - fields.push(("argv", argv.into())); - if argv_truncated { - fields.push(("argv_truncated", true.into())); - } - match outcome { - ExecOutcome::Succeeded => {} - ExecOutcome::Failed(value) => fields.push(("result", Value::from(value as i32))), - ExecOutcome::Unknown => fields.push(("result", Value::Null)), - } - self.record("process.exec", fields, internal); - } - - fn emit_exit(&mut self, task: u64, code: i32) { - self.pending.remove(&task); - self.socket_protocols.retain(|(owner, _), _| *owner != task); - self.bound_sockets.retain(|(owner, _), _| *owner != task); - let internal = self.internal_tasks.remove(&task); - - if !self.trace_processes { - return; - } - - let mut fields = task_fields(task); - fields.push(("code", Value::from(code & 0xff))); - self.record("process.exit", fields, internal); - } - - fn emit_return(&mut self, task: u64, kind: SyscallKind, value: i64) { - let internal_task = self.internal_tasks.contains(&task); + let owner = self.owner_of(task, bus, satp); + let internal_owner = self.internal_owners.contains(&owner); + let succeeded = value >= 0; let result = Value::from(value as i32); match kind { + SyscallKind::Identity { .. } => { + if value > 0 { + self.observe_identity(task, value as u32, bus, satp); + } + } SyscallKind::Clone { thread } => { - if !self.trace_processes || value <= 0 { + if value <= 0 { return; } - let mut fields = task_fields(task); - fields.push(("child_pid", Value::from(value))); + let child_id = value as u32; + if !thread { + self.record_fork(owner, task, child_id); + if internal_owner { + self.internal_owners.insert(Owner::Process(child_id)); + } + } + self.pair_parent(child_id, task, bus, satp); + + if !self.trace_processes { + return; + } + let mut fields = identity_fields(owner, task); + fields.push(("child_pid", Value::from(child_id))); fields.push(("thread", thread.into())); - self.record("process.fork", fields, internal_task); + self.record("process.fork", fields, internal_owner); } SyscallKind::Open { + directory_fd, path, write, read_write, create, truncate, + close_on_exec, } => { + let field = self.path_field(owner, directory_fd, path); + if succeeded + && let Some(text) = field.resolved().map(str::to_string) + && let Some(process) = self.process_mut(owner) + { + process.open(value as i32, text, close_on_exec); + } + if !self.trace_files { return; } @@ -181,57 +281,128 @@ impl SyscallTracer { } else { "read" }; - let internal = internal_task || is_vpod_plumbing(&path); - let mut fields = task_fields(task); - push_guest_string(&mut fields, PATH_KEYS, path); + let internal = internal_owner || is_vpod_plumbing(&field); + let mut fields = identity_fields(owner, task); + push_path(&mut fields, &PATH_KEYS, field); fields.push(("access", access.into())); fields.push(("create", create.into())); fields.push(("truncate", truncate.into())); fields.push(("result", result)); self.record("file.open", fields, internal); } - SyscallKind::Rename { from, to } => { + SyscallKind::Rename { + from_directory_fd, + from, + to_directory_fd, + to, + } => { + let from = self.path_field(owner, from_directory_fd, from); + let to = self.path_field(owner, to_directory_fd, to); if !self.trace_files { return; } - let internal = internal_task || is_vpod_plumbing(&from) || is_vpod_plumbing(&to); - let mut fields = task_fields(task); - push_guest_string(&mut fields, FROM_KEYS, from); - push_guest_string(&mut fields, TO_KEYS, to); + let internal = internal_owner || is_vpod_plumbing(&from) || is_vpod_plumbing(&to); + let mut fields = identity_fields(owner, task); + push_path(&mut fields, &FROM_KEYS, from); + push_path(&mut fields, &TO_KEYS, to); fields.push(("result", result)); self.record("file.rename", fields, internal); } - SyscallKind::Unlink { path, directory } => { + SyscallKind::Unlink { + directory_fd, + path, + directory, + } => { + let field = self.path_field(owner, directory_fd, path); if !self.trace_files { return; } - let internal = internal_task || is_vpod_plumbing(&path); - let mut fields = task_fields(task); - push_guest_string(&mut fields, PATH_KEYS, path); + let internal = internal_owner || is_vpod_plumbing(&field); + let mut fields = identity_fields(owner, task); + push_path(&mut fields, &PATH_KEYS, field); fields.push(("directory", directory.into())); fields.push(("result", result)); self.record("file.delete", fields, internal); } - SyscallKind::Mkdir { path } => { + SyscallKind::Mkdir { directory_fd, path } => { + let field = self.path_field(owner, directory_fd, path); if !self.trace_files { return; } - let internal = internal_task || is_vpod_plumbing(&path); - let mut fields = task_fields(task); - push_guest_string(&mut fields, PATH_KEYS, path); + let internal = internal_owner || is_vpod_plumbing(&field); + let mut fields = identity_fields(owner, task); + push_path(&mut fields, &PATH_KEYS, field); fields.push(("result", result)); self.record("dir.create", fields, internal); } SyscallKind::Truncate { path, size } => { - if !self.trace_files { + let field = self.path_field(owner, AT_FDCWD, path); + self.emit_truncate(owner, task, field, size, result, internal_owner); + } + SyscallKind::TruncateDescriptor { fd, size } => { + let field = match self.processes.path_of_descriptor(owner.process(), fd) { + Some(path) => PathField::known(path), + None => PathField::unknown(), + }; + self.emit_truncate(owner, task, field, size, result, internal_owner); + } + SyscallKind::ChangeDirectory { path } => { + if value != 0 { return; } - let internal = internal_task || is_vpod_plumbing(&path); - let mut fields = task_fields(task); - push_guest_string(&mut fields, PATH_KEYS, path); - fields.push(("size", size.into())); - fields.push(("result", result)); - self.record("file.truncate", fields, internal); + let field = self.path_field(owner, AT_FDCWD, path); + if let Some(text) = field.resolved().map(str::to_string) + && let Some(process) = self.process_mut(owner) + { + process.working_directory = Some(text); + } + } + SyscallKind::ChangeDirectoryDescriptor { fd } => { + if value != 0 { + return; + } + if let Some(path) = self.processes.path_of_descriptor(owner.process(), fd) + && let Some(process) = self.process_mut(owner) + { + process.working_directory = Some(path); + } + } + SyscallKind::Duplicate { fd, close_on_exec } => { + if succeeded && let Some(process) = self.process_mut(owner) { + process.duplicate(fd, value as i32, close_on_exec); + } + } + SyscallKind::DuplicateTo { + from_fd, + to_fd, + close_on_exec, + } => { + if succeeded && let Some(process) = self.process_mut(owner) { + process.duplicate(from_fd, to_fd, close_on_exec); + } + } + SyscallKind::Close { fd } => { + if !succeeded { + return; + } + if let Some(process) = self.process_mut(owner) { + process.close(fd); + } + self.socket_protocols.remove(&(owner, fd)); + self.bound_sockets.remove(&(owner, fd)); + } + SyscallKind::CloseRange { first, last } => { + if !succeeded { + return; + } + if let Some(process) = self.process_mut(owner) { + process.close_range(first, last); + } + let range = first as i32..=last.min(i32::MAX as u32) as i32; + self.socket_protocols + .retain(|(key, fd), _| *key != owner || !range.contains(fd)); + self.bound_sockets + .retain(|(key, fd), _| *key != owner || !range.contains(fd)); } SyscallKind::Socket { domain, @@ -242,8 +413,12 @@ impl SyscallTracer { SOCK_DGRAM => "udp", _ => return, }; - if value >= 0 && matches!(domain, AF_INET | AF_INET6) { - self.socket_protocols.insert((task, value as u64), protocol); + if succeeded && matches!(domain, AF_INET | AF_INET6) { + self.socket_protocols + .insert((owner, value as i32), protocol); + } + if succeeded && let Some(process) = self.process_mut(owner) { + process.close(value as i32); } } SyscallKind::Connect { fd, address } => { @@ -253,47 +428,281 @@ impl SyscallTracer { if !self.trace_network { return; } - let mut fields = task_fields(task); + let mut fields = identity_fields(owner, task); fields.push(( "protocol", - self.socket_protocols.get(&(task, fd)).copied().into(), + self.socket_protocols.get(&(owner, fd)).copied().into(), )); fields.push(("address", address.ip().to_string().into())); fields.push(("port", address.port().into())); fields.push(("host", self.name_of(address.ip()).into())); fields.push(("result", result)); - self.record("net.connect", fields, internal_task); + self.record("net.connect", fields, internal_owner); } SyscallKind::Bind { fd, address } => { if value == 0 && let Some(address) = address { - self.bound_sockets.insert((task, fd), address); + self.bound_sockets.insert((owner, fd), address); } } SyscallKind::Listen { fd } => { if value != 0 { return; } - let Some(address) = self.bound_sockets.remove(&(task, fd)) else { + let Some(address) = self.bound_sockets.remove(&(owner, fd)) else { return; }; if !self.trace_network { return; } - let mut fields = task_fields(task); + let mut fields = identity_fields(owner, task); fields.push(( "protocol", - self.socket_protocols.get(&(task, fd)).copied().into(), + self.socket_protocols.get(&(owner, fd)).copied().into(), )); fields.push(("address", address.ip().to_string().into())); fields.push(("port", address.port().into())); - self.record("net.listen", fields, internal_task); + self.record("net.listen", fields, internal_owner); } SyscallKind::Exec { .. } | SyscallKind::Exit { .. } => {} } } + #[allow(clippy::too_many_arguments)] + fn finish_exec( + &mut self, + task: u64, + directory_fd: i32, + path: GuestString, + argv: Vec, + argv_truncated: bool, + outcome: ExecOutcome, + bus: &mut B, + satp: u64, + ) { + let owner = self.owner_of(task, bus, satp); + let field = self.path_field(owner, directory_fd, path); + let runs_vpod_plumbing = field + .resolved() + .is_some_and(|text| text.starts_with(VPOD_HELPER_PREFIX)) + || handles_only_staging_files(&argv); + + if let ExecOutcome::Succeeded = outcome { + if runs_vpod_plumbing { + self.internal_owners.insert(owner); + } else { + self.internal_owners.remove(&owner); + } + if let Some(process) = self.process_mut(owner) { + process.keep_across_exec(); + } + } + + if !self.trace_processes { + return; + } + + let internal = runs_vpod_plumbing || self.internal_owners.contains(&owner); + let mut fields = identity_fields(owner, task); + if let Some(parent) = self.parent_of(owner) { + fields.push(("ppid", Value::from(parent))); + } + push_path(&mut fields, &PATH_KEYS, field); + fields.push(("argv", argv.into())); + if argv_truncated { + fields.push(("argv_truncated", true.into())); + } + match outcome { + ExecOutcome::Succeeded => {} + ExecOutcome::Failed(value) => fields.push(("result", Value::from(value as i32))), + ExecOutcome::Unknown => fields.push(("result", Value::Null)), + } + self.record("process.exec", fields, internal); + } + + fn emit_exit(&mut self, task: u64, code: i32, bus: &mut B, satp: u64) { + let owner = self.owner_of(task, bus, satp); + self.pending.remove(&task); + self.socket_protocols.retain(|(key, _), _| *key != owner); + self.bound_sockets.retain(|(key, _), _| *key != owner); + let internal = self.internal_owners.remove(&owner); + if let Some(process_id) = owner.process() { + self.processes.remove(process_id); + } + + if !self.trace_processes { + return; + } + + let mut fields = identity_fields(owner, task); + fields.push(("code", Value::from(code & 0xff))); + self.record("process.exit", fields, internal); + } + + fn emit_truncate( + &self, + owner: Owner, + task: u64, + field: PathField, + size: u64, + result: Value, + internal_owner: bool, + ) { + if !self.trace_files { + return; + } + let internal = internal_owner || is_vpod_plumbing(&field); + let mut fields = identity_fields(owner, task); + push_path(&mut fields, &PATH_KEYS, field); + fields.push(("size", size.into())); + fields.push(("result", result)); + self.record("file.truncate", fields, internal); + } + + fn owner_of(&mut self, task: u64, bus: &mut B, satp: u64) -> Owner { + match self.identities.process_of(task, bus, satp) { + Some(process_id) => { + self.adopt(process_id, task, bus, satp, 0); + Owner::Process(process_id) + } + None => Owner::Task(task), + } + } + + fn adopt( + &mut self, + process_id: u32, + task: u64, + bus: &mut B, + satp: u64, + depth: usize, + ) { + if let Some(fork) = self.processes.take_fork(process_id) { + self.processes.insert(process_id, fork.state); + return; + } + if self.processes.contains(process_id) { + return; + } + if depth >= MAX_ADOPTION_DEPTH { + self.processes.insert(process_id, Process::default()); + return; + } + + let parent_task = self.identities.parent_task_of(task, bus, satp); + let parent_id = + parent_task.and_then(|parent| self.identities.process_of(parent, bus, satp)); + let forking = parent_task.is_some_and(|parent| self.is_cloning(parent)); + let mut state = Process { + parent: parent_id, + ..Process::default() + }; + + if let (true, Some(parent_task), Some(parent_id)) = (forking, parent_task, parent_id) { + self.adopt(parent_id, parent_task, bus, satp, depth + 1); + if let Some(parent) = self.processes.get(parent_id) { + state.working_directory = parent.working_directory.clone(); + state.descriptors = parent.descriptors.clone(); + } + if self.internal_owners.contains(&Owner::Process(parent_id)) { + self.internal_owners.insert(Owner::Process(process_id)); + } + } + + self.processes.insert(process_id, state); + } + + fn is_cloning(&self, task: u64) -> bool { + matches!(self.pending.get(&task), Some(pending) + if matches!(pending.kind, SyscallKind::Clone { .. })) + } + + fn record_fork(&mut self, owner: Owner, task: u64, child_id: u32) { + let mut state = owner + .process() + .and_then(|process_id| self.processes.get(process_id)) + .cloned() + .unwrap_or_default(); + state.parent = owner.process(); + self.processes.record_fork(child_id, state, task); + } + + fn pair_parent( + &mut self, + child_id: u32, + parent_task: u64, + bus: &mut B, + satp: u64, + ) { + if self.identities.knows_parents() { + return; + } + if let Some(child_task) = self.tasks_by_id.remove(&child_id) + && self.identities.thread_of(child_task, bus, satp) == Some(child_id) + { + self.identities + .observe_parent(child_task, parent_task, bus, satp); + } + } + + fn observe_identity(&mut self, task: u64, value: u32, bus: &mut B, satp: u64) { + self.identities.observe_identity(task, value, bus, satp); + + if self.identities.knows_parents() { + self.tasks_by_id = HashMap::new(); + return; + } + + match self.processes.parent_task_of_fork(value) { + Some(parent_task) => self.identities.observe_parent(task, parent_task, bus, satp), + None => { + if self.tasks_by_id.len() >= MAX_TASKS_BY_ID { + self.tasks_by_id = HashMap::new(); + } + self.tasks_by_id.insert(value, task); + } + } + } + + fn process_mut(&mut self, owner: Owner) -> Option<&mut Process> { + self.processes.get_mut(owner.process()?) + } + + fn parent_of(&self, owner: Owner) -> Option { + self.processes.get(owner.process()?)?.parent + } + + fn path_field(&self, owner: Owner, directory_fd: i32, path: GuestString) -> PathField { + let (text, truncated) = match path { + GuestString::Unreadable => { + return PathField { + text: None, + truncated: false, + unreadable: true, + unresolved: false, + }; + } + GuestString::Value(text) => (text, false), + GuestString::Truncated(text) => (text, true), + }; + + match self.processes.resolve(owner.process(), directory_fd, &text) { + Some(resolved) => PathField { + text: Some(resolved), + truncated, + unreadable: false, + unresolved: false, + }, + None => PathField { + text: Some(text), + truncated, + unreadable: false, + unresolved: true, + }, + } + } + fn name_of(&self, address: IpAddr) -> Option { match address { IpAddr::V4(address) => self.tracer.name_of(address.octets()), @@ -302,6 +711,9 @@ impl SyscallTracer { } fn record(&self, kind: &str, mut fields: Vec<(&'static str, Value)>, internal: bool) { + if self.quiet { + return; + } if internal { fields.push(("internal", true.into())); } @@ -309,8 +721,11 @@ impl SyscallTracer { } } -fn task_fields(task: u64) -> Vec<(&'static str, Value)> { - vec![("task", Value::from(format!("{task:x}")))] +fn identity_fields(owner: Owner, task: u64) -> Vec<(&'static str, Value)> { + vec![ + ("task", Value::from(format!("{task:x}"))), + ("pid", owner.process().map_or(Value::Null, Value::from)), + ] } fn handles_only_staging_files(argv: &[String]) -> bool { @@ -328,26 +743,22 @@ fn handles_only_staging_files(argv: &[String]) -> bool { && operands.all(|operand| operand.starts_with(VPOD_STAGING_PREFIX)) } -fn is_vpod_plumbing(path: &GuestString) -> bool { - matches!(path, GuestString::Value(text) - if VPOD_DEVICES.contains(&text.as_str()) || text.starts_with(VPOD_STAGING_PREFIX)) +fn is_vpod_plumbing(field: &PathField) -> bool { + field + .resolved() + .is_some_and(|text| VPOD_DEVICES.contains(&text) || text.starts_with(VPOD_STAGING_PREFIX)) } -fn push_guest_string( - fields: &mut Vec<(&'static str, Value)>, - [key, truncated_key, unreadable_key]: [&'static str; 3], - value: GuestString, -) { - match value { - GuestString::Value(text) => fields.push((key, text.into())), - GuestString::Truncated(text) => { - fields.push((key, text.into())); - fields.push((truncated_key, true.into())); - } - GuestString::Unreadable => { - fields.push((key, Value::Null)); - fields.push((unreadable_key, true.into())); - } +fn push_path(fields: &mut Vec<(&'static str, Value)>, keys: &PathKeys, field: PathField) { + fields.push((keys.value, field.text.map_or(Value::Null, Value::from))); + if field.truncated { + fields.push((keys.truncated, true.into())); + } + if field.unreadable { + fields.push((keys.unreadable, true.into())); + } + if field.unresolved { + fields.push((keys.unresolved, true.into())); } } @@ -355,83 +766,208 @@ fn push_guest_string( mod tests { use super::*; use crate::trace::TraceOptions; + use riscv_core::FlatMemory; - const TASK: u64 = 0xffff_ffd8_0088_e600; + const PID_OFFSET: u64 = 1296; + const REAL_PARENT_OFFSET: u64 = 1312; + const KERNEL_BASE: u64 = 0xffff_ffd6_0000_0000; + const GUEST_BYTES: u64 = 32 * 1024 * 1024; const ECALL_PC: u64 = 0x4000; const AFTER_ECALL: u64 = ECALL_PC + 4; + const SHELL: u32 = 1; - fn guest_string(text: &str) -> GuestString { - GuestString::Value(text.to_string()) + struct Guest { + memory: FlatMemory, + tracer: Tracer, + syscalls: SyscallTracer, } - fn entry(kind: SyscallKind) -> SyscallEntry { + impl Guest { + fn new() -> Self { + Self::with_options(TraceOptions::default()) + } + + fn with_options(options: TraceOptions) -> Self { + let tracer = Tracer::new(options); + let syscalls = SyscallTracer::new(tracer.clone()); + let mut guest = Self { + memory: FlatMemory::new(GUEST_BYTES as usize), + tracer, + syscalls, + }; + guest.spawn(SHELL, SHELL, 0); + guest + } + + fn task_of(process_id: u32) -> u64 { + KERNEL_BASE + 0x10000 + process_id as u64 * 0x4000 + } + + fn slot(address: u64) -> usize { + (address & (GUEST_BYTES - 1)) as usize + } + + fn spawn(&mut self, process_id: u32, thread_id: u32, parent: u32) { + let task = Self::task_of(process_id); + let parent_task = if parent == 0 { + 0 + } else { + Self::task_of(parent) + }; + self.memory + .load_at(Self::slot(task + PID_OFFSET), &thread_id.to_le_bytes()); + self.memory + .load_at(Self::slot(task + PID_OFFSET + 4), &process_id.to_le_bytes()); + self.memory.load_at( + Self::slot(task + REAL_PARENT_OFFSET), + &parent_task.to_le_bytes(), + ); + } + + fn calibrate(&mut self) { + for process_id in [201u32, 202] { + self.spawn(process_id, process_id, 0); + self.call( + process_id, + SyscallKind::Identity { group: false }, + process_id as i64, + ); + } + assert!(self.syscalls.knows_process_ids()); + + for (parent, child) in [(203u32, 204u32), (205, 206)] { + self.spawn(parent, parent, 0); + self.fork_running_child_first( + parent, + child, + SyscallKind::Identity { group: false }, + child as i64, + ); + } + assert!(self.syscalls.identities.knows_parents()); + let _ = self.events(); + } + + fn spawn_thread(&mut self, thread_id: u32, process_id: u32) { + let task = Self::task_of(thread_id); + self.memory + .load_at(Self::slot(task + PID_OFFSET), &thread_id.to_le_bytes()); + self.memory + .load_at(Self::slot(task + PID_OFFSET + 4), &process_id.to_le_bytes()); + } + + fn seed(&mut self, process_id: u32, path: &str) { + self.syscalls + .seed_working_directory(process_id, path.to_string()); + } + + fn call(&mut self, process_id: u32, kind: SyscallKind, value: i64) { + self.call_on_task(Self::task_of(process_id), kind, value); + } + + fn call_on_task(&mut self, task: u64, kind: SyscallKind, value: i64) { + self.syscalls + .on_entry(entry(task, kind), &mut self.memory, 0); + self.syscalls + .on_return(task, AFTER_ECALL, value, &mut self.memory, 0); + } + + fn exec(&mut self, process_id: u32, path: &str, argv: &[&str]) { + let task = Self::task_of(process_id); + self.syscalls + .on_entry(entry(task, exec_kind(path, argv)), &mut self.memory, 0); + self.syscalls + .on_return(task, 0x1_0000, 0, &mut self.memory, 0); + } + + fn fork(&mut self, parent: u32, child: u32) { + self.spawn(child, child, parent); + self.call(parent, SyscallKind::Clone { thread: false }, child as i64); + } + + fn fork_running_child_first( + &mut self, + parent: u32, + child: u32, + inside_child: SyscallKind, + value: i64, + ) { + self.spawn(child, child, parent); + let parent_task = Self::task_of(parent); + self.syscalls.on_entry( + entry(parent_task, SyscallKind::Clone { thread: false }), + &mut self.memory, + 0, + ); + self.call(child, inside_child, value); + self.syscalls + .on_return(parent_task, AFTER_ECALL, child as i64, &mut self.memory, 0); + } + + fn events(&self) -> Vec { + String::from_utf8(self.tracer.drain(usize::MAX)) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + } + + fn entry(task: u64, kind: SyscallKind) -> SyscallEntry { SyscallEntry { - task: TASK, + task, number: 0, pc: ECALL_PC, kind, } } - fn exec(path: &str, argv: &[&str]) -> SyscallKind { + fn exec_kind(path: &str, argv: &[&str]) -> SyscallKind { SyscallKind::Exec { - path: guest_string(path), + directory_fd: AT_FDCWD, + path: GuestString::Value(path.to_string()), argv: argv.iter().map(|argument| argument.to_string()).collect(), argv_truncated: false, } } fn open(path: &str) -> SyscallKind { + open_at(AT_FDCWD, path) + } + + fn open_at(directory_fd: i32, path: &str) -> SyscallKind { SyscallKind::Open { - path: guest_string(path), + directory_fd, + path: GuestString::Value(path.to_string()), write: true, read_write: false, create: true, truncate: true, + close_on_exec: false, } } - fn traced() -> (Tracer, SyscallTracer) { - traced_with(TraceOptions::default()) - } - - fn traced_with(options: TraceOptions) -> (Tracer, SyscallTracer) { - let tracer = Tracer::new(options); - let syscalls = SyscallTracer::new(tracer.clone()); - (tracer, syscalls) - } - - fn call(syscalls: &mut SyscallTracer, kind: SyscallKind, value: i64) { - syscalls.on_entry(entry(kind)); - syscalls.on_return(TASK, AFTER_ECALL, value); - } - - fn succeed_exec(syscalls: &mut SyscallTracer, path: &str, argv: &[&str]) { - syscalls.on_entry(entry(exec(path, argv))); - syscalls.on_return(TASK, 0x1_0000, 0); - } - - fn drained(tracer: &Tracer) -> Vec { - String::from_utf8(tracer.drain(usize::MAX)) - .unwrap() - .lines() - .map(|line| serde_json::from_str(line).unwrap()) - .collect() - } - #[test] fn an_open_is_only_emitted_once_its_matching_return_arrives() { - let (tracer, mut syscalls) = traced(); + let mut guest = Guest::new(); + guest.calibrate(); + let task = Guest::task_of(SHELL); - syscalls.on_entry(entry(open("/tmp/trace-demo.txt"))); - assert!(drained(&tracer).is_empty()); + guest.syscalls.on_entry( + entry(task, open("/tmp/trace-demo.txt")), + &mut guest.memory, + 0, + ); + assert!(guest.events().is_empty()); - syscalls.on_return(TASK, AFTER_ECALL, 3); + guest + .syscalls + .on_return(task, AFTER_ECALL, 3, &mut guest.memory, 0); - let events = drained(&tracer); + let events = guest.events(); assert_eq!(events.len(), 1); assert_eq!(events[0]["kind"], "file.open"); - assert_eq!(events[0]["task"], "ffffffd80088e600"); + assert_eq!(events[0]["pid"], 1); assert_eq!(events[0]["path"], "/tmp/trace-demo.txt"); assert_eq!(events[0]["access"], "write"); assert_eq!(events[0]["result"], 3); @@ -440,32 +976,52 @@ mod tests { #[test] fn a_return_at_the_wrong_pc_is_dropped_not_misattributed() { - let (tracer, mut syscalls) = traced(); - - syscalls.on_entry(entry(SyscallKind::Mkdir { - path: guest_string("/tmp/new-dir"), - })); - syscalls.on_return(TASK, 0x9999, 0); + let mut guest = Guest::new(); + let task = Guest::task_of(SHELL); + + guest.syscalls.on_entry( + entry( + task, + SyscallKind::Mkdir { + directory_fd: AT_FDCWD, + path: GuestString::Value("/tmp/new-dir".into()), + }, + ), + &mut guest.memory, + 0, + ); + guest + .syscalls + .on_return(task, 0x9999, 0, &mut guest.memory, 0); - assert!(drained(&tracer).is_empty()); + assert!(guest.events().is_empty()); } #[test] fn a_failed_call_is_still_recorded_with_its_negative_result() { - let (tracer, mut syscalls) = traced(); + let mut guest = Guest::new(); + guest.call(SHELL, open("/etc/shadow"), -13); // EACCES - call(&mut syscalls, open("/etc/shadow"), -13); // EACCES + assert_eq!(guest.events()[0]["result"], -13); + } - assert_eq!(drained(&tracer)[0]["result"], -13); + #[test] + fn events_carry_no_process_id_until_the_offsets_are_calibrated() { + let mut guest = Guest::new(); + guest.call(SHELL, open("/tmp/early.txt"), 3); + + let events = guest.events(); + assert_eq!(events[0]["pid"], Value::Null); + assert_eq!(events[0]["task"], format!("{:x}", Guest::task_of(SHELL))); } #[test] fn a_successful_exec_is_recorded_when_the_task_resumes_in_the_new_program() { - let (tracer, mut syscalls) = traced(); - - succeed_exec(&mut syscalls, "/bin/sh", &["sh", "-c", "cd /app && make"]); + let mut guest = Guest::new(); + guest.calibrate(); + guest.exec(SHELL, "/bin/sh", &["sh", "-c", "cd /app && make"]); - let events = drained(&tracer); + let events = guest.events(); assert_eq!(events.len(), 1); assert_eq!(events[0]["kind"], "process.exec"); assert_eq!(events[0]["path"], "/bin/sh"); @@ -474,21 +1030,34 @@ mod tests { serde_json::json!(["sh", "-c", "cd /app && make"]) ); assert!(events[0].get("result").is_none()); - assert!(events[0].get("argv_truncated").is_none()); + } + + #[test] + fn an_exec_names_the_process_that_started_it() { + let mut guest = Guest::new(); + guest.calibrate(); + guest.fork(SHELL, 700); + guest.exec(700, "/bin/cat", &["cat", "notes.txt"]); + + let events = guest.events(); + let exec = events.last().unwrap(); + assert_eq!(exec["pid"], 700); + assert_eq!(exec["ppid"], 1); } #[test] fn a_path_search_probe_that_fails_is_marked_with_its_error() { - let (tracer, mut syscalls) = traced(); + let mut guest = Guest::new(); + guest.calibrate(); - call( - &mut syscalls, - exec("/usr/local/bin/git", &["git", "status"]), + guest.call( + SHELL, + exec_kind("/usr/local/bin/git", &["git", "status"]), -2, - ); // ENOENT - succeed_exec(&mut syscalls, "/usr/bin/git", &["git", "status"]); + ); + guest.exec(SHELL, "/usr/bin/git", &["git", "status"]); - let events = drained(&tracer); + let events = guest.events(); assert_eq!(events[0]["path"], "/usr/local/bin/git"); assert_eq!(events[0]["result"], -2); assert_eq!(events[1]["path"], "/usr/bin/git"); @@ -497,73 +1066,114 @@ mod tests { #[test] fn an_exec_whose_return_is_redirected_to_a_signal_handler_has_an_unknown_result() { - let (tracer, mut syscalls) = traced(); + let mut guest = Guest::new(); + let task = Guest::task_of(SHELL); - syscalls.on_entry(entry(exec("/usr/bin/missing", &["missing"]))); - syscalls.on_return(TASK, 0x7777, 10); // SIGUSR1 handler, a0 = signal number + guest.syscalls.on_entry( + entry(task, exec_kind("/usr/bin/missing", &["missing"])), + &mut guest.memory, + 0, + ); + guest + .syscalls + .on_return(task, 0x7777, 10, &mut guest.memory, 0); - let events = drained(&tracer); - assert_eq!(events[0]["result"], Value::Null); + assert_eq!(guest.events()[0]["result"], Value::Null); } #[test] fn a_truncated_command_line_says_so() { - let (tracer, mut syscalls) = traced(); - - syscalls.on_entry(entry(SyscallKind::Exec { - path: guest_string("/bin/sh"), - argv: vec!["sh".into(), "-c".into(), "x".repeat(10)], - argv_truncated: true, - })); - syscalls.on_return(TASK, 0x1_0000, 0); + let mut guest = Guest::new(); + let task = Guest::task_of(SHELL); + + guest.syscalls.on_entry( + entry( + task, + SyscallKind::Exec { + directory_fd: AT_FDCWD, + path: GuestString::Value("/bin/sh".into()), + argv: vec!["sh".into(), "-c".into(), "x".repeat(10)], + argv_truncated: true, + }, + ), + &mut guest.memory, + 0, + ); + guest + .syscalls + .on_return(task, 0x1_0000, 0, &mut guest.memory, 0); - assert_eq!(drained(&tracer)[0]["argv_truncated"], true); + assert_eq!(guest.events()[0]["argv_truncated"], true); } #[test] fn a_vpod_helper_is_internal_until_its_task_execs_something_else() { - let (tracer, mut syscalls) = traced(); + let mut guest = Guest::new(); + guest.calibrate(); - succeed_exec( - &mut syscalls, + guest.exec( + SHELL, "/usr/lib/vpod/vpod-seed-entropy", &["vpod-seed-entropy"], ); - call(&mut syscalls, open("/tmp/seed"), 3); - succeed_exec(&mut syscalls, "/usr/bin/wget", &["wget"]); - call(&mut syscalls, open("/tmp/page.html"), 4); + guest.call(SHELL, open("/tmp/seed"), 3); + guest.exec(SHELL, "/usr/bin/wget", &["wget"]); + guest.call(SHELL, open("/tmp/page.html"), 4); - let events = drained(&tracer); + let events = guest.events(); assert_eq!(events[0]["internal"], true); assert_eq!(events[1]["internal"], true); assert!(events[2].get("internal").is_none()); assert!(events[3].get("internal").is_none()); } + #[test] + fn a_helper_that_forks_keeps_its_children_internal_too() { + let mut guest = Guest::new(); + guest.calibrate(); + + guest.exec( + SHELL, + "/usr/lib/vpod/vpod-seed-entropy", + &["vpod-seed-entropy"], + ); + guest.fork(SHELL, 800); + guest.call(800, open("/tmp/helper-child"), 3); + + let events = guest.events(); + assert_eq!(events.last().unwrap()["internal"], true); + } + #[test] fn pyrunner_runs_user_code_so_its_activity_is_not_internal() { - let (tracer, mut syscalls) = traced(); + let mut guest = Guest::new(); + guest.calibrate(); - succeed_exec( - &mut syscalls, + guest.exec( + SHELL, "/usr/bin/python3.real", &["/usr/bin/python3.real", "/usr/lib/vpod/pyrunner.py"], ); - call(&mut syscalls, open("/data/results.csv"), 3); + guest.call(SHELL, open("/data/results.csv"), 3); - let events = drained(&tracer); - assert!(events.iter().all(|event| event.get("internal").is_none())); + assert!( + guest + .events() + .iter() + .all(|event| event.get("internal").is_none()) + ); } #[test] fn opening_a_vpod_device_or_staging_file_is_internal_but_the_console_is_not() { - let (tracer, mut syscalls) = traced(); + let mut guest = Guest::new(); + guest.calibrate(); - call(&mut syscalls, open("/dev/ttyS1"), 3); - call(&mut syscalls, open("/tmp/.vpod_cmd.b64"), 3); - call(&mut syscalls, open("/dev/ttyS0"), 3); + guest.call(SHELL, open("/dev/ttyS1"), 3); + guest.call(SHELL, open("/tmp/.vpod_cmd.b64"), 3); + guest.call(SHELL, open("/dev/ttyS0"), 3); - let events = drained(&tracer); + let events = guest.events(); assert_eq!(events[0]["internal"], true); assert_eq!(events[1]["internal"], true); assert!(events[2].get("internal").is_none()); @@ -571,27 +1181,24 @@ mod tests { #[test] fn staging_a_long_command_is_internal_but_running_it_is_not() { - let (tracer, mut syscalls) = traced(); + let mut guest = Guest::new(); + guest.calibrate(); - succeed_exec( - &mut syscalls, + guest.exec( + SHELL, "/bin/base64", &["base64", "-d", "/tmp/.vpod_cmd.b64"], ); - succeed_exec( - &mut syscalls, - "/bin/rm", - &["rm", "-f", "/tmp/.vpod_cmd.b64"], - ); - succeed_exec(&mut syscalls, "/bin/sh", &["sh", "/tmp/.vpod_cmd.sh"]); - succeed_exec( - &mut syscalls, + guest.exec(SHELL, "/bin/rm", &["rm", "-f", "/tmp/.vpod_cmd.b64"]); + guest.exec(SHELL, "/bin/sh", &["sh", "/tmp/.vpod_cmd.sh"]); + guest.exec( + SHELL, "/bin/rm", &["rm", "-f", "/tmp/.vpod_cmd.b64", "/tmp/notes.txt"], ); - succeed_exec(&mut syscalls, "/bin/rm", &["rm", "-f"]); + guest.exec(SHELL, "/bin/rm", &["rm", "-f"]); - let events = drained(&tracer); + let events = guest.events(); assert_eq!(events[0]["internal"], true); assert_eq!(events[1]["internal"], true); assert!(events[2].get("internal").is_none()); @@ -601,38 +1208,155 @@ mod tests { #[test] fn an_exit_reports_the_status_the_shell_would_see_and_forgets_the_task() { - let (tracer, mut syscalls) = traced(); + let mut guest = Guest::new(); + guest.calibrate(); - succeed_exec( - &mut syscalls, + guest.exec( + SHELL, "/usr/lib/vpod/vpod-seed-entropy", &["vpod-seed-entropy"], ); - syscalls.on_entry(entry(SyscallKind::Exit { code: 256 + 3 })); - succeed_exec(&mut syscalls, "/usr/bin/python3", &["python3"]); + guest.syscalls.on_entry( + entry(Guest::task_of(SHELL), SyscallKind::Exit { code: 256 + 3 }), + &mut guest.memory, + 0, + ); + guest.exec(SHELL, "/usr/bin/python3", &["python3"]); - let events = drained(&tracer); + let events = guest.events(); assert_eq!(events[1]["kind"], "process.exit"); assert_eq!(events[1]["code"], 3); assert_eq!(events[1]["internal"], true); assert!(events[2].get("internal").is_none()); } + #[test] + fn a_relative_path_is_reported_under_the_working_directory_it_was_opened_from() { + let mut guest = Guest::new(); + guest.calibrate(); + guest.seed(SHELL, "/"); + guest.fork(SHELL, 700); + guest.call( + 700, + SyscallKind::ChangeDirectory { + path: GuestString::Value("/app".into()), + }, + 0, + ); + guest.call(700, open("src/main.rs"), 3); + + let events = guest.events(); + let open = events.last().unwrap(); + assert_eq!(open["path"], "/app/src/main.rs"); + assert!(open.get("path_unresolved").is_none()); + } + + #[test] + fn a_child_keeps_resolving_paths_against_the_directory_it_inherited() { + let mut guest = Guest::new(); + guest.calibrate(); + guest.seed(SHELL, "/app"); + guest.fork(SHELL, 700); + guest.call(700, open("notes.txt"), 3); + + assert_eq!(guest.events().last().unwrap()["path"], "/app/notes.txt"); + } + + #[test] + fn a_child_that_runs_before_its_parent_returns_still_knows_where_it_is() { + let mut guest = Guest::new(); + guest.calibrate(); + guest.seed(SHELL, "/app"); + guest.fork_running_child_first(SHELL, 700, open("notes.txt"), 3); + + let events = guest.events(); + assert_eq!(events[0]["kind"], "file.open"); + assert_eq!(events[0]["pid"], 700); + assert_eq!(events[0]["path"], "/app/notes.txt"); + } + + #[test] + fn a_path_that_cannot_be_placed_is_reported_as_the_program_gave_it() { + let mut guest = Guest::new(); + guest.calibrate(); + guest.call(SHELL, open("notes.txt"), 3); + + let open = guest.events()[0].clone(); + assert_eq!(open["path"], "notes.txt"); + assert_eq!(open["path_unresolved"], true); + } + + #[test] + fn a_relative_path_under_a_directory_descriptor_is_placed_by_that_descriptor() { + let mut guest = Guest::new(); + guest.calibrate(); + guest.seed(SHELL, "/"); + guest.call(SHELL, open("/etc/ssl"), 7); + guest.call(SHELL, open_at(7, "certs/ca.pem"), 8); + + assert_eq!( + guest.events().last().unwrap()["path"], + "/etc/ssl/certs/ca.pem" + ); + } + + #[test] + fn truncating_through_a_descriptor_names_the_file_it_points_at() { + let mut guest = Guest::new(); + guest.calibrate(); + guest.seed(SHELL, "/"); + guest.call(SHELL, open("/tmp/report.csv"), 4); + guest.call(SHELL, SyscallKind::TruncateDescriptor { fd: 4, size: 0 }, 0); + + let truncate = guest.events().last().unwrap().clone(); + assert_eq!(truncate["kind"], "file.truncate"); + assert_eq!(truncate["path"], "/tmp/report.csv"); + assert_eq!(truncate["size"], 0); + } + + #[test] + fn truncating_a_descriptor_that_was_never_seen_opened_says_so() { + let mut guest = Guest::new(); + guest.calibrate(); + guest.call( + SHELL, + SyscallKind::TruncateDescriptor { fd: 9, size: 10 }, + 0, + ); + + let truncate = guest.events()[0].clone(); + assert_eq!(truncate["path"], Value::Null); + assert_eq!(truncate["path_unresolved"], true); + } + + #[test] + fn a_closed_descriptor_stops_naming_its_old_file() { + let mut guest = Guest::new(); + guest.calibrate(); + guest.seed(SHELL, "/"); + guest.call(SHELL, open("/tmp/report.csv"), 4); + guest.call(SHELL, SyscallKind::Close { fd: 4 }, 0); + guest.call(SHELL, SyscallKind::TruncateDescriptor { fd: 4, size: 0 }, 0); + + assert_eq!(guest.events().last().unwrap()["path"], Value::Null); + } + #[test] fn connect_reports_the_protocol_its_socket_was_created_with_and_the_dns_name() { - let (tracer, mut syscalls) = traced(); - tracer.remember_name([151, 101, 0, 223], "pypi.org"); + let mut guest = Guest::new(); + guest.calibrate(); + guest.tracer.remember_name([151, 101, 0, 223], "pypi.org"); - call( - &mut syscalls, + guest.call( + SHELL, SyscallKind::Socket { domain: AF_INET, socket_type: SOCK_STREAM | 0o4000, // SOCK_NONBLOCK }, 4, ); - call( - &mut syscalls, + guest.call( + SHELL, SyscallKind::Connect { fd: 4, address: Some("151.101.0.223:443".parse().unwrap()), @@ -640,7 +1364,7 @@ mod tests { -115, // EINPROGRESS ); - let events = drained(&tracer); + let events = guest.events(); assert_eq!(events.len(), 1); assert_eq!(events[0]["kind"], "net.connect"); assert_eq!(events[0]["protocol"], "tcp"); @@ -648,14 +1372,16 @@ mod tests { assert_eq!(events[0]["port"], 443); assert_eq!(events[0]["host"], "pypi.org"); assert_eq!(events[0]["result"], -115); + assert_eq!(events[0]["pid"], 1); } #[test] fn a_connect_on_a_socket_we_never_saw_created_has_no_protocol() { - let (tracer, mut syscalls) = traced(); + let mut guest = Guest::new(); + guest.calibrate(); - call( - &mut syscalls, + guest.call( + SHELL, SyscallKind::Connect { fd: 9, address: Some("[2a04:4e42::223]:443".parse().unwrap()), @@ -663,7 +1389,7 @@ mod tests { 0, ); - let events = drained(&tracer); + let events = guest.events(); assert_eq!(events[0]["protocol"], Value::Null); assert_eq!(events[0]["address"], "2a04:4e42::223"); assert_eq!(events[0]["host"], Value::Null); @@ -671,10 +1397,11 @@ mod tests { #[test] fn a_unix_socket_connect_is_not_a_network_event() { - let (tracer, mut syscalls) = traced(); + let mut guest = Guest::new(); + guest.calibrate(); - call( - &mut syscalls, + guest.call( + SHELL, SyscallKind::Connect { fd: 3, address: None, @@ -682,23 +1409,24 @@ mod tests { 0, ); - assert!(drained(&tracer).is_empty()); + assert!(guest.events().is_empty()); } #[test] fn a_listen_is_paired_with_its_bind_address() { - let (tracer, mut syscalls) = traced(); + let mut guest = Guest::new(); + guest.calibrate(); - call( - &mut syscalls, + guest.call( + SHELL, SyscallKind::Socket { domain: AF_INET, socket_type: SOCK_STREAM, }, 5, ); - call( - &mut syscalls, + guest.call( + SHELL, SyscallKind::Bind { fd: 5, address: Some("0.0.0.0:8080".parse().unwrap()), @@ -706,13 +1434,13 @@ mod tests { 0, ); assert!( - drained(&tracer).is_empty(), + guest.events().is_empty(), "socket and bind alone emit nothing" ); - call(&mut syscalls, SyscallKind::Listen { fd: 5 }, 0); + guest.call(SHELL, SyscallKind::Listen { fd: 5 }, 0); - let events = drained(&tracer); + let events = guest.events(); assert_eq!(events.len(), 1); assert_eq!(events[0]["kind"], "net.listen"); assert_eq!(events[0]["protocol"], "tcp"); @@ -722,75 +1450,117 @@ mod tests { #[test] fn listen_on_a_socket_that_was_never_seen_bound_emits_nothing() { - let (tracer, mut syscalls) = traced(); + let mut guest = Guest::new(); + guest.calibrate(); - call(&mut syscalls, SyscallKind::Listen { fd: 5 }, 0); + guest.call(SHELL, SyscallKind::Listen { fd: 5 }, 0); - assert!(drained(&tracer).is_empty()); + assert!(guest.events().is_empty()); } #[test] fn a_successful_fork_reports_the_child_pid() { - let (tracer, mut syscalls) = traced(); + let mut guest = Guest::new(); + guest.calibrate(); - call(&mut syscalls, SyscallKind::Clone { thread: false }, 4242); + guest.fork(SHELL, 742); - let events = drained(&tracer); + let events = guest.events(); assert_eq!(events[0]["kind"], "process.fork"); - assert_eq!(events[0]["child_pid"], 4242); + assert_eq!(events[0]["pid"], 1); + assert_eq!(events[0]["child_pid"], 742); assert_eq!(events[0]["thread"], false); } #[test] fn a_failed_fork_is_not_reported() { - let (tracer, mut syscalls) = traced(); + let mut guest = Guest::new(); + guest.calibrate(); + + guest.call(SHELL, SyscallKind::Clone { thread: false }, -11); // EAGAIN + + assert!(guest.events().is_empty()); + } - call(&mut syscalls, SyscallKind::Clone { thread: false }, -11); // EAGAIN + #[test] + fn a_thread_reports_the_process_it_belongs_to() { + let mut guest = Guest::new(); + guest.calibrate(); + guest.spawn(900, 900, SHELL); + guest.call(900, open("/tmp/from-main-thread"), 3); + + guest.spawn_thread(901, 900); + guest.call_on_task(Guest::task_of(901), open("/tmp/from-worker"), 4); + + let events = guest.events(); + assert_eq!(events[0]["pid"], 900); + assert_eq!(events[1]["pid"], 900); + assert_ne!(events[0]["task"], events[1]["task"]); + } - assert!(drained(&tracer).is_empty()); + #[test] + fn quiet_keeps_the_state_but_records_nothing() { + let mut guest = Guest::new(); + guest.syscalls.set_quiet(true); + guest.calibrate(); + guest.seed(SHELL, "/app"); + guest.call(SHELL, open("hidden.txt"), 3); + assert!(guest.events().is_empty()); + + guest.syscalls.set_quiet(false); + guest.call(SHELL, open("shown.txt"), 3); + + let events = guest.events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["path"], "/app/shown.txt"); } #[test] fn disabling_files_still_traces_network() { - let (tracer, mut syscalls) = traced_with(TraceOptions { + let mut guest = Guest::with_options(TraceOptions { files: false, ..TraceOptions::default() }); + guest.calibrate(); - call( - &mut syscalls, + guest.call( + SHELL, SyscallKind::Mkdir { - path: guest_string("/tmp/x"), + directory_fd: AT_FDCWD, + path: GuestString::Value("/tmp/x".into()), }, 0, ); - assert!(drained(&tracer).is_empty()); + assert!(guest.events().is_empty()); - call( - &mut syscalls, + guest.call( + SHELL, SyscallKind::Connect { fd: 4, address: Some("10.0.2.2:443".parse().unwrap()), }, 0, ); - assert_eq!(drained(&tracer)[0]["kind"], "net.connect"); + assert_eq!(guest.events()[0]["kind"], "net.connect"); } #[test] fn an_unreadable_path_is_null_and_flagged() { - let (tracer, mut syscalls) = traced(); + let mut guest = Guest::new(); + guest.calibrate(); - call( - &mut syscalls, + guest.call( + SHELL, SyscallKind::Rename { + from_directory_fd: AT_FDCWD, from: GuestString::Truncated("/tmp/aaaa".into()), + to_directory_fd: AT_FDCWD, to: GuestString::Unreadable, }, -14, // EFAULT ); - let events = drained(&tracer); + let events = guest.events(); assert_eq!(events[0]["from"], "/tmp/aaaa"); assert_eq!(events[0]["from_truncated"], true); assert_eq!(events[0]["to"], Value::Null); diff --git a/crates/riscv-core/src/execute.rs b/crates/riscv-core/src/execute.rs index a50c69bf..9ef8e340 100644 --- a/crates/riscv-core/src/execute.rs +++ b/crates/riscv-core/src/execute.rs @@ -957,8 +957,9 @@ fn exec_amo(ctx: &mut ExecContext, inst: Instruction, raw: u32) #[cold] #[inline(never)] fn trace_syscall_entry(ctx: &mut ExecContext, pc: u64) { - if let Some(entry) = crate::syscall_trace::decode_entry(ctx, pc) { - ctx.bus.on_syscall_entry(entry); + let satp = crate::block::effective_satp(*ctx.priv_mode, ctx.csr.satp); + if let Some(entry) = crate::syscall_trace::decode_entry(ctx, pc, satp) { + ctx.bus.on_syscall_entry(entry, satp); } } @@ -966,8 +967,9 @@ fn trace_syscall_entry(ctx: &mut ExecContext, pc: u64) { #[inline(never)] fn trace_syscall_return(ctx: &mut ExecContext) { let value = ctx.regs.read(10) as i64; // a0 + let satp = crate::block::effective_satp(*ctx.priv_mode, ctx.csr.satp); ctx.bus - .on_syscall_return(ctx.csr.sscratch, ctx.csr.sepc, value); + .on_syscall_return(ctx.csr.sscratch, ctx.csr.sepc, value, satp); } fn exec_system(ctx: &mut ExecContext, inst: Instruction, raw: u32) -> StepResult { diff --git a/crates/riscv-core/src/lib.rs b/crates/riscv-core/src/lib.rs index e34edc68..8a7237ce 100644 --- a/crates/riscv-core/src/lib.rs +++ b/crates/riscv-core/src/lib.rs @@ -17,7 +17,7 @@ pub mod trap; pub use csr::{Csr, PrivMode}; pub use hart::Hart; pub use mmu::Mmu; -pub use syscall_trace::{GuestString, SyscallEntry, SyscallKind}; +pub use syscall_trace::{AT_FDCWD, GuestMemory, GuestString, SyscallEntry, SyscallKind}; pub use system_bus::{FlatMemory, SystemBus}; pub use trap::{StepResult, TrapCause}; diff --git a/crates/riscv-core/src/mmu.rs b/crates/riscv-core/src/mmu.rs index d36a86cd..2727621d 100644 --- a/crates/riscv-core/src/mmu.rs +++ b/crates/riscv-core/src/mmu.rs @@ -282,6 +282,19 @@ impl Mmu { .map_err(|_| MmuFault::InstructionPageFault(virtual_address)) } + pub fn translate_readable( + virtual_address: u64, + satp: u64, + bus: &mut impl SystemBus, + ) -> Option { + if satp >> 60 == 0 { + return Some(virtual_address); + } + walk_inner(virtual_address, satp, false, false, bus) + .ok() + .map(|(physical_address, _, _)| physical_address) + } + pub fn translate_load( &mut self, virtual_address: u64, diff --git a/crates/riscv-core/src/syscall_trace.rs b/crates/riscv-core/src/syscall_trace.rs index 6f4dba5a..fd6bf3d2 100644 --- a/crates/riscv-core/src/syscall_trace.rs +++ b/crates/riscv-core/src/syscall_trace.rs @@ -3,11 +3,21 @@ use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; use crate::execute::ExecContext; use crate::system_bus::SystemBus; +const SYS_DUP: u64 = 23; +const SYS_DUP3: u64 = 24; +const SYS_FCNTL: u64 = 25; const SYS_MKDIRAT: u64 = 34; const SYS_UNLINKAT: u64 = 35; const SYS_TRUNCATE: u64 = 45; +const SYS_FTRUNCATE: u64 = 46; +const SYS_CHDIR: u64 = 49; +const SYS_FCHDIR: u64 = 50; const SYS_OPENAT: u64 = 56; +const SYS_CLOSE: u64 = 57; const SYS_EXIT_GROUP: u64 = 94; +const SYS_SET_TID_ADDRESS: u64 = 96; +const SYS_GETPID: u64 = 172; +const SYS_GETTID: u64 = 178; const SYS_SOCKET: u64 = 198; const SYS_BIND: u64 = 200; const SYS_LISTEN: u64 = 201; @@ -17,10 +27,12 @@ const SYS_EXECVE: u64 = 221; const SYS_RENAMEAT2: u64 = 276; const SYS_EXECVEAT: u64 = 281; const SYS_CLONE3: u64 = 435; +const SYS_CLOSE_RANGE: u64 = 436; const SYS_OPENAT2: u64 = 437; const CLONE_THREAD: u64 = 0x0001_0000; const AT_REMOVEDIR: u64 = 0x200; +const AT_EMPTY_PATH: u64 = 0x1000; const AF_INET: u16 = 2; const AF_INET6: u16 = 10; @@ -29,11 +41,17 @@ const O_WRONLY: u32 = 0o1; const O_RDWR: u32 = 0o2; const O_CREAT: u32 = 0o100; const O_TRUNC: u32 = 0o1000; +const O_CLOEXEC: u32 = 0o2_000_000; + +const F_DUPFD: u64 = 0; +const F_DUPFD_CLOEXEC: u64 = 1030; const MAX_PATH_BYTES: usize = 4096; const MAX_ARGV_BYTES: usize = 32 * 1024; const MAX_ARGV_ENTRIES: usize = 1024; +pub const AT_FDCWD: i32 = -100; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum GuestString { Value(String), @@ -50,6 +68,7 @@ pub struct SyscallEntry { pub enum SyscallKind { Exec { + directory_fd: i32, path: GuestString, argv: Vec, argv_truncated: bool, @@ -60,61 +79,105 @@ pub enum SyscallKind { Clone { thread: bool, }, + Identity { + group: bool, + }, Open { + directory_fd: i32, path: GuestString, write: bool, read_write: bool, create: bool, truncate: bool, + close_on_exec: bool, }, Rename { + from_directory_fd: i32, from: GuestString, + to_directory_fd: i32, to: GuestString, }, Unlink { + directory_fd: i32, path: GuestString, directory: bool, }, Mkdir { + directory_fd: i32, path: GuestString, }, Truncate { path: GuestString, size: u64, }, + TruncateDescriptor { + fd: i32, + size: u64, + }, + ChangeDirectory { + path: GuestString, + }, + ChangeDirectoryDescriptor { + fd: i32, + }, + Duplicate { + fd: i32, + close_on_exec: bool, + }, + DuplicateTo { + from_fd: i32, + to_fd: i32, + close_on_exec: bool, + }, + Close { + fd: i32, + }, + CloseRange { + first: u32, + last: u32, + }, Socket { domain: u32, socket_type: u32, }, Connect { - fd: u64, + fd: i32, address: Option, }, Bind { - fd: u64, + fd: i32, address: Option, }, Listen { - fd: u64, + fd: i32, }, } -pub fn decode_entry(ctx: &mut ExecContext, pc: u64) -> Option { +pub fn decode_entry( + ctx: &mut ExecContext, + pc: u64, + satp: u64, +) -> Option { let task = ctx.csr.sscratch; let number = ctx.regs.read(17); // a7 let args: [u64; 6] = std::array::from_fn(|n| ctx.regs.read(10 + n)); // a0..a5 - let mut memory = GuestMemory::new(crate::block::effective_satp(*ctx.priv_mode, ctx.csr.satp)); + let mut memory = GuestMemory::new(satp); let kind = match number { SYS_EXECVE | SYS_EXECVEAT => { - let (path_address, argv_address) = if number == SYS_EXECVEAT { - (args[1], args[2]) + let (directory_fd, path_address, argv_address) = if number == SYS_EXECVEAT { + (args[0] as i32, args[1], args[2]) + } else { + (AT_FDCWD, args[0], args[1]) + }; + let path = if number == SYS_EXECVEAT && args[4] & AT_EMPTY_PATH != 0 { + GuestString::Value(String::new()) } else { - (args[0], args[1]) + memory.cstring(ctx.bus, path_address, MAX_PATH_BYTES) }; - let path = memory.cstring(ctx, path_address, MAX_PATH_BYTES); - let (argv, argv_truncated) = memory.argv(ctx, argv_address); + let (argv, argv_truncated) = memory.argv(ctx.bus, argv_address); SyscallKind::Exec { + directory_fd, path, argv, argv_truncated, @@ -127,50 +190,84 @@ pub fn decode_entry(ctx: &mut ExecContext, pc: u64) -> Option SyscallKind::Clone { - thread: memory.u64(ctx, args[0]).unwrap_or(0) & CLONE_THREAD != 0, + thread: memory.u64(ctx.bus, args[0]).unwrap_or(0) & CLONE_THREAD != 0, }, + SYS_SET_TID_ADDRESS | SYS_GETTID => SyscallKind::Identity { group: false }, + SYS_GETPID => SyscallKind::Identity { group: true }, SYS_OPENAT | SYS_OPENAT2 => { let flags = if number == SYS_OPENAT2 { - memory.u64(ctx, args[2]).unwrap_or(0) as u32 + memory.u64(ctx.bus, args[2]).unwrap_or(0) as u32 } else { args[2] as u32 }; SyscallKind::Open { - path: memory.cstring(ctx, args[1], MAX_PATH_BYTES), + directory_fd: args[0] as i32, + path: memory.cstring(ctx.bus, args[1], MAX_PATH_BYTES), write: flags & O_ACCMODE == O_WRONLY, read_write: flags & O_ACCMODE == O_RDWR, create: flags & O_CREAT != 0, truncate: flags & O_TRUNC != 0, + close_on_exec: flags & O_CLOEXEC != 0, } } SYS_RENAMEAT2 => SyscallKind::Rename { - from: memory.cstring(ctx, args[1], MAX_PATH_BYTES), - to: memory.cstring(ctx, args[3], MAX_PATH_BYTES), + from_directory_fd: args[0] as i32, + from: memory.cstring(ctx.bus, args[1], MAX_PATH_BYTES), + to_directory_fd: args[2] as i32, + to: memory.cstring(ctx.bus, args[3], MAX_PATH_BYTES), }, SYS_UNLINKAT => SyscallKind::Unlink { - path: memory.cstring(ctx, args[1], MAX_PATH_BYTES), + directory_fd: args[0] as i32, + path: memory.cstring(ctx.bus, args[1], MAX_PATH_BYTES), directory: args[2] & AT_REMOVEDIR != 0, }, SYS_MKDIRAT => SyscallKind::Mkdir { - path: memory.cstring(ctx, args[1], MAX_PATH_BYTES), + directory_fd: args[0] as i32, + path: memory.cstring(ctx.bus, args[1], MAX_PATH_BYTES), }, SYS_TRUNCATE => SyscallKind::Truncate { - path: memory.cstring(ctx, args[0], MAX_PATH_BYTES), + path: memory.cstring(ctx.bus, args[0], MAX_PATH_BYTES), size: args[1], }, + SYS_FTRUNCATE => SyscallKind::TruncateDescriptor { + fd: args[0] as i32, + size: args[1], + }, + SYS_CHDIR => SyscallKind::ChangeDirectory { + path: memory.cstring(ctx.bus, args[0], MAX_PATH_BYTES), + }, + SYS_FCHDIR => SyscallKind::ChangeDirectoryDescriptor { fd: args[0] as i32 }, + SYS_DUP => SyscallKind::Duplicate { + fd: args[0] as i32, + close_on_exec: false, + }, + SYS_FCNTL if matches!(args[1], F_DUPFD | F_DUPFD_CLOEXEC) => SyscallKind::Duplicate { + fd: args[0] as i32, + close_on_exec: args[1] == F_DUPFD_CLOEXEC, + }, + SYS_DUP3 => SyscallKind::DuplicateTo { + from_fd: args[0] as i32, + to_fd: args[1] as i32, + close_on_exec: args[2] as u32 & O_CLOEXEC != 0, + }, + SYS_CLOSE => SyscallKind::Close { fd: args[0] as i32 }, + SYS_CLOSE_RANGE => SyscallKind::CloseRange { + first: args[0] as u32, + last: args[1] as u32, + }, SYS_SOCKET => SyscallKind::Socket { domain: args[0] as u32, socket_type: args[1] as u32, }, SYS_CONNECT => SyscallKind::Connect { - fd: args[0], - address: memory.socket_address(ctx, args[1]), + fd: args[0] as i32, + address: memory.socket_address(ctx.bus, args[1]), }, SYS_BIND => SyscallKind::Bind { - fd: args[0], - address: memory.socket_address(ctx, args[1]), + fd: args[0] as i32, + address: memory.socket_address(ctx.bus, args[1]), }, - SYS_LISTEN => SyscallKind::Listen { fd: args[0] }, + SYS_LISTEN => SyscallKind::Listen { fd: args[0] as i32 }, _ => return None, }; @@ -182,14 +279,14 @@ pub fn decode_entry(ctx: &mut ExecContext, pc: u64) -> Option Self { + pub fn new(satp: u64) -> Self { Self { satp, virtual_page: u64::MAX, @@ -197,39 +294,41 @@ impl GuestMemory { } } - fn byte(&mut self, ctx: &mut ExecContext, virtual_address: u64) -> Option { + pub fn byte(&mut self, bus: &mut B, virtual_address: u64) -> Option { let virtual_page = virtual_address >> 12; if virtual_page != self.virtual_page { - let physical_address = ctx - .mmu - .translate_load(virtual_address, self.satp, ctx.bus) - .ok()?; - self.host_page = ctx.bus.ram_load_page(physical_address)?; + let physical_address = + crate::mmu::Mmu::translate_readable(virtual_address, self.satp, bus)?; + self.host_page = bus.ram_load_page(physical_address)?; self.virtual_page = virtual_page; } Some(unsafe { *self.host_page.add((virtual_address & 0xfff) as usize) }) } - fn array( + pub fn array( &mut self, - ctx: &mut ExecContext, + bus: &mut B, virtual_address: u64, ) -> Option<[u8; N]> { let mut bytes = [0u8; N]; for (offset, byte) in bytes.iter_mut().enumerate() { - *byte = self.byte(ctx, virtual_address.wrapping_add(offset as u64))?; + *byte = self.byte(bus, virtual_address.wrapping_add(offset as u64))?; } Some(bytes) } - fn u64(&mut self, ctx: &mut ExecContext, virtual_address: u64) -> Option { - self.array(ctx, virtual_address).map(u64::from_le_bytes) + pub fn u32(&mut self, bus: &mut B, virtual_address: u64) -> Option { + self.array(bus, virtual_address).map(u32::from_le_bytes) + } + + pub fn u64(&mut self, bus: &mut B, virtual_address: u64) -> Option { + self.array(bus, virtual_address).map(u64::from_le_bytes) } fn cstring( &mut self, - ctx: &mut ExecContext, + bus: &mut B, virtual_address: u64, max_bytes: usize, ) -> GuestString { @@ -240,7 +339,7 @@ impl GuestMemory { let mut bytes = Vec::new(); loop { let next = virtual_address.wrapping_add(bytes.len() as u64); - match self.byte(ctx, next) { + match self.byte(bus, next) { None if bytes.is_empty() => return GuestString::Unreadable, None => return GuestString::Truncated(lossy(bytes)), Some(0) => return GuestString::Value(lossy(bytes)), @@ -252,11 +351,7 @@ impl GuestMemory { } } - fn argv( - &mut self, - ctx: &mut ExecContext, - virtual_address: u64, - ) -> (Vec, bool) { + fn argv(&mut self, bus: &mut B, virtual_address: u64) -> (Vec, bool) { let mut argv = Vec::new(); if virtual_address == 0 { return (argv, false); @@ -264,7 +359,7 @@ impl GuestMemory { let mut budget = MAX_ARGV_BYTES; for index in 0..MAX_ARGV_ENTRIES as u64 { - let Some(pointer) = self.u64(ctx, virtual_address.wrapping_add(index * 8)) else { + let Some(pointer) = self.u64(bus, virtual_address.wrapping_add(index * 8)) else { return (argv, true); }; if pointer == 0 { @@ -274,7 +369,7 @@ impl GuestMemory { return (argv, true); } - match self.cstring(ctx, pointer, budget) { + match self.cstring(bus, pointer, budget) { GuestString::Value(argument) => { budget = budget.saturating_sub(argument.len()); argv.push(argument); @@ -292,23 +387,23 @@ impl GuestMemory { fn socket_address( &mut self, - ctx: &mut ExecContext, + bus: &mut B, virtual_address: u64, ) -> Option { if virtual_address == 0 { return None; } - let family = u16::from_le_bytes(self.array(ctx, virtual_address)?); - let port = u16::from_be_bytes(self.array(ctx, virtual_address + 2)?); + let family = u16::from_le_bytes(self.array(bus, virtual_address)?); + let port = u16::from_be_bytes(self.array(bus, virtual_address + 2)?); match family { AF_INET => { - let octets: [u8; 4] = self.array(ctx, virtual_address + 4)?; + let octets: [u8; 4] = self.array(bus, virtual_address + 4)?; Some(SocketAddr::from((Ipv4Addr::from(octets), port))) } AF_INET6 => { - let octets: [u8; 16] = self.array(ctx, virtual_address + 8)?; + let octets: [u8; 16] = self.array(bus, virtual_address + 8)?; Some(SocketAddr::from((Ipv6Addr::from(octets), port))) } _ => None, @@ -406,11 +501,11 @@ mod tests { true } - fn on_syscall_entry(&mut self, entry: SyscallEntry) { + fn on_syscall_entry(&mut self, entry: SyscallEntry, _satp: u64) { self.entries.push((entry.number, entry.kind)); } - fn on_syscall_return(&mut self, task: u64, return_pc: u64, value: i64) { + fn on_syscall_return(&mut self, task: u64, return_pc: u64, value: i64, _satp: u64) { self.returns.push((task, return_pc, value)); } } @@ -438,22 +533,50 @@ mod tests { let kind = only_entry(user_ecall(|cpu, bus| { bus.write_cstring(0x2000, "/tmp/trace-demo.txt"); cpu.regs.write(17, SYS_OPENAT); + cpu.regs.write(10, AT_FDCWD as u64); // a0: dirfd cpu.regs.write(11, 0x2000); // a1: path cpu.regs.write(12, (O_WRONLY | O_CREAT | O_TRUNC) as u64); // a2: flags })); let SyscallKind::Open { + directory_fd, path, write, read_write, create, truncate, + close_on_exec, } = kind else { panic!("expected Open"); }; + assert_eq!(directory_fd, AT_FDCWD); assert_eq!(path, GuestString::Value("/tmp/trace-demo.txt".into())); - assert!(write && create && truncate && !read_write); + assert!(write && create && truncate && !read_write && !close_on_exec); + } + + #[test] + fn openat_keeps_the_directory_descriptor_a_relative_path_is_read_against() { + let kind = only_entry(user_ecall(|cpu, bus| { + bus.write_cstring(0x2000, "package.json"); + cpu.regs.write(17, SYS_OPENAT); + cpu.regs.write(10, 7); + cpu.regs.write(11, 0x2000); + cpu.regs.write(12, O_CLOEXEC as u64); + })); + + let SyscallKind::Open { + directory_fd, + path, + close_on_exec, + .. + } = kind + else { + panic!("expected Open"); + }; + assert_eq!(directory_fd, 7); + assert_eq!(path, GuestString::Value("package.json".into())); + assert!(close_on_exec); } #[test] @@ -474,6 +597,7 @@ mod tests { })); let SyscallKind::Exec { + directory_fd, path, argv, argv_truncated, @@ -481,11 +605,33 @@ mod tests { else { panic!("expected Exec"); }; + assert_eq!(directory_fd, AT_FDCWD); assert_eq!(path, GuestString::Value("/bin/sh".into())); assert_eq!(argv, ["sh", "-c", "cd /app && make"]); assert!(!argv_truncated); } + #[test] + fn execveat_on_a_descriptor_alone_has_an_empty_path() { + let kind = only_entry(user_ecall(|cpu, bus| { + bus.write_u64(0x3200, 0); + cpu.regs.write(17, SYS_EXECVEAT); + cpu.regs.write(10, 9); // a0: dirfd + cpu.regs.write(11, 0); // a1: path + cpu.regs.write(12, 0x3200); // a2: argv + cpu.regs.write(14, AT_EMPTY_PATH); // a4: flags + })); + + let SyscallKind::Exec { + directory_fd, path, .. + } = kind + else { + panic!("expected Exec"); + }; + assert_eq!(directory_fd, 9); + assert_eq!(path, GuestString::Value(String::new())); + } + #[test] fn a_command_line_past_the_argv_budget_is_kept_up_to_it_and_marked() { let kind = only_entry(user_ecall(|cpu, bus| { @@ -524,7 +670,7 @@ mod tests { cpu.regs.write(11, 0x4000); })); - let SyscallKind::Mkdir { path } = kind else { + let SyscallKind::Mkdir { path, .. } = kind else { panic!("expected Mkdir"); }; let GuestString::Truncated(text) = path else { @@ -541,7 +687,7 @@ mod tests { cpu.regs.write(11, 0x4000); })); - let SyscallKind::Mkdir { path } = kind else { + let SyscallKind::Mkdir { path, .. } = kind else { panic!("expected Mkdir"); }; assert!(matches!(path, GuestString::Value(text) if text.len() == MAX_PATH_BYTES)); @@ -554,7 +700,7 @@ mod tests { cpu.regs.write(11, 0); })); - let SyscallKind::Mkdir { path } = kind else { + let SyscallKind::Mkdir { path, .. } = kind else { panic!("expected Mkdir"); }; assert_eq!(path, GuestString::Unreadable); @@ -568,12 +714,104 @@ mod tests { }); assert_eq!(bus.device_reads, 0, "tracing read a device register"); - let SyscallKind::Mkdir { path } = only_entry(bus) else { + let SyscallKind::Mkdir { path, .. } = only_entry(bus) else { panic!("expected Mkdir"); }; assert_eq!(path, GuestString::Unreadable); } + #[test] + fn chdir_and_fchdir_are_decoded() { + let kind = only_entry(user_ecall(|cpu, bus| { + bus.write_cstring(0x2000, "/app"); + cpu.regs.write(17, SYS_CHDIR); + cpu.regs.write(10, 0x2000); + })); + assert!(matches!(kind, SyscallKind::ChangeDirectory { path } + if path == GuestString::Value("/app".into()))); + + let kind = only_entry(user_ecall(|cpu, _bus| { + cpu.regs.write(17, SYS_FCHDIR); + cpu.regs.write(10, 5); + })); + assert!(matches!( + kind, + SyscallKind::ChangeDirectoryDescriptor { fd: 5 } + )); + } + + #[test] + fn descriptor_calls_that_move_paths_between_numbers_are_decoded() { + let kind = only_entry(user_ecall(|cpu, _bus| { + cpu.regs.write(17, SYS_DUP3); + cpu.regs.write(10, 4); + cpu.regs.write(11, 9); + cpu.regs.write(12, O_CLOEXEC as u64); + })); + assert!(matches!( + kind, + SyscallKind::DuplicateTo { + from_fd: 4, + to_fd: 9, + close_on_exec: true + } + )); + + let kind = only_entry(user_ecall(|cpu, _bus| { + cpu.regs.write(17, SYS_FCNTL); + cpu.regs.write(10, 3); + cpu.regs.write(11, F_DUPFD_CLOEXEC); + })); + assert!(matches!( + kind, + SyscallKind::Duplicate { + fd: 3, + close_on_exec: true + } + )); + + let kind = only_entry(user_ecall(|cpu, _bus| { + cpu.regs.write(17, SYS_CLOSE_RANGE); + cpu.regs.write(10, 3); + cpu.regs.write(11, u32::MAX as u64); + })); + assert!(matches!( + kind, + SyscallKind::CloseRange { + first: 3, + last: u32::MAX + } + )); + } + + #[test] + fn an_fcntl_that_is_not_a_duplicate_is_not_decoded() { + let bus = user_ecall(|cpu, _bus| { + cpu.regs.write(17, SYS_FCNTL); + cpu.regs.write(10, 3); + cpu.regs.write(11, 4); // F_SETFL + }); + assert!(bus.entries.is_empty()); + } + + #[test] + fn the_calls_that_name_a_task_are_marked_thread_or_process() { + let kind = only_entry(user_ecall(|cpu, _bus| { + cpu.regs.write(17, SYS_GETTID); + })); + assert!(matches!(kind, SyscallKind::Identity { group: false })); + + let kind = only_entry(user_ecall(|cpu, _bus| { + cpu.regs.write(17, SYS_GETPID); + })); + assert!(matches!(kind, SyscallKind::Identity { group: true })); + + let kind = only_entry(user_ecall(|cpu, _bus| { + cpu.regs.write(17, SYS_SET_TID_ADDRESS); + })); + assert!(matches!(kind, SyscallKind::Identity { group: false })); + } + #[test] fn connect_decodes_an_ipv4_sockaddr() { let kind = only_entry(user_ecall(|cpu, bus| { diff --git a/crates/riscv-core/src/system_bus.rs b/crates/riscv-core/src/system_bus.rs index 00e2dd87..b5878cee 100644 --- a/crates/riscv-core/src/system_bus.rs +++ b/crates/riscv-core/src/system_bus.rs @@ -40,12 +40,12 @@ pub trait SystemBus { false } - fn on_syscall_entry(&mut self, entry: SyscallEntry) { - let _ = entry; + fn on_syscall_entry(&mut self, entry: SyscallEntry, satp: u64) { + let _ = (entry, satp); } - fn on_syscall_return(&mut self, task: u64, return_pc: u64, value: i64) { - let _ = (task, return_pc, value); + fn on_syscall_return(&mut self, task: u64, return_pc: u64, value: i64, satp: u64) { + let _ = (task, return_pc, value, satp); } } diff --git a/crates/wasi-component/src/api/executor.rs b/crates/wasi-component/src/api/executor.rs index ca88e6ea..a7f29670 100644 --- a/crates/wasi-component/src/api/executor.rs +++ b/crates/wasi-component/src/api/executor.rs @@ -89,7 +89,7 @@ impl Guest for Executor { SESSION_MANAGER.trace_start(handle, options) } - fn session_trace_drain(handle: u64, max_bytes: u32) -> Result, String> { + fn session_trace_drain(handle: u64, max_bytes: u32) -> Result { SESSION_MANAGER.trace_drain(handle, max_bytes) } diff --git a/crates/wasi-component/src/api/session.rs b/crates/wasi-component/src/api/session.rs index 8f91c73a..1fc72d2e 100644 --- a/crates/wasi-component/src/api/session.rs +++ b/crates/wasi-component/src/api/session.rs @@ -27,6 +27,12 @@ const PYRUNNER_STAGE_CHUNK: usize = 2500; const SHELL_PROMPT_SENTINEL: &[u8] = b"\x1fvpod\x1f"; +const WORKING_DIRECTORY_MARKER: &str = "vpod-cwd "; +const WORKING_DIRECTORY_TIMEOUT_SECONDS: u64 = 15; +const WORKING_DIRECTORY_PROBE: &str = "( for p in /proc/[0-9]*; do \ + cd -P \"$p/cwd\" 2>/dev/null && echo \"vpod-cwd ${p#/proc/} $PWD\"; \ + done ) 2>/dev/null\n"; + const AOT_MISMATCH_PROBE_THRESHOLD: u64 = 64; fn warn_if_aot_mismatch(hart: &Hart) { @@ -151,6 +157,47 @@ fn begin_shell_exec(session: &mut Session, code: String, timeout_secs: u64, mode )); } +fn learn_working_directories(session: &mut Session) { + if !session.is_shell || session.shell_lost || session.exec.is_some() { + return; + } + + session.bus.uart.drain_tx(); + for byte in WORKING_DIRECTORY_PROBE.bytes() { + session.bus.uart.push_rx(byte); + } + + let prompt = session.prompt.clone(); + let output = repl::capture_output( + &mut session.bus, + &mut session.hart, + &prompt, + WORKING_DIRECTORY_TIMEOUT_SECONDS, + true, + None, + false, + ); + repl::drain_ctrl_with_grace(&mut session.bus, &mut session.hart); + session.bus.uart.drain_tx(); + session.bus.uart_stderr.drain_tx(); + + for line in output.lines() { + let Some(reported) = line.trim().strip_prefix(WORKING_DIRECTORY_MARKER) else { + continue; + }; + let Some((process_id, path)) = reported.split_once(' ') else { + continue; + }; + if let Ok(process_id) = process_id.parse::() + && path.starts_with('/') + { + session + .bus + .seed_trace_working_directory(process_id, path.to_string()); + } + } +} + fn restore_terminal(session: &mut Session) { for byte in b"stty icanon -echo\n" { session.bus.uart.push_rx(*byte); @@ -841,10 +888,16 @@ impl SessionManager { }, }); + if session.bus.traces_syscalls() { + session.bus.set_trace_quiet(true); + learn_working_directories(session); + session.bus.set_trace_quiet(false); + } + Ok(()) } - pub fn trace_drain(&self, handle: u64, max_bytes: u32) -> Result, String> { + pub fn trace_drain(&self, handle: u64, max_bytes: u32) -> Result { let sessions = self.sessions.borrow(); let session = sessions .get(&handle) @@ -855,7 +908,7 @@ impl SessionManager { .tracer() .ok_or_else(|| "tracing is not enabled for this session".to_string())?; - Ok(tracer.drain(max_bytes as usize)) + Ok(tracer.drain_text(max_bytes as usize)) } pub fn trace_stop(&self, handle: u64) -> Result<(), String> { From 19de0515131df95dfaab54031c2456f315798226 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Thu, 17 Sep 2026 23:27:36 +0200 Subject: [PATCH 09/11] add process and PID tracking to trace activities and process trees --- crates/wasi-component/vpod.wit | 2 +- sdks/python/tests/conftest.py | 5 +- sdks/python/tests/test_trace_integration.py | 46 +++- sdks/python/vpod/trace.py | 81 +++++- sdks/trace-summaries.json | 245 ++++++++++-------- sdks/typescript/src/node/transport.ts | 2 +- sdks/typescript/src/runtime.ts | 4 +- sdks/typescript/src/trace.ts | 99 +++++-- sdks/typescript/src/worker/dispatch.ts | 2 +- .../tests/integration/trace.test.mjs | 32 ++- 10 files changed, 372 insertions(+), 146 deletions(-) diff --git a/crates/wasi-component/vpod.wit b/crates/wasi-component/vpod.wit index 669e2744..a3589167 100644 --- a/crates/wasi-component/vpod.wit +++ b/crates/wasi-component/vpod.wit @@ -46,6 +46,6 @@ interface executor { session-resume: func(snapshot-path: string, delta-path: string, command: string, prompt: string, mounts: list) -> result; session-trace-start: func(handle: u64, options: trace-options) -> result<_, string>; - session-trace-drain: func(handle: u64, max-bytes: u32) -> result, string>; + session-trace-drain: func(handle: u64, max-bytes: u32) -> result; session-trace-stop: func(handle: u64) -> result<_, string>; } diff --git a/sdks/python/tests/conftest.py b/sdks/python/tests/conftest.py index e5cd31ca..15f3326d 100644 --- a/sdks/python/tests/conftest.py +++ b/sdks/python/tests/conftest.py @@ -86,11 +86,12 @@ def record_exec(sid, command): "wall_ms": 0, "kind": "process.exec", "task": f"{sid:x}", + "pid": 700 + session["seq"], "path": "/bin/sh", "argv": ["sh", "-c", command], } session["seq"] += 1 - session["pending"].append((json.dumps(event) + "\n").encode()) + session["pending"].append(json.dumps(event) + "\n") def fake_session_trace_start(sid, options): traced_sessions[sid] = {"options": options, "pending": [], "seq": 0} @@ -101,7 +102,7 @@ def fake_session_trace_drain(sid, max_bytes): if session is None: return FakeVariant(tag="err", payload="tracing is not enabled for this session") pending, session["pending"] = session["pending"], [] - return FakeVariant(tag="ok", payload=b"".join(pending)) + return FakeVariant(tag="ok", payload="".join(pending)) def fake_session_trace_stop(sid): traced_sessions.pop(sid, None) diff --git a/sdks/python/tests/test_trace_integration.py b/sdks/python/tests/test_trace_integration.py index c18effde..a7e5c0fa 100644 --- a/sdks/python/tests/test_trace_integration.py +++ b/sdks/python/tests/test_trace_integration.py @@ -5,13 +5,19 @@ pytestmark = pytest.mark.integration +def programs(nodes): + for node in nodes: + yield node + yield from programs(node.children) + + def test_a_shell_script_records_the_commands_it_runs_and_the_files_it_writes(): script = "mkdir -p /tmp/traced && echo hi > /tmp/traced/out.txt && cat /tmp/traced/out.txt" with Sandbox.create(trace=True) as sbx: result = sbx.commands.run(f"sh -c '{script}'") assert result.success - commands = [node.argv for node in result.trace.processes()] + commands = [node.argv for node in programs(result.trace.processes())] assert commands[0] == ["sh", "-c", script] assert any(argv[0] == "cat" for argv in commands), commands @@ -20,6 +26,44 @@ def test_a_shell_script_records_the_commands_it_runs_and_the_files_it_writes(): assert result.trace.complete +def test_a_command_says_which_program_started_which_and_under_what_pid(): + script = "mkdir -p /tmp/tree && cd /tmp/tree && cat /etc/hostname > host.txt" + with Sandbox.create(trace=True) as sbx: + result = sbx.commands.run(f"sh -c '{script}'") + assert result.success + assert result.trace.complete + + roots = result.trace.processes() + assert len(roots) == 1, [node.argv for node in roots] + assert roots[0].argv == ["sh", "-c", script] + assert isinstance(roots[0].pid, int) + + children = {node.argv[0]: node for node in roots[0].children} + assert {"mkdir", "cat"} <= set(children), list(children) + assert children["cat"].exit_code == 0 + assert children["mkdir"].pid != roots[0].pid + + +def test_a_relative_path_is_recorded_where_the_file_actually_is(): + script = "mkdir -p /tmp/rel && cd /tmp/rel && cat /etc/hostname > copy.txt" + with Sandbox.create(trace=True) as sbx: + result = sbx.commands.run(f"sh -c '{script}'") + assert result.success + + written = {file.path: file for file in result.trace.files() if file.written} + assert "/tmp/rel/copy.txt" in written, list(written) + assert written["/tmp/rel/copy.txt"].processes + + +def test_code_run_resolves_paths_against_the_interpreters_own_directory(): + with Sandbox.create(trace=True) as sbx: + execution = sbx.code.run("open('rel_from_code.txt', 'w').write('x')") + assert execution.success, execution.error + + written = [file.path for file in execution.trace.files() if file.written] + assert any(path.endswith("/rel_from_code.txt") for path in written), written + + def test_code_run_activity_belongs_to_the_user_not_to_vpod(): with Sandbox.create(trace=True) as sbx: execution = sbx.code.run("open('/tmp/from_code.txt', 'w').write('x')") diff --git a/sdks/python/vpod/trace.py b/sdks/python/vpod/trace.py index 460b0f8d..3f3e70fe 100644 --- a/sdks/python/vpod/trace.py +++ b/sdks/python/vpod/trace.py @@ -57,6 +57,7 @@ class FileActivity: renamed_to: Optional[str] = None renamed_from: Optional[str] = None denied: bool = False + processes: list[int] = field(default_factory=list) @dataclass @@ -75,6 +76,7 @@ class NetworkActivity: bytes_out: int = 0 bytes_in: int = 0 failed: bool = True + processes: list[int] = field(default_factory=list) @dataclass @@ -99,7 +101,10 @@ def events(self) -> list[dict]: @property def complete(self) -> bool: - return not any(event["kind"] == "trace.dropped" for event in self._events) + return not any( + event["kind"] == "trace.dropped" or event.get("pid", 0) is None + for event in self._events + ) def to_jsonl(self) -> str: return "".join( @@ -109,12 +114,17 @@ def to_jsonl(self) -> str: def files(self, internal: bool = False, noise: bool = False) -> list[FileActivity]: activities: dict[str, FileActivity] = {} + touching: dict[str, set[int]] = {} + current: dict = {} def activity(path) -> Optional[FileActivity]: if not isinstance(path, str): return None if path not in activities: activities[path] = FileActivity(path) + touching[path] = set() + if (pid := current.get("pid")) is not None: + touching[path].add(pid) return activities[path] def mark_denied(path, result) -> None: @@ -125,6 +135,7 @@ def mark_denied(path, result) -> None: if event.get("internal") and not internal: continue kind = event["kind"] + current["pid"] = event.get("pid") if kind in ("file.open", "mount.open"): succeeded = event["result"] >= 0 if kind == "file.open" else event["result"] == 0 @@ -171,10 +182,14 @@ def mark_denied(path, result) -> None: entry.read |= bytes_read > 0 entry.written |= bytes_written > 0 + for path, entry in activities.items(): + entry.processes = sorted(touching[path]) + return [entry for entry in activities.values() if noise or not _is_noise(entry)] def network(self, internal: bool = False) -> list[NetworkActivity]: activities: dict[tuple[str, int], NetworkActivity] = {} + touching: dict[tuple[str, int], set[int]] = {} def activity(event) -> Optional[NetworkActivity]: address, port = event.get("address"), event.get("port") @@ -186,9 +201,12 @@ def activity(event) -> Optional[NetworkActivity]: activities[key] = NetworkActivity( host=None, address=address, port=port, protocol=None ) + touching[key] = set() entry = activities[key] entry.host = entry.host or event.get("host") + if (pid := event.get("pid")) is not None: + touching[key].add(pid) return entry for event in self._events: @@ -216,29 +234,68 @@ def activity(event) -> Optional[NetworkActivity]: entry.host = entry.host or urlsplit(event["url"]).hostname entry.requests.append(HttpRequest(event["method"], event["url"])) + for key, entry in activities.items(): + entry.processes = sorted(touching[key]) + return list(activities.values()) def processes(self, internal: bool = False) -> list[ProcessNode]: - nodes: list[tuple[ProcessNode, bool]] = [] - running_by_task: dict[str, ProcessNode] = {} + parents = self._parents() + roots: list[ProcessNode] = [] + latest: dict[int, ProcessNode] = {} for event in self._events: kind = event["kind"] + pid = event.get("pid") + if kind == "process.exec" and "result" not in event: + if event.get("internal") and not internal: + continue node = ProcessNode( - pid=None, + pid=pid, path=event.get("path"), argv=list(event.get("argv", [])), exit_code=None, started_at=event["guest_ns"], ) - nodes.append((node, bool(event.get("internal")))) - running_by_task[event["task"]] = node - elif kind == "process.exit" and event["task"] in running_by_task: - running_by_task.pop(event["task"]).exit_code = event["code"] + program = _closest_program(pid, parents, latest) + (roots if program is None else program.children).append(node) + if pid is not None: + latest[pid] = node + elif kind == "process.exit" and pid in latest and latest[pid].exit_code is None: + latest[pid].exit_code = event["code"] - return [node for node, is_internal in nodes if internal or not is_internal] + return roots + + def _parents(self) -> dict[int, int]: + parents: dict[int, int] = {} + for event in self._events: + kind = event["kind"] + if kind == "process.fork" and not event.get("thread"): + if event.get("pid") is not None: + parents.setdefault(event["child_pid"], event["pid"]) + elif kind == "process.exec" and event.get("ppid") is not None: + parents.setdefault(event["pid"], event["ppid"]) + return parents + + +def _closest_program( + pid: Optional[int], parents: dict[int, int], latest: dict[int, ProcessNode] +) -> Optional[ProcessNode]: + if pid is None: + return None + if (node := latest.get(pid)) is not None: + return node + + seen = {pid} + ancestor = parents.get(pid) + while ancestor is not None and ancestor not in seen: + if (node := latest.get(ancestor)) is not None: + return node + seen.add(ancestor) + ancestor = parents.get(ancestor) + return None def _is_noise(entry: FileActivity) -> bool: @@ -329,13 +386,11 @@ def _drain(self) -> None: if session_id is None: return - drained = bytes( - unwrap_result(exports["session-trace-drain"](session_id, DRAIN_ALL_BYTES)) - ) + drained = unwrap_result(exports["session-trace-drain"](session_id, DRAIN_ALL_BYTES)) if not drained: return - events = [json.loads(line) for line in drained.decode().splitlines() if line] + events = [json.loads(line) for line in drained.splitlines() if line] with self._lock: self._events.extend(events) watchers = list(self._watchers) diff --git a/sdks/trace-summaries.json b/sdks/trace-summaries.json index 41d4f5aa..9c69bbd9 100644 --- a/sdks/trace-summaries.json +++ b/sdks/trace-summaries.json @@ -3,148 +3,183 @@ { "name": "an agent command that installs, downloads and edits files", "events": [ - {"v": 1, "seq": 0, "guest_ns": 1000, "wall_ms": 1, "kind": "file.open", "task": "a1", "path": "/dev/ttyS1", "access": "write", "create": true, "truncate": true, "result": 3, "internal": true}, - {"v": 1, "seq": 1, "guest_ns": 1000, "wall_ms": 1, "kind": "process.fork", "task": "a1", "child_pid": 563, "thread": false}, - {"v": 1, "seq": 2, "guest_ns": 2000, "wall_ms": 1, "kind": "process.exec", "task": "b2", "path": "/usr/local/bin/sh", "argv": ["sh", "-c", "cd /app && make"], "result": -2}, - {"v": 1, "seq": 3, "guest_ns": 2000, "wall_ms": 1, "kind": "process.exec", "task": "b2", "path": "/bin/sh", "argv": ["sh", "-c", "cd /app && make"]}, - {"v": 1, "seq": 4, "guest_ns": 3000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "/etc/ld-musl-riscv64.path", "access": "read", "create": false, "truncate": false, "result": 3}, - {"v": 1, "seq": 5, "guest_ns": 3000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "/usr/lib/libz.so.1", "access": "read", "create": false, "truncate": false, "result": 3}, - {"v": 1, "seq": 6, "guest_ns": 3000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "/proc/self/stat", "access": "read", "create": false, "truncate": false, "result": 4}, - {"v": 1, "seq": 7, "guest_ns": 4000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "Makefile", "access": "read", "create": false, "truncate": false, "result": 3}, - {"v": 1, "seq": 8, "guest_ns": 4000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "/app/missing.h", "access": "read", "create": false, "truncate": false, "result": -2}, - {"v": 1, "seq": 9, "guest_ns": 5000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "/etc/shadow", "access": "read", "create": false, "truncate": false, "result": -13}, - {"v": 1, "seq": 10, "guest_ns": 5000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "/app/build/.out.tmp", "access": "write", "create": true, "truncate": true, "result": 5}, - {"v": 1, "seq": 11, "guest_ns": 6000, "wall_ms": 1, "kind": "file.rename", "task": "b2", "from": "/app/build/.out.tmp", "to": "/app/build/out", "result": 0}, - {"v": 1, "seq": 12, "guest_ns": 6000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "/app/build/ext.so", "access": "write", "create": true, "truncate": true, "result": 6}, - {"v": 1, "seq": 13, "guest_ns": 6000, "wall_ms": 1, "kind": "file.open", "task": "b2", "path": "/app/db.sqlite", "access": "read-write", "create": false, "truncate": false, "result": 7}, - {"v": 1, "seq": 14, "guest_ns": 7000, "wall_ms": 1, "kind": "dir.create", "task": "b2", "path": "/app/cache", "result": 0}, - {"v": 1, "seq": 15, "guest_ns": 7000, "wall_ms": 1, "kind": "dir.create", "task": "b2", "path": "/app/build", "result": -17}, - {"v": 1, "seq": 16, "guest_ns": 7000, "wall_ms": 1, "kind": "file.truncate", "task": "b2", "path": "/app/log.txt", "size": 0, "result": 0}, - {"v": 1, "seq": 17, "guest_ns": 8000, "wall_ms": 1, "kind": "file.delete", "task": "b2", "path": "/app/old.o", "directory": false, "result": 0}, - {"v": 1, "seq": 18, "guest_ns": 8000, "wall_ms": 1, "kind": "file.delete", "task": "b2", "path": "/root/.ssh/authorized_keys", "directory": false, "result": -1}, - {"v": 1, "seq": 19, "guest_ns": 8000, "wall_ms": 1, "kind": "file.delete", "task": "c3", "path": "/tmp/.vpod_cmd.b64", "directory": false, "result": 0, "internal": true}, + {"v": 1, "seq": 0, "guest_ns": 1000, "wall_ms": 1, "kind": "file.open", "task": "a1", "pid": 1, "path": "/dev/ttyS1", "access": "write", "create": true, "truncate": true, "result": 3, "internal": true}, + {"v": 1, "seq": 1, "guest_ns": 1000, "wall_ms": 1, "kind": "process.fork", "task": "a1", "pid": 1, "child_pid": 563, "thread": false}, + {"v": 1, "seq": 2, "guest_ns": 2000, "wall_ms": 1, "kind": "process.exec", "task": "b2", "pid": 563, "ppid": 1, "path": "/usr/local/bin/sh", "argv": ["sh", "-c", "cd /app && make"], "result": -2}, + {"v": 1, "seq": 3, "guest_ns": 2000, "wall_ms": 1, "kind": "process.exec", "task": "b2", "pid": 563, "ppid": 1, "path": "/bin/sh", "argv": ["sh", "-c", "cd /app && make"]}, + {"v": 1, "seq": 4, "guest_ns": 3000, "wall_ms": 1, "kind": "file.open", "task": "b2", "pid": 563, "path": "/etc/ld-musl-riscv64.path", "access": "read", "create": false, "truncate": false, "result": 3}, + {"v": 1, "seq": 5, "guest_ns": 3000, "wall_ms": 1, "kind": "file.open", "task": "b2", "pid": 563, "path": "/usr/lib/libz.so.1", "access": "read", "create": false, "truncate": false, "result": 3}, + {"v": 1, "seq": 6, "guest_ns": 3000, "wall_ms": 1, "kind": "file.open", "task": "b2", "pid": 563, "path": "/proc/self/stat", "access": "read", "create": false, "truncate": false, "result": 4}, + {"v": 1, "seq": 7, "guest_ns": 4000, "wall_ms": 1, "kind": "file.open", "task": "b2", "pid": 563, "path": "/app/Makefile", "access": "read", "create": false, "truncate": false, "result": 3}, + {"v": 1, "seq": 8, "guest_ns": 4000, "wall_ms": 1, "kind": "file.open", "task": "b2", "pid": 563, "path": "/app/missing.h", "access": "read", "create": false, "truncate": false, "result": -2}, + {"v": 1, "seq": 9, "guest_ns": 5000, "wall_ms": 1, "kind": "file.open", "task": "b2", "pid": 563, "path": "/etc/shadow", "access": "read", "create": false, "truncate": false, "result": -13}, + {"v": 1, "seq": 10, "guest_ns": 5000, "wall_ms": 1, "kind": "file.open", "task": "b2", "pid": 563, "path": "/app/build/.out.tmp", "access": "write", "create": true, "truncate": true, "result": 5}, + {"v": 1, "seq": 11, "guest_ns": 6000, "wall_ms": 1, "kind": "file.rename", "task": "b2", "pid": 563, "from": "/app/build/.out.tmp", "to": "/app/build/out", "result": 0}, + {"v": 1, "seq": 12, "guest_ns": 6000, "wall_ms": 1, "kind": "file.open", "task": "b2", "pid": 563, "path": "/app/build/ext.so", "access": "write", "create": true, "truncate": true, "result": 6}, + {"v": 1, "seq": 13, "guest_ns": 6000, "wall_ms": 1, "kind": "file.open", "task": "b2", "pid": 563, "path": "/app/db.sqlite", "access": "read-write", "create": false, "truncate": false, "result": 7}, + {"v": 1, "seq": 14, "guest_ns": 7000, "wall_ms": 1, "kind": "dir.create", "task": "b2", "pid": 563, "path": "/app/cache", "result": 0}, + {"v": 1, "seq": 15, "guest_ns": 7000, "wall_ms": 1, "kind": "dir.create", "task": "b2", "pid": 563, "path": "/app/build", "result": -17}, + {"v": 1, "seq": 16, "guest_ns": 7000, "wall_ms": 1, "kind": "file.truncate", "task": "b2", "pid": 563, "path": "/app/log.txt", "size": 0, "result": 0}, + {"v": 1, "seq": 17, "guest_ns": 8000, "wall_ms": 1, "kind": "file.delete", "task": "b2", "pid": 563, "path": "/app/old.o", "directory": false, "result": 0}, + {"v": 1, "seq": 18, "guest_ns": 8000, "wall_ms": 1, "kind": "file.delete", "task": "b2", "pid": 563, "path": "/root/.ssh/authorized_keys", "directory": false, "result": -1}, + {"v": 1, "seq": 19, "guest_ns": 8000, "wall_ms": 1, "kind": "file.delete", "task": "c3", "pid": 700, "path": "/tmp/.vpod_cmd.b64", "directory": false, "result": 0, "internal": true}, {"v": 1, "seq": 20, "guest_ns": 9000, "wall_ms": 1, "kind": "net.dns", "name": "pypi.org", "type": "A", "answers": ["151.101.192.223"]}, - {"v": 1, "seq": 21, "guest_ns": 9000, "wall_ms": 1, "kind": "net.connect", "task": "b2", "protocol": "tcp", "address": "151.101.192.223", "port": 443, "host": "pypi.org", "result": 0}, + {"v": 1, "seq": 21, "guest_ns": 9000, "wall_ms": 1, "kind": "net.connect", "task": "b2", "pid": 563, "protocol": "tcp", "address": "151.101.192.223", "port": 443, "host": "pypi.org", "result": 0}, {"v": 1, "seq": 22, "guest_ns": 9500, "wall_ms": 1, "kind": "net.http", "protocol": "https", "method": "GET", "url": "https://pypi.org/simple/requests/", "address": "151.101.192.223", "port": 443}, {"v": 1, "seq": 23, "guest_ns": 9600, "wall_ms": 1, "kind": "net.http", "protocol": "https", "method": "GET", "url": "https://pypi.org/simple/urllib3/", "address": "151.101.192.223", "port": 443}, {"v": 1, "seq": 24, "guest_ns": 9700, "wall_ms": 1, "kind": "net.flow", "protocol": "tcp", "address": "151.101.192.223", "port": 443, "host": "pypi.org", "bytes_out": 252, "bytes_in": 84487, "duration_ns": 300, "failed": false}, - {"v": 1, "seq": 25, "guest_ns": 9800, "wall_ms": 1, "kind": "net.connect", "task": "b2", "protocol": "tcp", "address": "151.101.192.223", "port": 443, "host": "pypi.org", "result": -115}, + {"v": 1, "seq": 25, "guest_ns": 9800, "wall_ms": 1, "kind": "net.connect", "task": "b2", "pid": 563, "protocol": "tcp", "address": "151.101.192.223", "port": 443, "host": "pypi.org", "result": -115}, {"v": 1, "seq": 26, "guest_ns": 9900, "wall_ms": 1, "kind": "net.flow", "protocol": "tcp", "address": "151.101.192.223", "port": 443, "host": "pypi.org", "bytes_out": 100, "bytes_in": 1000, "duration_ns": 50, "failed": false}, - {"v": 1, "seq": 27, "guest_ns": 10000, "wall_ms": 1, "kind": "net.connect", "task": "b2", "protocol": null, "address": "127.0.0.1", "port": 9, "host": null, "result": -111}, + {"v": 1, "seq": 27, "guest_ns": 10000, "wall_ms": 1, "kind": "net.connect", "task": "e5", "pid": 564, "protocol": null, "address": "127.0.0.1", "port": 9, "host": null, "result": -111}, {"v": 1, "seq": 28, "guest_ns": 10000, "wall_ms": 1, "kind": "net.http", "protocol": "http", "method": "POST", "url": "http://example.com/upload", "address": "93.184.215.14", "port": 80}, {"v": 1, "seq": 29, "guest_ns": 10000, "wall_ms": 1, "kind": "net.flow", "protocol": "tcp", "address": "93.184.215.14", "port": 80, "host": null, "bytes_out": 11, "bytes_in": 0, "duration_ns": 5, "failed": true}, {"v": 1, "seq": 30, "guest_ns": 10500, "wall_ms": 1, "kind": "net.udp", "address": "8.8.8.8", "port": 123, "host": null}, - {"v": 1, "seq": 31, "guest_ns": 10600, "wall_ms": 1, "kind": "net.connect", "task": "c3", "protocol": "tcp", "address": "10.0.2.2", "port": 8080, "host": null, "result": 0, "internal": true}, - {"v": 1, "seq": 32, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.open", "pid": 12, "path": "/data/input.csv", "access": "read", "truncate": false, "result": 0}, - {"v": 1, "seq": 33, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.close", "pid": 12, "path": "/data/input.csv", "bytes_read": 4096, "bytes_written": 0}, - {"v": 1, "seq": 34, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.create", "pid": 12, "path": "/data/report.md", "result": 0}, - {"v": 1, "seq": 35, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.close", "pid": 12, "path": "/data/report.md", "bytes_read": 0, "bytes_written": 512}, - {"v": 1, "seq": 36, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.mkdir", "pid": 12, "path": "/data/out", "result": 0}, - {"v": 1, "seq": 37, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.rename", "pid": 12, "from": "/data/draft.md", "to": "/data/out/final.md", "result": 0}, - {"v": 1, "seq": 38, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.delete", "pid": 12, "path": "/data/stale.lock", "directory": false, "result": 0}, - {"v": 1, "seq": 39, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.truncate", "pid": 12, "path": "/data/log.txt", "size": 0, "result": 0}, - {"v": 1, "seq": 40, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.open", "pid": 12, "path": "/data/secret.key", "access": "read", "truncate": false, "result": -13}, - {"v": 1, "seq": 41, "guest_ns": 12000, "wall_ms": 1, "kind": "process.exec", "task": "d4", "path": "/usr/lib/vpod/vpod-seed-entropy", "argv": ["vpod-seed-entropy", "ab12"], "internal": true}, - {"v": 1, "seq": 42, "guest_ns": 12000, "wall_ms": 1, "kind": "process.exit", "task": "d4", "code": 0, "internal": true}, - {"v": 1, "seq": 43, "guest_ns": 13000, "wall_ms": 1, "kind": "process.exec", "task": "b2", "path": "/usr/bin/make", "argv": ["make"]}, - {"v": 1, "seq": 44, "guest_ns": 14000, "wall_ms": 1, "kind": "process.exec", "task": "e5", "path": "/usr/bin/cc", "argv": ["cc", "-o", "build/out", "main.c"]}, - {"v": 1, "seq": 45, "guest_ns": 15000, "wall_ms": 1, "kind": "process.exit", "task": "e5", "code": 1}, - {"v": 1, "seq": 46, "guest_ns": 16000, "wall_ms": 1, "kind": "process.exit", "task": "b2", "code": 2}, - {"v": 1, "seq": 47, "guest_ns": 17000, "wall_ms": 1, "kind": "process.exec", "task": "b2", "path": "/usr/bin/python3", "argv": ["python3", "-c", "print(1)"], "argv_truncated": true} + {"v": 1, "seq": 31, "guest_ns": 10600, "wall_ms": 1, "kind": "net.connect", "task": "c3", "pid": 700, "protocol": "tcp", "address": "10.0.2.2", "port": 8080, "host": null, "result": 0, "internal": true}, + {"v": 1, "seq": 32, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.open", "pid": 563, "path": "/data/input.csv", "access": "read", "truncate": false, "result": 0}, + {"v": 1, "seq": 33, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.close", "pid": 563, "path": "/data/input.csv", "bytes_read": 4096, "bytes_written": 0}, + {"v": 1, "seq": 34, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.create", "pid": 563, "path": "/data/report.md", "result": 0}, + {"v": 1, "seq": 35, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.close", "pid": 563, "path": "/data/report.md", "bytes_read": 0, "bytes_written": 512}, + {"v": 1, "seq": 36, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.mkdir", "pid": 563, "path": "/data/out", "result": 0}, + {"v": 1, "seq": 37, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.rename", "pid": 563, "from": "/data/draft.md", "to": "/data/out/final.md", "result": 0}, + {"v": 1, "seq": 38, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.delete", "pid": 563, "path": "/data/stale.lock", "directory": false, "result": 0}, + {"v": 1, "seq": 39, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.truncate", "pid": 563, "path": "/data/log.txt", "size": 0, "result": 0}, + {"v": 1, "seq": 40, "guest_ns": 11000, "wall_ms": 1, "kind": "mount.open", "pid": 563, "path": "/data/secret.key", "access": "read", "truncate": false, "result": -13}, + {"v": 1, "seq": 41, "guest_ns": 12000, "wall_ms": 1, "kind": "process.exec", "task": "d4", "pid": 570, "ppid": 1, "path": "/usr/lib/vpod/vpod-seed-entropy", "argv": ["vpod-seed-entropy", "ab12"], "internal": true}, + {"v": 1, "seq": 42, "guest_ns": 12000, "wall_ms": 1, "kind": "process.exit", "task": "d4", "pid": 570, "code": 0, "internal": true}, + {"v": 1, "seq": 43, "guest_ns": 13000, "wall_ms": 1, "kind": "process.exec", "task": "b2", "pid": 563, "ppid": 1, "path": "/usr/bin/make", "argv": ["make"]}, + {"v": 1, "seq": 44, "guest_ns": 13500, "wall_ms": 1, "kind": "process.fork", "task": "b2", "pid": 563, "child_pid": 564, "thread": false}, + {"v": 1, "seq": 45, "guest_ns": 14000, "wall_ms": 1, "kind": "process.exec", "task": "e5", "pid": 564, "ppid": 563, "path": "/usr/bin/cc", "argv": ["cc", "-o", "build/out", "main.c"]}, + {"v": 1, "seq": 46, "guest_ns": 15000, "wall_ms": 1, "kind": "process.exit", "task": "e5", "pid": 564, "code": 1}, + {"v": 1, "seq": 47, "guest_ns": 15200, "wall_ms": 1, "kind": "process.fork", "task": "b2", "pid": 563, "child_pid": 565, "thread": false}, + {"v": 1, "seq": 48, "guest_ns": 15300, "wall_ms": 1, "kind": "process.fork", "task": "f6", "pid": 565, "child_pid": 566, "thread": false}, + {"v": 1, "seq": 49, "guest_ns": 15400, "wall_ms": 1, "kind": "process.exec", "task": "g7", "pid": 566, "ppid": 565, "path": "/bin/echo", "argv": ["echo", "done"]}, + {"v": 1, "seq": 50, "guest_ns": 15500, "wall_ms": 1, "kind": "process.exit", "task": "g7", "pid": 566, "code": 0}, + {"v": 1, "seq": 51, "guest_ns": 16000, "wall_ms": 1, "kind": "process.exit", "task": "b2", "pid": 563, "code": 2}, + {"v": 1, "seq": 52, "guest_ns": 16500, "wall_ms": 1, "kind": "process.fork", "task": "a1", "pid": 1, "child_pid": 571, "thread": false}, + {"v": 1, "seq": 53, "guest_ns": 17000, "wall_ms": 1, "kind": "process.exec", "task": "h8", "pid": 571, "ppid": 1, "path": "/usr/bin/python3", "argv": ["python3", "-c", "print(1)"], "argv_truncated": true}, + {"v": 1, "seq": 54, "guest_ns": 17500, "wall_ms": 1, "kind": "process.fork", "task": "h8", "pid": 571, "child_pid": 572, "thread": true} ], "complete": true, "files": [ - {"path": "Makefile", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/etc/shadow", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true}, - {"path": "/app/build/.out.tmp", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": "/app/build/out", "renamed_from": null, "denied": false}, - {"path": "/app/build/out", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": "/app/build/.out.tmp", "denied": false}, - {"path": "/app/build/ext.so", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/app/db.sqlite", "read": true, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/app/cache", "read": false, "written": false, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/app/log.txt", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/app/old.o", "read": false, "written": false, "created": false, "deleted": true, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/root/.ssh/authorized_keys", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true}, - {"path": "/data/input.csv", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/data/report.md", "read": false, "written": true, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/data/out", "read": false, "written": false, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/data/draft.md", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": "/data/out/final.md", "renamed_from": null, "denied": false}, - {"path": "/data/out/final.md", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": "/data/draft.md", "denied": false}, - {"path": "/data/stale.lock", "read": false, "written": false, "created": false, "deleted": true, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/data/log.txt", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/data/secret.key", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true} + {"path": "/app/Makefile", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/etc/shadow", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true, "processes": [563]}, + {"path": "/app/build/.out.tmp", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": "/app/build/out", "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/app/build/out", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": "/app/build/.out.tmp", "denied": false, "processes": [563]}, + {"path": "/app/build/ext.so", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/app/db.sqlite", "read": true, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/app/cache", "read": false, "written": false, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/app/log.txt", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/app/old.o", "read": false, "written": false, "created": false, "deleted": true, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/root/.ssh/authorized_keys", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true, "processes": [563]}, + {"path": "/data/input.csv", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/data/report.md", "read": false, "written": true, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/data/out", "read": false, "written": false, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/data/draft.md", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": "/data/out/final.md", "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/data/out/final.md", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": "/data/draft.md", "denied": false, "processes": [563]}, + {"path": "/data/stale.lock", "read": false, "written": false, "created": false, "deleted": true, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/data/log.txt", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/data/secret.key", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true, "processes": [563]} ], "files_including_internal_and_noise": [ - {"path": "/dev/ttyS1", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/etc/ld-musl-riscv64.path", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/usr/lib/libz.so.1", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/proc/self/stat", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "Makefile", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/etc/shadow", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true}, - {"path": "/app/build/.out.tmp", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": "/app/build/out", "renamed_from": null, "denied": false}, - {"path": "/app/build/out", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": "/app/build/.out.tmp", "denied": false}, - {"path": "/app/build/ext.so", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/app/db.sqlite", "read": true, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/app/cache", "read": false, "written": false, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/app/log.txt", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/app/old.o", "read": false, "written": false, "created": false, "deleted": true, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/root/.ssh/authorized_keys", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true}, - {"path": "/tmp/.vpod_cmd.b64", "read": false, "written": false, "created": false, "deleted": true, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/data/input.csv", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/data/report.md", "read": false, "written": true, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/data/out", "read": false, "written": false, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/data/draft.md", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": "/data/out/final.md", "renamed_from": null, "denied": false}, - {"path": "/data/out/final.md", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": "/data/draft.md", "denied": false}, - {"path": "/data/stale.lock", "read": false, "written": false, "created": false, "deleted": true, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/data/log.txt", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/data/secret.key", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true} + {"path": "/dev/ttyS1", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [1]}, + {"path": "/etc/ld-musl-riscv64.path", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/usr/lib/libz.so.1", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/proc/self/stat", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/app/Makefile", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/etc/shadow", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true, "processes": [563]}, + {"path": "/app/build/.out.tmp", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": "/app/build/out", "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/app/build/out", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": "/app/build/.out.tmp", "denied": false, "processes": [563]}, + {"path": "/app/build/ext.so", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/app/db.sqlite", "read": true, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/app/cache", "read": false, "written": false, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/app/log.txt", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/app/old.o", "read": false, "written": false, "created": false, "deleted": true, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/root/.ssh/authorized_keys", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true, "processes": [563]}, + {"path": "/tmp/.vpod_cmd.b64", "read": false, "written": false, "created": false, "deleted": true, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [700]}, + {"path": "/data/input.csv", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/data/report.md", "read": false, "written": true, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/data/out", "read": false, "written": false, "created": true, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/data/draft.md", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": "/data/out/final.md", "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/data/out/final.md", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": "/data/draft.md", "denied": false, "processes": [563]}, + {"path": "/data/stale.lock", "read": false, "written": false, "created": false, "deleted": true, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/data/log.txt", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/data/secret.key", "read": false, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": true, "processes": [563]} ], "network": [ - {"host": "pypi.org", "address": "151.101.192.223", "port": 443, "protocol": "tcp", "requests": [{"method": "GET", "url": "https://pypi.org/simple/requests/"}, {"method": "GET", "url": "https://pypi.org/simple/urllib3/"}], "bytes_out": 352, "bytes_in": 85487, "failed": false}, - {"host": null, "address": "127.0.0.1", "port": 9, "protocol": null, "requests": [], "bytes_out": 0, "bytes_in": 0, "failed": true}, - {"host": "example.com", "address": "93.184.215.14", "port": 80, "protocol": "tcp", "requests": [{"method": "POST", "url": "http://example.com/upload"}], "bytes_out": 11, "bytes_in": 0, "failed": true}, - {"host": null, "address": "8.8.8.8", "port": 123, "protocol": "udp", "requests": [], "bytes_out": 0, "bytes_in": 0, "failed": false} + {"host": "pypi.org", "address": "151.101.192.223", "port": 443, "protocol": "tcp", "requests": [{"method": "GET", "url": "https://pypi.org/simple/requests/"}, {"method": "GET", "url": "https://pypi.org/simple/urllib3/"}], "bytes_out": 352, "bytes_in": 85487, "failed": false, "processes": [563]}, + {"host": null, "address": "127.0.0.1", "port": 9, "protocol": null, "requests": [], "bytes_out": 0, "bytes_in": 0, "failed": true, "processes": [564]}, + {"host": "example.com", "address": "93.184.215.14", "port": 80, "protocol": "tcp", "requests": [{"method": "POST", "url": "http://example.com/upload"}], "bytes_out": 11, "bytes_in": 0, "failed": true, "processes": []}, + {"host": null, "address": "8.8.8.8", "port": 123, "protocol": "udp", "requests": [], "bytes_out": 0, "bytes_in": 0, "failed": false, "processes": []} ], "network_including_internal": [ - {"host": "pypi.org", "address": "151.101.192.223", "port": 443, "protocol": "tcp", "requests": [{"method": "GET", "url": "https://pypi.org/simple/requests/"}, {"method": "GET", "url": "https://pypi.org/simple/urllib3/"}], "bytes_out": 352, "bytes_in": 85487, "failed": false}, - {"host": null, "address": "127.0.0.1", "port": 9, "protocol": null, "requests": [], "bytes_out": 0, "bytes_in": 0, "failed": true}, - {"host": "example.com", "address": "93.184.215.14", "port": 80, "protocol": "tcp", "requests": [{"method": "POST", "url": "http://example.com/upload"}], "bytes_out": 11, "bytes_in": 0, "failed": true}, - {"host": null, "address": "8.8.8.8", "port": 123, "protocol": "udp", "requests": [], "bytes_out": 0, "bytes_in": 0, "failed": false}, - {"host": null, "address": "10.0.2.2", "port": 8080, "protocol": "tcp", "requests": [], "bytes_out": 0, "bytes_in": 0, "failed": false} + {"host": "pypi.org", "address": "151.101.192.223", "port": 443, "protocol": "tcp", "requests": [{"method": "GET", "url": "https://pypi.org/simple/requests/"}, {"method": "GET", "url": "https://pypi.org/simple/urllib3/"}], "bytes_out": 352, "bytes_in": 85487, "failed": false, "processes": [563]}, + {"host": null, "address": "127.0.0.1", "port": 9, "protocol": null, "requests": [], "bytes_out": 0, "bytes_in": 0, "failed": true, "processes": [564]}, + {"host": "example.com", "address": "93.184.215.14", "port": 80, "protocol": "tcp", "requests": [{"method": "POST", "url": "http://example.com/upload"}], "bytes_out": 11, "bytes_in": 0, "failed": true, "processes": []}, + {"host": null, "address": "8.8.8.8", "port": 123, "protocol": "udp", "requests": [], "bytes_out": 0, "bytes_in": 0, "failed": false, "processes": []}, + {"host": null, "address": "10.0.2.2", "port": 8080, "protocol": "tcp", "requests": [], "bytes_out": 0, "bytes_in": 0, "failed": false, "processes": [700]} ], "processes": [ - {"pid": null, "path": "/bin/sh", "argv": ["sh", "-c", "cd /app && make"], "exit_code": null, "started_at": 2000, "children": []}, - {"pid": null, "path": "/usr/bin/make", "argv": ["make"], "exit_code": 2, "started_at": 13000, "children": []}, - {"pid": null, "path": "/usr/bin/cc", "argv": ["cc", "-o", "build/out", "main.c"], "exit_code": 1, "started_at": 14000, "children": []}, - {"pid": null, "path": "/usr/bin/python3", "argv": ["python3", "-c", "print(1)"], "exit_code": null, "started_at": 17000, "children": []} + {"pid": 563, "path": "/bin/sh", "argv": ["sh", "-c", "cd /app && make"], "exit_code": null, "started_at": 2000, "children": [ + {"pid": 563, "path": "/usr/bin/make", "argv": ["make"], "exit_code": 2, "started_at": 13000, "children": [ + {"pid": 564, "path": "/usr/bin/cc", "argv": ["cc", "-o", "build/out", "main.c"], "exit_code": 1, "started_at": 14000, "children": []}, + {"pid": 566, "path": "/bin/echo", "argv": ["echo", "done"], "exit_code": 0, "started_at": 15400, "children": []} + ]} + ]}, + {"pid": 571, "path": "/usr/bin/python3", "argv": ["python3", "-c", "print(1)"], "exit_code": null, "started_at": 17000, "children": []} ], "processes_including_internal": [ - {"pid": null, "path": "/bin/sh", "argv": ["sh", "-c", "cd /app && make"], "exit_code": null, "started_at": 2000, "children": []}, - {"pid": null, "path": "/usr/lib/vpod/vpod-seed-entropy", "argv": ["vpod-seed-entropy", "ab12"], "exit_code": 0, "started_at": 12000, "children": []}, - {"pid": null, "path": "/usr/bin/make", "argv": ["make"], "exit_code": 2, "started_at": 13000, "children": []}, - {"pid": null, "path": "/usr/bin/cc", "argv": ["cc", "-o", "build/out", "main.c"], "exit_code": 1, "started_at": 14000, "children": []}, - {"pid": null, "path": "/usr/bin/python3", "argv": ["python3", "-c", "print(1)"], "exit_code": null, "started_at": 17000, "children": []} + {"pid": 563, "path": "/bin/sh", "argv": ["sh", "-c", "cd /app && make"], "exit_code": null, "started_at": 2000, "children": [ + {"pid": 563, "path": "/usr/bin/make", "argv": ["make"], "exit_code": 2, "started_at": 13000, "children": [ + {"pid": 564, "path": "/usr/bin/cc", "argv": ["cc", "-o", "build/out", "main.c"], "exit_code": 1, "started_at": 14000, "children": []}, + {"pid": 566, "path": "/bin/echo", "argv": ["echo", "done"], "exit_code": 0, "started_at": 15400, "children": []} + ]} + ]}, + {"pid": 570, "path": "/usr/lib/vpod/vpod-seed-entropy", "argv": ["vpod-seed-entropy", "ab12"], "exit_code": 0, "started_at": 12000, "children": []}, + {"pid": 571, "path": "/usr/bin/python3", "argv": ["python3", "-c", "print(1)"], "exit_code": null, "started_at": 17000, "children": []} ] }, { "name": "a buffer that overflowed", "events": [ - {"v": 1, "seq": 0, "guest_ns": 1, "wall_ms": 1, "kind": "file.open", "task": "a1", "path": "/tmp/a", "access": "write", "create": true, "truncate": true, "result": 3}, + {"v": 1, "seq": 0, "guest_ns": 1, "wall_ms": 1, "kind": "file.open", "task": "a1", "pid": 563, "path": "/tmp/a", "access": "write", "create": true, "truncate": true, "result": 3}, {"v": 1, "seq": 1, "guest_ns": 2, "wall_ms": 1, "kind": "trace.dropped", "count": 912}, - {"v": 1, "seq": 2, "guest_ns": 3, "wall_ms": 1, "kind": "file.open", "task": "a1", "path": "/tmp/b", "access": "read", "create": false, "truncate": false, "result": 3} + {"v": 1, "seq": 2, "guest_ns": 3, "wall_ms": 1, "kind": "file.open", "task": "a1", "pid": 563, "path": "/tmp/b", "access": "read", "create": false, "truncate": false, "result": 3} ], "complete": false, "files": [ - {"path": "/tmp/a", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/tmp/b", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false} + {"path": "/tmp/a", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/tmp/b", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]} ], "files_including_internal_and_noise": [ - {"path": "/tmp/a", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false}, - {"path": "/tmp/b", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false} + {"path": "/tmp/a", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]}, + {"path": "/tmp/b", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [563]} ], "network": [], "network_including_internal": [], "processes": [], "processes_including_internal": [] + }, + { + "name": "a guest whose process ids were never calibrated", + "events": [ + {"v": 1, "seq": 0, "guest_ns": 1000, "wall_ms": 1, "kind": "process.exec", "task": "a1", "pid": null, "path": "/bin/sh", "argv": ["sh", "-c", "cat data/notes.txt"]}, + {"v": 1, "seq": 1, "guest_ns": 2000, "wall_ms": 1, "kind": "file.open", "task": "a1", "pid": null, "path": "data/notes.txt", "access": "read", "create": false, "truncate": false, "result": 3, "path_unresolved": true} + ], + "complete": false, + "files": [ + {"path": "data/notes.txt", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": []} + ], + "files_including_internal_and_noise": [ + {"path": "data/notes.txt", "read": true, "written": false, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": []} + ], + "network": [], + "network_including_internal": [], + "processes": [ + {"pid": null, "path": "/bin/sh", "argv": ["sh", "-c", "cat data/notes.txt"], "exit_code": null, "started_at": 1000, "children": []} + ], + "processes_including_internal": [ + {"pid": null, "path": "/bin/sh", "argv": ["sh", "-c", "cat data/notes.txt"], "exit_code": null, "started_at": 1000, "children": []} + ] } ] } diff --git a/sdks/typescript/src/node/transport.ts b/sdks/typescript/src/node/transport.ts index cfe3fbee..34a3e255 100644 --- a/sdks/typescript/src/node/transport.ts +++ b/sdks/typescript/src/node/transport.ts @@ -37,7 +37,7 @@ interface Executor { mounts: never[], ): bigint; sessionTraceStart?(handle: bigint, options: WireTraceOptions): void; - sessionTraceDrain?(handle: bigint, maxBytes: number): Uint8Array; + sessionTraceDrain?(handle: bigint, maxBytes: number): string; sessionTraceStop?(handle: bigint): void; } diff --git a/sdks/typescript/src/runtime.ts b/sdks/typescript/src/runtime.ts index c4570aef..4db7311c 100644 --- a/sdks/typescript/src/runtime.ts +++ b/sdks/typescript/src/runtime.ts @@ -161,8 +161,8 @@ export class SandboxRuntime { return this.#transport.call({ kind: "session-trace-start", handle, options }); } - sessionTraceDrain(handle: bigint, maxBytes: number): Promise { - return this.#transport.call({ kind: "session-trace-drain", handle, maxBytes }); + sessionTraceDrain(handle: bigint, maxBytes: number): Promise { + return this.#transport.call({ kind: "session-trace-drain", handle, maxBytes }); } sessionTraceStop(handle: bigint): Promise { diff --git a/sdks/typescript/src/trace.ts b/sdks/typescript/src/trace.ts index a501c5c7..c1c571bc 100644 --- a/sdks/typescript/src/trace.ts +++ b/sdks/typescript/src/trace.ts @@ -34,6 +34,7 @@ export interface FileActivity { renamedTo: string | null; renamedFrom: string | null; denied: boolean; + processes: number[]; } export interface HttpRequest { @@ -50,6 +51,7 @@ export interface NetworkActivity { bytesOut: number; bytesIn: number; failed: boolean; + processes: number[]; } export interface ProcessNode { @@ -118,6 +120,26 @@ function hostOf(url: string): string | null { } } +function closestProgram( + pid: number | null, + parents: Map, + latest: Map, +): ProcessNode | null { + if (pid === null) return null; + const own = latest.get(pid); + if (own !== undefined) return own; + + const seen = new Set([pid]); + let ancestor = parents.get(pid); + while (ancestor !== undefined && !seen.has(ancestor)) { + const node = latest.get(ancestor); + if (node !== undefined) return node; + seen.add(ancestor); + ancestor = parents.get(ancestor); + } + return null; +} + function isNoise(entry: FileActivity): boolean { const path = entry.path; if (NOISE_DIRECTORIES.includes(path) || NOISE_PREFIXES.some((prefix) => path.startsWith(prefix))) { @@ -146,7 +168,9 @@ export class Trace { } get complete(): boolean { - return !this.#events.some((event) => event.kind === "trace.dropped"); + return !this.#events.some( + (event) => event.kind === "trace.dropped" || ("pid" in event && event.pid === null), + ); } toJSONL(): string { @@ -155,6 +179,8 @@ export class Trace { files(options: { internal?: boolean; noise?: boolean } = {}): FileActivity[] { const activities = new Map(); + const touching = new Map>(); + let touchedBy: number | null = null; const activity = (path: unknown): FileActivity | null => { if (typeof path !== "string") return null; @@ -169,9 +195,12 @@ export class Trace { renamedTo: null, renamedFrom: null, denied: false, + processes: [], }; activities.set(path, entry); + touching.set(path, new Set()); } + if (touchedBy !== null) touching.get(path)?.add(touchedBy); return entry; }; @@ -184,6 +213,7 @@ export class Trace { for (const event of this.#events) { if (event.internal === true && !options.internal) continue; const result = event.result as number; + touchedBy = typeof event.pid === "number" ? event.pid : null; switch (event.kind) { case "file.open": @@ -263,18 +293,23 @@ export class Trace { } } + for (const [path, entry] of activities) { + entry.processes = [...(touching.get(path) ?? [])].sort((first, second) => first - second); + } + return [...activities.values()].filter((entry) => options.noise || !isNoise(entry)); } network(options: { internal?: boolean } = {}): NetworkActivity[] { const activities = new Map(); + const touching = new Map>(); for (const event of this.#events) { if (event.internal === true && !options.internal) continue; if (!["net.connect", "net.flow", "net.udp", "net.http"].includes(event.kind)) continue; if (typeof event.address !== "string" || typeof event.port !== "number") continue; - const key = `${event.address}${event.port}`; + const key = `${event.address} ${event.port}`; let entry = activities.get(key); if (entry === undefined) { entry = { @@ -286,10 +321,13 @@ export class Trace { bytesOut: 0, bytesIn: 0, failed: true, + processes: [], }; activities.set(key, entry); + touching.set(key, new Set()); } entry.host ??= text(event.host); + if (typeof event.pid === "number") touching.get(key)?.add(event.pid); switch (event.kind) { case "net.connect": @@ -313,36 +351,61 @@ export class Trace { } } + for (const [key, entry] of activities) { + entry.processes = [...(touching.get(key) ?? [])].sort((first, second) => first - second); + } + return [...activities.values()]; } processes(options: { internal?: boolean } = {}): ProcessNode[] { - const nodes: { node: ProcessNode; internal: boolean }[] = []; - const runningByTask = new Map(); + const parents = this.#parents(); + const roots: ProcessNode[] = []; + const latest = new Map(); for (const event of this.#events) { + const pid = typeof event.pid === "number" ? event.pid : null; + if (event.kind === "process.exec" && !("result" in event)) { + if (event.internal === true && !options.internal) continue; const node: ProcessNode = { - pid: null, + pid, path: text(event.path), argv: Array.isArray(event.argv) ? event.argv.map(String) : [], exitCode: null, startedAt: event.guest_ns, children: [], }; - nodes.push({ node, internal: event.internal === true }); - runningByTask.set(String(event.task), node); - } else if (event.kind === "process.exit") { - const task = String(event.task); - const node = runningByTask.get(task); - if (node !== undefined) { + const program = closestProgram(pid, parents, latest); + (program === null ? roots : program.children).push(node); + if (pid !== null) latest.set(pid, node); + } else if (event.kind === "process.exit" && pid !== null) { + const node = latest.get(pid); + if (node !== undefined && node.exitCode === null) { node.exitCode = event.code as number; - runningByTask.delete(task); } } } - return nodes.filter((entry) => options.internal || !entry.internal).map((entry) => entry.node); + return roots; + } + + #parents(): Map { + const parents = new Map(); + + for (const event of this.#events) { + if (event.kind === "process.fork" && event.thread !== true) { + if (typeof event.pid === "number" && typeof event.child_pid === "number") { + if (!parents.has(event.child_pid)) parents.set(event.child_pid, event.pid); + } + } else if (event.kind === "process.exec" && typeof event.ppid === "number") { + if (typeof event.pid === "number" && !parents.has(event.pid)) { + parents.set(event.pid, event.ppid); + } + } + } + + return parents; } } @@ -370,8 +433,7 @@ class Watcher { export class TraceRecorder { readonly #options: WireTraceOptions | null; - readonly #drainSession: (maxBytes: number) => Promise; - readonly #decoder = new TextDecoder(); + readonly #drainSession: (maxBytes: number) => Promise; #events: TraceEvent[] = []; #watchers = new Set(); #closed = false; @@ -379,7 +441,7 @@ export class TraceRecorder { /** @internal */ constructor( options: WireTraceOptions | null, - drainSession: (maxBytes: number) => Promise, + drainSession: (maxBytes: number) => Promise, ) { this.#options = options; this.#drainSession = drainSession; @@ -441,10 +503,9 @@ export class TraceRecorder { async _drain(): Promise { if (!this.enabled) return; const drained = await this.#drainSession(DRAIN_ALL_BYTES); - if (drained === null || drained.byteLength === 0) return; + if (drained === null || drained.length === 0) return; - const events = this.#decoder - .decode(drained) + const events = drained .split("\n") .filter((line) => line.length > 0) .map((line) => JSON.parse(line) as TraceEvent); diff --git a/sdks/typescript/src/worker/dispatch.ts b/sdks/typescript/src/worker/dispatch.ts index 0e2b3b66..d1e58ae3 100644 --- a/sdks/typescript/src/worker/dispatch.ts +++ b/sdks/typescript/src/worker/dispatch.ts @@ -54,7 +54,7 @@ export interface Executor { mounts: never[], ): bigint; sessionTraceStart?(handle: bigint, options: WireTraceOptions): void; - sessionTraceDrain?(handle: bigint, maxBytes: number): Uint8Array; + sessionTraceDrain?(handle: bigint, maxBytes: number): string; sessionTraceStop?(handle: bigint): void; } diff --git a/sdks/typescript/tests/integration/trace.test.mjs b/sdks/typescript/tests/integration/trace.test.mjs index d43feaeb..03d45ec1 100644 --- a/sdks/typescript/tests/integration/trace.test.mjs +++ b/sdks/typescript/tests/integration/trace.test.mjs @@ -5,6 +5,13 @@ import { describe, it } from "node:test"; import { createTestSandbox, loadSdk, locateSnapshot, skipReason } from "../helpers.mjs"; +function* programs(nodes) { + for (const node of nodes) { + yield node; + yield* programs(node.children); + } +} + async function withTracedSandbox(body) { const sandbox = await createTestSandbox({ trace: true }); try { @@ -22,7 +29,7 @@ describe("trace", { skip: skipReason() ?? false }, () => { ); assert.equal(result.exitCode, 0); - const commands = result.trace.processes().map((process) => process.argv); + const commands = [...programs(result.trace.processes())].map((process) => process.argv); assert.deepEqual(commands[0], [ "sh", "-c", @@ -36,6 +43,29 @@ describe("trace", { skip: skipReason() ?? false }, () => { }); }); + it("says which program started which, and where relative paths landed", async () => { + await withTracedSandbox(async (sandbox) => { + const script = "mkdir -p /tmp/tree && cd /tmp/tree && cat /etc/hostname > host.txt"; + const result = await sandbox.commands.run(`sh -c '${script}'`); + assert.equal(result.exitCode, 0); + assert.equal(result.trace.complete, true); + + const roots = result.trace.processes(); + assert.equal(roots.length, 1, JSON.stringify(roots.map((node) => node.argv))); + assert.deepEqual(roots[0].argv, ["sh", "-c", script]); + assert.equal(typeof roots[0].pid, "number"); + + const children = new Map(roots[0].children.map((node) => [node.argv[0], node])); + assert.ok(children.has("mkdir") && children.has("cat"), JSON.stringify([...children.keys()])); + assert.equal(children.get("cat").exitCode, 0); + + const written = result.trace.files().filter((file) => file.written); + const copy = written.find((file) => file.path === "/tmp/tree/host.txt"); + assert.ok(copy, JSON.stringify(written.map((file) => file.path))); + assert.ok(copy.processes.length > 0); + }); + }); + it("keeps each command's events to that command and the whole run on the sandbox", async () => { await withTracedSandbox(async (sandbox) => { const first = await sandbox.commands.run("touch /tmp/first"); From 8c5a17047d8a4b74ccef7ae2bd43f804836377a9 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Fri, 18 Sep 2026 00:51:20 +0200 Subject: [PATCH 10/11] detect and report io_uring usage as trace blind events --- crates/machine/src/trace/syscalls.rs | 29 ++++++++++++++++ crates/riscv-core/src/syscall_trace.rs | 4 +++ crates/wasi-component/src/api/session.rs | 13 ++++---- sdks/python/tests/test_trace_integration.py | 30 +++++++++++++++++ sdks/python/vpod/trace.py | 3 +- sdks/trace-summaries.json | 37 ++++++++++++++------- sdks/typescript/src/trace.ts | 5 ++- 7 files changed, 101 insertions(+), 20 deletions(-) diff --git a/crates/machine/src/trace/syscalls.rs b/crates/machine/src/trace/syscalls.rs index 8ea70b09..127afb4c 100644 --- a/crates/machine/src/trace/syscalls.rs +++ b/crates/machine/src/trace/syscalls.rs @@ -102,6 +102,7 @@ pub struct SyscallTracer { trace_files: bool, trace_network: bool, quiet: bool, + reported_blind: bool, identities: Identities, processes: Processes, pending: HashMap, @@ -130,6 +131,7 @@ impl SyscallTracer { trace_network: tracer.traces_network(), tracer, quiet: false, + reported_blind: false, identities: Identities::default(), processes: Processes::default(), pending: HashMap::new(), @@ -233,6 +235,15 @@ impl SyscallTracer { self.observe_identity(task, value as u32, bus, satp); } } + SyscallKind::RingUse => { + if !succeeded || self.reported_blind { + return; + } + self.reported_blind = true; + let mut fields = identity_fields(owner, task); + fields.push(("reason", "io-uring".into())); + self.record("trace.blind", fields, false); + } SyscallKind::Clone { thread } => { if value <= 0 { return; @@ -1472,6 +1483,24 @@ mod tests { assert_eq!(events[0]["thread"], false); } + #[test] + fn a_ring_the_tracer_cannot_see_through_is_reported_once() { + let mut guest = Guest::new(); + guest.calibrate(); + + guest.call(SHELL, SyscallKind::RingUse, -1); // refused, nothing was hidden + assert!(guest.events().is_empty()); + + guest.call(SHELL, SyscallKind::RingUse, 3); + guest.call(SHELL, SyscallKind::RingUse, 4); + + let events = guest.events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["kind"], "trace.blind"); + assert_eq!(events[0]["reason"], "io-uring"); + assert_eq!(events[0]["pid"], 1); + } + #[test] fn a_failed_fork_is_not_reported() { let mut guest = Guest::new(); diff --git a/crates/riscv-core/src/syscall_trace.rs b/crates/riscv-core/src/syscall_trace.rs index fd6bf3d2..c21214e0 100644 --- a/crates/riscv-core/src/syscall_trace.rs +++ b/crates/riscv-core/src/syscall_trace.rs @@ -26,6 +26,8 @@ const SYS_CLONE: u64 = 220; const SYS_EXECVE: u64 = 221; const SYS_RENAMEAT2: u64 = 276; const SYS_EXECVEAT: u64 = 281; +const SYS_IO_URING_SETUP: u64 = 425; +const SYS_IO_URING_ENTER: u64 = 426; const SYS_CLONE3: u64 = 435; const SYS_CLOSE_RANGE: u64 = 436; const SYS_OPENAT2: u64 = 437; @@ -82,6 +84,7 @@ pub enum SyscallKind { Identity { group: bool, }, + RingUse, Open { directory_fd: i32, path: GuestString, @@ -192,6 +195,7 @@ pub fn decode_entry( SYS_CLONE3 => SyscallKind::Clone { thread: memory.u64(ctx.bus, args[0]).unwrap_or(0) & CLONE_THREAD != 0, }, + SYS_IO_URING_SETUP | SYS_IO_URING_ENTER => SyscallKind::RingUse, SYS_SET_TID_ADDRESS | SYS_GETTID => SyscallKind::Identity { group: false }, SYS_GETPID => SyscallKind::Identity { group: true }, SYS_OPENAT | SYS_OPENAT2 => { diff --git a/crates/wasi-component/src/api/session.rs b/crates/wasi-component/src/api/session.rs index 1fc72d2e..e3958c44 100644 --- a/crates/wasi-component/src/api/session.rs +++ b/crates/wasi-component/src/api/session.rs @@ -28,8 +28,9 @@ const PYRUNNER_STAGE_CHUNK: usize = 2500; const SHELL_PROMPT_SENTINEL: &[u8] = b"\x1fvpod\x1f"; const WORKING_DIRECTORY_MARKER: &str = "vpod-cwd "; -const WORKING_DIRECTORY_TIMEOUT_SECONDS: u64 = 15; -const WORKING_DIRECTORY_PROBE: &str = "( for p in /proc/[0-9]*; do \ +const TRACE_START_TIMEOUT_SECONDS: u64 = 15; +const TRACE_START_COMMAND: &str = "echo 2 > /proc/sys/kernel/io_uring_disabled 2>/dev/null; \ + ( for p in /proc/[0-9]*; do \ cd -P \"$p/cwd\" 2>/dev/null && echo \"vpod-cwd ${p#/proc/} $PWD\"; \ done ) 2>/dev/null\n"; @@ -157,13 +158,13 @@ fn begin_shell_exec(session: &mut Session, code: String, timeout_secs: u64, mode )); } -fn learn_working_directories(session: &mut Session) { +fn prepare_tracing(session: &mut Session) { if !session.is_shell || session.shell_lost || session.exec.is_some() { return; } session.bus.uart.drain_tx(); - for byte in WORKING_DIRECTORY_PROBE.bytes() { + for byte in TRACE_START_COMMAND.bytes() { session.bus.uart.push_rx(byte); } @@ -172,7 +173,7 @@ fn learn_working_directories(session: &mut Session) { &mut session.bus, &mut session.hart, &prompt, - WORKING_DIRECTORY_TIMEOUT_SECONDS, + TRACE_START_TIMEOUT_SECONDS, true, None, false, @@ -890,7 +891,7 @@ impl SessionManager { if session.bus.traces_syscalls() { session.bus.set_trace_quiet(true); - learn_working_directories(session); + prepare_tracing(session); session.bus.set_trace_quiet(false); } diff --git a/sdks/python/tests/test_trace_integration.py b/sdks/python/tests/test_trace_integration.py index a7e5c0fa..eabac6f1 100644 --- a/sdks/python/tests/test_trace_integration.py +++ b/sdks/python/tests/test_trace_integration.py @@ -95,6 +95,36 @@ def test_vpod_plumbing_is_hidden_unless_asked_for(): assert "/dev/ttyS1" in [file.path for file in trace.files(internal=True, noise=True)] +SETUP_A_RING = ( + 'python3 -c "import ctypes; libc = ctypes.CDLL(None, use_errno=True); ' + "libc.syscall.restype = ctypes.c_long; " + "print(libc.syscall(ctypes.c_long(425), ctypes.c_long(4), " + 'ctypes.create_string_buffer(120)))"' +) + + +def test_tracing_closes_the_io_uring_blind_spot(): + reader = "cat /proc/sys/kernel/io_uring_disabled" + + with Sandbox.create(trace=True) as sbx: + assert sbx.commands.run(reader).stdout.strip() == "2" + + with Sandbox.create() as sbx: + assert sbx.commands.run(reader).stdout.strip() == "0" + + +def test_a_guest_that_turns_io_uring_back_on_is_reported_not_hidden(): + with Sandbox.create(trace=True) as sbx: + result = sbx.commands.run( + f"echo 0 > /proc/sys/kernel/io_uring_disabled; {SETUP_A_RING}" + ) + assert result.success, result.stderr + assert not result.stdout.strip().startswith("-1"), result.stdout + + assert any(event["kind"] == "trace.blind" for event in result.trace.events) + assert not result.trace.complete + + def test_a_resumed_sandbox_starts_a_fresh_trace(): with Sandbox.create(trace=True) as sbx: sbx.commands.run("touch /tmp/before-suspend") diff --git a/sdks/python/vpod/trace.py b/sdks/python/vpod/trace.py index 3f3e70fe..7d646752 100644 --- a/sdks/python/vpod/trace.py +++ b/sdks/python/vpod/trace.py @@ -102,7 +102,8 @@ def events(self) -> list[dict]: @property def complete(self) -> bool: return not any( - event["kind"] == "trace.dropped" or event.get("pid", 0) is None + event["kind"] in ("trace.dropped", "trace.blind") + or event.get("pid", 0) is None for event in self._events ) diff --git a/sdks/trace-summaries.json b/sdks/trace-summaries.json index 9c69bbd9..b3bc91b9 100644 --- a/sdks/trace-summaries.json +++ b/sdks/trace-summaries.json @@ -119,21 +119,11 @@ {"host": null, "address": "10.0.2.2", "port": 8080, "protocol": "tcp", "requests": [], "bytes_out": 0, "bytes_in": 0, "failed": false, "processes": [700]} ], "processes": [ - {"pid": 563, "path": "/bin/sh", "argv": ["sh", "-c", "cd /app && make"], "exit_code": null, "started_at": 2000, "children": [ - {"pid": 563, "path": "/usr/bin/make", "argv": ["make"], "exit_code": 2, "started_at": 13000, "children": [ - {"pid": 564, "path": "/usr/bin/cc", "argv": ["cc", "-o", "build/out", "main.c"], "exit_code": 1, "started_at": 14000, "children": []}, - {"pid": 566, "path": "/bin/echo", "argv": ["echo", "done"], "exit_code": 0, "started_at": 15400, "children": []} - ]} - ]}, + {"pid": 563, "path": "/bin/sh", "argv": ["sh", "-c", "cd /app && make"], "exit_code": null, "started_at": 2000, "children": [{"pid": 563, "path": "/usr/bin/make", "argv": ["make"], "exit_code": 2, "started_at": 13000, "children": [{"pid": 564, "path": "/usr/bin/cc", "argv": ["cc", "-o", "build/out", "main.c"], "exit_code": 1, "started_at": 14000, "children": []}, {"pid": 566, "path": "/bin/echo", "argv": ["echo", "done"], "exit_code": 0, "started_at": 15400, "children": []}]}]}, {"pid": 571, "path": "/usr/bin/python3", "argv": ["python3", "-c", "print(1)"], "exit_code": null, "started_at": 17000, "children": []} ], "processes_including_internal": [ - {"pid": 563, "path": "/bin/sh", "argv": ["sh", "-c", "cd /app && make"], "exit_code": null, "started_at": 2000, "children": [ - {"pid": 563, "path": "/usr/bin/make", "argv": ["make"], "exit_code": 2, "started_at": 13000, "children": [ - {"pid": 564, "path": "/usr/bin/cc", "argv": ["cc", "-o", "build/out", "main.c"], "exit_code": 1, "started_at": 14000, "children": []}, - {"pid": 566, "path": "/bin/echo", "argv": ["echo", "done"], "exit_code": 0, "started_at": 15400, "children": []} - ]} - ]}, + {"pid": 563, "path": "/bin/sh", "argv": ["sh", "-c", "cd /app && make"], "exit_code": null, "started_at": 2000, "children": [{"pid": 563, "path": "/usr/bin/make", "argv": ["make"], "exit_code": 2, "started_at": 13000, "children": [{"pid": 564, "path": "/usr/bin/cc", "argv": ["cc", "-o", "build/out", "main.c"], "exit_code": 1, "started_at": 14000, "children": []}, {"pid": 566, "path": "/bin/echo", "argv": ["echo", "done"], "exit_code": 0, "started_at": 15400, "children": []}]}]}, {"pid": 570, "path": "/usr/lib/vpod/vpod-seed-entropy", "argv": ["vpod-seed-entropy", "ab12"], "exit_code": 0, "started_at": 12000, "children": []}, {"pid": 571, "path": "/usr/bin/python3", "argv": ["python3", "-c", "print(1)"], "exit_code": null, "started_at": 17000, "children": []} ] @@ -180,6 +170,29 @@ "processes_including_internal": [ {"pid": null, "path": "/bin/sh", "argv": ["sh", "-c", "cat data/notes.txt"], "exit_code": null, "started_at": 1000, "children": []} ] + }, + { + "name": "a guest that got behind the tracer with io_uring", + "events": [ + {"v": 1, "seq": 0, "guest_ns": 1000, "wall_ms": 1, "kind": "process.exec", "task": "a1", "pid": 601, "ppid": 1, "path": "/usr/bin/node", "argv": ["node", "build.js"]}, + {"v": 1, "seq": 1, "guest_ns": 2000, "wall_ms": 1, "kind": "trace.blind", "task": "a1", "pid": 601, "reason": "io-uring"}, + {"v": 1, "seq": 2, "guest_ns": 3000, "wall_ms": 1, "kind": "file.open", "task": "a1", "pid": 601, "path": "/app/out.js", "access": "write", "create": true, "truncate": true, "result": 3} + ], + "complete": false, + "files": [ + {"path": "/app/out.js", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [601]} + ], + "files_including_internal_and_noise": [ + {"path": "/app/out.js", "read": false, "written": true, "created": false, "deleted": false, "renamed_to": null, "renamed_from": null, "denied": false, "processes": [601]} + ], + "network": [], + "network_including_internal": [], + "processes": [ + {"pid": 601, "path": "/usr/bin/node", "argv": ["node", "build.js"], "exit_code": null, "started_at": 1000, "children": []} + ], + "processes_including_internal": [ + {"pid": 601, "path": "/usr/bin/node", "argv": ["node", "build.js"], "exit_code": null, "started_at": 1000, "children": []} + ] } ] } diff --git a/sdks/typescript/src/trace.ts b/sdks/typescript/src/trace.ts index c1c571bc..674b60de 100644 --- a/sdks/typescript/src/trace.ts +++ b/sdks/typescript/src/trace.ts @@ -169,7 +169,10 @@ export class Trace { get complete(): boolean { return !this.#events.some( - (event) => event.kind === "trace.dropped" || ("pid" in event && event.pid === null), + (event) => + event.kind === "trace.dropped" || + event.kind === "trace.blind" || + ("pid" in event && event.pid === null), ); } From 1a02223b3cc466f315dd6b9e411ca5699b4bdb3a Mon Sep 17 00:00:00 2001 From: Mavdol Date: Fri, 18 Sep 2026 10:04:03 +0200 Subject: [PATCH 11/11] use is_multiple_of for kernel pointer alignment check --- crates/machine/src/trace/identity.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/machine/src/trace/identity.rs b/crates/machine/src/trace/identity.rs index 9567507e..7a531b7f 100644 --- a/crates/machine/src/trace/identity.rs +++ b/crates/machine/src/trace/identity.rs @@ -165,7 +165,7 @@ fn lowest_settled(table: &HashMap>) -> Option { } fn is_kernel_pointer(pointer: u64) -> bool { - pointer >> 56 == 0xff && pointer % 8 == 0 + pointer >> 56 == 0xff && pointer.is_multiple_of(8) } #[cfg(test)]