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,
archive_root: Option<PathBuf>,
}
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,
archive_root: None,
}
}
pub fn with_archive_root(mut self, archive_root: PathBuf) -> Self {
self.archive_root = Some(archive_root);
self
}
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 fn archive_root(&self) -> Option<&Path> {
self.archive_root.as_deref()
}
pub async fn restore_from_archive(
&self,
relative_path: &str,
revision: &str,
) -> Result<(), SyncError> {
Self::validate_relative_path(relative_path)?;
let archive_root = self.archive_root.as_ref().ok_or_else(|| -> SyncError {
InfraError::Transfer {
reason: format!(
"restore_from_archive: route has no archive_root (src={}, dest={})",
self.src, self.dest
),
}
.into()
})?;
if self.direction != TransferDirection::Push {
return Err(InfraError::Transfer {
reason: "restore_from_archive: only Push routes are supported".into(),
}
.into());
}
let archive_full = archive_root.join(revision).join(relative_path);
let dest_full = Self::safe_join(&self.dest_file_root, relative_path);
let archive_str = archive_full.to_str().ok_or_else(|| -> SyncError {
InfraError::Transfer {
reason: format!("archive path not valid UTF-8: {}", archive_full.display()),
}
.into()
})?;
let dest_str = dest_full.to_str().ok_or_else(|| -> SyncError {
InfraError::Transfer {
reason: format!("dest path not valid UTF-8: {}", dest_full.display()),
}
.into()
})?;
tracing::info!(
archive = archive_str,
dest = dest_str,
"route::restore_from_archive: moveto reverse"
);
self.backend
.archive_move(archive_str, dest_str)
.await
.map_err(Into::into)
}
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)
}
}
}
fn build_archive_path(&self, relative_path: &str) -> Option<String> {
let archive_root = self.archive_root.as_ref()?;
let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
let archive_dest = archive_root.join(&ts).join(relative_path);
Some(archive_dest.to_string_lossy().into_owned())
}
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()
})?;
if let Some(archive_dest) = self.build_archive_path(relative_path) {
tracing::debug!(
src = dest_str,
archive = %archive_dest,
"route::delete: archive_move (soft-delete)"
);
return self
.backend
.archive_move(dest_str, &archive_dest)
.await
.map_err(Into::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 => {
if let Some(archive_root) = &self.archive_root {
let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
let dest_root_str = self.dest_file_root.to_str().unwrap_or_default();
let archive_dest = archive_root.join(&ts);
let archive_dest_str = archive_dest.to_string_lossy().into_owned();
tracing::debug!(
src = dest_root_str,
archive = %archive_dest_str,
count = relative_paths.len(),
"route::delete_batch: archive_move_batch (soft-delete)"
);
return self
.backend
.archive_move_batch(dest_root_str, &archive_dest_str, relative_paths)
.await
.into_iter()
.map(|(k, v)| (k, v.map_err(Into::into)))
.collect();
}
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(),
});
}
if path.chars().any(|c| c.is_control()) {
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());
}
#[test]
fn validate_rejects_control_chars() {
assert!(TransferRoute::validate_relative_path("evil\n__VDSL_EOF__\nrm -rf ~\n.png").is_err());
assert!(TransferRoute::validate_relative_path("foo\nbar.png").is_err());
assert!(TransferRoute::validate_relative_path("foo\rbar.png").is_err());
assert!(TransferRoute::validate_relative_path("foo\tbar.png").is_err());
assert!(TransferRoute::validate_relative_path("foo\0bar.png").is_err());
}
}