@@ -0,0 +1,162 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user