1use std::collections::HashMap;
10use std::path::Path;
11use std::sync::Mutex as StdMutex;
12
13use async_trait::async_trait;
14use chrono::{DateTime, NaiveDateTime, Utc};
15use secrecy::{ExposeSecret, SecretBox};
16
17use super::backend::{ProgressFn, RemoteFile, StorageBackend};
18use super::shell::{LocalShell, RemoteShell};
19use crate::infra::error::InfraError;
20
21const DEFAULT_RCLONE_TIMEOUT_SECS: u64 = 300;
26
27const RCLONE_TIMEOUT_ENV: &str = "VDSL_RCLONE_TIMEOUT";
32
33const MIN_RCLONE_TIMEOUT_SECS: u64 = 10;
35
36const BATCH_PER_FILE_TIMEOUT_SECS: u64 = 30;
42
43const SFTP_OPTIMIZATION_FLAGS: &[&str] = &["--sftp-set-modtime=false", "--sftp-disable-hashcheck"];
55
56const SFTP_BATCH_CHUNK_SIZE: usize = 100;
64
65const BATCH_CHUNK_MAX_RETRIES: u32 = 1;
67
68fn resolve_timeout(explicit: Option<u64>) -> u64 {
77 let raw = explicit
78 .or_else(|| {
79 std::env::var(RCLONE_TIMEOUT_ENV)
80 .ok()
81 .and_then(|v| v.parse::<u64>().ok())
82 })
83 .unwrap_or(DEFAULT_RCLONE_TIMEOUT_SECS);
84 raw.max(MIN_RCLONE_TIMEOUT_SECS)
85}
86
87pub struct RcloneBackend {
102 remote: SecretBox<String>,
105 shell: Box<dyn RemoteShell>,
107 timeout_secs: u64,
109 progress: StdMutex<Option<ProgressFn>>,
111}
112
113impl RcloneBackend {
114 pub fn new(remote: impl Into<String>) -> Self {
125 Self {
126 remote: SecretBox::new(Box::new(remote.into())),
127 shell: Box::new(LocalShell),
128 timeout_secs: resolve_timeout(None),
129 progress: StdMutex::new(None),
130 }
131 }
132
133 pub fn with_shell(remote: impl Into<String>, shell: Box<dyn RemoteShell>) -> Self {
135 Self {
136 remote: SecretBox::new(Box::new(remote.into())),
137 shell,
138 timeout_secs: resolve_timeout(None),
139 progress: StdMutex::new(None),
140 }
141 }
142
143 pub fn with_timeout(mut self, timeout_secs: u64) -> Self {
148 self.timeout_secs = resolve_timeout(Some(timeout_secs));
149 self
150 }
151
152 fn remote_path(&self, path: &str) -> Result<String, InfraError> {
157 let path = path.trim_matches('/');
158 if path.starts_with('-') {
160 return Err(InfraError::Transfer {
161 reason: format!("invalid remote path (starts with '-'): {path}"),
162 });
163 }
164 if path.split('/').any(|seg| seg == "..") {
166 return Err(InfraError::Transfer {
167 reason: format!("invalid remote path (contains '..' traversal): {path}"),
168 });
169 }
170 let remote = self.remote.expose_secret();
171 if path.is_empty() {
172 Ok(remote.clone())
173 } else {
174 Ok(format!("{remote}/{path}"))
175 }
176 }
177
178 fn is_sftp(&self) -> bool {
183 self.remote.expose_secret().starts_with(":sftp")
184 }
185
186 async fn exec_rclone(&self, args: &[&str]) -> Result<String, InfraError> {
191 self.exec_rclone_with_timeout(args, self.timeout_secs).await
192 }
193
194 async fn exec_rclone_with_timeout(
199 &self,
200 args: &[&str],
201 timeout_secs: u64,
202 ) -> Result<String, InfraError> {
203 let mut full_args = vec!["rclone"];
204 full_args.extend_from_slice(args);
205 if self.is_sftp() {
206 full_args.extend_from_slice(SFTP_OPTIMIZATION_FLAGS);
207 }
208
209 let output = self.shell.exec(&full_args, Some(timeout_secs)).await?;
210
211 if !output.success {
212 return Err(InfraError::Transfer {
213 reason: format!(
214 "rclone failed (exit {}): {}",
215 output
216 .exit_code
217 .map_or("signal".to_string(), |c| c.to_string()),
218 output.stderr.trim()
219 ),
220 });
221 }
222
223 Ok(output.stdout)
224 }
225}
226
227#[async_trait]
228impl StorageBackend for RcloneBackend {
229 async fn push(&self, local_path: &Path, remote_path: &str) -> Result<(), InfraError> {
230 let dest = self.remote_path(remote_path)?;
231 let local_str = local_path.to_str().ok_or_else(|| -> InfraError {
232 InfraError::Transfer {
233 reason: format!(
234 "local path is not valid UTF-8: {}",
235 local_path.to_string_lossy()
236 ),
237 }
238 })?;
239 self.exec_rclone(&["copyto", local_str, &dest]).await?;
240 Ok(())
241 }
242
243 async fn pull(&self, remote_path: &str, local_path: &Path) -> Result<(), InfraError> {
244 let src = self.remote_path(remote_path)?;
245 if let Some(parent) = local_path.parent() {
247 if let Some(parent_str) = parent.to_str() {
248 if !parent_str.is_empty() {
249 let _ = self
250 .shell
251 .exec(&["mkdir", "-p", parent_str], Some(10))
252 .await;
253 }
254 }
255 }
256 let local_str = local_path.to_str().ok_or_else(|| -> InfraError {
257 InfraError::Transfer {
258 reason: format!(
259 "local path is not valid UTF-8: {}",
260 local_path.to_string_lossy()
261 ),
262 }
263 })?;
264 self.exec_rclone(&["copyto", &src, local_str]).await?;
265 Ok(())
266 }
267
268 async fn list(&self, remote_path: &str) -> Result<Vec<RemoteFile>, InfraError> {
269 let target = self.remote_path(remote_path)?;
270 let output = self
274 .exec_rclone(&["lsf", "-R", "--format", "pst", "--files-only", &target])
275 .await?;
276
277 let mut files = Vec::new();
278 for line in output.lines() {
279 let parts: Vec<&str> = line.splitn(3, ';').collect();
280 if parts.len() < 2 {
281 continue;
282 }
283 let path = parts[0];
284 let size = match parts[1].trim().parse::<u64>() {
285 Ok(s) => Some(s),
286 Err(e) => {
287 tracing::debug!(
288 path = path,
289 raw_size = parts[1].trim(),
290 error = %e,
291 "rclone lsf: size parse failed, treating as unknown"
292 );
293 None
294 }
295 };
296 let modified_at = parts.get(2).and_then(|ts| parse_rclone_timestamp(ts));
297 files.push(RemoteFile {
298 path: path.to_string(),
299 size,
300 modified_at,
301 });
302 }
303 Ok(files)
304 }
305
306 async fn exists(&self, remote_path: &str) -> Result<bool, InfraError> {
310 let target = self.remote_path(remote_path)?;
311 let result = self.exec_rclone(&["lsf", &target]).await;
312 match result {
313 Ok(output) => Ok(!output.trim().is_empty()),
314 Err(e) => {
315 tracing::debug!(
316 remote_path = remote_path,
317 error = %e,
318 "rclone exists check failed, returning false"
319 );
320 Ok(false)
321 }
322 }
323 }
324
325 async fn delete(&self, remote_path: &str) -> Result<(), InfraError> {
326 let target = self.remote_path(remote_path)?;
327 match self
334 .exec_rclone(&["deletefile", &target, "--retries", "1"])
335 .await
336 {
337 Ok(_) => Ok(()),
338 Err(e) => {
339 let msg = e.to_string();
340 if msg.contains("exit 4") || msg.contains("not found") {
343 tracing::debug!(
344 remote_path = remote_path,
345 "rclone deletefile: object already absent, treating as success"
346 );
347 Ok(())
348 } else {
349 Err(e)
350 }
351 }
352 }
353 }
354
355 async fn archive_move(
361 &self,
362 src_remote_path: &str,
363 archive_remote_path: &str,
364 ) -> Result<(), InfraError> {
365 let src = self.remote_path(src_remote_path)?;
366 let dest = self.remote_path(archive_remote_path)?;
367 match self
368 .exec_rclone(&["moveto", &src, &dest, "--retries", "1"])
369 .await
370 {
371 Ok(_) => Ok(()),
372 Err(e) => {
373 let msg = e.to_string();
374 if msg.contains("exit 4") || msg.contains("not found") {
378 tracing::debug!(
379 src = src_remote_path,
380 dest = archive_remote_path,
381 "rclone moveto: src already absent, treating as success"
382 );
383 Ok(())
384 } else {
385 Err(e)
386 }
387 }
388 }
389 }
390
391 async fn push_batch(
399 &self,
400 src_root: &Path,
401 dest_root: &str,
402 relative_paths: &[String],
403 ) -> HashMap<String, Result<(), InfraError>> {
404 if relative_paths.is_empty() {
405 return HashMap::new();
406 }
407
408 let dest_full = match self.remote_path(dest_root) {
409 Ok(d) => d,
410 Err(_) => {
411 let reason = format!("invalid dest_root for batch push: {dest_root}");
412 return Self::all_batch_err(relative_paths, &reason);
413 }
414 };
415
416 let src_root_str = match src_root.to_str() {
417 Some(s) => s.to_string(),
418 None => {
419 let reason = format!(
420 "src_root is not valid UTF-8: {}",
421 src_root.to_string_lossy()
422 );
423 return Self::all_batch_err(relative_paths, &reason);
424 }
425 };
426
427 self.exec_batch_chunked(
428 relative_paths,
429 "push",
430 |chunk, list_filename, sftp_flags, _chunk_timeout| {
431 let file_list = chunk.join("\n");
432 let src = &src_root_str;
433 let dest = &dest_full;
434 format!(
435 "cat <<'__VDSL_EOF__' > /tmp/{list_filename}\n\
436 {file_list}\n\
437 __VDSL_EOF__\n\
438 rclone copy {src} {dest} \
439 --files-from /tmp/{list_filename} --transfers 8{sftp_flags}; \
440 _rc=$?; rm -f /tmp/{list_filename}; exit $_rc"
441 )
442 },
443 )
444 .await
445 }
446
447 async fn pull_batch(
451 &self,
452 src_root: &str,
453 dest_root: &Path,
454 relative_paths: &[String],
455 ) -> HashMap<String, Result<(), InfraError>> {
456 if relative_paths.is_empty() {
457 return HashMap::new();
458 }
459
460 let src_full = match self.remote_path(src_root) {
461 Ok(s) => s,
462 Err(_) => {
463 let reason = format!("invalid src_root for batch pull: {src_root}");
464 return Self::all_batch_err(relative_paths, &reason);
465 }
466 };
467
468 let dest_root_str = match dest_root.to_str() {
469 Some(s) => s.to_string(),
470 None => {
471 let reason = format!(
472 "dest_root is not valid UTF-8: {}",
473 dest_root.to_string_lossy()
474 );
475 return Self::all_batch_err(relative_paths, &reason);
476 }
477 };
478
479 self.exec_batch_chunked(
480 relative_paths,
481 "pull",
482 |chunk, list_filename, sftp_flags, _chunk_timeout| {
483 let file_list = chunk.join("\n");
484 let src = &src_full;
485 let dest = &dest_root_str;
486 format!(
487 "cat <<'__VDSL_EOF__' > /tmp/{list_filename}\n\
488 {file_list}\n\
489 __VDSL_EOF__\n\
490 rclone copy {src} {dest} \
491 --files-from /tmp/{list_filename} --transfers 8{sftp_flags}; \
492 _rc=$?; rm -f /tmp/{list_filename}; exit $_rc"
493 )
494 },
495 )
496 .await
497 }
498
499 async fn delete_batch(
503 &self,
504 remote_root: &str,
505 relative_paths: &[String],
506 ) -> HashMap<String, Result<(), InfraError>> {
507 if relative_paths.is_empty() {
508 return HashMap::new();
509 }
510
511 let remote_full = match self.remote_path(remote_root) {
512 Ok(r) => r,
513 Err(_) => {
514 return Self::all_batch_err(
515 relative_paths,
516 &format!("invalid remote_root for batch delete: {remote_root}"),
517 );
518 }
519 };
520
521 self.exec_batch_chunked(
522 relative_paths,
523 "delete",
524 |chunk, list_filename, sftp_flags, _chunk_timeout| {
525 let file_list = chunk.join("\n");
526 let dest = &remote_full;
527 format!(
528 "cat <<'__VDSL_EOF__' > /tmp/{list_filename}\n\
529 {file_list}\n\
530 __VDSL_EOF__\n\
531 rclone delete {dest} \
532 --files-from /tmp/{list_filename} --transfers 8{sftp_flags}; \
533 _rc=$?; rm -f /tmp/{list_filename}; exit $_rc"
534 )
535 },
536 )
537 .await
538 }
539
540 async fn archive_move_batch(
545 &self,
546 src_root: &str,
547 archive_dest_root: &str,
548 relative_paths: &[String],
549 ) -> HashMap<String, Result<(), InfraError>> {
550 if relative_paths.is_empty() {
551 return HashMap::new();
552 }
553
554 let src_full = match self.remote_path(src_root) {
555 Ok(r) => r,
556 Err(_) => {
557 return Self::all_batch_err(
558 relative_paths,
559 &format!("invalid src_root for batch archive_move: {src_root}"),
560 );
561 }
562 };
563
564 let dest_full = match self.remote_path(archive_dest_root) {
565 Ok(r) => r,
566 Err(_) => {
567 return Self::all_batch_err(
568 relative_paths,
569 &format!(
570 "invalid archive_dest_root for batch archive_move: {archive_dest_root}"
571 ),
572 );
573 }
574 };
575
576 self.exec_batch_chunked(
577 relative_paths,
578 "archive_move",
579 |chunk, list_filename, sftp_flags, _chunk_timeout| {
580 let file_list = chunk.join("\n");
581 let src = &src_full;
582 let dest = &dest_full;
583 format!(
584 "cat <<'__VDSL_EOF__' > /tmp/{list_filename}\n\
585 {file_list}\n\
586 __VDSL_EOF__\n\
587 rclone move {src} {dest} \
588 --files-from /tmp/{list_filename} --transfers 8{sftp_flags}; \
589 _rc=$?; rm -f /tmp/{list_filename}; exit $_rc"
590 )
591 },
592 )
593 .await
594 }
595
596 fn supports_batch(&self) -> bool {
597 true
598 }
599
600 fn backend_type(&self) -> &str {
601 "rclone"
602 }
603
604 fn set_progress_callback(&self, callback: Option<ProgressFn>) {
605 if let Ok(mut guard) = self.progress.lock() {
606 *guard = callback;
607 }
608 }
609
610 async fn ensure(&self) -> Result<(), InfraError> {
611 let check = self.shell.exec(&["which", "rclone"], Some(10)).await;
613 let rclone_found = matches!(&check, Ok(out) if out.success);
614
615 if !rclone_found {
616 tracing::info!("rclone not found, attempting install via .deb package");
618 let install_script = concat!(
619 "curl -sL https://downloads.rclone.org/rclone-current-linux-amd64.deb -o /tmp/rclone.deb",
620 " && dpkg -i /tmp/rclone.deb",
621 " && rm -f /tmp/rclone.deb",
622 );
623 let install_result = self.shell.exec_script(install_script, Some(120)).await;
624
625 match &install_result {
626 Ok(out) if out.success => {
627 tracing::info!("rclone installed successfully via .deb");
628 }
629 Ok(out) => {
630 tracing::debug!(
632 exit_code = out.exit_code,
633 stderr = out.stderr.trim(),
634 "dpkg install failed, falling back to install.sh"
635 );
636 let fallback_script = concat!(
639 "(command -v unzip >/dev/null 2>&1 || ",
640 "(apt-get update -qq && apt-get install -y -qq unzip)) && ",
641 "curl -sL https://rclone.org/install.sh | bash",
642 );
643 let fallback = self
644 .shell
645 .exec_script(fallback_script, Some(180))
646 .await;
647 match &fallback {
648 Ok(o) if o.success => {
649 tracing::info!("rclone installed successfully via install.sh");
650 }
651 Ok(o) => {
652 return Err(InfraError::Init(format!(
653 "rclone install failed (exit {}): {}",
654 o.exit_code.unwrap_or(-1),
655 o.stderr.trim()
656 )));
657 }
658 Err(e) => {
659 return Err(InfraError::Init(format!(
660 "rclone install.sh exec failed: {e}"
661 )));
662 }
663 }
664 }
665 Err(e) => {
666 return Err(InfraError::Init(format!(
667 "rclone .deb install exec failed: {e}"
668 )));
669 }
670 }
671
672 let recheck = self.shell.exec(&["which", "rclone"], Some(10)).await;
674 match &recheck {
675 Ok(out) if out.success => {}
676 _ => {
677 return Err(InfraError::Init(
678 "rclone still not found after install attempt".to_string(),
679 ));
680 }
681 }
682 }
683
684 let remote = self.remote.expose_secret();
686 self.exec_rclone_with_timeout(&["lsf", "--max-depth", "1", remote], 30)
687 .await
688 .map_err(|e| InfraError::Init(format!("rclone connectivity test failed: {e}")))?;
689
690 Ok(())
691 }
692}
693
694impl RcloneBackend {
695 fn sftp_flags_for_script(&self) -> &'static str {
700 if self.is_sftp() {
701 " --sftp-set-modtime=false --sftp-disable-hashcheck"
702 } else {
703 ""
704 }
705 }
706
707 fn batch_chunk_size(&self) -> usize {
709 if self.is_sftp() {
710 SFTP_BATCH_CHUNK_SIZE
711 } else {
712 usize::MAX }
714 }
715
716 async fn exec_batch_chunked<F>(
725 &self,
726 relative_paths: &[String],
727 operation: &str,
728 build_script: F,
729 ) -> HashMap<String, Result<(), InfraError>>
730 where
731 F: Fn(&[String], &str, &str, u64) -> String,
732 {
733 let chunk_size = self.batch_chunk_size();
734 let sftp_flags = self.sftp_flags_for_script();
735 let total = relative_paths.len();
736 let chunks: Vec<&[String]> = relative_paths.chunks(chunk_size).collect();
737 let num_chunks = chunks.len();
738
739 if num_chunks > 1 {
740 tracing::info!(
741 operation,
742 total,
743 num_chunks,
744 chunk_size,
745 "batch_{operation}: chunked transfer start"
746 );
747 }
748
749 let mut all_results = HashMap::with_capacity(total);
750 let mut completed = 0usize;
751
752 for (i, chunk) in chunks.iter().enumerate() {
753 let chunk_num = i + 1;
754 let chunk_timeout =
755 self.timeout_secs + (chunk.len() as u64 * BATCH_PER_FILE_TIMEOUT_SECS);
756 let list_filename =
757 format!("vdsl-{operation}-{}.txt", uuid::Uuid::new_v4().as_simple());
758
759 if num_chunks > 1 {
760 tracing::info!(
761 operation,
762 chunk = chunk_num,
763 num_chunks,
764 files = chunk.len(),
765 completed,
766 total,
767 "batch_{operation}: chunk start"
768 );
769 }
770
771 let script = build_script(chunk, &list_filename, sftp_flags, chunk_timeout);
772
773 let mut attempt = 0u32;
774 let chunk_result = loop {
775 let result = self.shell.exec_script(&script, Some(chunk_timeout)).await;
776
777 match &result {
778 Ok(output) if output.success => break Ok(()),
779 Ok(output) => {
780 let err_msg = format!(
781 "rclone failed (exit {}): {}",
782 output
783 .exit_code
784 .map_or("signal".to_string(), |c| c.to_string()),
785 output.stderr.trim()
786 );
787 if attempt < BATCH_CHUNK_MAX_RETRIES {
788 attempt += 1;
789 tracing::warn!(
790 operation,
791 chunk = chunk_num,
792 attempt,
793 error = %err_msg,
794 "batch_{operation}: chunk failed, retrying"
795 );
796 continue;
797 }
798 break Err(format!("batch {operation} failed: {err_msg}"));
799 }
800 Err(e) => {
801 if attempt < BATCH_CHUNK_MAX_RETRIES {
802 attempt += 1;
803 tracing::warn!(
804 operation,
805 chunk = chunk_num,
806 attempt,
807 error = %e,
808 "batch_{operation}: chunk failed, retrying"
809 );
810 continue;
811 }
812 break Err(format!("batch {operation} failed: {e}"));
813 }
814 }
815 };
816
817 match chunk_result {
818 Ok(()) => {
819 for p in *chunk {
820 all_results.insert(p.clone(), Ok(()));
821 }
822 completed += chunk.len();
823 }
824 Err(reason) => {
825 for p in *chunk {
826 all_results.insert(
827 p.clone(),
828 Err(InfraError::Transfer {
829 reason: reason.clone(),
830 }),
831 );
832 }
833 tracing::error!(
835 operation,
836 chunk = chunk_num,
837 failed_files = chunk.len(),
838 reason = %reason,
839 "batch_{operation}: chunk failed after retries, continuing"
840 );
841 }
842 }
843
844 if let Ok(guard) = self.progress.lock() {
846 if let Some(cb) = guard.as_ref() {
847 cb(&format!(
848 "{operation}: chunk {chunk_num}/{num_chunks} ({completed}/{total})"
849 ));
850 }
851 }
852
853 if num_chunks > 1 {
854 tracing::info!(
855 operation,
856 chunk = chunk_num,
857 num_chunks,
858 completed,
859 total,
860 "batch_{operation}: chunk done"
861 );
862 }
863 }
864
865 if num_chunks > 1 {
866 let failed = total - completed;
867 tracing::info!(
868 operation,
869 total,
870 completed,
871 failed,
872 "batch_{operation}: all chunks done"
873 );
874 }
875
876 all_results
877 }
878
879 fn all_batch_err(
881 relative_paths: &[String],
882 reason: &str,
883 ) -> HashMap<String, Result<(), InfraError>> {
884 relative_paths
885 .iter()
886 .map(|p| {
887 (
888 p.clone(),
889 Err(InfraError::Transfer {
890 reason: reason.to_string(),
891 }),
892 )
893 })
894 .collect()
895 }
896}
897
898fn parse_rclone_timestamp(s: &str) -> Option<DateTime<Utc>> {
903 let trimmed = s.trim();
904 NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f")
906 .or_else(|_| NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S"))
907 .ok()
908 .map(|naive| naive.and_utc())
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914
915 #[test]
916 fn remote_path_construction() {
917 let b = RcloneBackend::new(":b2,account=kid,key=k:bucket");
918 assert_eq!(
919 b.remote_path("models/ckpt.safetensors").unwrap(),
920 ":b2,account=kid,key=k:bucket/models/ckpt.safetensors"
921 );
922 assert_eq!(
923 b.remote_path("/leading/slash").unwrap(),
924 ":b2,account=kid,key=k:bucket/leading/slash"
925 );
926 assert_eq!(b.remote_path("").unwrap(), ":b2,account=kid,key=k:bucket");
927 }
928
929 #[test]
930 fn remote_path_rejects_flag_like_input() {
931 let b = RcloneBackend::new("remote:bucket");
932 assert!(b.remote_path("--config=/etc/rclone.conf").is_err());
933 assert!(b.remote_path("-v").is_err());
934 }
935
936 #[test]
937 fn remote_path_rejects_traversal() {
938 let b = RcloneBackend::new("remote:bucket");
939 assert!(b.remote_path("../../etc/passwd").is_err());
940 assert!(b.remote_path("foo/../bar").is_err());
941 assert!(b.remote_path("..").is_err());
942 assert!(b.remote_path("./valid").is_ok());
944 assert!(b.remote_path("a/.../b").is_ok());
946 }
947
948 #[test]
949 fn backend_type() {
950 let b = RcloneBackend::new("remote:bucket");
951 assert_eq!(b.backend_type(), "rclone");
952 }
953
954 #[test]
955 fn parse_rclone_timestamp_nanoseconds() {
956 let ts = parse_rclone_timestamp("2024-01-15T10:30:00.123456789");
957 assert!(ts.is_some());
958 let dt = ts.unwrap();
959 assert_eq!(dt.year(), 2024);
960 assert_eq!(dt.month(), 1);
961 assert_eq!(dt.day(), 15);
962 assert_eq!(dt.hour(), 10);
963 assert_eq!(dt.minute(), 30);
964 }
965
966 #[test]
967 fn parse_rclone_timestamp_no_fraction() {
968 let ts = parse_rclone_timestamp("2024-01-15T10:30:00");
969 assert!(ts.is_some());
970 }
971
972 #[test]
973 fn parse_rclone_timestamp_invalid() {
974 assert!(parse_rclone_timestamp("not-a-date").is_none());
975 assert!(parse_rclone_timestamp("").is_none());
976 }
977
978 #[test]
979 fn is_sftp_detection() {
980 let sftp = RcloneBackend::new(":sftp,host=1.2.3.4,port=22,user=root:");
981 assert!(sftp.is_sftp());
982 assert_eq!(
983 sftp.sftp_flags_for_script(),
984 " --sftp-set-modtime=false --sftp-disable-hashcheck"
985 );
986
987 let b2 = RcloneBackend::new(":b2,account=kid,key=k:bucket");
988 assert!(!b2.is_sftp());
989 assert_eq!(b2.sftp_flags_for_script(), "");
990 }
991
992 use chrono::Datelike;
993 use chrono::Timelike;
994}