1use 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
19pub struct RemoteFileSystem {
21 channel: Arc<AgentChannel>,
22 connection_string: String,
24}
25
26impl RemoteFileSystem {
27 pub fn new(channel: Arc<AgentChannel>, connection_string: String) -> Self {
29 Self {
30 channel,
31 connection_string,
32 }
33 }
34
35 pub fn connection_string(&self) -> &str {
37 &self.connection_string
38 }
39
40 pub fn is_connected(&self) -> bool {
42 self.channel.is_connected()
43 }
44
45 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 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 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 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 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 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 let agent_reported_size = result
193 .get("size")
194 .and_then(|v| v.as_u64())
195 .map(|s| s as usize);
196
197 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 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 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 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 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 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 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 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 while let Some(data) = data_rx.blocking_recv() {
624 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
625 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 drop(result_rx);
643 return Ok(());
644 }
645 }
646 }
647 }
648 }
649
650 drop(result_rx.blocking_recv());
653 Ok(())
654 }
655}
656
657struct 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
684struct 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 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
723struct 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 #[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}