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