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;
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 metadata = path
.metadata()
.map_err(|e| Error::ExecutionFailed(format!("stat {}: {}", path.display(), e)))?;
if metadata.len() > MAX_FILE_SIZE {
return Err(Error::ExecutionFailed(format!(
"File too large: {} bytes (limit: {} bytes)",
metadata.len(),
MAX_FILE_SIZE
)));
}
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 content = if metadata.len() <= max_bytes {
std::fs::read_to_string(&path)
.map_err(|e| Error::ExecutionFailed(format!("read {}: {}", path.display(), e)))?
} else {
let file = std::fs::File::open(&path)
.map_err(|e| Error::ExecutionFailed(format!("open {}: {}", path.display(), e)))?;
use std::io::Read;
let mut buf = String::new();
let mut handle = file.take(max_bytes);
handle
.read_to_string(&mut buf)
.map_err(|e| Error::ExecutionFailed(format!("read {}: {}", path.display(), e)))?;
buf
};
let truncated = metadata.len() > max_bytes;
Ok(Output {
success: true,
data: serde_json::json!({
"content": content,
"path": path.display().to_string(),
"bytes_read": content.len(),
"file_size": metadata.len(),
"truncated": truncated,
}),
message: Some(format!(
"Read {} bytes from {}{}",
content.len(),
path.display(),
if truncated { " (truncated)" } else { "" }
)),
})
}
}
#[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();
}
}