use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result};
use super::tail::resolve_session_path;
use super::types::{Event, FileAccess};
use crate::logger::sanitizer;
pub fn run_replay(session_arg: &Path, speed: Option<f64>, json_output: bool) -> Result<()> {
let path = resolve_session_path(session_arg)?;
if !path.exists() {
anyhow::bail!("session log file '{}' does not exist", path.display());
}
let file = File::open(&path).with_context(|| format!("open {}", path.display()))?;
let reader = BufReader::new(file);
let mut events = Vec::new();
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.contains("\"_vetto\":") {
continue;
}
if let Ok(event) = serde_json::from_str::<Event>(trimmed) {
events.push(event);
}
}
if events.is_empty() {
println!("No recorded events found in {}.", path.display());
return Ok(());
}
events.sort_by_key(|e| e.ts());
let speed_factor = speed.unwrap_or(0.0);
let start_ts = events[0].ts();
let mut prev_ts = start_ts;
if !json_output {
println!("=== vetto sandbox session replay: {} ===", path.display());
println!(
"Events: {} | Speed: {}",
events.len(),
if speed_factor > 0.0 {
format!("{speed_factor}x")
} else {
"instant".into()
}
);
println!("NOTE: Replaying sandbox security & observation telemetry, not terminal input.\n");
println!("{:<12} {:<8} {:<16} DETAILS", "OFFSET", "DELTA", "EVENT");
println!("{:-<12} {:-<8} {:-<16} {:-<40}", "", "", "", "");
}
for event in &events {
let cur_ts = event.ts();
let offset_ms = cur_ts
.signed_duration_since(start_ts)
.num_milliseconds()
.max(0);
let delta_ms = cur_ts
.signed_duration_since(prev_ts)
.num_milliseconds()
.max(0);
if speed_factor > 0.0 && delta_ms > 0 {
let sleep_ms = ((delta_ms as f64) / speed_factor).min(5000.0) as u64;
if sleep_ms > 0 {
std::thread::sleep(Duration::from_millis(sleep_ms));
}
}
prev_ts = cur_ts;
if json_output {
println!("{}", serde_json::to_string(event)?);
} else {
let offset_str = format_millis_offset(offset_ms as u64);
let delta_str = format!("+{:.3}s", (delta_ms as f64) / 1000.0);
let kind = event.kind();
let details = describe_replay_event(event);
println!(
"{:<12} {:<8} {:<16} {}",
offset_str, delta_str, kind, details
);
}
}
if !json_output {
println!("\n=== Replay complete ===");
}
Ok(())
}
fn format_millis_offset(ms: u64) -> String {
let total_secs = ms / 1000;
let millis = ms % 1000;
let mins = total_secs / 60;
let secs = total_secs % 60;
format!("{mins:02}:{secs:02}.{millis:03}")
}
fn describe_replay_event(event: &Event) -> String {
match event {
Event::SessionStarted {
pid,
tier,
net_mode,
profile,
..
} => {
format!("pid={pid} tier={tier} net={net_mode} profile={profile}")
}
Event::SessionEnded {
exit_code,
duration_secs,
..
} => {
format!("exit_code={exit_code} duration={duration_secs}s")
}
Event::FileObserved {
comm,
pid,
path,
access,
..
} => {
let acc = match access {
FileAccess::Read => "read",
FileAccess::Write => "write",
FileAccess::Unknown => "open",
};
format!("{comm}[{pid}] {acc} {path}")
}
Event::ExecObserved { pid, argv, .. } => {
format!(
"pid={pid} exec: {}",
sanitizer::sanitize_line(&argv.join(" "))
)
}
Event::BlockedAttempt {
comm,
pid,
path,
source,
..
} => {
format!("{comm}[{pid}] BLOCKED '{path}' via {source}")
}
Event::NetRequest {
host,
port,
allowed,
..
} => {
format!(
"{}:{} -> {}",
host,
port,
if *allowed { "ALLOW" } else { "DENIED" }
)
}
Event::DnsResolved { host, ips, .. } => {
format!("dns {host} -> {:?}", ips)
}
Event::NetEgress {
host,
ip,
port,
bytes_tx,
bytes_rx,
..
} => {
format!("net {host} ({ip}:{port}) tx={bytes_tx} rx={bytes_rx}")
}
Event::NetQuotaExceeded {
host,
limit_bytes,
used_bytes,
..
} => {
format!("quota exceeded {host}: {used_bytes}/{limit_bytes} bytes")
}
Event::SecretMasked { path, .. } => {
format!("masked secret mount: {path}")
}
Event::Notice { message, .. } => message.clone(),
Event::SessionTimeout { .. } => "session deadline reached, sandbox torn down".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
#[test]
fn format_millis_offset_renders_correctly() {
assert_eq!(format_millis_offset(0), "00:00.000");
assert_eq!(format_millis_offset(1500), "00:01.500");
assert_eq!(format_millis_offset(65432), "01:05.432");
}
#[test]
fn describe_replay_event_formats_all_types() {
let blocked = Event::BlockedAttempt {
ts: Utc::now(),
pid: 42,
comm: "cat".into(),
path: "/etc/shadow".into(),
source: "landlock".into(),
};
let desc = describe_replay_event(&blocked);
assert!(desc.contains("BLOCKED"));
assert!(desc.contains("/etc/shadow"));
}
}