use runtimo_core::{
capabilities::{FileRead, FileWrite, Kill, ShellExec},
execute_with_telemetry, BackupManager, Capability, ProcessSnapshot,
RuntimoConfig, Telemetry, WalReader, WalWriter,
};
use serde_json::json;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
fn unique_test_dir() -> PathBuf {
let ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
std::env::temp_dir().join(format!("runtimo_robust_{}_{}", std::process::id(), ns))
}
fn wal_path(base: &std::path::Path) -> PathBuf {
base.join("wal_dir/wal.jsonl")
}
fn backup_dir(base: &std::path::Path) -> PathBuf {
base.join("backups")
}
fn setup() -> PathBuf {
let d = unique_test_dir();
fs::create_dir_all(&d).ok();
fs::create_dir_all(wal_path(&d).parent().unwrap()).ok();
d
}
fn cleanup(dir: &PathBuf) {
let _ = fs::remove_dir_all(dir);
}
fn make_file(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
let p = dir.join(name);
let mut f = fs::File::create(&p).unwrap();
write!(f, "{}", content).unwrap();
p
}
fn ctx(id: impl Into<String>) -> runtimo_core::Context {
runtimo_core::Context {
dry_run: false,
job_id: id.into(),
working_dir: std::env::temp_dir(),
}
}
#[test]
fn edge_empty_content_roundtrip() {
let dir = setup();
let target = dir.join("empty.txt");
FileWrite::new(backup_dir(&dir))
.expect("Failed to create FileWrite")
.execute(
&json!({ "path": target.to_str().unwrap(), "content": "" }),
&ctx("edge1"),
)
.unwrap();
let r = FileRead
.execute(&json!({ "path": target.to_str().unwrap() }), &ctx("edge2"))
.unwrap();
assert_eq!(r.data["content"].as_str().unwrap(), "");
cleanup(&dir);
}
#[test]
fn edge_single_char_roundtrip() {
let dir = setup();
let target = dir.join("single.txt");
for ch in &["a", " ", "0", "\n", "\t"] {
let _ = fs::remove_file(&target);
FileWrite::new(backup_dir(&dir))
.expect("Failed to create FileWrite")
.execute(
&json!({ "path": target.to_str().unwrap(), "content": ch }),
&ctx(format!("edge_{}", ch.escape_default())),
)
.unwrap();
let r = FileRead
.execute(&json!({ "path": target.to_str().unwrap() }), &ctx("edge_read"))
.unwrap();
assert_eq!(r.data["content"].as_str().unwrap(), *ch);
}
cleanup(&dir);
}
#[test]
fn edge_long_filename() {
let dir = setup();
let long_name = format!("{}.txt", "x".repeat(200));
let target = dir.join(&long_name);
FileWrite::new(backup_dir(&dir))
.expect("Failed to create FileWrite")
.execute(
&json!({ "path": target.to_str().unwrap(), "content": "long name" }),
&ctx("edge_long"),
)
.unwrap();
assert!(target.exists());
cleanup(&dir);
}
#[test]
fn edge_whitespace_only() {
let dir = setup();
let p = make_file(&dir, "ws.txt", " \n\t\n ");
let r = FileRead
.execute(&json!({ "path": p.to_str().unwrap() }), &ctx("edge_ws"))
.unwrap();
assert_eq!(r.data["content"].as_str().unwrap(), " \n\t\n ");
cleanup(&dir);
}
#[test]
fn edge_null_bytes_in_content() {
let dir = setup();
let p = make_file(&dir, "null.bin", "hello\0world");
let r = FileRead
.execute(&json!({ "path": p.to_str().unwrap() }), &ctx("edge_null"))
.unwrap();
assert!(r.success || r.data["content"].is_null());
cleanup(&dir);
}
#[test]
fn edge_concurrent_writes_different_files() {
let dir = setup();
let bw = backup_dir(&dir);
let handles: Vec<_> = (0..5)
.map(|i| {
let d = dir.clone();
let bw = bw.clone();
std::thread::spawn(move || {
let target = d.join(format!("concurrent_{}.txt", i));
FileWrite::new(bw)
.unwrap()
.execute(
&json!({
"path": target.to_str().unwrap(),
"content": format!("thread {}", i)
}),
&ctx(format!("concurrent_{}", i)),
)
.unwrap();
})
})
.collect();
for h in handles {
h.join().unwrap();
}
for i in 0..5 {
let target = dir.join(format!("concurrent_{}.txt", i));
let content = fs::read_to_string(&target).unwrap();
assert_eq!(content, format!("thread {}", i));
}
cleanup(&dir);
}
#[test]
fn sec_encoded_path_traversal() {
let result = FileRead.validate(&json!({ "path": "/tmp/%2e%2e/etc/passwd" }));
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("does not exist"), "Expected 'does not exist', got: {}", err);
}
#[test]
fn sec_null_byte_in_path() {
let result = FileRead.validate(&json!({ "path": "/tmp/test\0.txt" }));
assert!(result.is_err());
}
#[test]
fn sec_symlink_chain_escape() {
let dir = setup();
let link1 = dir.join("link1");
let link2 = dir.join("link2");
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
if symlink("/etc/hostname", &link1).is_ok()
&& symlink(&link1, &link2).is_ok()
{
let result = FileRead.execute(
&json!({ "path": link2.to_str().unwrap() }),
&ctx("sec_chain"),
);
assert!(result.is_err() || !result.as_ref().unwrap().success);
let _ = fs::remove_file(&link2);
let _ = fs::remove_file(&link1);
}
}
cleanup(&dir);
}
#[test]
fn sec_type_confusion_in_args() {
assert!(FileRead.validate(&json!({ "path": 12345 })).is_err());
assert!(FileRead.validate(&json!({ "path": ["/tmp/x.txt"] })).is_err());
assert!(FileRead.validate(&json!({ "path": { "file": "/tmp/x.txt" } })).is_err());
assert!(FileRead.validate(&json!({ "path": null })).is_err());
assert!(FileRead.validate(&json!({ "path": true })).is_err());
}
#[test]
fn sec_shellexec_dangerous_commands_logged() {
let dangerous = vec![
"echo test", "cat /dev/null", "true", ];
for cmd in dangerous {
let result = ShellExec.execute(
&json!({ "cmd": cmd }),
&ctx(format!("sec_{}", cmd.replace(' ', "_"))),
);
assert!(result.is_ok(), "Command '{}' should not panic: {:?}", cmd, result);
}
}
#[test]
fn err_read_directory() {
let dir = setup();
let result = FileRead.execute(
&json!({ "path": dir.to_str().unwrap() }),
&ctx("err_dir"),
);
assert!(result.is_err() || !result.unwrap().success);
cleanup(&dir);
}
#[test]
fn err_write_readonly_location() {
let dir = setup();
let readonly_dir = dir.join("readonly");
fs::create_dir_all(&readonly_dir).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&readonly_dir).unwrap().permissions();
perms.set_mode(0o555); fs::set_permissions(&readonly_dir, perms).ok();
}
let target = readonly_dir.join("test.txt");
let result = FileWrite::new(backup_dir(&dir))
.expect("Failed to create FileWrite")
.execute(
&json!({ "path": target.to_str().unwrap(), "content": "test" }),
&ctx("err_readonly"),
);
let _ = result;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&readonly_dir).unwrap().permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(&readonly_dir, perms);
}
cleanup(&dir);
}
#[test]
fn err_wal_invalid_path() {
let result = WalWriter::create(PathBuf::from("/nonexistent/deep/path/wal.jsonl").as_path());
assert!(result.is_err());
}
#[test]
fn err_backup_nonexistent_source() {
let dir = setup();
let mgr = BackupManager::new(backup_dir(&dir)).unwrap();
let result = mgr.create_backup(
&PathBuf::from("/tmp/nonexistent_runtimo_file_12345.txt"),
"test-job",
);
assert!(result.is_err());
cleanup(&dir);
}
#[test]
fn err_kill_invalid_signal() {
let result = Kill.execute(
&json!({ "pid": 999998, "signal": 999 }),
&ctx("err_signal"),
);
let _ = result;
}
#[test]
fn ctx_config_prefixes_merged() {
let tmp = unique_test_dir();
let config_dir = tmp.join("runtimo");
fs::create_dir_all(&config_dir).unwrap();
let config_path = config_dir.join("config.toml");
let mut config = RuntimoConfig::default();
config.allowed_paths.push("/srv".to_string());
let content = toml::to_string_pretty(&config).unwrap();
fs::write(&config_path, content).unwrap();
let loaded_content = fs::read_to_string(&config_path).unwrap();
let loaded: RuntimoConfig = toml::from_str(&loaded_content).unwrap();
assert!(loaded.allowed_paths.contains(&"/srv".to_string()));
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn ctx_env_var_and_config_both_active() {
let tmp = unique_test_dir();
let config_dir = tmp.join("runtimo");
fs::create_dir_all(&config_dir).unwrap();
let config_path = config_dir.join("config.toml");
let mut config = RuntimoConfig::default();
config.allowed_paths.push("/srv".to_string());
config.allowed_paths.push("/opt".to_string());
let content = toml::to_string_pretty(&config).unwrap();
fs::write(&config_path, content).unwrap();
let loaded_content = fs::read_to_string(&config_path).unwrap();
let loaded: RuntimoConfig = toml::from_str(&loaded_content).unwrap();
assert!(loaded.allowed_paths.contains(&"/srv".to_string()));
assert!(loaded.allowed_paths.contains(&"/opt".to_string()));
let defaults = RuntimoConfig::get_allowed_prefixes();
assert!(defaults.contains(&"/tmp".to_string()));
assert!(defaults.contains(&"/home".to_string()));
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn ctx_invalid_toml_returns_defaults() {
let tmp = unique_test_dir();
let config_dir = tmp.join("runtimo");
fs::create_dir_all(&config_dir).unwrap();
let config_path = config_dir.join("config.toml");
fs::write(&config_path, "this is not valid toml {{{").unwrap();
let content = fs::read_to_string(&config_path).unwrap();
let result: Result<RuntimoConfig, _> = toml::from_str(&content);
assert!(result.is_err() || result.unwrap().allowed_paths.is_empty());
let defaults = RuntimoConfig::get_allowed_prefixes();
assert!(defaults.contains(&"/tmp".to_string()));
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn ctx_path_validation_uses_config() {
let defaults = RuntimoConfig::get_allowed_prefixes();
assert!(defaults.contains(&"/tmp".to_string()));
assert!(defaults.contains(&"/home".to_string()));
assert!(defaults.contains(&"/var/tmp".to_string()));
std::env::set_var("RUNTIMO_ALLOWED_PATHS", "/srv:/opt");
let extended = RuntimoConfig::get_allowed_prefixes();
assert!(extended.contains(&"/srv".to_string()));
assert!(extended.contains(&"/opt".to_string()));
std::env::remove_var("RUNTIMO_ALLOWED_PATHS");
use runtimo_core::validation::path::{validate_path, PathContext};
let ctx = PathContext {
require_exists: false,
require_file: false,
..Default::default()
};
let result = validate_path("/tmp/myapp/config.yaml", &ctx);
assert!(result.is_ok(), "Expected /tmp to be allowed: {:?}", result);
}
#[test]
fn sem_backup_numbering_preserves_original() {
let dir = setup();
let bw = backup_dir(&dir);
let target = dir.join("numbered.txt");
let job_dir = bw.join("job1");
fs::write(&target, "original").unwrap();
FileWrite::new(bw.clone())
.expect("Failed to create FileWrite")
.execute(
&json!({ "path": target.to_str().unwrap(), "content": "first" }),
&ctx("job1"),
)
.unwrap();
FileWrite::new(bw.clone())
.expect("Failed to create FileWrite")
.execute(
&json!({ "path": target.to_str().unwrap(), "content": "second" }),
&ctx("job1"),
)
.unwrap();
FileWrite::new(bw.clone())
.expect("Failed to create FileWrite")
.execute(
&json!({ "path": target.to_str().unwrap(), "content": "third" }),
&ctx("job1"),
)
.unwrap();
let backup0 = job_dir.join("numbered.txt");
let backup1 = job_dir.join("numbered.txt.1");
let backup2 = job_dir.join("numbered.txt.2");
assert!(backup0.exists(), "Original backup should exist");
assert!(backup1.exists(), "First numbered backup should exist");
assert!(backup2.exists(), "Second numbered backup should exist");
assert_eq!(fs::read_to_string(&backup0).unwrap(), "original");
assert_eq!(fs::read_to_string(&backup1).unwrap(), "first");
assert_eq!(fs::read_to_string(&backup2).unwrap(), "second");
assert_eq!(fs::read_to_string(&target).unwrap(), "third");
cleanup(&dir);
}
#[test]
fn sem_wal_seq_monotonic() {
let dir = setup();
let wp = wal_path(&dir);
let p = make_file(&dir, "seq.txt", "test");
for _ in 0..5 {
execute_with_telemetry(
&FileRead,
&json!({ "path": p.to_str().unwrap() }),
false,
&wp,
)
.unwrap();
}
let reader = WalReader::load(&wp).unwrap();
let events = reader.events();
assert!(events.len() >= 10);
for i in 1..events.len() {
assert!(
events[i].seq > events[i - 1].seq,
"WAL seq not monotonic: {} <= {} at index {}",
events[i].seq,
events[i - 1].seq,
i
);
}
cleanup(&dir);
}
#[test]
fn sem_telemetry_temporal_order() {
let dir = setup();
let wp = wal_path(&dir);
let p = make_file(&dir, "time.txt", "test");
let result = execute_with_telemetry(
&FileRead,
&json!({ "path": p.to_str().unwrap() }),
false,
&wp,
)
.unwrap();
assert!(
result.telemetry_after.timestamp >= result.telemetry_before.timestamp,
"Telemetry after should be >= before"
);
cleanup(&dir);
}
#[test]
fn sem_process_summary_consistency() {
let snap = ProcessSnapshot::capture();
assert_eq!(
snap.summary.total_processes,
snap.processes.len(),
"Summary total_processes should match process list length"
);
let actual_zombies = snap
.processes
.iter()
.filter(|p| p.stat.starts_with('Z'))
.count();
assert_eq!(
snap.summary.zombie_count, actual_zombies,
"Summary zombie_count should match actual zombies in list"
);
let total_cpu: f32 = snap.processes.iter().map(|p| p.cpu_percent).sum();
assert!(
(snap.summary.total_cpu_percent - total_cpu).abs() < 0.01,
"Summary total_cpu should match sum of individual CPUs"
);
}
#[test]
fn drift_telemetry_format_stable() {
let tel = Telemetry::capture();
let serialized = serde_json::to_string(&tel).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert!(parsed.get("timestamp").is_some());
assert!(parsed.get("system").is_some());
assert!(parsed.get("hardware").is_some());
assert!(parsed.get("services").is_some());
assert!(parsed.get("network").is_some());
let system = parsed["system"].as_object().unwrap();
assert!(system.contains_key("cpu_model"));
assert!(system.contains_key("ram_total"));
assert!(system.contains_key("ram_free"));
assert!(system.contains_key("disk_used_percent"));
}
#[test]
fn drift_process_snapshot_format_stable() {
let snap = ProcessSnapshot::capture();
let serialized = serde_json::to_string(&snap).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert!(parsed.get("timestamp").is_some());
assert!(parsed.get("processes").is_some());
assert!(parsed.get("summary").is_some());
let summary = parsed["summary"].as_object().unwrap();
assert!(summary.contains_key("total_processes"));
assert!(summary.contains_key("total_cpu_percent"));
assert!(summary.contains_key("total_mem_percent"));
assert!(summary.contains_key("zombie_count"));
}
#[test]
fn drift_wal_event_format_stable() {
let dir = setup();
let wp = wal_path(&dir);
let p = make_file(&dir, "drift.txt", "test");
execute_with_telemetry(
&FileRead,
&json!({ "path": p.to_str().unwrap() }),
false,
&wp,
)
.unwrap();
let reader = WalReader::load(&wp).unwrap();
let events = reader.events();
assert!(!events.is_empty());
let event = &events[0];
let serialized = serde_json::to_string(event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert!(parsed.get("seq").is_some());
assert!(parsed.get("ts").is_some());
assert!(parsed.get("type").is_some());
assert!(parsed.get("job_id").is_some());
}
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn prop_write_read_roundtrip(content in ".*") {
let dir = setup();
let target = dir.join("prop.txt");
let write_result = FileWrite::new(backup_dir(&dir))
.expect("Failed to create FileWrite")
.execute(
&json!({ "path": target.to_str().unwrap(), "content": &content }),
&ctx("prop_write"),
);
if write_result.is_ok() {
let read_result = FileRead
.execute(&json!({ "path": target.to_str().unwrap() }), &ctx("prop_read"));
if read_result.is_ok() {
let r = read_result.unwrap();
let read_content = r.data["content"].as_str().unwrap();
prop_assert_eq!(read_content, content, "Roundtrip failed");
}
}
cleanup(&dir);
}
}
proptest! {
#[test]
fn prop_backup_no_duplicates(n in 1usize..10) {
let dir = setup();
let bw = backup_dir(&dir);
let target = dir.join("prop_backup.txt");
let job_dir = bw.join("job_prop");
fs::write(&target, "original").unwrap();
FileWrite::new(bw.clone())
.unwrap()
.execute(
&json!({ "path": target.to_str().unwrap(), "content": "first" }),
&ctx("job_prop"),
)
.unwrap();
for i in 1..n {
FileWrite::new(bw.clone())
.unwrap()
.execute(
&json!({ "path": target.to_str().unwrap(), "content": format!("write {}", i) }),
&ctx("job_prop"),
)
.unwrap();
}
let mut backups: Vec<_> = std::fs::read_dir(&job_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
backups.sort();
let mut seen = std::collections::HashSet::new();
for name in &backups {
prop_assert!(seen.insert(name.clone()), "Duplicate backup: {}", name);
}
cleanup(&dir);
}
}
proptest! {
#[test]
fn prop_path_validation_consistent(filename in "[a-zA-Z0-9_-]{1,50}") {
use runtimo_core::validation::path::{validate_path, PathContext};
let ctx = PathContext {
require_exists: false,
require_file: false,
..Default::default()
};
let path1 = format!("/tmp/{}", filename);
let path2 = format!("/tmp/{}", filename);
let result1 = validate_path(&path1, &ctx);
let result2 = validate_path(&path2, &ctx);
prop_assert_eq!(result1.is_ok(), result2.is_ok(),
"Path validation not consistent for same path");
}
}
proptest! {
#[test]
fn prop_wal_cleanup_preserves_recent(n in 1usize..20) {
let dir = setup();
let wp = wal_path(&dir);
let p = make_file(&dir, "cleanup.txt", "test");
for _ in 0..n {
execute_with_telemetry(
&FileRead,
&json!({ "path": p.to_str().unwrap() }),
false,
&wp,
).unwrap();
}
let reader_before = WalReader::load(&wp).unwrap();
let count_before = reader_before.events().len();
let removed = WalWriter::cleanup(&wp, 86400 * 365).unwrap();
let reader_after = WalReader::load(&wp).unwrap();
let count_after = reader_after.events().len();
prop_assert_eq!(count_before, count_after,
"Cleanup with 1-year max_age should not remove events");
prop_assert_eq!(removed, 0, "Should remove 0 events");
cleanup(&dir);
}
}
}