Skip to main content

fresh/services/remote/
filesystem.rs

1//! Remote filesystem implementation
2//!
3//! Implements the FileSystem trait for remote operations via SSH agent.
4
5use crate::model::filesystem::{
6    DirEntry, EntryType, FileMetadata, FilePermissions, FileReader, FileSystem, FileWriter, WriteOp,
7};
8use crate::services::remote::channel::{AgentChannel, ChannelError};
9use crate::services::remote::protocol::{
10    append_params, decode_base64, ls_params, patch_params, read_params, stat_params,
11    sudo_write_params, truncate_params, write_params, PatchOp, RemoteDirEntry, RemoteMetadata,
12};
13use std::io::{self, Cursor, Read, Seek, Write};
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::{Duration, UNIX_EPOCH};
17
18/// Remote filesystem that communicates with the Python agent
19pub struct RemoteFileSystem {
20    channel: Arc<AgentChannel>,
21    /// Display string for the connection
22    connection_string: String,
23}
24
25impl RemoteFileSystem {
26    /// Create a new remote filesystem from an agent channel
27    pub fn new(channel: Arc<AgentChannel>, connection_string: String) -> Self {
28        Self {
29            channel,
30            connection_string,
31        }
32    }
33
34    /// Get the connection string for display
35    pub fn connection_string(&self) -> &str {
36        &self.connection_string
37    }
38
39    /// Check if connected
40    pub fn is_connected(&self) -> bool {
41        self.channel.is_connected()
42    }
43
44    /// Convert a ChannelError to io::Error
45    fn to_io_error(e: ChannelError) -> io::Error {
46        match e {
47            ChannelError::Io(e) => e,
48            ChannelError::Remote(msg) => {
49                let kind = if msg.contains("not found") || msg.contains("No such file") {
50                    io::ErrorKind::NotFound
51                } else if msg.contains("permission denied") {
52                    io::ErrorKind::PermissionDenied
53                } else if msg.contains("is a directory") {
54                    io::ErrorKind::IsADirectory
55                } else if msg.contains("not a directory") {
56                    io::ErrorKind::NotADirectory
57                } else {
58                    io::ErrorKind::Other
59                };
60                io::Error::new(kind, msg)
61            }
62            e => io::Error::new(io::ErrorKind::Other, e.to_string()),
63        }
64    }
65
66    /// Convert remote metadata to FileMetadata
67    fn convert_metadata(rm: &RemoteMetadata, name: &str) -> FileMetadata {
68        let modified = if rm.mtime > 0 {
69            Some(UNIX_EPOCH + Duration::from_secs(rm.mtime as u64))
70        } else {
71            None
72        };
73
74        let is_hidden = name.starts_with('.');
75        let permissions = FilePermissions::from_mode(rm.mode);
76        let is_readonly = permissions.is_readonly();
77
78        let mut meta = FileMetadata::new(rm.size)
79            .with_hidden(is_hidden)
80            .with_readonly(is_readonly)
81            .with_permissions(permissions);
82
83        if let Some(m) = modified {
84            meta = meta.with_modified(m);
85        }
86
87        #[cfg(unix)]
88        {
89            meta.uid = Some(rm.uid);
90            meta.gid = Some(rm.gid);
91        }
92
93        meta
94    }
95
96    /// Convert remote dir entry to DirEntry
97    fn convert_dir_entry(re: &RemoteDirEntry) -> DirEntry {
98        let entry_type = if re.link {
99            EntryType::Symlink
100        } else if re.dir {
101            EntryType::Directory
102        } else {
103            EntryType::File
104        };
105
106        let modified = if re.mtime > 0 {
107            Some(UNIX_EPOCH + Duration::from_secs(re.mtime as u64))
108        } else {
109            None
110        };
111
112        let is_hidden = re.name.starts_with('.');
113        let permissions = FilePermissions::from_mode(re.mode);
114        let is_readonly = permissions.is_readonly();
115
116        let mut metadata = FileMetadata::new(re.size)
117            .with_hidden(is_hidden)
118            .with_readonly(is_readonly)
119            .with_permissions(permissions);
120
121        if let Some(m) = modified {
122            metadata = metadata.with_modified(m);
123        }
124
125        let mut entry = DirEntry::new(PathBuf::from(&re.path), re.name.clone(), entry_type);
126        entry.metadata = Some(metadata);
127        entry.symlink_target_is_dir = re.link_dir;
128
129        entry
130    }
131}
132
133impl FileSystem for RemoteFileSystem {
134    fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
135        let path_str = path.to_string_lossy();
136        let (data_chunks, _result) = self
137            .channel
138            .request_with_data_blocking("read", read_params(&path_str, None, None))
139            .map_err(Self::to_io_error)?;
140
141        // Collect all streaming data chunks
142        let mut content = Vec::new();
143        for chunk in data_chunks {
144            if let Some(b64) = chunk.get("data").and_then(|v| v.as_str()) {
145                let decoded = decode_base64(b64)
146                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
147                content.extend(decoded);
148            }
149        }
150
151        Ok(content)
152    }
153
154    fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>> {
155        let path_str = path.to_string_lossy();
156        let (data_chunks, _result) = self
157            .channel
158            .request_with_data_blocking("read", read_params(&path_str, Some(offset), Some(len)))
159            .map_err(Self::to_io_error)?;
160
161        // Collect all streaming data chunks
162        let mut content = Vec::new();
163        for chunk in data_chunks {
164            if let Some(b64) = chunk.get("data").and_then(|v| v.as_str()) {
165                let decoded = decode_base64(b64)
166                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
167                content.extend(decoded);
168            }
169        }
170
171        Ok(content)
172    }
173
174    fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()> {
175        let path_str = path.to_string_lossy();
176        self.channel
177            .request_blocking("write", write_params(&path_str, data))
178            .map_err(Self::to_io_error)?;
179        Ok(())
180    }
181
182    fn create_file(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
183        // Create an empty file first
184        self.write_file(path, &[])?;
185        Ok(Box::new(RemoteFileWriter::new(
186            self.channel.clone(),
187            path.to_path_buf(),
188        )))
189    }
190
191    fn open_file(&self, path: &Path) -> io::Result<Box<dyn FileReader>> {
192        // Read the entire file into memory for seeking
193        let data = self.read_file(path)?;
194        Ok(Box::new(RemoteFileReader::new(data)))
195    }
196
197    fn open_file_for_write(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
198        Ok(Box::new(RemoteFileWriter::new(
199            self.channel.clone(),
200            path.to_path_buf(),
201        )))
202    }
203
204    fn open_file_for_append(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
205        // Use append-only writer that sends only new data
206        Ok(Box::new(AppendingRemoteFileWriter::new(
207            self.channel.clone(),
208            path.to_path_buf(),
209        )))
210    }
211
212    fn set_file_length(&self, path: &Path, len: u64) -> io::Result<()> {
213        let path_str = path.to_string_lossy();
214        self.channel
215            .request_blocking("truncate", truncate_params(&path_str, len))
216            .map_err(Self::to_io_error)?;
217        Ok(())
218    }
219
220    fn write_patched(&self, src_path: &Path, dst_path: &Path, ops: &[WriteOp]) -> io::Result<()> {
221        // Convert WriteOps to protocol PatchOps
222        let patch_ops: Vec<PatchOp> = ops
223            .iter()
224            .map(|op| match op {
225                WriteOp::Copy { offset, len } => PatchOp::copy(*offset, *len),
226                WriteOp::Insert { data } => PatchOp::insert(data),
227            })
228            .collect();
229
230        let src_str = src_path.to_string_lossy();
231        let dst_str = dst_path.to_string_lossy();
232        let dst_param = if src_path == dst_path {
233            None
234        } else {
235            Some(dst_str.as_ref())
236        };
237
238        self.channel
239            .request_blocking("patch", patch_params(&src_str, dst_param, &patch_ops))
240            .map_err(Self::to_io_error)?;
241        Ok(())
242    }
243
244    fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
245        let params = serde_json::json!({
246            "from": from.to_string_lossy(),
247            "to": to.to_string_lossy()
248        });
249        self.channel
250            .request_blocking("mv", params)
251            .map_err(Self::to_io_error)?;
252        Ok(())
253    }
254
255    fn copy(&self, from: &Path, to: &Path) -> io::Result<u64> {
256        let params = serde_json::json!({
257            "from": from.to_string_lossy(),
258            "to": to.to_string_lossy()
259        });
260        let result = self
261            .channel
262            .request_blocking("cp", params)
263            .map_err(Self::to_io_error)?;
264
265        Ok(result.get("size").and_then(|v| v.as_u64()).unwrap_or(0))
266    }
267
268    fn remove_file(&self, path: &Path) -> io::Result<()> {
269        let params = serde_json::json!({"path": path.to_string_lossy()});
270        self.channel
271            .request_blocking("rm", params)
272            .map_err(Self::to_io_error)?;
273        Ok(())
274    }
275
276    fn remove_dir(&self, path: &Path) -> io::Result<()> {
277        let params = serde_json::json!({"path": path.to_string_lossy()});
278        self.channel
279            .request_blocking("rmdir", params)
280            .map_err(Self::to_io_error)?;
281        Ok(())
282    }
283
284    fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
285        let path_str = path.to_string_lossy();
286        let result = self
287            .channel
288            .request_blocking("stat", stat_params(&path_str, true))
289            .map_err(Self::to_io_error)?;
290
291        let rm: RemoteMetadata = serde_json::from_value(result)
292            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
293
294        let name = path
295            .file_name()
296            .map(|n| n.to_string_lossy().to_string())
297            .unwrap_or_default();
298        Ok(Self::convert_metadata(&rm, &name))
299    }
300
301    fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
302        let path_str = path.to_string_lossy();
303        let result = self
304            .channel
305            .request_blocking("stat", stat_params(&path_str, false))
306            .map_err(Self::to_io_error)?;
307
308        let rm: RemoteMetadata = serde_json::from_value(result)
309            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
310
311        let name = path
312            .file_name()
313            .map(|n| n.to_string_lossy().to_string())
314            .unwrap_or_default();
315        Ok(Self::convert_metadata(&rm, &name))
316    }
317
318    fn is_dir(&self, path: &Path) -> io::Result<bool> {
319        let path_str = path.to_string_lossy();
320        let result = self
321            .channel
322            .request_blocking("stat", stat_params(&path_str, true))
323            .map_err(Self::to_io_error)?;
324
325        Ok(result.get("dir").and_then(|v| v.as_bool()).unwrap_or(false))
326    }
327
328    fn is_file(&self, path: &Path) -> io::Result<bool> {
329        let path_str = path.to_string_lossy();
330        let result = self
331            .channel
332            .request_blocking("stat", stat_params(&path_str, true))
333            .map_err(Self::to_io_error)?;
334
335        Ok(result
336            .get("file")
337            .and_then(|v| v.as_bool())
338            .unwrap_or(false))
339    }
340
341    fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()> {
342        #[cfg(unix)]
343        {
344            let params = serde_json::json!({
345                "path": path.to_string_lossy(),
346                "mode": permissions.mode()
347            });
348            self.channel
349                .request_blocking("chmod", params)
350                .map_err(Self::to_io_error)?;
351        }
352        #[cfg(not(unix))]
353        {
354            let _ = (path, permissions);
355        }
356        Ok(())
357    }
358
359    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
360        let path_str = path.to_string_lossy();
361        let result = self
362            .channel
363            .request_blocking("ls", ls_params(&path_str))
364            .map_err(Self::to_io_error)?;
365
366        let entries: Vec<RemoteDirEntry> = result
367            .get("entries")
368            .and_then(|v| serde_json::from_value(v.clone()).ok())
369            .unwrap_or_default();
370
371        Ok(entries.iter().map(Self::convert_dir_entry).collect())
372    }
373
374    fn create_dir(&self, path: &Path) -> io::Result<()> {
375        let params = serde_json::json!({"path": path.to_string_lossy()});
376        self.channel
377            .request_blocking("mkdir", params)
378            .map_err(Self::to_io_error)?;
379        Ok(())
380    }
381
382    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
383        let params = serde_json::json!({
384            "path": path.to_string_lossy(),
385            "parents": true
386        });
387        self.channel
388            .request_blocking("mkdir", params)
389            .map_err(Self::to_io_error)?;
390        Ok(())
391    }
392
393    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
394        let params = serde_json::json!({"path": path.to_string_lossy()});
395        let result = self
396            .channel
397            .request_blocking("realpath", params)
398            .map_err(Self::to_io_error)?;
399
400        let canonical = result.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
401            io::Error::new(io::ErrorKind::InvalidData, "missing path in response")
402        })?;
403
404        Ok(PathBuf::from(canonical))
405    }
406
407    fn current_uid(&self) -> u32 {
408        // We don't know the remote user's UID easily, return 0
409        // This is used for ownership checks which we skip for remote
410        0
411    }
412
413    fn remote_connection_info(&self) -> Option<&str> {
414        Some(&self.connection_string)
415    }
416
417    fn home_dir(&self) -> io::Result<PathBuf> {
418        let result = self
419            .channel
420            .request_blocking("info", serde_json::json!({}))
421            .map_err(Self::to_io_error)?;
422
423        let home = result.get("home").and_then(|v| v.as_str()).ok_or_else(|| {
424            io::Error::new(io::ErrorKind::InvalidData, "missing home in response")
425        })?;
426
427        Ok(PathBuf::from(home))
428    }
429
430    fn unique_temp_path(&self, dest_path: &Path) -> PathBuf {
431        // Use /tmp on the remote system, not local temp_dir
432        let temp_dir = PathBuf::from("/tmp");
433        let file_name = dest_path
434            .file_name()
435            .unwrap_or_else(|| std::ffi::OsStr::new("fresh-save"));
436        let timestamp = std::time::SystemTime::now()
437            .duration_since(std::time::UNIX_EPOCH)
438            .map(|d| d.as_nanos())
439            .unwrap_or(0);
440        temp_dir.join(format!(
441            "{}-{}-{}.tmp",
442            file_name.to_string_lossy(),
443            std::process::id(),
444            timestamp
445        ))
446    }
447
448    fn sudo_write(
449        &self,
450        path: &Path,
451        data: &[u8],
452        mode: u32,
453        uid: u32,
454        gid: u32,
455    ) -> io::Result<()> {
456        let path_str = path.to_string_lossy();
457        self.channel
458            .request_blocking(
459                "sudo_write",
460                sudo_write_params(&path_str, data, mode, uid, gid),
461            )
462            .map_err(Self::to_io_error)?;
463        Ok(())
464    }
465}
466
467/// Remote file reader - wraps in-memory data
468struct RemoteFileReader {
469    cursor: Cursor<Vec<u8>>,
470}
471
472impl RemoteFileReader {
473    fn new(data: Vec<u8>) -> Self {
474        Self {
475            cursor: Cursor::new(data),
476        }
477    }
478}
479
480impl Read for RemoteFileReader {
481    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
482        self.cursor.read(buf)
483    }
484}
485
486impl Seek for RemoteFileReader {
487    fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
488        self.cursor.seek(pos)
489    }
490}
491
492impl FileReader for RemoteFileReader {}
493
494/// Remote file writer - buffers writes and flushes on sync
495struct RemoteFileWriter {
496    channel: Arc<AgentChannel>,
497    path: PathBuf,
498    buffer: Vec<u8>,
499}
500
501impl RemoteFileWriter {
502    fn new(channel: Arc<AgentChannel>, path: PathBuf) -> Self {
503        Self {
504            channel,
505            path,
506            buffer: Vec::new(),
507        }
508    }
509}
510
511impl Write for RemoteFileWriter {
512    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
513        self.buffer.extend_from_slice(buf);
514        Ok(buf.len())
515    }
516
517    fn flush(&mut self) -> io::Result<()> {
518        // Flush is a no-op; actual write happens on sync_all
519        Ok(())
520    }
521}
522
523impl FileWriter for RemoteFileWriter {
524    fn sync_all(&self) -> io::Result<()> {
525        let path_str = self.path.to_string_lossy();
526        self.channel
527            .request_blocking("write", write_params(&path_str, &self.buffer))
528            .map_err(RemoteFileSystem::to_io_error)?;
529        Ok(())
530    }
531}
532
533/// Remote file writer for append operations - only sends new data
534struct AppendingRemoteFileWriter {
535    channel: Arc<AgentChannel>,
536    path: PathBuf,
537    buffer: Vec<u8>,
538}
539
540impl AppendingRemoteFileWriter {
541    fn new(channel: Arc<AgentChannel>, path: PathBuf) -> Self {
542        Self {
543            channel,
544            path,
545            buffer: Vec::new(),
546        }
547    }
548}
549
550impl Write for AppendingRemoteFileWriter {
551    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
552        self.buffer.extend_from_slice(buf);
553        Ok(buf.len())
554    }
555
556    fn flush(&mut self) -> io::Result<()> {
557        Ok(())
558    }
559}
560
561impl FileWriter for AppendingRemoteFileWriter {
562    fn sync_all(&self) -> io::Result<()> {
563        if self.buffer.is_empty() {
564            return Ok(());
565        }
566        let path_str = self.path.to_string_lossy();
567        self.channel
568            .request_blocking("append", append_params(&path_str, &self.buffer))
569            .map_err(RemoteFileSystem::to_io_error)?;
570        Ok(())
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    #[test]
579    fn test_convert_metadata() {
580        let rm = RemoteMetadata {
581            size: 1234,
582            mtime: 1700000000,
583            mode: 0o644,
584            uid: 1000,
585            gid: 1000,
586            dir: false,
587            file: true,
588            link: false,
589        };
590
591        let meta = RemoteFileSystem::convert_metadata(&rm, "test.txt");
592        assert_eq!(meta.size, 1234);
593        assert!(!meta.is_hidden);
594        assert!(!meta.is_readonly);
595
596        let meta = RemoteFileSystem::convert_metadata(&rm, ".hidden");
597        assert!(meta.is_hidden);
598    }
599
600    #[test]
601    fn test_convert_dir_entry() {
602        let re = RemoteDirEntry {
603            name: "file.rs".to_string(),
604            path: "/home/user/file.rs".to_string(),
605            dir: false,
606            file: true,
607            link: false,
608            link_dir: false,
609            size: 100,
610            mtime: 1700000000,
611            mode: 0o644,
612        };
613
614        let entry = RemoteFileSystem::convert_dir_entry(&re);
615        assert_eq!(entry.name, "file.rs");
616        assert_eq!(entry.entry_type, EntryType::File);
617        assert!(!entry.is_symlink());
618    }
619}