1use std::ops::Range;
6use std::path::{Path, PathBuf};
7use std::time::{Duration, SystemTime};
8
9use lazy_regex::{Lazy, Regex};
10use remotefs::File;
11use remotefs::fs::{
12 FileType, Metadata, ReadStream, RemoteError, RemoteErrorType, RemoteFs, RemoteResult, UnixPex,
13 UnixPexClass, Welcome, WriteStream,
14};
15
16use super::SshOpts;
17use crate::SshSession;
18use crate::utils::{fmt as fmt_utils, parser as parser_utils, path as path_utils};
19
20static LS_RE: Lazy<Regex> = lazy_regex!(
22 r#"^(?<sym_dir>[\-ld])(?<pex>[\-rwxsStT]{9})(?<sec_ctx>\.|\+|\@)?\s+(?<n_links>\d+)\s+(?<uid>.+)\s+(?<gid>.+)\s+(?<size>\d+)\s+(?<date_time>\w{3}\s+\d{1,2}\s+(?:\d{1,2}:\d{1,2}|\d{4}))\s+(?<name>.+)$"#
23);
24
25pub struct ScpFs<S>
27where
28 S: SshSession,
29{
30 session: Option<S>,
31 wrkdir: PathBuf,
32 opts: SshOpts,
33}
34
35#[cfg(feature = "libssh2")]
36#[cfg_attr(docsrs, doc(cfg(feature = "libssh2")))]
37impl ScpFs<super::backend::LibSsh2Session> {
38 pub fn libssh2(opts: SshOpts) -> Self {
40 Self {
41 session: None,
42 wrkdir: PathBuf::from("/"),
43 opts,
44 }
45 }
46}
47
48#[cfg(feature = "libssh")]
49#[cfg_attr(docsrs, doc(cfg(feature = "libssh")))]
50impl ScpFs<super::backend::LibSshSession> {
51 pub fn libssh(opts: SshOpts) -> Self {
53 Self {
54 session: None,
55 wrkdir: PathBuf::from("/"),
56 opts,
57 }
58 }
59}
60
61#[cfg(feature = "russh")]
62#[cfg_attr(docsrs, doc(cfg(feature = "russh")))]
63impl<T> ScpFs<super::backend::RusshSession<T>>
64where
65 T: russh::client::Handler + Default + Send + 'static,
66{
67 pub fn russh(opts: SshOpts, runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Self {
69 let opts = opts.runtime(runtime);
70 Self {
71 session: None,
72 wrkdir: PathBuf::from("/"),
73 opts,
74 }
75 }
76}
77
78impl<S> ScpFs<S>
79where
80 S: SshSession,
81{
82 pub fn session(&mut self) -> Option<&mut S> {
84 self.session.as_mut()
85 }
86
87 fn check_connection(&mut self) -> RemoteResult<()> {
91 if self.is_connected() {
92 Ok(())
93 } else {
94 Err(RemoteError::new(RemoteErrorType::NotConnected))
95 }
96 }
97
98 fn parse_ls_output(&self, path: &Path, line: &str) -> Result<File, ()> {
100 trace!("Parsing LS line: '{line}'");
102 match LS_RE.captures(line) {
104 Some(metadata) => {
106 if metadata.len() < 8 {
109 return Err(());
110 }
111 let (is_dir, is_symlink): (bool, bool) = match &metadata["sym_dir"] {
115 "-" => (false, false),
116 "l" => (false, true),
117 "d" => (true, false),
118 _ => return Err(()), };
120 if metadata["pex"].len() < 9 {
122 return Err(());
123 }
124
125 let pex = |range: Range<usize>| {
126 let mut count: u8 = 0;
127 for (i, c) in metadata["pex"][range].chars().enumerate() {
128 match c {
129 '-' => {}
130 _ => {
131 count += match i {
132 0 => 4,
133 1 => 2,
134 2 => 1,
135 _ => 0,
136 }
137 }
138 }
139 }
140 count
141 };
142
143 let mode = UnixPex::new(
145 UnixPexClass::from(pex(0..3)),
146 UnixPexClass::from(pex(3..6)),
147 UnixPexClass::from(pex(6..9)),
148 );
149
150 let modified: SystemTime = match parser_utils::parse_lstime(
152 &metadata["date_time"],
153 "%b %d %Y",
154 "%b %d %H:%M",
155 ) {
156 Ok(t) => t,
157 Err(_) => SystemTime::UNIX_EPOCH,
158 };
159 let uid: Option<u32> = metadata["uid"].parse::<u32>().ok();
161 let gid: Option<u32> = metadata["gid"].parse::<u32>().ok();
163 let size = metadata["size"].parse::<u64>().unwrap_or(0);
165 let (file_name, symlink): (String, Option<PathBuf>) = match is_symlink {
167 true => self.get_name_and_link(&metadata["name"]),
168 false => (String::from(&metadata["name"]), None),
169 };
170 let file_name = PathBuf::from(&file_name)
172 .file_name()
173 .map(|x| x.to_string_lossy().to_string())
174 .unwrap_or(file_name);
175 if file_name.as_str() == "." || file_name.as_str() == ".." {
177 return Err(());
178 }
179 let mut path: PathBuf = path.to_path_buf();
181 path.push(file_name.as_str());
182 let file_type = if symlink.is_some() {
184 FileType::Symlink
185 } else if is_dir {
186 FileType::Directory
187 } else {
188 FileType::File
189 };
190 let metadata = Metadata {
192 accessed: None,
193 created: None,
194 file_type,
195 gid,
196 mode: Some(mode),
197 modified: Some(modified),
198 size,
199 symlink,
200 uid,
201 };
202 trace!(
203 "Found entry at {} with metadata {:?}",
204 path.display(),
205 metadata
206 );
207 Ok(File { path, metadata })
209 }
210 None => Err(()),
211 }
212 }
213
214 fn get_name_and_link(&self, token: &str) -> (String, Option<PathBuf>) {
218 let tokens: Vec<&str> = token.split(" -> ").collect();
219 let filename: String = String::from(*tokens.first().unwrap());
220 let symlink: Option<PathBuf> = tokens.get(1).map(PathBuf::from);
221 (filename, symlink)
222 }
223
224 fn assert_stat_command(&mut self, cmd: String) -> RemoteResult<()> {
226 match self.session.as_mut().unwrap().cmd(cmd) {
227 Ok((0, _)) => Ok(()),
228 Ok(_) => Err(RemoteError::new(RemoteErrorType::StatFailed)),
229 Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
230 }
231 }
232
233 fn is_directory(&mut self, path: &Path) -> RemoteResult<bool> {
235 let path = path_utils::absolutize(self.wrkdir.as_path(), path);
236 match self
237 .session
238 .as_mut()
239 .unwrap()
240 .cmd(format!("test -d \"{}\"", path.display()))
241 {
242 Ok((0, _)) => Ok(true),
243 Ok(_) => Ok(false),
244 Err(err) => Err(RemoteError::new_ex(RemoteErrorType::StatFailed, err)),
245 }
246 }
247}
248
249impl<S> RemoteFs for ScpFs<S>
250where
251 S: SshSession,
252{
253 fn connect(&mut self) -> RemoteResult<Welcome> {
254 debug!("Initializing SFTP connection...");
255 let mut session = S::connect(&self.opts)?;
256 let banner = session.banner()?;
258 debug!(
259 "Connection established: {}",
260 banner.as_deref().unwrap_or("")
261 );
262 debug!("Getting working directory...");
264 self.wrkdir = session
265 .cmd("pwd")
266 .map(|(_rc, output)| PathBuf::from(output.as_str().trim()))?;
267 self.session = Some(session);
269 info!(
270 "Connection established; working directory: {}",
271 self.wrkdir.display()
272 );
273 Ok(Welcome::default().banner(banner))
274 }
275
276 fn disconnect(&mut self) -> RemoteResult<()> {
277 debug!("Disconnecting from remote...");
278 if let Some(session) = self.session.as_ref() {
279 match session.disconnect() {
281 Ok(_) => {
282 self.session = None;
284 Ok(())
285 }
286 Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ConnectionError, err)),
287 }
288 } else {
289 Err(RemoteError::new(RemoteErrorType::NotConnected))
290 }
291 }
292
293 fn is_connected(&mut self) -> bool {
294 self.session
295 .as_ref()
296 .map(|x| x.authenticated().unwrap_or_default())
297 .unwrap_or(false)
298 }
299
300 fn pwd(&mut self) -> RemoteResult<PathBuf> {
301 self.check_connection()?;
302 Ok(self.wrkdir.clone())
303 }
304
305 fn change_dir(&mut self, dir: &Path) -> RemoteResult<PathBuf> {
306 self.check_connection()?;
307 let dir = path_utils::absolutize(self.wrkdir.as_path(), dir);
308 debug!("Changing working directory to {}", dir.display());
309 match self
310 .session
311 .as_mut()
312 .unwrap()
313 .cmd(format!("cd \"{}\"; echo $?; pwd", dir.display()))
314 {
315 Ok((rc, output)) => {
316 if rc != 0 {
317 return Err(RemoteError::new_ex(
318 RemoteErrorType::ProtocolError,
319 format!("Failed to change directory: {}", output),
320 ));
321 }
322 let output: String = String::from(output.as_str().trim());
324 match output.as_str().starts_with('0') {
326 true => {
327 self.wrkdir = PathBuf::from(&output.as_str()[1..].trim());
329 debug!("Changed working directory to {}", self.wrkdir.display());
330 Ok(self.wrkdir.clone())
331 }
332 false => Err(RemoteError::new_ex(
333 RemoteErrorType::NoSuchFileOrDirectory,
335 format!("\"{}\"", dir.display()),
336 )),
337 }
338 }
339 Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
340 }
341 }
342
343 fn list_dir(&mut self, path: &Path) -> RemoteResult<Vec<File>> {
344 self.check_connection()?;
345 let path = path_utils::absolutize(self.wrkdir.as_path(), path);
346 debug!("Getting file entries in {}", path.display());
347 if !self.exists(path.as_path()).ok().unwrap_or(false) {
349 return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
350 }
351 match self
352 .session
353 .as_mut()
354 .unwrap()
355 .cmd(format!("unset LANG; ls -la \"{}/\"", path.display()).as_str())
356 {
357 Ok((rc, output)) => {
358 if rc != 0 {
359 return Err(RemoteError::new_ex(
360 RemoteErrorType::ProtocolError,
361 format!("Failed to list directory: {}", output),
362 ));
363 }
364 let lines: Vec<&str> = output.as_str().lines().collect();
366 let mut entries: Vec<File> = Vec::with_capacity(lines.len());
367 for line in lines.iter() {
368 if let Ok(entry) = self.parse_ls_output(path.as_path(), line) {
371 entries.push(entry);
372 }
373 }
374 debug!(
375 "Found {} out of {} valid file entries",
376 entries.len(),
377 lines.len()
378 );
379 Ok(entries)
380 }
381 Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
382 }
383 }
384
385 fn stat(&mut self, path: &Path) -> RemoteResult<File> {
386 self.check_connection()?;
387 let path = path_utils::absolutize(self.wrkdir.as_path(), path);
388 debug!("Stat {}", path.display());
389 let cmd = match self.is_directory(path.as_path())? {
391 true => format!("ls -ld \"{}\"", path.display()),
392 false => format!("ls -l \"{}\"", path.display()),
393 };
394 match self.session.as_mut().unwrap().cmd(cmd.as_str()) {
395 Ok((rc, line)) => {
396 if rc != 0 {
397 return Err(RemoteError::new_ex(
398 RemoteErrorType::NoSuchFileOrDirectory,
399 format!("Failed to stat file: {line}"),
400 ));
401 }
402 let parent: PathBuf = match path.as_path().parent() {
404 Some(p) => PathBuf::from(p),
405 None => {
406 return Err(RemoteError::new_ex(
407 RemoteErrorType::StatFailed,
408 "Path has no parent",
409 ));
410 }
411 };
412 match self.parse_ls_output(parent.as_path(), line.as_str().trim()) {
413 Ok(entry) => Ok(entry),
414 Err(_) => Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory)),
415 }
416 }
417 Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
418 }
419 }
420
421 fn exists(&mut self, path: &Path) -> RemoteResult<bool> {
422 self.check_connection()?;
423 let path = path_utils::absolutize(self.wrkdir.as_path(), path);
424 match self
425 .session
426 .as_mut()
427 .unwrap()
428 .cmd(format!("test -e \"{}\"", path.display()))
429 {
430 Ok((0, _)) => Ok(true),
431 Ok(_) => Ok(false),
432 Err(err) => Err(RemoteError::new_ex(RemoteErrorType::StatFailed, err)),
433 }
434 }
435
436 fn setstat(&mut self, path: &Path, metadata: Metadata) -> RemoteResult<()> {
437 self.check_connection()?;
438 let path = path_utils::absolutize(self.wrkdir.as_path(), path);
439 debug!("Setting attributes for {}", path.display());
440 if !self.exists(path.as_path()).ok().unwrap_or(false) {
441 return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
442 }
443 if let Some(mode) = metadata.mode {
445 self.assert_stat_command(format!(
446 "chmod {:o} \"{}\"",
447 u32::from(mode),
448 path.display()
449 ))?;
450 }
451 if let Some(user) = metadata.uid {
452 self.assert_stat_command(format!(
453 "chown {}{} \"{}\"",
454 user,
455 metadata.gid.map(|x| format!(":{x}")).unwrap_or_default(),
456 path.display()
457 ))?;
458 }
459 if let Some(accessed) = metadata.accessed {
461 self.assert_stat_command(format!(
462 "touch -a -t {} \"{}\"",
463 fmt_utils::fmt_time_utc(accessed, "%Y%m%d%H%M.%S"),
464 path.display()
465 ))?;
466 }
467 if let Some(modified) = metadata.modified {
468 self.assert_stat_command(format!(
469 "touch -m -t {} \"{}\"",
470 fmt_utils::fmt_time_utc(modified, "%Y%m%d%H%M.%S"),
471 path.display()
472 ))?;
473 }
474 Ok(())
475 }
476
477 fn remove_file(&mut self, path: &Path) -> RemoteResult<()> {
478 self.check_connection()?;
479 let path = path_utils::absolutize(self.wrkdir.as_path(), path);
480 if !self.exists(path.as_path()).ok().unwrap_or(false) {
481 return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
482 }
483 debug!("Removing file {}", path.display());
484 match self
485 .session
486 .as_mut()
487 .unwrap()
488 .cmd(format!("rm -f \"{}\"", path.display()))
489 {
490 Ok((0, _)) => Ok(()),
491 Ok(_) => Err(RemoteError::new(RemoteErrorType::CouldNotRemoveFile)),
492 Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
493 }
494 }
495
496 fn remove_dir(&mut self, path: &Path) -> RemoteResult<()> {
497 self.check_connection()?;
498 let path = path_utils::absolutize(self.wrkdir.as_path(), path);
499 if !self.exists(path.as_path()).ok().unwrap_or(false) {
500 return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
501 }
502 debug!("Removing directory {}", path.display());
503 match self
504 .session
505 .as_mut()
506 .unwrap()
507 .cmd(format!("rmdir \"{}\"", path.display()))
508 {
509 Ok((0, _)) => Ok(()),
510 Ok(_) => Err(RemoteError::new(RemoteErrorType::DirectoryNotEmpty)),
511 Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
512 }
513 }
514
515 fn remove_dir_all(&mut self, path: &Path) -> RemoteResult<()> {
516 self.check_connection()?;
517 let path = path_utils::absolutize(self.wrkdir.as_path(), path);
518 if !self.exists(path.as_path()).ok().unwrap_or(false) {
519 return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
520 }
521 debug!("Removing directory {} recursively", path.display());
522 match self
523 .session
524 .as_mut()
525 .unwrap()
526 .cmd(format!("rm -rf \"{}\"", path.display()))
527 {
528 Ok((0, _)) => Ok(()),
529 Ok(_) => Err(RemoteError::new(RemoteErrorType::CouldNotRemoveFile)),
530 Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
531 }
532 }
533
534 fn create_dir(&mut self, path: &Path, mode: UnixPex) -> RemoteResult<()> {
535 self.check_connection()?;
536 let path = path_utils::absolutize(self.wrkdir.as_path(), path);
537 if self.exists(path.as_path()).ok().unwrap_or(false) {
538 return Err(RemoteError::new(RemoteErrorType::DirectoryAlreadyExists));
539 }
540 let mode = format!("{:o}", u32::from(mode));
541 debug!(
542 "Creating directory at {} with mode {}",
543 path.display(),
544 mode
545 );
546 match self.session.as_mut().unwrap().cmd(format!(
547 "mkdir -m {} \"{}\"",
548 mode,
549 path.display()
550 )) {
551 Ok((0, _)) => Ok(()),
552 Ok(_) => Err(RemoteError::new(RemoteErrorType::FileCreateDenied)),
553 Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
554 }
555 }
556
557 fn symlink(&mut self, path: &Path, target: &Path) -> RemoteResult<()> {
558 self.check_connection()?;
559 let path = path_utils::absolutize(self.wrkdir.as_path(), path);
560 debug!(
561 "Creating a symlink at {} pointing at {}",
562 path.display(),
563 target.display()
564 );
565 if !self.exists(target).ok().unwrap_or(false) {
566 return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
567 }
568 if self.exists(path.as_path()).ok().unwrap_or(false) {
569 return Err(RemoteError::new(RemoteErrorType::FileCreateDenied));
570 }
571 match self.session.as_mut().unwrap().cmd(format!(
572 "ln -s \"{}\" \"{}\"",
573 target.display(),
574 path.display()
575 )) {
576 Ok((0, _)) => Ok(()),
577 Ok(_) => Err(RemoteError::new(RemoteErrorType::FileCreateDenied)),
578 Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
579 }
580 }
581
582 fn copy(&mut self, src: &Path, dest: &Path) -> RemoteResult<()> {
583 self.check_connection()?;
584 let src = path_utils::absolutize(self.wrkdir.as_path(), src);
585 if !self.exists(src.as_path()).ok().unwrap_or(false) {
587 return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
588 }
589 let dest = path_utils::absolutize(self.wrkdir.as_path(), dest);
590 debug!("Copying {} to {}", src.display(), dest.display());
591 match self
592 .session
593 .as_mut()
594 .unwrap()
595 .cmd(format!("cp -rf \"{}\" \"{}\"", src.display(), dest.display()).as_str())
596 {
597 Ok((0, _)) => Ok(()),
598 Ok(_) => Err(RemoteError::new_ex(
599 RemoteErrorType::FileCreateDenied,
601 format!("\"{}\"", dest.display()),
602 )),
603 Err(err) => Err(RemoteError::new_ex(
604 RemoteErrorType::ProtocolError,
605 err.to_string(),
606 )),
607 }
608 }
609
610 fn mov(&mut self, src: &Path, dest: &Path) -> RemoteResult<()> {
611 self.check_connection()?;
612 let src = path_utils::absolutize(self.wrkdir.as_path(), src);
613 if !self.exists(src.as_path()).ok().unwrap_or(false) {
615 return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
616 }
617 let dest = path_utils::absolutize(self.wrkdir.as_path(), dest);
618 debug!("Moving {} to {}", src.display(), dest.display());
619 match self
620 .session
621 .as_mut()
622 .unwrap()
623 .cmd(format!("mv -f \"{}\" \"{}\"", src.display(), dest.display()).as_str())
624 {
625 Ok((0, _)) => Ok(()),
626 Ok(_) => Err(RemoteError::new_ex(
627 RemoteErrorType::FileCreateDenied,
629 format!("\"{}\"", dest.display()),
630 )),
631 Err(err) => Err(RemoteError::new_ex(RemoteErrorType::ProtocolError, err)),
632 }
633 }
634
635 fn exec(&mut self, cmd: &str) -> RemoteResult<(u32, String)> {
636 self.check_connection()?;
637 debug!(r#"Executing command "{cmd}""#);
638 self.session
639 .as_mut()
640 .unwrap()
641 .cmd_at(cmd, self.wrkdir.as_path())
642 }
643
644 fn append(&mut self, _path: &Path, _metadata: &Metadata) -> RemoteResult<WriteStream> {
645 Err(RemoteError::new(RemoteErrorType::UnsupportedFeature))
646 }
647
648 fn create(&mut self, path: &Path, metadata: &Metadata) -> RemoteResult<WriteStream> {
649 self.check_connection()?;
650 let path = path_utils::absolutize(self.wrkdir.as_path(), path);
651 debug!("Creating file {}", path.display());
652 trace!("blocked channel");
653 let mode = metadata.mode.map(u32::from).unwrap_or(0o644) as i32;
654 let accessed = metadata
655 .accessed
656 .unwrap_or(SystemTime::UNIX_EPOCH)
657 .duration_since(SystemTime::UNIX_EPOCH)
658 .ok()
659 .unwrap_or(Duration::ZERO)
660 .as_secs();
661 let modified = metadata
662 .modified
663 .unwrap_or(SystemTime::UNIX_EPOCH)
664 .duration_since(SystemTime::UNIX_EPOCH)
665 .ok()
666 .unwrap_or(Duration::ZERO)
667 .as_secs();
668 trace!("Creating file with mode {mode:o}, accessed: {accessed}, modified: {modified}");
669 match self.session.as_mut().unwrap().scp_send(
670 path.as_path(),
671 mode,
672 metadata.size,
673 Some((modified, accessed)),
674 ) {
675 Ok(channel) => Ok(WriteStream::from(channel)),
676 Err(err) => {
677 error!("Failed to create file: {err}");
678 Err(RemoteError::new_ex(RemoteErrorType::FileCreateDenied, err))
679 }
680 }
681 }
682
683 fn open(&mut self, path: &Path) -> RemoteResult<ReadStream> {
684 self.check_connection()?;
685 let path = path_utils::absolutize(self.wrkdir.as_path(), path);
686 debug!("Opening file {} for read", path.display());
687 if !self.exists(path.as_path()).ok().unwrap_or(false) {
689 return Err(RemoteError::new(RemoteErrorType::NoSuchFileOrDirectory));
690 }
691 trace!("blocked channel");
692 match self.session.as_mut().unwrap().scp_recv(path.as_path()) {
693 Ok(channel) => Ok(ReadStream::from(channel)),
694 Err(err) => {
695 error!("Failed to open file: {err}");
696 Err(RemoteError::new_ex(RemoteErrorType::CouldNotOpenFile, err))
697 }
698 }
699 }
700}
701
702#[cfg(test)]
703mod tests;