1 //! Reading and writing files through the ACP client instead of the disk.
2 //!
3 //! An editor that advertises `fs.readTextFile` / `fs.writeTextFile` in its
4 //! `initialize` request will serve `fs/read_text_file` and `fs/write_text_file`
5 //! from its own buffers. Going through it is what lets `read_file` see changes
6 //! the user has not saved yet, and lets `edit_file` land in the open buffer
7 //! rather than underneath it, where the editor would have to reconcile the
8 //! file on disk with a buffer the user may have modified.
9 //!
10 //! The tool code in `tools.rs` has no access to the connection, so this module
11 //! is the seam, the same shape as the one `mcp::call_tool` gives MCP: `main.rs`
12 //! registers a [`ClientFileSystem`] once the client has said what it supports,
13 //! and the file tools ask [`route_for`] whether a path should go through it.
14 //!
15 //! The disk stays the fallback everywhere. Nothing is registered in the TUI or
16 //! headless modes, a path outside the session's roots is not the editor's to
17 //! serve, and a request the client fails or never answers is retried on disk,
18 //! so a client with a partial or broken implementation costs a log line rather
19 //! than the tool call.
20
21 use std::path::{Path, PathBuf};
22 use std::sync::{Arc, RwLock};
23 use std::time::Duration;
24
25 use async_trait::async_trait;
26
27 /// How long the client gets to answer one file request before the disk is used
28 /// instead. A buffer read or write is local work for an editor; a client that
29 /// takes longer than this has most likely dropped the request.
30 const CLIENT_FS_TIMEOUT: Duration = Duration::from_secs(30);
31
32 /// The client's side of ACP's file-system methods. Errors are plain strings:
33 /// they only ever reach the log, since the caller falls back to the disk.
34 #[async_trait]
35 pub trait ClientFileSystem: Send + Sync {
36 async fn read_text_file(&self, session_id: &str, path: &Path) -> Result<String, String>;
37 async fn write_text_file(
38 &self,
39 session_id: &str,
40 path: &Path,
41 content: &str,
42 ) -> Result<(), String>;
43 }
44
45 #[derive(Clone)]
46 struct Registered {
47 fs: Arc<dyn ClientFileSystem>,
48 read: bool,
49 write: bool,
50 }
51
52 /// The connected client's file system, with the two capabilities it
53 /// advertised. Capabilities are fixed for the connection, and the process
54 /// serves one connection, so this is set once from `initialize`.
55 static CLIENT: RwLock<Option<Registered>> = RwLock::new(None);
56
57 /// Record what the client offered in `clientCapabilities.fs`. A client that
58 /// offered neither method leaves the disk as the only path.
59 pub fn register(fs: Arc<dyn ClientFileSystem>, read: bool, write: bool) {
60 let registered = (read || write).then_some(Registered { fs, read, write });
61 if let Ok(mut guard) = CLIENT.write() {
62 *guard = registered;
63 }
64 }
65
66 /// `SIGIT_CLIENT_FS=off` keeps every file tool on the disk, for a client whose
67 /// file-system methods misbehave in a way the fallback cannot see.
68 pub fn disabled_by_env() -> bool {
69 std::env::var("SIGIT_CLIENT_FS")
70 .map(|value| {
71 matches!(
72 value.trim().to_ascii_lowercase().as_str(),
73 "off" | "0" | "false"
74 )
75 })
76 .unwrap_or(false)
77 }
78
79 /// One file's way through the client, for the session that asked.
80 pub struct Route {
81 fs: Arc<dyn ClientFileSystem>,
82 session_id: String,
83 read: bool,
84 write: bool,
85 }
86
87 /// The client route for `path`, or `None` when the tool should use the disk:
88 /// no client file system is registered, no session is live, or the path is
89 /// outside the session's roots. `path` must be absolute, which is also what
90 /// the protocol requires of the paths it carries.
91 pub fn route_for(session_id: Option<&str>, path: &Path) -> Option<Route> {
92 let registered = CLIENT.read().ok()?.clone()?;
93 let session_id = session_id?;
94 if !path.is_absolute() || !in_roots(path, &crate::workspace::project_dirs()) {
95 return None;
96 }
97 Some(Route {
98 fs: registered.fs,
99 session_id: session_id.to_string(),
100 read: registered.read,
101 write: registered.write,
102 })
103 }
104
105 impl Route {
106 /// The file's text: the client's copy when it can read, the disk's when it
107 /// cannot or the request failed.
108 pub async fn read(&self, path: &Path) -> std::io::Result<String> {
109 if self.read {
110 let request = self.fs.read_text_file(&self.session_id, path);
111 match tokio::time::timeout(CLIENT_FS_TIMEOUT, request).await {
112 Ok(Ok(content)) => return Ok(content),
113 Ok(Err(error)) => log::warn!(
114 "fs/read_text_file failed for {}: {error}; reading from disk",
115 path.display()
116 ),
117 Err(_) => log::warn!(
118 "fs/read_text_file timed out for {}; reading from disk",
119 path.display()
120 ),
121 }
122 }
123 std::fs::read_to_string(path)
124 }
125
126 /// Write the file's text through the client when it can write, and to the
127 /// disk when it cannot or the request failed.
128 pub async fn write(&self, path: &Path, content: &str) -> std::io::Result<()> {
129 if self.write {
130 let request = self.fs.write_text_file(&self.session_id, path, content);
131 match tokio::time::timeout(CLIENT_FS_TIMEOUT, request).await {
132 Ok(Ok(())) => return Ok(()),
133 Ok(Err(error)) => log::warn!(
134 "fs/write_text_file failed for {}: {error}; writing to disk",
135 path.display()
136 ),
137 Err(_) => log::warn!(
138 "fs/write_text_file timed out for {}; writing to disk",
139 path.display()
140 ),
141 }
142 }
143 std::fs::write(path, content)
144 }
145 }
146
147 /// Whether `path` sits inside one of `roots`.
148 ///
149 /// Both sides are resolved first, so a root reached through a symlink (macOS
150 /// spells `/tmp` as `/private/tmp`) still contains its files, and a `..` that
151 /// climbs out of a root does not pass as being inside it. `client_terminal`
152 /// uses the same test for a command's directory.
153 pub(crate) fn in_roots(path: &Path, roots: &[PathBuf]) -> bool {
154 let path = resolve(path);
155 roots
156 .iter()
157 .any(|root| path.starts_with(crate::workspace::canonical_key(root)))
158 }
159
160 /// `path` with its deepest existing ancestor canonicalized. A file that is
161 /// about to be created cannot be canonicalized itself, but its directory can.
162 fn resolve(path: &Path) -> PathBuf {
163 let normalized = crate::workspace::normalize(path);
164 let mut missing: Vec<&std::ffi::OsStr> = Vec::new();
165 let mut existing = normalized.as_path();
166 loop {
167 if let Ok(canonical) = existing.canonicalize() {
168 return missing
169 .iter()
170 .rev()
171 .fold(canonical, |joined, part| joined.join(part));
172 }
173 match (existing.parent(), existing.file_name()) {
174 (Some(parent), Some(name)) => {
175 missing.push(name);
176 existing = parent;
177 }
178 _ => return normalized,
179 }
180 }
181 }
182
183 #[cfg(test)]
184 mod tests {
185 use super::*;
186
187 /// A client holding one buffer's text, which can be told to fail.
188 struct FakeClient {
189 buffer: &'static str,
190 fail: bool,
191 written: std::sync::Mutex<Vec<(String, PathBuf, String)>>,
192 }
193
194 impl FakeClient {
195 fn new(buffer: &'static str, fail: bool) -> Arc<Self> {
196 Arc::new(Self {
197 buffer,
198 fail,
199 written: std::sync::Mutex::default(),
200 })
201 }
202 }
203
204 #[async_trait]
205 impl ClientFileSystem for FakeClient {
206 async fn read_text_file(&self, _session_id: &str, _path: &Path) -> Result<String, String> {
207 if self.fail {
208 return Err("no such buffer".to_string());
209 }
210 Ok(self.buffer.to_string())
211 }
212
213 async fn write_text_file(
214 &self,
215 session_id: &str,
216 path: &Path,
217 content: &str,
218 ) -> Result<(), String> {
219 if self.fail {
220 return Err("read-only buffer".to_string());
221 }
222 self.written.lock().unwrap().push((
223 session_id.to_string(),
224 path.to_path_buf(),
225 content.to_string(),
226 ));
227 Ok(())
228 }
229 }
230
231 fn route(client: &Arc<FakeClient>, read: bool, write: bool) -> Route {
232 Route {
233 fs: Arc::clone(client) as Arc<dyn ClientFileSystem>,
234 session_id: "session-1".to_string(),
235 read,
236 write,
237 }
238 }
239
240 #[tokio::test]
241 async fn reads_and_writes_go_to_the_client_when_it_offers_them() {
242 let dir = scratch("client");
243 let file = dir.join("a.txt");
244 std::fs::write(&file, "on disk").unwrap();
245 let client = FakeClient::new("in the buffer", false);
246 let route = route(&client, true, true);
247
248 assert_eq!(route.read(&file).await.unwrap(), "in the buffer");
249 route.write(&file, "edited").await.unwrap();
250
251 // The client owns the write, so the disk is left for it to update.
252 assert_eq!(std::fs::read_to_string(&file).unwrap(), "on disk");
253 assert_eq!(
254 *client.written.lock().unwrap(),
255 vec![("session-1".to_string(), file.clone(), "edited".to_string())]
256 );
257
258 std::fs::remove_dir_all(&dir).ok();
259 }
260
261 #[tokio::test]
262 async fn a_capability_the_client_did_not_offer_stays_on_disk() {
263 let dir = scratch("partial");
264 let file = dir.join("a.txt");
265 std::fs::write(&file, "on disk").unwrap();
266 let client = FakeClient::new("in the buffer", false);
267 let route = route(&client, false, false);
268
269 assert_eq!(route.read(&file).await.unwrap(), "on disk");
270 route.write(&file, "edited").await.unwrap();
271
272 assert_eq!(std::fs::read_to_string(&file).unwrap(), "edited");
273 assert!(client.written.lock().unwrap().is_empty());
274
275 std::fs::remove_dir_all(&dir).ok();
276 }
277
278 #[tokio::test]
279 async fn a_request_the_client_fails_falls_back_to_disk() {
280 let dir = scratch("failing");
281 let file = dir.join("a.txt");
282 std::fs::write(&file, "on disk").unwrap();
283 let client = FakeClient::new("in the buffer", true);
284 let route = route(&client, true, true);
285
286 assert_eq!(route.read(&file).await.unwrap(), "on disk");
287 route.write(&file, "edited").await.unwrap();
288
289 assert_eq!(std::fs::read_to_string(&file).unwrap(), "edited");
290
291 std::fs::remove_dir_all(&dir).ok();
292 }
293
294 fn scratch(name: &str) -> PathBuf {
295 let dir =
296 std::env::temp_dir().join(format!("sigit-client-fs-{name}-{}", uuid::Uuid::new_v4()));
297 std::fs::create_dir_all(&dir).unwrap();
298 dir
299 }
300
301 #[test]
302 fn a_file_inside_a_root_is_in_roots() {
303 let root = scratch("inside");
304 std::fs::write(root.join("a.txt"), "a").unwrap();
305
306 assert!(in_roots(&root.join("a.txt"), std::slice::from_ref(&root)));
307 // Not created yet, so only its directory can be resolved.
308 assert!(in_roots(
309 &root.join("new").join("b.txt"),
310 std::slice::from_ref(&root)
311 ));
312
313 std::fs::remove_dir_all(&root).ok();
314 }
315
316 #[test]
317 fn a_file_outside_every_root_is_not() {
318 let root = scratch("root");
319 let other = scratch("other");
320 std::fs::write(other.join("a.txt"), "a").unwrap();
321
322 assert!(!in_roots(&other.join("a.txt"), std::slice::from_ref(&root)));
323 // `..` out of the root names the sibling, however the path starts.
324 let climbing = root
325 .join("..")
326 .join(other.file_name().unwrap())
327 .join("a.txt");
328 assert!(!in_roots(&climbing, std::slice::from_ref(&root)));
329 assert!(!in_roots(&root.join("a.txt"), &[]));
330
331 std::fs::remove_dir_all(&root).ok();
332 std::fs::remove_dir_all(&other).ok();
333 }
334
335 #[cfg(unix)]
336 #[test]
337 fn a_symlink_out_of_the_root_is_not_inside_it() {
338 let root = scratch("link-root");
339 let outside = scratch("link-outside");
340 std::fs::write(outside.join("secret.txt"), "s").unwrap();
341 std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
342
343 assert!(!in_roots(
344 &root.join("link").join("secret.txt"),
345 std::slice::from_ref(&root)
346 ));
347
348 std::fs::remove_dir_all(&root).ok();
349 std::fs::remove_dir_all(&outside).ok();
350 }
351 }