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 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 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
598struct 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
625struct 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 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
664struct 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 #[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}