use crate::capability::{Capability, Context, Output};
use crate::validation::path::{validate_path, PathContext};
use crate::{Error, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::io::Read;
const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
const DEFAULT_MAX_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileReadArgs {
pub path: String,
pub max_bytes: Option<u64>,
}
pub struct FileRead;
impl Capability for FileRead {
fn name(&self) -> &'static str {
"FileRead"
}
fn description(&self) -> &'static str {
"Read the contents of a file. Validates path existence, rejects directories and path traversal."
}
fn schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string" },
"max_bytes": { "type": "integer", "minimum": 1, "maximum": 10485760 }
},
"required": ["path"]
})
}
fn validate(&self, args: &Value) -> Result<()> {
let args: FileReadArgs = serde_json::from_value(args.clone())
.map_err(|e| Error::SchemaValidationFailed(e.to_string()))?;
let ctx = PathContext {
require_exists: true,
require_file: true,
..Default::default()
};
validate_path(&args.path, &ctx).map_err(Error::SchemaValidationFailed)?;
Ok(())
}
fn execute(&self, args: &Value, _ctx: &Context) -> Result<Output> {
let args: FileReadArgs = serde_json::from_value(args.clone())
.map_err(|e| Error::ExecutionFailed(e.to_string()))?;
let ctx = PathContext {
require_exists: true,
require_file: true,
..Default::default()
};
let path = validate_path(&args.path, &ctx)
.map_err(|e| Error::ExecutionFailed(format!("path validation: {}", e)))?;
let max_bytes = args.max_bytes.unwrap_or(DEFAULT_MAX_BYTES);
if max_bytes > MAX_FILE_SIZE {
return Err(Error::ExecutionFailed(format!(
"max_bytes {} exceeds maximum allowed {}",
max_bytes, MAX_FILE_SIZE
)));
}
let file = open_file_nofollow(&path)
.map_err(|e| Error::ExecutionFailed(format!("open {}: {}", path.display(), e)))?;
let mut limited = file.take(max_bytes);
let mut raw_bytes = Vec::with_capacity(
std::cmp::min(max_bytes as usize, 64 * 1024),
);
let bytes_read = limited
.read_to_end(&mut raw_bytes)
.map_err(|e| Error::ExecutionFailed(format!("read {}: {}", path.display(), e)))?;
let bytes_read = bytes_read as u64;
let truncated = bytes_read >= max_bytes;
let is_binary = detect_binary(&raw_bytes);
let data = if is_binary {
serde_json::json!({
"content_type": "binary",
"path": path.display().to_string(),
"bytes_read": bytes_read,
"truncated": truncated,
"message": "Binary file detected — content not returned as text",
})
} else {
let content = bytes_to_utf8_string(&raw_bytes);
if path.extension().is_some_and(|ext| ext == "json") {
match serde_json::from_slice::<Value>(raw_bytes.as_slice()) {
Ok(parsed) => serde_json::json!({
"content": parsed,
"content_type": "json",
"path": path.display().to_string(),
"bytes_read": bytes_read,
"truncated": truncated,
}),
Err(_) => serde_json::json!({
"content": content,
"content_type": "text",
"path": path.display().to_string(),
"bytes_read": bytes_read,
"truncated": truncated,
}),
}
} else {
serde_json::json!({
"content": content,
"content_type": "text",
"path": path.display().to_string(),
"bytes_read": bytes_read,
"truncated": truncated,
})
}
};
Ok(Output {
success: true,
data,
message: Some(format!(
"Read {} bytes from {}{}",
bytes_read,
path.display(),
if truncated { " (truncated)" } else { "" }
)),
})
}
}
#[cfg(unix)]
fn open_file_nofollow(path: &std::path::Path) -> std::io::Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
}
#[cfg(not(unix))]
fn open_file_nofollow(path: &std::path::Path) -> std::io::Result<std::fs::File> {
std::fs::File::open(path)
}
fn detect_binary(data: &[u8]) -> bool {
data.contains(&0)
}
fn bytes_to_utf8_string(bytes: &[u8]) -> String {
match String::from_utf8(bytes.to_vec()) {
Ok(s) => s,
Err(e) => {
let valid_up_to = e.utf8_error().valid_up_to();
String::from_utf8(bytes[..valid_up_to].to_vec())
.unwrap_or_else(|_| String::new())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn reads_existing_file() {
let mut tmp = std::env::temp_dir();
tmp.push("runtimo_test_read.txt");
{
let mut f = std::fs::File::create(&tmp).unwrap();
writeln!(f, "hello world").unwrap();
}
let result = FileRead
.execute(
&serde_json::json!({ "path": tmp.to_str().unwrap() }),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap();
assert!(result.success);
assert!(result.data["content"]
.as_str()
.unwrap()
.contains("hello world"));
std::fs::remove_file(&tmp).ok();
}
#[test]
fn rejects_missing_file() {
let err = FileRead
.validate(&serde_json::json!({
"path": "/tmp/nonexistent_runtimo_test.txt"
}))
.unwrap_err();
assert!(err.to_string().contains("does not exist"));
}
#[test]
fn rejects_empty_path() {
assert!(FileRead
.validate(&serde_json::json!({ "path": "" }))
.is_err());
}
#[test]
fn test_max_bytes_limits_output() {
let mut tmp = std::env::temp_dir();
tmp.push("runtimo_test_max_bytes.txt");
{
let mut f = std::fs::File::create(&tmp).unwrap();
for _ in 0..100 {
writeln!(f, "hello world line").unwrap();
}
}
let result = FileRead
.execute(
&serde_json::json!({ "path": tmp.to_str().unwrap(), "max_bytes": 50 }),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap();
assert!(result.success);
assert!(result.data["truncated"].as_bool() == Some(true));
assert!(result.data["bytes_read"].as_u64().unwrap() <= 50);
std::fs::remove_file(&tmp).ok();
}
#[test]
fn test_max_bytes_rejects_exceeding_limit() {
let result = FileRead.execute(
&serde_json::json!({ "path": "/etc/hosts", "max_bytes": 9999999999u64 }),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
);
assert!(result.is_err());
}
#[test]
fn test_file_read_default_max_bytes() {
let mut tmp = std::env::temp_dir();
tmp.push("runtimo_test_default_max.txt");
std::fs::write(&tmp, "small content").unwrap();
let result = FileRead
.execute(
&serde_json::json!({ "path": tmp.to_str().unwrap() }),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap();
assert!(result.success);
assert!(result.data["truncated"].as_bool() == Some(false));
std::fs::remove_file(&tmp).ok();
}
#[test]
fn test_file_read_json_parsed_for_agents() {
let mut tmp = std::env::temp_dir();
tmp.push("runtimo_test_agent.json");
std::fs::write(&tmp, r#"{"key": "value", "nested": {"a": 1}}"#).unwrap();
let result = FileRead
.execute(
&serde_json::json!({ "path": tmp.to_str().unwrap() }),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap();
assert!(result.success);
assert!(result.data["content"].is_object());
assert_eq!(result.data["content"]["key"].as_str(), Some("value"));
assert_eq!(result.data["content"]["nested"]["a"].as_u64(), Some(1));
assert_eq!(result.data["content_type"].as_str(), Some("json"));
std::fs::remove_file(&tmp).ok();
}
#[test]
fn test_binary_file_detected() {
let mut tmp = std::env::temp_dir();
tmp.push("runtimo_test_binary.bin");
std::fs::write(&tmp, b"hello\x00world").unwrap();
let result = FileRead
.execute(
&serde_json::json!({ "path": tmp.to_str().unwrap() }),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap();
assert!(result.success);
assert_eq!(result.data["content_type"].as_str(), Some("binary"));
assert_eq!(result.data["bytes_read"].as_u64(), Some(11));
std::fs::remove_file(&tmp).ok();
}
#[test]
fn test_utf8_boundary_truncation() {
let mut tmp = std::env::temp_dir();
tmp.push("runtimo_test_utf8.txt");
std::fs::write(&tmp, b"caf\xc3\xa9").unwrap();
let result = FileRead
.execute(
&serde_json::json!({ "path": tmp.to_str().unwrap(), "max_bytes": 4 }),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap();
assert!(result.success);
let content = result.data["content"].as_str().unwrap();
assert_eq!(content, "caf");
std::fs::remove_file(&tmp).ok();
}
#[test]
fn test_bytes_read_reports_raw_bytes() {
let mut tmp = std::env::temp_dir();
tmp.push("runtimo_test_bytes_read.txt");
std::fs::write(&tmp, "café\n").unwrap();
let result = FileRead
.execute(
&serde_json::json!({ "path": tmp.to_str().unwrap() }),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
)
.unwrap();
assert!(result.success);
assert_eq!(result.data["bytes_read"].as_u64(), Some(6));
std::fs::remove_file(&tmp).ok();
}
#[test]
fn test_symlink_rejected_by_nofollow() {
let link_path = std::env::temp_dir().join("runtimo_nofollow_test");
let _ = std::fs::remove_file(&link_path);
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
if symlink("/etc/hostname", &link_path).is_ok() {
let result = FileRead.execute(
&serde_json::json!({ "path": link_path.to_str().unwrap() }),
&Context {
dry_run: false,
job_id: "test".into(),
working_dir: std::env::temp_dir(),
},
);
assert!(result.is_err(), "symlink should be rejected by O_NOFOLLOW");
std::fs::remove_file(&link_path).ok();
}
}
}
}