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    fn walk_files(
602        &self,
603        root: &Path,
604        skip_dirs: &[&str],
605        cancel: &std::sync::atomic::AtomicBool,
606        on_file: &mut dyn FnMut(&Path, &str) -> bool,
607    ) -> io::Result<()> {
608        let path_str = root.to_string_lossy();
609        let params = serde_json::json!({
610            "path": path_str,
611            "skip_dirs": skip_dirs,
612        });
613
614        // Server-side walk: the remote agent walks the tree and streams
615        // back batches of relative paths.  We process each batch as it
616        // arrives, keeping memory bounded.
617        let (mut data_rx, result_rx) = self
618            .channel
619            .request_streaming_blocking("walk_files", params)
620            .map_err(Self::to_io_error)?;
621
622        // Process streaming batches
623        while let Some(data) = data_rx.blocking_recv() {
624            if cancel.load(std::sync::atomic::Ordering::Relaxed) {
625                // Drop receivers — server sees send fail and stops
626                drop(data_rx);
627                drop(result_rx);
628                return Ok(());
629            }
630
631            if let Some(files) = data.get("files").and_then(|v| v.as_array()) {
632                for file in files {
633                    if cancel.load(std::sync::atomic::Ordering::Relaxed) {
634                        drop(result_rx);
635                        return Ok(());
636                    }
637                    if let Some(rel) = file.as_str() {
638                        let abs = root.join(rel);
639                        if !on_file(&abs, rel) {
640                            // Caller limit reached — drop receivers to signal
641                            // cancellation to the server
642                            drop(result_rx);
643                            return Ok(());
644                        }
645                    }
646                }
647            }
648        }
649
650        // Drain the final result — channel may already be closed if the
651        // server finished before we read this, which is fine.
652        drop(result_rx.blocking_recv());
653        Ok(())
654    }
655}
656
657/// Remote file reader - wraps in-memory data
658struct RemoteFileReader {
659    cursor: Cursor<Vec<u8>>,
660}
661
662impl RemoteFileReader {
663    fn new(data: Vec<u8>) -> Self {
664        Self {
665            cursor: Cursor::new(data),
666        }
667    }
668}
669
670impl Read for RemoteFileReader {
671    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
672        self.cursor.read(buf)
673    }
674}
675
676impl Seek for RemoteFileReader {
677    fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
678        self.cursor.seek(pos)
679    }
680}
681
682impl FileReader for RemoteFileReader {}
683
684/// Remote file writer - buffers writes and flushes on sync
685struct RemoteFileWriter {
686    channel: Arc<AgentChannel>,
687    path: PathBuf,
688    buffer: Vec<u8>,
689}
690
691impl RemoteFileWriter {
692    fn new(channel: Arc<AgentChannel>, path: PathBuf) -> Self {
693        Self {
694            channel,
695            path,
696            buffer: Vec::new(),
697        }
698    }
699}
700
701impl Write for RemoteFileWriter {
702    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
703        self.buffer.extend_from_slice(buf);
704        Ok(buf.len())
705    }
706
707    fn flush(&mut self) -> io::Result<()> {
708        // Flush is a no-op; actual write happens on sync_all
709        Ok(())
710    }
711}
712
713impl FileWriter for RemoteFileWriter {
714    fn sync_all(&self) -> io::Result<()> {
715        let path_str = self.path.to_string_lossy();
716        self.channel
717            .request_blocking("write", write_params(&path_str, &self.buffer))
718            .map_err(RemoteFileSystem::to_io_error)?;
719        Ok(())
720    }
721}
722
723/// Remote file writer for append operations - only sends new data
724struct AppendingRemoteFileWriter {
725    channel: Arc<AgentChannel>,
726    path: PathBuf,
727    buffer: Vec<u8>,
728}
729
730impl AppendingRemoteFileWriter {
731    fn new(channel: Arc<AgentChannel>, path: PathBuf) -> Self {
732        Self {
733            channel,
734            path,
735            buffer: Vec::new(),
736        }
737    }
738}
739
740impl Write for AppendingRemoteFileWriter {
741    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
742        self.buffer.extend_from_slice(buf);
743        Ok(buf.len())
744    }
745
746    fn flush(&mut self) -> io::Result<()> {
747        Ok(())
748    }
749}
750
751impl FileWriter for AppendingRemoteFileWriter {
752    fn sync_all(&self) -> io::Result<()> {
753        if self.buffer.is_empty() {
754            return Ok(());
755        }
756        let path_str = self.path.to_string_lossy();
757        self.channel
758            .request_blocking("append", append_params(&path_str, &self.buffer))
759            .map_err(RemoteFileSystem::to_io_error)?;
760        Ok(())
761    }
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767
768    #[test]
769    fn test_convert_metadata() {
770        // Use the current user's uid/gid so the file appears writable regardless
771        // of which user runs the test (on Unix, is_readonly checks effective user).
772        #[cfg(unix)]
773        let (uid, gid) = {
774            let (euid, groups) = crate::model::filesystem::StdFileSystem::current_user_groups();
775            (euid, *groups.first().unwrap_or(&0u32))
776        };
777        #[cfg(not(unix))]
778        let (uid, gid) = (1000u32, 1000u32);
779
780        let rm = RemoteMetadata {
781            size: 1234,
782            mtime: 1700000000,
783            mode: 0o644,
784            uid,
785            gid,
786            dir: false,
787            file: true,
788            link: false,
789        };
790
791        let meta = RemoteFileSystem::convert_metadata(&rm, "test.txt");
792        assert_eq!(meta.size, 1234);
793        assert!(!meta.is_hidden);
794        assert!(!meta.is_readonly);
795
796        let meta = RemoteFileSystem::convert_metadata(&rm, ".hidden");
797        assert!(meta.is_hidden);
798    }
799
800    #[test]
801    fn test_convert_dir_entry() {
802        let re = RemoteDirEntry {
803            name: "file.rs".to_string(),
804            path: "/home/user/file.rs".to_string(),
805            dir: false,
806            file: true,
807            link: false,
808            link_dir: false,
809            size: 100,
810            mtime: 1700000000,
811            mode: 0o644,
812        };
813
814        let entry = RemoteFileSystem::convert_dir_entry(&re);
815        assert_eq!(entry.name, "file.rs");
816        assert_eq!(entry.entry_type, EntryType::File);
817        assert!(!entry.is_symlink());
818    }
819}