thndrs_lib/core/acp/
fs.rs1use std::path::{Path, PathBuf};
4
5use crate::app::ToolStatus;
6use crate::tools::{self, WriteOp, WriteResult, hash_content};
7
8#[derive(Clone, Debug, Eq, PartialEq)]
10pub struct AcpReadResult {
11 pub content: String,
13 pub output: Vec<String>,
15}
16
17#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct AcpWriteResult {
20 pub output: Vec<String>,
22 pub write_result: WriteResult,
24}
25
26pub 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
45pub 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
76pub 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}