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 home_dir(&self) -> io::Result<PathBuf> {
480        let result = self
481            .channel
482            .request_blocking("info", serde_json::json!({}))
483            .map_err(Self::to_io_error)?;
484
485        let home = result.get("home").and_then(|v| v.as_str()).ok_or_else(|| {
486            io::Error::new(io::ErrorKind::InvalidData, "missing home in response")
487        })?;
488
489        Ok(PathBuf::from(home))
490    }
491
492    fn unique_temp_path(&self, dest_path: &Path) -> PathBuf {
493        // Query the remote system's temp directory instead of hardcoding /tmp,
494        // which doesn't exist on Windows remotes. Falls back to /tmp if the
495        // info request fails (e.g. older agent without temp_dir support).
496        let temp_dir = Self::parse_temp_dir_from_info(
497            self.channel
498                .request_blocking("info", serde_json::json!({}))
499                .ok()
500                .as_ref(),
501        );
502        let file_name = dest_path
503            .file_name()
504            .unwrap_or_else(|| std::ffi::OsStr::new("fresh-save"));
505        let timestamp = std::time::SystemTime::now()
506            .duration_since(std::time::UNIX_EPOCH)
507            .map(|d| d.as_nanos())
508            .unwrap_or(0);
509        temp_dir.join(format!(
510            "{}-{}-{}.tmp",
511            file_name.to_string_lossy(),
512            std::process::id(),
513            timestamp
514        ))
515    }
516
517    fn search_file(
518        &self,
519        path: &Path,
520        pattern: &str,
521        opts: &crate::model::filesystem::FileSearchOptions,
522        cursor: &mut crate::model::filesystem::FileSearchCursor,
523    ) -> io::Result<Vec<crate::model::filesystem::SearchMatch>> {
524        if cursor.done {
525            return Ok(vec![]);
526        }
527
528        let path_str = path.to_string_lossy();
529        let mut params = serde_json::json!({
530            "path": path_str,
531            "pattern": pattern,
532            "fixed_string": opts.fixed_string,
533            "case_sensitive": opts.case_sensitive,
534            "whole_word": opts.whole_word,
535            "max_matches": opts.max_matches,
536            "offset": cursor.offset,
537            "running_line": cursor.running_line,
538        });
539        if let Some(end) = cursor.end_offset {
540            params["end_offset"] = serde_json::json!(end);
541        }
542
543        let result = self
544            .channel
545            .request_blocking("search_file", params)
546            .map_err(Self::to_io_error)?;
547
548        cursor.offset = result
549            .get("next_offset")
550            .and_then(|v| v.as_u64())
551            .unwrap_or(0) as usize;
552        cursor.running_line = result
553            .get("running_line")
554            .and_then(|v| v.as_u64())
555            .unwrap_or(1) as usize;
556        cursor.done = result.get("done").and_then(|v| v.as_bool()).unwrap_or(true);
557
558        let matches: Vec<crate::model::filesystem::SearchMatch> = result
559            .get("matches")
560            .and_then(|v| v.as_array())
561            .map(|arr| {
562                arr.iter()
563                    .filter_map(|m| {
564                        Some(crate::model::filesystem::SearchMatch {
565                            byte_offset: m.get("byte_offset")?.as_u64()? as usize,
566                            length: m.get("length")?.as_u64()? as usize,
567                            line: m.get("line")?.as_u64()? as usize,
568                            column: m.get("column")?.as_u64()? as usize,
569                            context: m.get("context")?.as_str()?.to_string(),
570                        })
571                    })
572                    .collect()
573            })
574            .unwrap_or_default();
575
576        Ok(matches)
577    }
578
579    fn sudo_write(
580        &self,
581        path: &Path,
582        data: &[u8],
583        mode: u32,
584        uid: u32,
585        gid: u32,
586    ) -> io::Result<()> {
587        let path_str = path.to_string_lossy();
588        self.channel
589            .request_blocking(
590                "sudo_write",
591                sudo_write_params(&path_str, data, mode, uid, gid),
592            )
593            .map_err(Self::to_io_error)?;
594        Ok(())
595    }
596}
597
598/// Remote file reader - wraps in-memory data
599struct RemoteFileReader {
600    cursor: Cursor<Vec<u8>>,
601}
602
603impl RemoteFileReader {
604    fn new(data: Vec<u8>) -> Self {
605        Self {
606            cursor: Cursor::new(data),
607        }
608    }
609}
610
611impl Read for RemoteFileReader {
612    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
613        self.cursor.read(buf)
614    }
615}
616
617impl Seek for RemoteFileReader {
618    fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
619        self.cursor.seek(pos)
620    }
621}
622
623impl FileReader for RemoteFileReader {}
624
625/// Remote file writer - buffers writes and flushes on sync
626struct RemoteFileWriter {
627    channel: Arc<AgentChannel>,
628    path: PathBuf,
629    buffer: Vec<u8>,
630}
631
632impl RemoteFileWriter {
633    fn new(channel: Arc<AgentChannel>, path: PathBuf) -> Self {
634        Self {
635            channel,
636            path,
637            buffer: Vec::new(),
638        }
639    }
640}
641
642impl Write for RemoteFileWriter {
643    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
644        self.buffer.extend_from_slice(buf);
645        Ok(buf.len())
646    }
647
648    fn flush(&mut self) -> io::Result<()> {
649        // Flush is a no-op; actual write happens on sync_all
650        Ok(())
651    }
652}
653
654impl FileWriter for RemoteFileWriter {
655    fn sync_all(&self) -> io::Result<()> {
656        let path_str = self.path.to_string_lossy();
657        self.channel
658            .request_blocking("write", write_params(&path_str, &self.buffer))
659            .map_err(RemoteFileSystem::to_io_error)?;
660        Ok(())
661    }
662}
663
664/// Remote file writer for append operations - only sends new data
665struct AppendingRemoteFileWriter {
666    channel: Arc<AgentChannel>,
667    path: PathBuf,
668    buffer: Vec<u8>,
669}
670
671impl AppendingRemoteFileWriter {
672    fn new(channel: Arc<AgentChannel>, path: PathBuf) -> Self {
673        Self {
674            channel,
675            path,
676            buffer: Vec::new(),
677        }
678    }
679}
680
681impl Write for AppendingRemoteFileWriter {
682    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
683        self.buffer.extend_from_slice(buf);
684        Ok(buf.len())
685    }
686
687    fn flush(&mut self) -> io::Result<()> {
688        Ok(())
689    }
690}
691
692impl FileWriter for AppendingRemoteFileWriter {
693    fn sync_all(&self) -> io::Result<()> {
694        if self.buffer.is_empty() {
695            return Ok(());
696        }
697        let path_str = self.path.to_string_lossy();
698        self.channel
699            .request_blocking("append", append_params(&path_str, &self.buffer))
700            .map_err(RemoteFileSystem::to_io_error)?;
701        Ok(())
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    #[test]
710    fn test_convert_metadata() {
711        // Use the current user's uid/gid so the file appears writable regardless
712        // of which user runs the test (on Unix, is_readonly checks effective user).
713        #[cfg(unix)]
714        let (uid, gid) = {
715            let (euid, groups) = crate::model::filesystem::StdFileSystem::current_user_groups();
716            (euid, *groups.first().unwrap_or(&0u32))
717        };
718        #[cfg(not(unix))]
719        let (uid, gid) = (1000u32, 1000u32);
720
721        let rm = RemoteMetadata {
722            size: 1234,
723            mtime: 1700000000,
724            mode: 0o644,
725            uid,
726            gid,
727            dir: false,
728            file: true,
729            link: false,
730        };
731
732        let meta = RemoteFileSystem::convert_metadata(&rm, "test.txt");
733        assert_eq!(meta.size, 1234);
734        assert!(!meta.is_hidden);
735        assert!(!meta.is_readonly);
736
737        let meta = RemoteFileSystem::convert_metadata(&rm, ".hidden");
738        assert!(meta.is_hidden);
739    }
740
741    #[test]
742    fn test_convert_dir_entry() {
743        let re = RemoteDirEntry {
744            name: "file.rs".to_string(),
745            path: "/home/user/file.rs".to_string(),
746            dir: false,
747            file: true,
748            link: false,
749            link_dir: false,
750            size: 100,
751            mtime: 1700000000,
752            mode: 0o644,
753        };
754
755        let entry = RemoteFileSystem::convert_dir_entry(&re);
756        assert_eq!(entry.name, "file.rs");
757        assert_eq!(entry.entry_type, EntryType::File);
758        assert!(!entry.is_symlink());
759    }
760}