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