Skip to main content

thndrs_lib/core/acp/
fs.rs

1//! ACP filesystem callbacks.
2
3use std::path::{Path, PathBuf};
4
5use crate::app::ToolStatus;
6use crate::tools::{self, WriteOp, WriteResult, hash_content};
7
8/// Successful ACP read callback output.
9#[derive(Clone, Debug, Eq, PartialEq)]
10pub struct AcpReadResult {
11    /// File content returned to the ACP agent.
12    pub content: String,
13    /// Transcript lines describing the request.
14    pub output: Vec<String>,
15}
16
17/// Successful ACP write callback output.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct AcpWriteResult {
20    /// Transcript lines describing the request.
21    pub output: Vec<String>,
22    /// File-write audit metadata.
23    pub write_result: WriteResult,
24}
25
26/// Read a workspace-contained UTF-8 text file for an ACP agent.
27pub fn read_text_file(
28    path: &Path, root: &Path, line: Option<u32>, limit: Option<u32>,
29) -> Result<AcpReadResult, String> {
30    let resolved = resolve_existing_file(path, root)?;
31    let bytes = std::fs::read(&resolved).map_err(|err| format!("read failed: {err}"))?;
32    if bytes.len() > tools::MAX_OUTPUT_BYTES {
33        return Err(format!(
34            "read denied: file is {} bytes, exceeding {} byte cap",
35            bytes.len(),
36            tools::MAX_OUTPUT_BYTES
37        ));
38    }
39    let content = String::from_utf8(bytes).map_err(|_| "read denied: file is not valid UTF-8".to_string())?;
40    let content = select_lines(&content, line, limit);
41    let output = vec![format!("read {} ({} bytes)", resolved.display(), content.len())];
42    Ok(AcpReadResult { content, output })
43}
44
45/// Write a workspace-contained UTF-8 text file for an ACP agent.
46pub fn write_text_file(path: &Path, root: &Path, content: &str) -> Result<AcpWriteResult, String> {
47    let resolved = tools::resolve_workspace_path(root, path).map_err(|err| err.to_string())?;
48    if let Ok(metadata) = std::fs::metadata(&resolved)
49        && metadata.is_dir()
50    {
51        return Err(format!("write denied: {} is a directory", resolved.display()));
52    }
53
54    let before = match std::fs::read_to_string(&resolved) {
55        Ok(existing) => Some(existing),
56        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
57        Err(err) => return Err(format!("write denied: existing file is not readable UTF-8: {err}")),
58    };
59
60    if let Some(parent) = resolved.parent() {
61        std::fs::create_dir_all(parent).map_err(|err| format!("failed to create parent directories: {err}"))?;
62    }
63    std::fs::write(&resolved, content).map_err(|err| format!("write failed: {err}"))?;
64
65    let result = WriteResult {
66        op: if before.is_some() { WriteOp::Replace } else { WriteOp::Create },
67        path: resolved,
68        before_hash: before.as_ref().map(|text| hash_content(text)),
69        before_bytes: before.as_ref().map(|text| text.len()),
70        after_hash: hash_content(content),
71        after_bytes: content.len(),
72    };
73    Ok(AcpWriteResult { output: vec![result.summary()], write_result: result })
74}
75
76/// Build a failed tool event payload for ACP filesystem callbacks.
77pub fn failed_output(message: &str) -> (Vec<String>, ToolStatus) {
78    (vec![message.to_string()], ToolStatus::Failed)
79}
80
81fn resolve_existing_file(path: &Path, root: &Path) -> Result<PathBuf, String> {
82    let resolved = tools::resolve_workspace_path(root, path).map_err(|err| err.to_string())?;
83    let metadata = std::fs::metadata(&resolved).map_err(|err| format!("read denied: {err}"))?;
84    if metadata.is_dir() {
85        return Err(format!("read denied: {} is a directory", resolved.display()));
86    }
87    Ok(resolved)
88}
89
90fn select_lines(content: &str, line: Option<u32>, limit: Option<u32>) -> String {
91    let Some(line) = line else {
92        return content.to_string();
93    };
94    let start = line.max(1) as usize;
95    let limit = limit.unwrap_or(u32::MAX).max(1) as usize;
96    content
97        .lines()
98        .skip(start.saturating_sub(1))
99        .take(limit)
100        .collect::<Vec<_>>()
101        .join("\n")
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn read_text_file_ok() {
110        let dir = tempfile::tempdir().expect("temp dir");
111        std::fs::write(dir.path().join("a.txt"), "one\ntwo\nthree\n").expect("write");
112
113        let result = read_text_file(&dir.path().join("a.txt"), dir.path(), Some(2), Some(1)).expect("read");
114
115        assert_eq!(result.content, "two");
116    }
117
118    #[test]
119    fn write_text_file_ok() {
120        let dir = tempfile::tempdir().expect("temp dir");
121
122        let result = write_text_file(&dir.path().join("a.txt"), dir.path(), "hello").expect("write");
123
124        assert_eq!(
125            std::fs::read_to_string(dir.path().join("a.txt")).expect("read"),
126            "hello"
127        );
128        assert_eq!(result.write_result.op, WriteOp::Create);
129    }
130
131    #[test]
132    fn traversal_denied() {
133        let dir = tempfile::tempdir().expect("temp dir");
134        let outside = dir.path().parent().expect("parent").join("outside.txt");
135
136        let err = read_text_file(&outside, dir.path(), None, None).unwrap_err();
137
138        assert!(err.contains("escapes workspace root"));
139    }
140
141    #[cfg(unix)]
142    #[test]
143    fn symlink_escape_denied() {
144        let dir = tempfile::tempdir().expect("temp dir");
145        let outside = tempfile::tempdir().expect("outside");
146        std::fs::write(outside.path().join("secret.txt"), "secret").expect("write");
147        std::os::unix::fs::symlink(outside.path().join("secret.txt"), dir.path().join("link.txt")).expect("symlink");
148
149        let err = read_text_file(&dir.path().join("link.txt"), dir.path(), None, None).unwrap_err();
150
151        assert!(err.contains("escapes workspace root"));
152    }
153
154    #[test]
155    fn oversized_read_denied() {
156        let dir = tempfile::tempdir().expect("temp dir");
157        std::fs::write(dir.path().join("big.txt"), "x".repeat(tools::MAX_OUTPUT_BYTES + 1)).expect("write");
158
159        let err = read_text_file(&dir.path().join("big.txt"), dir.path(), None, None).unwrap_err();
160
161        assert!(err.contains("exceeding"));
162    }
163
164    #[test]
165    fn non_utf8_read_denied() {
166        let dir = tempfile::tempdir().expect("temp dir");
167        std::fs::write(dir.path().join("bin.dat"), [0xff, 0xfe]).expect("write");
168
169        let err = read_text_file(&dir.path().join("bin.dat"), dir.path(), None, None).unwrap_err();
170
171        assert!(err.contains("UTF-8"));
172    }
173}