use std::path::{Path, PathBuf};
use std::collections::HashMap;
use crate::application::error::SyncError;
use crate::domain::location::LocationId;
use crate::infra::backend::StorageBackend;
use crate::infra::error::InfraError;
use crate::infra::shell::RemoteShell;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TransferDirection {
#[default]
Push,
Pull,
}
pub struct TransferRoute {
src: LocationId,
dest: LocationId,
src_file_root: PathBuf,
dest_file_root: PathBuf,
backend: Box<dyn StorageBackend>,
src_shell: Option<Box<dyn RemoteShell>>,
direction: TransferDirection,
time_per_gb: f64,
priority: u32,
}
impl TransferRoute {
pub fn new(
src: LocationId,
dest: LocationId,
src_file_root: PathBuf,
dest_file_root: PathBuf,
backend: Box<dyn StorageBackend>,
) -> Self {
Self {
src,
dest,
src_file_root,
dest_file_root,
backend,
src_shell: None,
direction: TransferDirection::Push,
time_per_gb: 1.0,
priority: 100,
}
}
pub fn direction(mut self, direction: TransferDirection) -> Self {
self.direction = direction;
self
}
pub fn with_src_shell(mut self, shell: Box<dyn RemoteShell>) -> Self {
self.src_shell = Some(shell);
self
}
pub fn with_cost(mut self, time_per_gb: f64, priority: u32) -> Self {
self.time_per_gb = time_per_gb;
self.priority = priority;
self
}
pub fn time_per_gb(&self) -> f64 {
self.time_per_gb
}
pub fn priority(&self) -> u32 {
self.priority
}
pub fn src(&self) -> &LocationId {
&self.src
}
pub fn dest(&self) -> &LocationId {
&self.dest
}
pub fn src_file_root(&self) -> &Path {
&self.src_file_root
}
pub fn is_pull(&self) -> bool {
self.direction == TransferDirection::Pull
}
pub(crate) fn backend(&self) -> &dyn StorageBackend {
&*self.backend
}
pub async fn transfer(&self, relative_path: &str) -> Result<(), SyncError> {
Self::validate_relative_path(relative_path)?;
let src_path = self.src_file_root.join(relative_path);
let dest_path = Self::safe_join(&self.dest_file_root, relative_path);
match self.direction {
TransferDirection::Push => {
let dest_str = dest_path.to_str().ok_or_else(|| -> SyncError {
InfraError::Transfer {
reason: format!(
"dest path is not valid UTF-8: {}",
dest_path.to_string_lossy()
),
}
.into()
})?;
self.backend
.push(&src_path, dest_str)
.await
.map_err(Into::into)
}
TransferDirection::Pull => {
let src_str = src_path.to_str().ok_or_else(|| -> SyncError {
InfraError::Transfer {
reason: format!(
"src path is not valid UTF-8: {}",
src_path.to_string_lossy()
),
}
.into()
})?;
self.backend
.pull(src_str, &dest_path)
.await
.map_err(Into::into)
}
}
}
pub async fn delete(&self, relative_path: &str) -> Result<(), SyncError> {
Self::validate_relative_path(relative_path)?;
let dest_path = Self::safe_join(&self.dest_file_root, relative_path);
match self.direction {
TransferDirection::Push => {
let dest_str = dest_path.to_str().ok_or_else(|| -> SyncError {
InfraError::Transfer {
reason: format!(
"dest path is not valid UTF-8: {}",
dest_path.to_string_lossy()
),
}
.into()
})?;
self.backend.delete(dest_str).await.map_err(Into::into)
}
TransferDirection::Pull => {
match tokio::fs::remove_file(&dest_path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(SyncError::from(e)),
}
}
}
}
pub async fn transfer_batch(
&self,
relative_paths: &[String],
) -> HashMap<String, Result<(), SyncError>> {
if relative_paths.is_empty() {
return HashMap::new();
}
for rel in relative_paths {
if Self::validate_relative_path(rel).is_err() {
return relative_paths
.iter()
.map(|p| {
(
p.clone(),
Err(SyncError::OutsideSyncRoot { path: p.clone() }),
)
})
.collect();
}
}
match self.direction {
TransferDirection::Push => {
let dest_root_str = self.dest_file_root.to_str().unwrap_or_default();
self.backend
.push_batch(&self.src_file_root, dest_root_str, relative_paths)
.await
.into_iter()
.map(|(k, v)| (k, v.map_err(Into::into)))
.collect()
}
TransferDirection::Pull => {
let src_root_str = self.src_file_root.to_str().unwrap_or_default();
self.backend
.pull_batch(src_root_str, &self.dest_file_root, relative_paths)
.await
.into_iter()
.map(|(k, v)| (k, v.map_err(Into::into)))
.collect()
}
}
}
pub async fn delete_batch(
&self,
relative_paths: &[String],
) -> HashMap<String, Result<(), SyncError>> {
if relative_paths.is_empty() {
return HashMap::new();
}
for rel in relative_paths {
if Self::validate_relative_path(rel).is_err() {
return relative_paths
.iter()
.map(|p| {
(
p.clone(),
Err(SyncError::OutsideSyncRoot { path: p.clone() }),
)
})
.collect();
}
}
match self.direction {
TransferDirection::Push => {
let dest_root_str = self.dest_file_root.to_str().unwrap_or_default();
self.backend
.delete_batch(dest_root_str, relative_paths)
.await
.into_iter()
.map(|(k, v)| (k, v.map_err(Into::into)))
.collect()
}
TransferDirection::Pull => {
let mut results = HashMap::with_capacity(relative_paths.len());
for rel in relative_paths {
let result = self.delete(rel).await;
results.insert(rel.clone(), result);
}
results
}
}
}
pub fn supports_batch(&self) -> bool {
self.backend.supports_batch()
}
pub async fn src_file_exists(&self, relative_path: &str) -> Result<bool, SyncError> {
Self::validate_relative_path(relative_path)?;
let full_path = self.src_file_root.join(relative_path);
match &self.src_shell {
None => {
tokio::fs::try_exists(&full_path)
.await
.map_err(SyncError::from)
}
Some(shell) => {
let path_str = full_path.to_str().ok_or_else(|| -> SyncError {
InfraError::Transfer {
reason: format!(
"src path is not valid UTF-8: {}",
full_path.to_string_lossy()
),
}
.into()
})?;
let output = shell.exec(&["test", "-f", path_str], Some(10)).await?;
Ok(output.success)
}
}
}
fn validate_relative_path(path: &str) -> Result<(), SyncError> {
let path = path.trim_start_matches('/');
if path.split('/').any(|seg| seg == "..") {
return Err(SyncError::OutsideSyncRoot {
path: path.to_string(),
});
}
Ok(())
}
pub(crate) fn safe_join(root: &Path, relative: &str) -> PathBuf {
root.join(relative.trim_start_matches('/'))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_join_normal() {
assert_eq!(
TransferRoute::safe_join(Path::new("vdsl/output"), "images/001.png"),
PathBuf::from("vdsl/output/images/001.png")
);
}
#[test]
fn safe_join_trailing_slash() {
assert_eq!(
TransferRoute::safe_join(Path::new("root/"), "file.png"),
PathBuf::from("root/file.png")
);
}
#[test]
fn safe_join_leading_slash() {
assert_eq!(
TransferRoute::safe_join(Path::new("root"), "/file.png"),
PathBuf::from("root/file.png")
);
}
#[test]
fn safe_join_empty_root() {
assert_eq!(
TransferRoute::safe_join(Path::new(""), "file.png"),
PathBuf::from("file.png")
);
}
#[test]
fn safe_join_both_slashes() {
assert_eq!(
TransferRoute::safe_join(Path::new("root/"), "/file.png"),
PathBuf::from("root/file.png")
);
}
#[test]
fn validate_rejects_traversal() {
assert!(TransferRoute::validate_relative_path("../../etc/passwd").is_err());
assert!(TransferRoute::validate_relative_path("foo/../bar").is_err());
assert!(TransferRoute::validate_relative_path("..").is_err());
}
#[test]
fn validate_allows_safe_paths() {
assert!(TransferRoute::validate_relative_path("images/001.png").is_ok());
assert!(TransferRoute::validate_relative_path("./valid").is_ok());
assert!(TransferRoute::validate_relative_path("a/.../b").is_ok());
assert!(TransferRoute::validate_relative_path("").is_ok());
}
}