163 lines
4.6 KiB
Rust
163 lines
4.6 KiB
Rust
|
|
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();
|
||
|
|
}
|
||
|
|
}
|