remozipsy 0.2.0

Remote Zip Sync - sync remote zip to local fs
Documentation
use std::{
    future::Future,
    path::{Path, PathBuf, StripPrefixError},
};

use bytes::Bytes;
use path_clean::PathClean;

use crate::{
    model::FileInfo,
    proto::{FileSystem, calculate_local_unix_path},
};

#[derive(Debug, Clone)]
pub struct TokioLocalStorage {
    base:         PathBuf,
    ignore_parts: Vec<String>,
}

#[derive(Debug, thiserror::Error)]
pub enum TokioLocalStorageError {
    #[error("An operation outside of the base directory is requested, is this some kind of escape attack? Dir: {0}")]
    AccessOutOfBaseDirectory(PathBuf),
    #[error("Filename not within root directory, is this some kind of escape attack?")]
    StripPrefixError(#[from] StripPrefixError),
    #[error("Io Error: {0}")]
    Io(#[from] std::io::Error),
    #[error("Invalid UTF-8 filename. This code requires filenames to match UTF-8")]
    InvalidUtf8Filename,
}

impl TokioLocalStorage {
    pub fn new(base: PathBuf, ignore_parts: Vec<String>) -> Self { Self { base, ignore_parts } }

    fn sub_path(&self, path: &str) -> Result<PathBuf, TokioLocalStorageError> {
        let path = self.base.join(path).clean();

        if !path.starts_with(&self.base) {
            return Err(TokioLocalStorageError::AccessOutOfBaseDirectory(path));
        }

        Ok(path)
    }
}

impl FileSystem for TokioLocalStorage {
    type Error = TokioLocalStorageError;
    type StorePrepare = tokio::fs::File;

    fn all_files(&mut self) -> impl Future<Output = Result<Vec<FileInfo>, Self::Error>> + Send {
        let mut nextdirs = vec![self.base.clone()];
        let mut file_infos = Vec::new();
        async move {
            while let Some(next) = nextdirs.pop() {
                let mut dir = tokio::fs::read_dir(&next).await?;
                while let Some(entry) = dir.next_entry().await? {
                    let path = entry.path();

                    let relative_path = path.strip_prefix(&self.base)?;

                    if self.ignore_parts.iter().any(|ignore| relative_path.starts_with(ignore)) {
                        continue;
                    }

                    if path.is_dir() {
                        nextdirs.push(path);
                    } else {
                        parse_file_info(&self.base, path, &mut file_infos).await?;
                    }
                }
            }

            Ok(file_infos)
        }
    }

    fn prepare_store_file(
        &self,
        info: FileInfo,
    ) -> impl Future<Output = Result<Self::StorePrepare, Self::Error>> + Send {
        let path = self.sub_path(&info.local_unix_path);
        async move {
            let path = path?;
            if let Some(parent) = path.parent() {
                tokio::fs::create_dir_all(parent).await?;
            };
            let file = tokio::fs::File::create(path).await?;
            Ok(file)
        }
    }

    #[expect(clippy::manual_async_fn)]
    fn store_file(
        &self,
        mut prepared: Self::StorePrepare,
        mut data: Bytes,
    ) -> impl Future<Output = Result<(), Self::Error>> + Send {
        async move {
            use tokio::io::AsyncWriteExt;
            prepared.write_all_buf(&mut data).await?;
            Ok(())
        }
    }

    fn delete_file(&self, info: FileInfo) -> impl Future<Output = Result<(), Self::Error>> + Send {
        let path = self.sub_path(&info.local_unix_path);
        async move {
            let path = path?;
            tokio::fs::remove_file(path).await?;
            Ok(())
        }
    }
}

async fn parse_file_info(
    root: &Path,
    path: PathBuf,
    file_infos: &mut Vec<FileInfo>,
) -> Result<(), TokioLocalStorageError> {
    let file_bytes = tokio::fs::read(&path).await?;
    let crc32 = crc32fast::hash(&file_bytes);
    let local_unix_path = calculate_local_unix_path(root, &path).ok_or(TokioLocalStorageError::InvalidUtf8Filename)?;

    file_infos.push(FileInfo { crc32, local_unix_path });
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_tokio_local_storage_all_files() -> Result<(), Box<dyn std::error::Error>> {
        let base_dir = std::env::current_dir()?.join("tests/testfiles");
        assert!(base_dir.is_dir());

        let mut storage = TokioLocalStorage::new(base_dir, vec![".gitignore".to_string()]);

        let mut expected_files = vec![FileInfo {
            local_unix_path: "example1.zip".to_string(),
            crc32: 858847700,
        }];
        expected_files.sort_by_key(|e| e.local_unix_path.clone());

        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap();
        let file_infos = rt.block_on(storage.all_files())?;

        assert_eq!(file_infos, expected_files);

        Ok(())
    }

    #[test]
    fn test_tokio_local_storage_all_files_recursive() -> Result<(), Box<dyn std::error::Error>> {
        let base_dir = std::env::current_dir()?.join("tests");
        assert!(base_dir.is_dir());

        let mut storage = TokioLocalStorage::new(base_dir, vec!["testfiles/.gitignore".to_string()]);

        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap();
        let file_infos = rt.block_on(storage.all_files())?;

        println!("{:?}", file_infos);

        // don't be exact, to not crash for each new file
        assert!(file_infos.len() >= 2);

        assert!(!file_infos.iter().any(|e| e.local_unix_path == "testfiles/.gitignore"));
        assert!(file_infos.iter().any(|e| e.local_unix_path == "testfiles/example1.zip"));
        Ok(())
    }

    #[test]
    fn test_sub_path() {
        let base_dir = TokioLocalStorage::new(PathBuf::from("/tmp"), Vec::new());
        assert_eq!("/tmp/foo", base_dir.sub_path("foo").unwrap().as_os_str());
        assert_eq!("/tmp/foo/bar", base_dir.sub_path("foo/bar").unwrap().as_os_str());
        assert!(base_dir.sub_path("../illegal").is_err());
        assert!(base_dir.sub_path("../root").is_err());
    }
}