Files
mp-ai-module-controller/src/scripts/file_ops.rs
T

163 lines
4.6 KiB
Rust
Raw Normal View History

2026-07-21 11:38:11 -05:00
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FilePayload {
pub filename: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WriteFilePayload {
pub filename: String,
pub content: String,
}
pub fn run_write_note(payload: WriteFilePayload) -> Result<()> {
let path = write_note(payload)?;
print_json(
"write_note",
serde_json::json!({ "path": path.display().to_string() }),
)
}
pub fn run_append_to_file(payload: WriteFilePayload) -> Result<()> {
let path = append_to_file(payload)?;
print_json(
"append_to_file",
serde_json::json!({ "path": path.display().to_string() }),
)
}
pub fn run_read_file(payload: FilePayload) -> Result<()> {
let contents = read_file(payload)?;
print_json("read_file", serde_json::json!({ "content": contents }))
}
pub fn run_delete_file(payload: FilePayload) -> Result<()> {
let path = delete_file(payload)?;
print_json(
"delete_file",
serde_json::json!({ "path": path.display().to_string() }),
)
}
pub fn write_note(payload: WriteFilePayload) -> Result<PathBuf> {
let path = safe_notes_path(&payload.filename)?;
ensure_parent(&path)?;
fs::write(&path, payload.content).with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
pub fn append_to_file(payload: WriteFilePayload) -> Result<PathBuf> {
let path = safe_notes_path(&payload.filename)?;
ensure_parent(&path)?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("opening {}", path.display()))?;
file.write_all(payload.content.as_bytes())?;
Ok(path)
}
pub fn read_file(payload: FilePayload) -> Result<String> {
let path = safe_notes_path(&payload.filename)?;
fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))
}
pub fn delete_file(payload: FilePayload) -> Result<PathBuf> {
let path = safe_notes_path(&payload.filename)?;
fs::remove_file(&path).with_context(|| format!("deleting {}", path.display()))?;
Ok(path)
}
fn safe_notes_path(filename: &str) -> Result<PathBuf> {
let trimmed = filename.trim();
if trimmed.is_empty() {
bail!("filename must not be empty");
}
let relative = Path::new(trimmed);
if relative.is_absolute() {
bail!("filename must be relative to notes/");
}
for component in relative.components() {
match component {
Component::Normal(_) => {}
_ => bail!("filename must not contain path traversal or special components"),
}
}
Ok(PathBuf::from("notes").join(relative))
}
fn ensure_parent(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
Ok(())
}
fn print_json(action_id: &str, payload: serde_json::Value) -> Result<()> {
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"action_id": action_id,
"output": payload
}))?
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_unsafe_paths() {
assert!(safe_notes_path("../secret.txt").is_err());
assert!(safe_notes_path("/tmp/secret.txt").is_err());
assert!(safe_notes_path("").is_err());
assert_eq!(
safe_notes_path("daily/note.txt").unwrap(),
PathBuf::from("notes/daily/note.txt")
);
}
#[test]
fn writes_appends_reads_and_deletes_note() {
let temp = tempfile::tempdir().unwrap();
let previous = std::env::current_dir().unwrap();
std::env::set_current_dir(temp.path()).unwrap();
write_note(WriteFilePayload {
filename: "test.txt".to_string(),
content: "hello".to_string(),
})
.unwrap();
append_to_file(WriteFilePayload {
filename: "test.txt".to_string(),
content: " world".to_string(),
})
.unwrap();
assert_eq!(
read_file(FilePayload {
filename: "test.txt".to_string()
})
.unwrap(),
"hello world"
);
delete_file(FilePayload {
filename: "test.txt".to_string(),
})
.unwrap();
assert!(!Path::new("notes/test.txt").exists());
std::env::set_current_dir(previous).unwrap();
}
}