signer-storage 0.4.1

Signer storage and caching package.
Documentation
use futures::stream::TryStreamExt;
use signer_core::SignerRemoteResource;
use signer_remote::RemoteError;
use signer_remote::remote::{HttpClient, HttpClientConfig};
use std::path::PathBuf;
use tokio::fs::{self, File};
use tokio::io::AsyncWriteExt;

// --- Moved from signer-daemon/src/cache.rs ---
/// 缓存配置
#[derive(Debug, Clone)]
pub struct CacheConfig {
    /// 缓存目录
    pub cache_dir: PathBuf,
}

impl CacheConfig {
    /// 创建一个新的缓存配置
    pub fn new(cache_dir: PathBuf) -> Self {
        Self { cache_dir }
    }
}

// --- New Error Type ---
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),
    #[error("Reqwest error: {0}")]
    Reqwest(#[from] reqwest::Error),
    #[error("Signer remote error: {0}")]
    SignerRemote(#[from] RemoteError),
    #[error("Serde json error: {0}")]
    SerdeJson(#[from] serde_json::Error),
    #[error("Custom error: {0}")]
    Msg(String),
}

// --- Moved from signer-daemon/src/remote_resource_service.rs ---

/// 远端资源服务
///
/// 提供处理远端资源下载和缓存的静态方法
pub struct RemoteResourceService;

impl RemoteResourceService {
    /// 从本地缓存中查询文件
    pub fn find_cache(
        cache_config: &CacheConfig,
        resource: &SignerRemoteResource,
    ) -> Result<Option<PathBuf>, StorageError> {
        let cache_path = cache_config
            .cache_dir
            .join(&resource.pubkey)
            .join(&resource.hash);
        if cache_path.exists() {
            Ok(Some(cache_path))
        } else {
            Ok(None)
        }
    }

    /// 从远端下载文件到缓存
    pub async fn download(
        cache_config: &CacheConfig,
        resource: &SignerRemoteResource,
    ) -> Result<PathBuf, StorageError> {
        // 检查缓存是否存在
        if let Some(cache_path) = Self::find_cache(cache_config, resource)? {
            return Ok(cache_path);
        }

        // 遍历远端服务查找并下载
        for remote in &resource.remotes {
            let config = HttpClientConfig::new_no_auth(remote.clone());
            let client = HttpClient::new(config);

            let result = client
                .get_raw(&format!(
                    "/api/storage/item/{}/{}",
                    &resource.pubkey, &resource.hash
                ))
                .await;

            if let Ok(res) = result {
                let cache_dir = cache_config.cache_dir.join(&resource.pubkey);
                fs::create_dir_all(&cache_dir).await?;
                let cache_path = cache_dir.join(&resource.hash);

                let mut file = File::create(&cache_path).await?;
                let mut response_stream = res.bytes_stream();

                while let Some(chunk) = response_stream.try_next().await? {
                    file.write_all(&chunk).await?;
                }

                return Ok(cache_path);
            }
            // 如果请求失败,则继续尝试下一个 remote
        }

        Err(StorageError::Msg(
            "在所有远端都找不到或无法下载该文件".to_string(),
        ))
    }
}

// --- Tests ---
#[cfg(test)]
mod test {
    use super::*;
    // Note: This test now has a dev-dependency on signer-daemon
    use signer_core::{SignerKeys, SignerUser};
    use signer_remote::{SignerDaemonStorage, SignerRemote};
    use tempfile::tempdir;

    type TestResult<T> = Result<T, Box<dyn std::error::Error>>;

    async fn setup() -> TestResult<(SignerKeys, SignerUser, String)> {
        let keys = SignerKeys::generate()?;
        let user = SignerUser {
            pub_key: keys.pub_key.clone(),
            username: "tester".to_string(),
            ..Default::default()
        };
        let remote_addr = "http://localhost:8080".to_string();
        // This part is problematic as it requires a running daemon.
        // For now, I will keep it, but this highlights that these are integration tests.
        let remote = SignerRemote::new(&remote_addr);
        remote.ping(&keys, &user).await?;
        Ok((keys, user, remote_addr))
    }

    #[tokio::test]
    #[ignore] // Ignoring tests as they require a live daemon and are more for integration.
    async fn test_download_and_cache_hit() -> TestResult<()> {
        let (keys, user, remote_addr) = setup().await?;
        let temp_dir = tempdir()?;
        let cache_config = CacheConfig::new(temp_dir.path().to_path_buf());

        // 1. 上传一个文件获取 hash
        let storage = SignerDaemonStorage::new(remote_addr.clone(), keys, user.clone());
        let source_path = PathBuf::from("../../Cargo.toml");
        let item = storage.upload(source_path.clone()).await?;

        // 2. 首次下载
        let resource = SignerRemoteResource {
            pubkey: item.pubkey.clone(),
            hash: item.hash.clone(),
            remotes: vec![remote_addr.clone()],
            file_name: Some(item.file_name.clone()),
            file_size: None,
        };

        let start_time = std::time::Instant::now();
        let downloaded_path = RemoteResourceService::download(&cache_config, &resource).await?;
        let first_download_duration = start_time.elapsed();

        // 验证下载内容
        let source_content = tokio::fs::read(source_path).await?;
        let downloaded_content = tokio::fs::read(&downloaded_path).await?;
        assert_eq!(source_content, downloaded_content);

        // 3. 再次下载(应该命中缓存)
        let start_time = std::time::Instant::now();
        let cached_path = RemoteResourceService::download(&cache_config, &resource).await?;
        let second_download_duration = start_time.elapsed();

        assert_eq!(downloaded_path, cached_path);
        assert!(
            second_download_duration < first_download_duration,
            "缓存命中应该比首次下载快"
        );

        Ok(())
    }

    #[tokio::test]
    #[ignore]
    async fn test_download_with_one_invalid_remote() -> TestResult<()> {
        let (keys, user, remote_addr) = setup().await?;
        let temp_dir = tempdir()?;
        let cache_config = CacheConfig::new(temp_dir.path().to_path_buf());

        let storage = SignerDaemonStorage::new(remote_addr.clone(), keys, user.clone());
        let source_path = PathBuf::from("../../Cargo.toml");
        let item = storage.upload(source_path).await?;

        let invalid_remote = "http://localhost:12345".to_string();
        let resource = SignerRemoteResource {
            pubkey: item.pubkey,
            hash: item.hash,
            remotes: vec![invalid_remote, remote_addr],
            file_name: Some(item.file_name.clone()),
            file_size: None,
        };

        let downloaded_path = RemoteResourceService::download(&cache_config, &resource).await?;
        assert!(downloaded_path.exists());

        Ok(())
    }

    #[tokio::test]
    #[ignore]
    async fn test_download_with_all_invalid_remotes() -> TestResult<()> {
        let (_, _, _remote_addr) = setup().await?;
        let temp_dir = tempdir()?;
        let cache_config = CacheConfig::new(temp_dir.path().to_path_buf());

        let resource = SignerRemoteResource {
            pubkey: "any_pubkey".to_string(),
            hash: "any_hash".to_string(),
            remotes: vec![
                "http://localhost:12345".to_string(),
                "http://localhost:54321".to_string(),
            ],
            file_name: Some("any_file_name".to_string()),
            file_size: None,
        };

        let result = RemoteResourceService::download(&cache_config, &resource).await;
        assert!(result.is_err());

        Ok(())
    }
}