use crate::backup::BackupManager;
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::path::PathBuf;
const MAX_WRITE_SIZE: usize = 100 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileWriteArgs {
pub path: String,
pub content: String,
#[serde(default)]
pub append: bool,
}
pub struct FileWrite {
backup_mgr: BackupManager,
}
impl FileWrite {
pub fn new(backup_dir: PathBuf) -> Result<Self> {
Ok(Self {
backup_mgr: BackupManager::new(backup_dir)?,
})
}
}
impl Capability for FileWrite {
fn name(&self) -> &'static str {
"FileWrite"
}
fn schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string" },
"content": { "type": "string" },
"append": { "type": "boolean" }
},
"required": ["path", "content"]
})
}
fn validate(&self, args: &Value) -> Result<()> {
let args: FileWriteArgs = serde_json::from_value(args.clone())
.map_err(|e| Error::SchemaValidationFailed(e.to_string()))?;
let ctx = PathContext {
require_exists: false,
require_file: false,
..Default::default()
};
validate_path(&args.path, &ctx).map_err(Error::SchemaValidationFailed)?;
Ok(())
}
fn execute(&self, args: &Value, ctx: &Context) -> Result<Output> {
let args: FileWriteArgs = serde_json::from_value(args.clone())
.map_err(|e| Error::ExecutionFailed(e.to_string()))?;
if args.content.len() > MAX_WRITE_SIZE {
return Err(Error::ExecutionFailed(format!(
"Content too large: {} bytes (limit: {} bytes)",
args.content.len(),
MAX_WRITE_SIZE
)));
}
let write_ctx = PathContext {
require_exists: false,
require_file: false,
..Default::default()
};
let path = validate_path(&args.path, &write_ctx)
.map_err(|e| Error::ExecutionFailed(format!("path validation: {}", e)))?;
let backup_path = if path.exists() {
match self.backup_mgr.create_backup(&path, &ctx.job_id) {
Ok(bp) => Some(bp),
Err(e) => return Err(Error::ExecutionFailed(format!("backup: {}", e))),
}
} else {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
Error::ExecutionFailed(format!("mkdir {}: {}", parent.display(), e))
})?;
}
None
};
if ctx.dry_run {
return Ok(Output {
success: true,
data: serde_json::json!({
"path": path.display().to_string(),
"content_length": args.content.len(),
"dry_run": true,
"backup_path": backup_path.map(|p| p.to_string_lossy().to_string()),
}),
message: Some(format!(
"DRY RUN: would write {} bytes to {}",
args.content.len(),
path.display()
)),
});
}
if args.append {
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| Error::ExecutionFailed(format!("open {}: {}", path.display(), e)))?;
file.write_all(args.content.as_bytes())
.map_err(|e| Error::ExecutionFailed(format!("write {}: {}", path.display(), e)))?;
} else {
std::fs::write(&path, &args.content)
.map_err(|e| Error::ExecutionFailed(format!("write {}: {}", path.display(), e)))?;
}
Ok(Output {
success: true,
data: serde_json::json!({
"path": path.display().to_string(),
"bytes_written": args.content.len(),
"append": args.append,
"backup_path": backup_path.map(|p| p.to_string_lossy().to_string()),
}),
message: Some(format!(
"Wrote {} bytes to {}",
args.content.len(),
path.display()
)),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_backup_dir() -> PathBuf {
std::env::temp_dir().join("runtimo_fw_test")
}
#[test]
fn writes_new_file() {
let target = std::env::temp_dir().join("runtimo_fw_new.txt");
let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
let result = cap
.execute(
&serde_json::json!({
"path": target.to_str().unwrap(),
"content": "hello from runtimo"
}),
&Context {
dry_run: false,
job_id: "t1".into(),
working_dir: std::env::temp_dir(),
},
)
.expect("Execution failed");
assert!(result.success);
assert_eq!(
std::fs::read_to_string(&target).unwrap(),
"hello from runtimo"
);
std::fs::remove_file(&target).ok();
std::fs::remove_dir_all(test_backup_dir()).ok();
}
#[test]
fn dry_run_does_not_write() {
let target = std::env::temp_dir().join("runtimo_fw_dry.txt");
let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
cap.execute(
&serde_json::json!({
"path": target.to_str().unwrap(),
"content": "should not exist"
}),
&Context {
dry_run: true,
job_id: "t2".into(),
working_dir: std::env::temp_dir(),
},
)
.expect("Execution failed");
assert!(!target.exists());
std::fs::remove_dir_all(test_backup_dir()).ok();
}
#[test]
fn rejects_path_traversal() {
let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
let err = cap
.validate(&serde_json::json!({
"path": "../../../etc/passwd",
"content": "malicious"
}))
.unwrap_err();
assert!(err.to_string().contains("traversal"));
std::fs::remove_dir_all(test_backup_dir()).ok();
}
}