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
602struct 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
629struct 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 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
668struct 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 #[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}