use futures_util::StreamExt;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HotReloadResult {
Success { artifact_id: Uuid },
Failed { artifact_id: Uuid, error: String },
}
pub static HOT_RELOAD_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
pub async fn download_artifact(
http_client: &reqwest::Client,
download_url: &str,
expected_sha256: &str,
artifact_dir: &Path,
) -> Result<PathBuf, String> {
let temp_filename = format!("artifact-{}.bca.tmp", Uuid::new_v4());
let temp_path = artifact_dir.join(&temp_filename);
tracing::info!(
download_url = %download_url,
temp_path = %temp_path.display(),
"Downloading artifact"
);
let response = http_client
.get(download_url)
.send()
.await
.map_err(|e| format!("download request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"download failed with status: {}",
response.status()
));
}
let mut file = tokio::fs::File::create(&temp_path)
.await
.map_err(|e| format!("failed to create temp file: {}", e))?;
let mut hasher = Sha256::new();
let mut stream = response.bytes_stream();
let mut total_bytes = 0u64;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| format!("download stream error: {}", e))?;
hasher.update(&chunk);
file.write_all(&chunk)
.await
.map_err(|e| format!("write error: {}", e))?;
total_bytes += chunk.len() as u64;
}
file.flush()
.await
.map_err(|e| format!("flush error: {}", e))?;
drop(file);
let computed_sha256 = hex::encode(hasher.finalize());
if computed_sha256 != expected_sha256 {
let _ = tokio::fs::remove_file(&temp_path).await;
return Err(format!(
"checksum mismatch: expected {}, got {}",
expected_sha256, computed_sha256
));
}
let final_filename = format!("artifact-{}.bca", Uuid::new_v4());
let final_path = artifact_dir.join(&final_filename);
tokio::fs::rename(&temp_path, &final_path)
.await
.map_err(|e| format!("rename failed: {}", e))?;
tracing::info!(
final_path = %final_path.display(),
size_bytes = total_bytes,
sha256 = %computed_sha256,
"Artifact downloaded and verified"
);
Ok(final_path)
}
pub fn compute_sha256(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::SocketAddr;
use tokio::io::AsyncReadExt;
use tokio::net::TcpListener;
async fn start_test_server(
content: Vec<u8>,
status: u16,
) -> (SocketAddr, tokio::task::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move {
if let Ok((mut socket, _)) = listener.accept().await {
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
let response = format!(
"HTTP/1.1 {} OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
status,
content.len()
);
let _ = socket.write_all(response.as_bytes()).await;
let _ = socket.write_all(&content).await;
}
});
(addr, handle)
}
#[tokio::test]
async fn test_download_artifact_success() {
let content = b"test artifact content for download verification";
let expected_sha256 = compute_sha256(content);
let (addr, server_handle) = start_test_server(content.to_vec(), 200).await;
let url = format!("http://{}/artifact.bca", addr);
let temp_dir = tempfile::tempdir().unwrap();
let client = reqwest::Client::new();
let result = download_artifact(&client, &url, &expected_sha256, temp_dir.path()).await;
assert!(result.is_ok(), "download should succeed: {:?}", result);
let artifact_path = result.unwrap();
assert!(artifact_path.exists(), "artifact file should exist");
let downloaded_content = tokio::fs::read(&artifact_path).await.unwrap();
assert_eq!(downloaded_content, content);
server_handle.abort();
}
#[tokio::test]
async fn test_download_artifact_checksum_mismatch() {
let content = b"test artifact content";
let wrong_sha256 = "0000000000000000000000000000000000000000000000000000000000000000";
let (addr, server_handle) = start_test_server(content.to_vec(), 200).await;
let url = format!("http://{}/artifact.bca", addr);
let temp_dir = tempfile::tempdir().unwrap();
let client = reqwest::Client::new();
let result = download_artifact(&client, &url, wrong_sha256, temp_dir.path()).await;
assert!(
result.is_err(),
"download should fail with checksum mismatch"
);
let error = result.unwrap_err();
assert!(
error.contains("checksum mismatch"),
"error should mention checksum: {}",
error
);
let files: Vec<_> = std::fs::read_dir(temp_dir.path()).unwrap().collect();
assert!(files.is_empty(), "temp file should be cleaned up");
server_handle.abort();
}
#[tokio::test]
async fn test_download_artifact_http_error() {
let (addr, server_handle) = start_test_server(vec![], 404).await;
let url = format!("http://{}/artifact.bca", addr);
let temp_dir = tempfile::tempdir().unwrap();
let client = reqwest::Client::new();
let result = download_artifact(&client, &url, "dummy", temp_dir.path()).await;
assert!(result.is_err(), "download should fail with HTTP error");
let error = result.unwrap_err();
assert!(
error.contains("404") || error.contains("status"),
"error should mention status: {}",
error
);
server_handle.abort();
}
#[tokio::test]
async fn test_download_artifact_connection_refused() {
let url = "http://127.0.0.1:1/artifact.bca";
let temp_dir = tempfile::tempdir().unwrap();
let client = reqwest::Client::new();
let result = download_artifact(&client, url, "dummy", temp_dir.path()).await;
assert!(
result.is_err(),
"download should fail with connection error"
);
let error = result.unwrap_err();
assert!(
error.contains("download request failed"),
"error should mention request failed: {}",
error
);
}
#[tokio::test]
async fn test_hot_reload_lock_prevents_concurrent_reloads() {
let guard = HOT_RELOAD_LOCK.try_lock();
assert!(guard.is_ok(), "first lock should succeed");
let second_guard = HOT_RELOAD_LOCK.try_lock();
assert!(second_guard.is_err(), "second lock should fail");
drop(guard);
let third_guard = HOT_RELOAD_LOCK.try_lock();
assert!(third_guard.is_ok(), "third lock should succeed after drop");
}
#[test]
fn test_compute_sha256() {
let data = b"hello world";
let hash = compute_sha256(data);
assert_eq!(
hash,
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
#[test]
fn test_compute_sha256_empty() {
let data = b"";
let hash = compute_sha256(data);
assert_eq!(
hash,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn test_hot_reload_result_equality() {
let id = Uuid::new_v4();
let success1 = HotReloadResult::Success { artifact_id: id };
let success2 = HotReloadResult::Success { artifact_id: id };
assert_eq!(success1, success2);
let failed1 = HotReloadResult::Failed {
artifact_id: id,
error: "test error".to_string(),
};
let failed2 = HotReloadResult::Failed {
artifact_id: id,
error: "test error".to_string(),
};
assert_eq!(failed1, failed2);
assert_ne!(success1, failed1);
}
}