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