diff --git a/Cargo.lock b/Cargo.lock index f778405a8..010f3b3ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1891,6 +1891,7 @@ dependencies = [ "deadpool-postgres", "duckdb", "encoding_rs", + "flate2", "font-kit", "futures", "iana-time-zone", @@ -1920,6 +1921,7 @@ dependencies = [ "sqlparser", "ssfmt", "sysinfo", + "tar", "tempfile", "tiberius", "tokio", diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml index 5d897ffc0..2f2320d46 100644 --- a/crates/dbx-core/Cargo.toml +++ b/crates/dbx-core/Cargo.toml @@ -95,5 +95,7 @@ async-trait = "0.1" bytes = "1" font-kit = { version = "0.14.3", optional = true } zip = { version = "4", default-features = false, features = ["deflate"] } +flate2 = "1" +tar = "0.4" sysinfo = { version = "0.32", features = ["system"] } tempfile = "3" diff --git a/crates/dbx-core/src/agent_service.rs b/crates/dbx-core/src/agent_service.rs index f77e1a394..dda4d6b32 100644 --- a/crates/dbx-core/src/agent_service.rs +++ b/crates/dbx-core/src/agent_service.rs @@ -1724,17 +1724,102 @@ fn db_type_for_jar_offline_entry(registry: &AgentRegistry, name: &str) -> Option } fn extract_tar_gz(archive: &Path, dest: &Path) -> Result<(), String> { - std::fs::create_dir_all(dest).map_err(|e| e.to_string())?; - let status = crate::process::new_std_command("tar") - .args(["xzf", &archive.to_string_lossy(), "-C", &dest.to_string_lossy(), "--strip-components=1"]) - .status() - .map_err(|e| format!("Failed to extract archive: {e}"))?; - if !status.success() { - return Err("Failed to extract JRE archive".to_string()); + let parent = dest.parent().ok_or_else(|| format!("Invalid JRE destination: {}", dest.display()))?; + std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create JRE directory: {e}"))?; + + let staging = tempfile::Builder::new() + .prefix(".jre-extract-") + .tempdir_in(parent) + .map_err(|e| format!("Failed to create JRE extraction directory: {e}"))?; + let file = std::fs::File::open(archive).map_err(|e| format!("Failed to open JRE archive: {e}"))?; + let decoder = flate2::read::GzDecoder::new(file); + tar::Archive::new(decoder).unpack(staging.path()).map_err(|e| format!("Failed to extract JRE archive: {e}"))?; + + let mut roots = std::fs::read_dir(staging.path()) + .map_err(|e| format!("Failed to inspect extracted JRE archive: {e}"))? + .collect::, _>>() + .map_err(|e| format!("Failed to inspect extracted JRE archive: {e}"))?; + if roots.len() != 1 { + return Err("Invalid JRE archive: expected a single top-level directory".to_string()); + } + + let root = roots.pop().expect("root count checked above"); + if !root.file_type().map_err(|e| format!("Failed to inspect extracted JRE archive: {e}"))?.is_dir() { + return Err("Invalid JRE archive: expected a top-level directory".to_string()); + } + + std::fs::create_dir_all(dest).map_err(|e| format!("Failed to create JRE directory: {e}"))?; + for entry in std::fs::read_dir(root.path()).map_err(|e| format!("Failed to inspect extracted JRE archive: {e}"))? { + let entry = entry.map_err(|e| format!("Failed to inspect extracted JRE archive: {e}"))?; + std::fs::rename(entry.path(), dest.join(entry.file_name())) + .map_err(|e| format!("Failed to install extracted JRE: {e}"))?; } Ok(()) } +#[cfg(test)] +mod jre_archive_tests { + use super::*; + use std::io::Cursor; + + fn append_file( + builder: &mut tar::Builder>, + path: &str, + data: &[u8], + mode: u32, + ) { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(mode); + header.set_cksum(); + builder.append_data(&mut header, path, Cursor::new(data)).unwrap(); + } + + #[test] + fn extracts_jre_archive_without_system_tools_and_strips_top_level_directory() { + let temp = tempfile::tempdir().unwrap(); + let archive_path = temp.path().join("jre.tar.gz"); + let encoder = flate2::write::GzEncoder::new( + std::fs::File::create(&archive_path).unwrap(), + flate2::Compression::default(), + ); + let mut builder = tar::Builder::new(encoder); + append_file(&mut builder, "jdk-21/bin/java", b"java", 0o755); + append_file(&mut builder, "jdk-21/conf/release", b"JAVA_VERSION=21", 0o644); + builder.into_inner().unwrap().finish().unwrap(); + + let dest = temp.path().join("managed-jre"); + extract_tar_gz(&archive_path, &dest).unwrap(); + + assert_eq!(std::fs::read(dest.join("bin/java")).unwrap(), b"java"); + assert_eq!(std::fs::read(dest.join("conf/release")).unwrap(), b"JAVA_VERSION=21"); + assert!(!dest.join("jdk-21").exists()); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(dest.join("bin/java")).unwrap().permissions().mode() & 0o777, 0o755); + } + } + + #[test] + fn rejects_jre_archive_without_a_single_top_level_directory() { + let temp = tempfile::tempdir().unwrap(); + let archive_path = temp.path().join("jre.tar.gz"); + let encoder = flate2::write::GzEncoder::new( + std::fs::File::create(&archive_path).unwrap(), + flate2::Compression::default(), + ); + let mut builder = tar::Builder::new(encoder); + append_file(&mut builder, "jdk-21/bin/java", b"java", 0o755); + append_file(&mut builder, "unexpected/readme.txt", b"unexpected", 0o644); + builder.into_inner().unwrap().finish().unwrap(); + + let error = extract_tar_gz(&archive_path, &temp.path().join("managed-jre")).unwrap_err(); + assert!(error.contains("single top-level directory"), "unexpected error: {error}"); + } +} + pub async fn import_agent_driver(am: &AgentManager, db_type: &str, source_path: &Path) -> Result<(), String> { // Manual imports replace the same artifact paths as downloads. Reuse the // install operation and per-driver locks so an import cannot race an @@ -2143,13 +2228,10 @@ mod agent_registry_install_tests { let java_path = payload.join(relative_java_path); std::fs::create_dir_all(java_path.parent().unwrap()).unwrap(); std::fs::write(java_path, b"java").unwrap(); - let archive = archive_root.join("runtime.tar.gz"); - let status = crate::process::new_std_command("tar") - .args(["czf", &archive.to_string_lossy(), "-C", &archive_root.to_string_lossy(), "payload"]) - .status() - .unwrap(); - assert!(status.success()); - std::fs::read(archive).unwrap() + let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + builder.append_dir_all("payload", &payload).unwrap(); + builder.into_inner().unwrap().finish().unwrap() } fn write_cached_jre_download(am: &AgentManager, jre_key: &str, version: &str, url: &str, archive: &[u8]) { diff --git a/crates/dbx-core/tests/agent_service.rs b/crates/dbx-core/tests/agent_service.rs index 56419f16d..095470068 100644 --- a/crates/dbx-core/tests/agent_service.rs +++ b/crates/dbx-core/tests/agent_service.rs @@ -963,22 +963,14 @@ fn test_jre_archive_bytes() -> Vec { let root = test_path("jre-archive"); let runtime_root = root.join("dbx-jre"); let bin_dir = runtime_root.join("bin"); - let archive = root.join("jre.tar.gz"); std::fs::create_dir_all(&bin_dir).unwrap(); std::fs::write(bin_dir.join("java"), b"java").unwrap(); std::fs::write(bin_dir.join("java.exe"), b"java").unwrap(); - let status = std::process::Command::new("tar") - .arg("czf") - .arg(&archive) - .arg("-C") - .arg(&root) - .arg("dbx-jre") - .status() - .unwrap(); - assert!(status.success()); - - let bytes = std::fs::read(&archive).unwrap(); + let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + builder.append_dir_all("dbx-jre", &runtime_root).unwrap(); + let bytes = builder.into_inner().unwrap().finish().unwrap(); std::fs::remove_dir_all(root).ok(); bytes }