diff --git a/Cargo.lock b/Cargo.lock index 89b94fd191..df12df505e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1752,6 +1752,7 @@ dependencies = [ "rustls 0.23.40", "serde", "serde_json", + "sha2 0.10.9", "sqlx", "tiberius", "tokio", diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml index 73b594ae2c..f538723bca 100644 --- a/crates/dbx-core/Cargo.toml +++ b/crates/dbx-core/Cargo.toml @@ -33,3 +33,4 @@ csv = "1" calamine = "0.30.1" base64 = "0.22" async-trait = "0.1" +sha2 = "0.10" diff --git a/crates/dbx-core/src/agent_service.rs b/crates/dbx-core/src/agent_service.rs index d82a4e81f9..ecbcb28589 100644 --- a/crates/dbx-core/src/agent_service.rs +++ b/crates/dbx-core/src/agent_service.rs @@ -135,3 +135,85 @@ pub fn github_url_to_r2_path(github_url: &str, category: &str) -> String { _ => format!("agents/{filename}"), } } + +pub fn ensure_driver_app_version( + db_type: &str, + driver: &crate::agent_manager::DriverInfo, + current_version: &str, +) -> Result<(), String> { + if is_app_version_compatible(&driver.min_app_version, current_version) { + return Ok(()); + } + Err(format!( + "{db_type} driver {} requires DBX {} or newer. Current DBX version is {}.", + driver.version, driver.min_app_version, current_version + )) +} + +pub fn is_app_version_compatible(min_app_version: &str, current_version: &str) -> bool { + !crate::update::is_newer_version(min_app_version, current_version) +} + +pub fn download_temp_path(dest: &std::path::Path) -> std::path::PathBuf { + let file_name = dest.file_name().and_then(|name| name.to_str()).unwrap_or("download"); + dest.with_file_name(format!("{file_name}.download")) +} + +pub fn verify_and_replace_download( + tmp: &std::path::Path, + dest: &std::path::Path, + expected_sha256: &str, +) -> Result<(), String> { + if let Err(err) = verify_file_sha256(tmp, expected_sha256) { + std::fs::remove_file(tmp).ok(); + return Err(err); + } + + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + if dest.exists() { + let backup = backup_path(dest); + std::fs::rename(dest, &backup).map_err(|e| format!("Failed to back up existing file: {e}"))?; + match std::fs::rename(tmp, dest) { + Ok(()) => { + std::fs::remove_file(&backup).ok(); + Ok(()) + } + Err(err) => { + let _ = std::fs::rename(&backup, dest); + Err(format!("Failed to replace downloaded file: {err}")) + } + } + } else { + std::fs::rename(tmp, dest).map_err(|e| format!("Failed to move downloaded file into place: {e}")) + } +} + +fn backup_path(dest: &std::path::Path) -> std::path::PathBuf { + let file_name = dest.file_name().and_then(|name| name.to_str()).unwrap_or("download"); + dest.with_file_name(format!("{file_name}.backup-{}", uuid::Uuid::new_v4())) +} + +pub fn verify_file_sha256(path: &std::path::Path, expected_sha256: &str) -> Result<(), String> { + use sha2::{Digest, Sha256}; + let expected = expected_sha256.trim().to_ascii_lowercase(); + let mut file = + std::fs::File::open(path).map_err(|e| format!("Failed to open file for SHA-256 verification: {e}"))?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8192]; + loop { + let n = std::io::Read::read(&mut file, &mut buffer) + .map_err(|e| format!("Failed to read file for SHA-256 verification: {e}"))?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + let actual = hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect::(); + if actual == expected { + Ok(()) + } else { + Err(format!("SHA-256 mismatch for {}: expected {expected}, got {actual}", path.display())) + } +} diff --git a/crates/dbx-core/tests/agent_service.rs b/crates/dbx-core/tests/agent_service.rs index 036fb31e4a..9ab62e7e79 100644 --- a/crates/dbx-core/tests/agent_service.rs +++ b/crates/dbx-core/tests/agent_service.rs @@ -2,6 +2,7 @@ use dbx_core::agent_manager::{ AgentManager, AgentRegistry, ArtifactInfo, DriverInfo, InstalledDriver, DEFAULT_JRE_KEY, }; use dbx_core::agent_service::{build_agent_list, github_url_to_r2_path, local_agent_jar_candidates}; +use dbx_core::agent_service::{is_app_version_compatible, verify_and_replace_download, verify_file_sha256}; fn test_manager(name: &str) -> AgentManager { let dir = std::env::temp_dir().join(format!("dbx-agent-service-{name}-{}", uuid::Uuid::new_v4())); @@ -90,3 +91,46 @@ fn github_agent_asset_urls_map_to_r2_paths_by_category() { "agents/drivers/dbx-agent-h2.jar" ); } + +fn test_path(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("dbx-agent-service-{name}-{}", uuid::Uuid::new_v4())) +} + +#[test] +fn accepts_current_app_when_min_version_is_not_newer() { + assert!(is_app_version_compatible("0.5.13", "0.5.13")); + assert!(is_app_version_compatible("0.5.12", "0.5.13")); + assert!(!is_app_version_compatible("0.5.14", "0.5.13")); +} + +#[test] +fn verifies_file_sha256() { + let path = test_path("sha256"); + std::fs::write(&path, b"dbx").unwrap(); + + verify_file_sha256(&path, "e4a49ac91ae97a25f60bbe2fa6e25809af94df975aa42d3db29edc17e44e6989").unwrap(); + let err = + verify_file_sha256(&path, "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff").unwrap_err(); + + assert!(err.contains("SHA-256 mismatch")); + std::fs::remove_file(path).ok(); +} + +#[test] +fn atomic_replace_keeps_existing_destination_when_hash_fails() { + let dir = test_path("atomic"); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("agent.jar"); + let tmp = dir.join("agent.jar.download"); + std::fs::write(&dest, b"old").unwrap(); + std::fs::write(&tmp, b"new").unwrap(); + + let err = + verify_and_replace_download(&tmp, &dest, "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + .unwrap_err(); + + assert!(err.contains("SHA-256 mismatch")); + assert_eq!(std::fs::read(&dest).unwrap(), b"old"); + assert!(!tmp.exists()); + std::fs::remove_dir_all(dir).ok(); +} diff --git a/crates/dbx-web/src/routes/agents.rs b/crates/dbx-web/src/routes/agents.rs index cd3fda4dd7..e4b4eb5396 100644 --- a/crates/dbx-web/src/routes/agents.rs +++ b/crates/dbx-web/src/routes/agents.rs @@ -8,8 +8,8 @@ use dbx_core::agent_manager::{ DEFAULT_JRE_KEY, }; use dbx_core::agent_service::{ - build_agent_list, fetch_registry, find_local_agent_jar, github_url_to_r2_path, install_local_agent, - invalidate_registry_cache, + build_agent_list, download_temp_path, ensure_driver_app_version, fetch_registry, find_local_agent_jar, + github_url_to_r2_path, install_local_agent, invalidate_registry_cache, verify_and_replace_download, }; use futures::Stream; use serde::Deserialize; @@ -215,6 +215,7 @@ async fn install_agent_from_registry( } return Err(format!("Unknown driver type: {db_type}")); }; + ensure_driver_app_version(db_type, driver, env!("CARGO_PKG_VERSION"))?; let jre_key = &driver.jre; let needs_jre = am.load_state().java_runtime.mode == JavaRuntimeMode::Managed && !am.is_jre_installed(jre_key); @@ -234,6 +235,7 @@ async fn install_agent_from_registry( &platform_jre.url, &github_url_to_r2_path(&platform_jre.url, "jre"), &jre_archive, + &platform_jre.sha256, platform_jre.size, Some(db_type), current, @@ -253,6 +255,7 @@ async fn install_agent_from_registry( &driver.jar.url, &github_url_to_r2_path(&driver.jar.url, "driver"), &jar_path, + &driver.jar.sha256, driver.jar.size, Some(db_type), current, @@ -278,10 +281,6 @@ async fn install_agent_from_registry( } async fn reinstall_jre_core(am: &AgentManager, jre_key: &str, tx: &broadcast::Sender) -> Result<(), String> { - let jre_dir = am.jre_dir(jre_key); - if jre_dir.exists() { - std::fs::remove_dir_all(&jre_dir).map_err(|err| format!("Failed to remove old JRE: {err}"))?; - } let registry = fetch_registry().await?; let jre_info = registry.resolve_jre(jre_key).ok_or_else(|| format!("No JRE definition for version: {jre_key}"))?; let platform = AgentManager::current_platform(); @@ -296,12 +295,17 @@ async fn reinstall_jre_core(am: &AgentManager, jre_key: &str, tx: &broadcast::Se &platform_jre.url, &github_url_to_r2_path(&platform_jre.url, "jre"), &jre_archive, + &platform_jre.sha256, platform_jre.size, None, None, None, ) .await?; + let jre_dir = am.jre_dir(jre_key); + if jre_dir.exists() { + std::fs::remove_dir_all(&jre_dir).map_err(|err| format!("Failed to remove old JRE: {err}"))?; + } extract_archive(&jre_archive, &jre_dir)?; std::fs::remove_file(&jre_archive).ok(); let mut local_state = am.load_state(); @@ -317,6 +321,7 @@ async fn download_with_progress( url: &str, r2_path: &str, dest: &std::path::Path, + expected_sha256: &str, total_size: u64, db_type: Option<&str>, current: Option, @@ -325,6 +330,7 @@ async fn download_with_progress( if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent).map_err(|err| err.to_string())?; } + let tmp = download_temp_path(dest); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(300)) .build() @@ -333,14 +339,16 @@ async fn download_with_progress( .await .map_err(|err| format!("Failed to download {url}: {err}"))?; let content_length = resp.content_length().unwrap_or(total_size); - let mut file = std::fs::File::create(dest).map_err(|err| format!("Failed to create file: {err}"))?; + let mut file = std::fs::File::create(&tmp).map_err(|err| format!("Failed to create temp file: {err}"))?; let mut downloaded = 0; while let Some(chunk) = resp.chunk().await.map_err(|err| format!("Download stream error: {err}"))? { std::io::Write::write_all(&mut file, &chunk).map_err(|err| format!("Failed to write chunk: {err}"))?; downloaded += chunk.len() as u64; send_install_progress(tx, step, downloaded, content_length, db_type, current, total_drivers); } - Ok(()) + std::io::Write::flush(&mut file).map_err(|err| format!("Failed to flush temp file: {err}"))?; + drop(file); + verify_and_replace_download(&tmp, dest, expected_sha256) } fn send_install_progress( diff --git a/src-tauri/src/commands/agents.rs b/src-tauri/src/commands/agents.rs index a1a3a26aba..9f8f4a4c5c 100644 --- a/src-tauri/src/commands/agents.rs +++ b/src-tauri/src/commands/agents.rs @@ -6,8 +6,8 @@ use dbx_core::agent_manager::{ AgentDriverInfo, AgentManager, InstalledDriver, JavaRuntimeConfig, JavaRuntimeMode, DEFAULT_JRE_KEY, }; use dbx_core::agent_service::{ - build_agent_list, fetch_registry, find_local_agent_jar, github_url_to_r2_path, install_local_agent, - invalidate_registry_cache, + build_agent_list, download_temp_path, ensure_driver_app_version, fetch_registry, find_local_agent_jar, + github_url_to_r2_path, install_local_agent, invalidate_registry_cache, verify_and_replace_download, }; use dbx_core::connection::AppState; @@ -49,6 +49,7 @@ pub async fn install_agent( } return Err(format!("Unknown driver type: {db_type}")); }; + ensure_driver_app_version(&db_type, driver, env!("CARGO_PKG_VERSION"))?; let jre_key = &driver.jre; let needs_jre = am.load_state().java_runtime.mode == JavaRuntimeMode::Managed && !am.is_jre_installed(jre_key); @@ -73,6 +74,7 @@ pub async fn install_agent( &platform_jre.url, &github_url_to_r2_path(&platform_jre.url, "jre"), &jre_archive, + &platform_jre.sha256, platform_jre.size, ) .await?; @@ -99,6 +101,7 @@ pub async fn install_agent( &driver.jar.url, &github_url_to_r2_path(&driver.jar.url, "driver"), &jar_path, + &driver.jar.sha256, driver.jar.size, ) .await?; @@ -135,6 +138,7 @@ pub async fn upgrade_all_agents(app: tauri::AppHandle, state: State<'_, Arc Result<(), String> { let am = &state.agent_manager; let key = jre_key.as_deref().unwrap_or(DEFAULT_JRE_KEY); - let jre_dir = am.jre_dir(key); - if jre_dir.exists() { - std::fs::remove_dir_all(&jre_dir).map_err(|e| format!("Failed to remove old JRE: {e}"))?; - } let registry = fetch_registry().await?; let jre_info = registry.resolve_jre(key).ok_or_else(|| format!("No JRE definition for version: {key}"))?; let platform = AgentManager::current_platform(); @@ -312,9 +314,14 @@ pub async fn reinstall_jre( &platform_jre.url, &github_url_to_r2_path(&platform_jre.url, "jre"), &jre_archive, + &platform_jre.sha256, platform_jre.size, ) .await?; + let jre_dir = am.jre_dir(key); + if jre_dir.exists() { + std::fs::remove_dir_all(&jre_dir).map_err(|e| format!("Failed to remove old JRE: {e}"))?; + } extract_archive(&jre_archive, &jre_dir)?; std::fs::remove_file(&jre_archive).ok(); let mut local_state = am.load_state(); @@ -330,11 +337,13 @@ async fn download_with_progress( url: &str, r2_path: &str, dest: &std::path::Path, + expected_sha256: &str, total_size: u64, ) -> Result<(), String> { if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } + let tmp = download_temp_path(dest); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(300)) .build() @@ -345,7 +354,7 @@ async fn download_with_progress( .map_err(|e| format!("Failed to download {url}: {e}"))?; let content_length = resp.content_length().unwrap_or(total_size); - let mut file = std::fs::File::create(dest).map_err(|e| format!("Failed to create file: {e}"))?; + let mut file = std::fs::File::create(&tmp).map_err(|e| format!("Failed to create temp file: {e}"))?; let mut downloaded: u64 = 0; let mut bytes = resp; while let Some(chunk) = bytes.chunk().await.map_err(|e| format!("Download stream error: {e}"))? { @@ -356,7 +365,9 @@ async fn download_with_progress( serde_json::json!({ "step": step, "downloaded": downloaded, "total": content_length }), ); } - Ok(()) + std::io::Write::flush(&mut file).map_err(|e| format!("Failed to flush temp file: {e}"))?; + drop(file); + verify_and_replace_download(&tmp, dest, expected_sha256) } fn extract_archive(archive: &std::path::Path, dest: &std::path::Path) -> Result<(), String> {