use crate::agent_service::AgentProgressEvent; use crate::plugins::PluginRuntimeEnv; use crate::plugins::{PluginManifest, SUPPORTED_PLUGIN_PROTOCOL_VERSION}; use crate::update::{fetch_latest_release, is_newer_version, JdbcPluginLatest}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::io::Read; use std::path::{Path, PathBuf}; const JDBC_PLUGIN_DOWNLOAD_URL: &str = "https://github.com/t8y2/dbx/releases/latest/download/dbx-jdbc-plugin-latest.zip"; const JDBC_PLUGIN_R2_PATH: &str = "releases/latest/dbx-jdbc-plugin-latest.zip"; const OFFLINE_JDBC_MANIFEST_ENTRY: &str = "jdbc/offline-manifest.json"; const OFFLINE_JDBC_FORMAT_VERSION: u32 = 1; const OFFLINE_JDBC_PLUGIN_ENTRY: &str = "jdbc/plugin.zip"; pub const PRESTOSQL_JDBC_DRIVER_VERSION: &str = "350"; pub const PRESTOSQL_JDBC_DRIVER_COORDINATE: &str = "io.prestosql:presto-jdbc:350"; pub const PRESTOSQL_JDBC_DRIVER_REPOSITORY: &str = "https://repo.maven.apache.org/maven2/"; const PRESTOSQL_JDBC_DRIVER_URL: &str = "https://repo.maven.apache.org/maven2/io/prestosql/presto-jdbc/350/presto-jdbc-350.jar"; #[derive(Debug, Clone, Serialize)] pub struct JdbcDriverInfo { pub name: String, pub path: String, pub size: u64, pub bundle_id: Option, } #[derive(Debug, Clone, Deserialize)] pub struct JdbcMavenInstallRequest { pub coordinate: String, #[serde(default)] pub repositories: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JdbcMavenBundleInfo { pub id: String, pub coordinate: String, pub scope: String, pub repositories: Vec, pub installed_at: String, pub path: String, pub artifacts: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JdbcMavenArtifactInfo { pub group_id: String, pub artifact_id: String, pub version: String, pub classifier: String, pub extension: String, pub file_name: String, pub path: String, pub size: u64, pub sha256: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JdbcLocalBundleInfo { pub id: String, pub name: String, pub installed_at: String, pub path: String, pub artifacts: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JdbcLocalArtifactInfo { pub file_name: String, pub path: String, pub size: u64, pub sha256: String, } #[derive(Debug, Clone, Serialize)] pub struct JdbcPluginStatus { pub installed: bool, pub version: Option, pub protocol_version: Option, pub compatible: bool, pub latest_version: Option, pub latest_protocol_version: Option, pub update_available: bool, pub path: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct OfflineJdbcImportResult { pub plugin_installed: bool, pub bundles_installed: Vec, } #[derive(Debug, Deserialize)] struct OfflineJdbcManifest { format_version: u32, plugin_entry: String, bundles: Vec, } #[derive(Debug, Deserialize)] struct OfflineJdbcBundle { id: String, coordinate: String, } // ---- JDBC Drivers ---- pub fn list_jdbc_drivers(plugins_root: &Path) -> Result, String> { list_jdbc_drivers_from_dir(&jdbc_drivers_dir(plugins_root)) } pub fn list_jdbc_maven_bundles(plugins_root: &Path) -> Result, String> { list_jdbc_maven_bundles_from_dir(&jdbc_maven_drivers_dir(plugins_root)) } pub fn list_jdbc_local_bundles(plugins_root: &Path) -> Result, String> { list_jdbc_local_bundles_from_dir(&jdbc_local_drivers_dir(plugins_root)) } pub fn import_jdbc_drivers(plugins_root: &Path, paths: &[String]) -> Result, String> { if paths.len() > 1 { install_jdbc_local_bundle(plugins_root, paths)?; return list_jdbc_drivers(plugins_root); } let drivers_dir = jdbc_drivers_dir(plugins_root); std::fs::create_dir_all(&drivers_dir).map_err(|err| err.to_string())?; for path in paths { let source = PathBuf::from(path); if !source.exists() { return Err(format!("Driver JAR does not exist: {}", source.display())); } if source.extension().and_then(|ext| ext.to_str()).map(|ext| ext.eq_ignore_ascii_case("jar")) != Some(true) { return Err(format!("Only .jar files can be imported: {}", source.display())); } let file_name = source .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| format!("Invalid driver file path: {}", source.display()))?; let target = drivers_dir.join(file_name); if paths_refer_to_same_file(&source, &target) { continue; } let target = unique_target_path(&drivers_dir, file_name); std::fs::copy(&source, &target) .map_err(|err| format!("Failed to import {} to {}: {err}", source.display(), target.display()))?; } list_jdbc_drivers_from_dir(&drivers_dir) } pub fn delete_jdbc_driver(plugins_root: &Path, path: &str) -> Result, String> { let drivers_dir = jdbc_drivers_dir(plugins_root); let drivers_dir = drivers_dir.canonicalize().map_err(|err| err.to_string())?; let target = PathBuf::from(path).canonicalize().map_err(|err| err.to_string())?; if !target.starts_with(&drivers_dir) { return Err("Driver file is outside the JDBC drivers directory".to_string()); } std::fs::remove_file(&target).map_err(|err| err.to_string())?; list_jdbc_drivers_from_dir(&drivers_dir) } pub fn delete_jdbc_maven_bundle(plugins_root: &Path, bundle_id: &str) -> Result, String> { if !is_safe_bundle_id(bundle_id) { return Err("Invalid JDBC Maven bundle id".to_string()); } let bundles_dir = jdbc_maven_drivers_dir(plugins_root); let target = bundles_dir.join(bundle_id); if target.exists() { std::fs::remove_dir_all(&target).map_err(|err| err.to_string())?; } list_jdbc_drivers(plugins_root) } pub fn delete_jdbc_local_bundle(plugins_root: &Path, bundle_id: &str) -> Result, String> { if !is_safe_bundle_id(bundle_id) { return Err("Invalid JDBC local bundle id".to_string()); } let target = jdbc_local_drivers_dir(plugins_root).join(bundle_id); if target.exists() { std::fs::remove_dir_all(&target).map_err(|err| err.to_string())?; } list_jdbc_drivers(plugins_root) } pub async fn install_jdbc_driver_from_maven( plugins_root: &Path, request: JdbcMavenInstallRequest, env: PluginRuntimeEnv, ) -> Result, String> { let coordinate = request.coordinate.trim().to_string(); if coordinate.is_empty() { return Err("Maven coordinate is required".to_string()); } let plugin_dir = plugins_root.join("jdbc"); let resolver = jdbc_maven_resolver_executable(&plugin_dir); if !resolver.exists() { return Err("JDBC Maven resolver is not installed. Update or reinstall the JDBC plugin.".to_string()); } let mut repositories = if request.repositories.is_empty() { vec!["https://repo.maven.apache.org/maven2/".to_string()] } else { request.repositories.into_iter().map(|repo| repo.trim().to_string()).filter(|repo| !repo.is_empty()).collect() }; if repositories.is_empty() { repositories.push("https://repo.maven.apache.org/maven2/".to_string()); } let local_repo = plugin_dir.join("maven-cache"); std::fs::create_dir_all(&local_repo).map_err(|err| err.to_string())?; let mut resolved = resolve_maven_artifacts(&resolver, &coordinate, &local_repo, &repositories, &env).await?; if let Some(orai18n) = oracle_orai18n_companion(&resolved) { match resolve_maven_artifacts(&resolver, &orai18n, &local_repo, &repositories, &env).await { Ok(mut companion) => resolved.artifacts.append(&mut companion.artifacts), Err(_) => { // A missing orai18n companion must not block the driver install; // affected charsets surface a dedicated hint at runtime. } } } let root = plugins_root.to_path_buf(); tokio::task::spawn_blocking(move || install_jdbc_maven_bundle(&root, &coordinate, &resolved)) .await .map_err(|err| err.to_string())??; list_jdbc_drivers(plugins_root) } const ORACLE_JDBC_GROUP_ID: &str = "com.oracle.database.jdbc"; const ORACLE_NLS_GROUP_ID: &str = "com.oracle.database.nls"; const ORAI18N_ARTIFACT_ID: &str = "orai18n"; /// Oracle's thin driver jar ships converters for a small default charset set; /// multibyte charsets such as ZHS16GBK need the orai18n companion jar, otherwise /// every metadata call that reads dictionary comments fails wholesale. DBeaver /// ships the same pairing. fn oracle_orai18n_companion(resolved: &MavenResolveOutput) -> Option { let ojdbc = resolved .artifacts .iter() .find(|artifact| artifact.group_id == ORACLE_JDBC_GROUP_ID && artifact.artifact_id.starts_with("ojdbc"))?; if resolved .artifacts .iter() .any(|artifact| artifact.group_id == ORACLE_NLS_GROUP_ID && artifact.artifact_id == ORAI18N_ARTIFACT_ID) { return None; } Some(format!("{ORACLE_NLS_GROUP_ID}:{ORAI18N_ARTIFACT_ID}:{}", ojdbc.version)) } async fn resolve_maven_artifacts( resolver: &Path, coordinate: &str, local_repo: &Path, repositories: &[String], env: &PluginRuntimeEnv, ) -> Result { let mut command = crate::process::new_tokio_command(resolver); env.apply_to(&mut command); command.arg("resolve").arg("--coordinate").arg(coordinate).arg("--local-repo").arg(local_repo); for repo in repositories { command.arg("--repo").arg(repo); } let output = command.output().await.map_err(|err| format!("Failed to run JDBC Maven resolver: {err}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(if stderr.is_empty() { "JDBC Maven resolver failed".to_string() } else { stderr }); } let stdout = String::from_utf8_lossy(&output.stdout); serde_json::from_str(&stdout).map_err(|err| format!("Failed to parse JDBC Maven resolver output: {err}")) } pub async fn install_prestosql_jdbc_driver(plugins_root: &Path) -> Result, String> { let plugin_dir = plugins_root.join("jdbc"); let temp_dir = plugin_dir.join("downloads"); std::fs::create_dir_all(&temp_dir).map_err(|err| err.to_string())?; let temp_file = temp_dir.join("presto-jdbc-350.jar.tmp"); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(300)) .build() .map_err(|err| err.to_string())?; let mut resp = client .get(PRESTOSQL_JDBC_DRIVER_URL) .header(reqwest::header::USER_AGENT, "dbx-prestosql-driver-installer") .send() .await .and_then(|resp| resp.error_for_status()) .map_err(|err| format!("Failed to download PrestoSQL JDBC driver: {err}"))?; let mut file = std::fs::File::create(&temp_file) .map_err(|err| format!("Failed to create PrestoSQL JDBC driver temp file: {err}"))?; 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}"))?; } std::io::Write::flush(&mut file).map_err(|err| format!("Failed to flush temp file: {err}"))?; drop(file); let root = plugins_root.to_path_buf(); let repositories = vec![PRESTOSQL_JDBC_DRIVER_REPOSITORY.to_string()]; tokio::task::spawn_blocking(move || { install_single_jdbc_maven_artifact( &root, PRESTOSQL_JDBC_DRIVER_COORDINATE, "io.prestosql", "presto-jdbc", PRESTOSQL_JDBC_DRIVER_VERSION, &temp_file, &repositories, )?; let _ = std::fs::remove_file(&temp_file); Ok::<(), String>(()) }) .await .map_err(|err| err.to_string())??; list_jdbc_drivers(plugins_root) } /// Imports the optional JDBC payload embedded in a full offline Agent ZIP. /// Older Agent packages have no JDBC manifest and remain valid. pub fn import_offline_jdbc_payload( plugins_root: &Path, zip_path: &Path, ) -> Result, String> { if !zip_path .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.to_ascii_lowercase().ends_with(".zip")) { return Ok(None); } let file = std::fs::File::open(zip_path).map_err(|err| format!("Failed to open offline package: {err}"))?; let mut archive = zip::ZipArchive::new(file).map_err(|err| format!("Invalid offline package ZIP: {err}"))?; let manifest_raw = match read_zip_entry(&mut archive, OFFLINE_JDBC_MANIFEST_ENTRY, 1024 * 1024) { Ok(raw) => raw, Err(error) if error == format!("Offline package entry not found: {OFFLINE_JDBC_MANIFEST_ENTRY}") => { return Ok(None); } Err(error) => return Err(error), }; let manifest: OfflineJdbcManifest = serde_json::from_slice(&manifest_raw).map_err(|err| format!("Failed to parse offline JDBC manifest: {err}"))?; validate_offline_jdbc_manifest(&manifest)?; let plugin_bytes = read_zip_entry(&mut archive, &manifest.plugin_entry, 512 * 1024 * 1024)?; let maven_dir = jdbc_maven_drivers_dir(plugins_root); std::fs::create_dir_all(&maven_dir).map_err(|err| err.to_string())?; let staging_root = maven_dir.join(format!(".offline-import-{}", uuid::Uuid::new_v4())); let import_result = (|| { std::fs::create_dir_all(&staging_root).map_err(|err| err.to_string())?; let mut staged_bundles = Vec::with_capacity(manifest.bundles.len()); for bundle in &manifest.bundles { let staged = stage_offline_jdbc_bundle(&mut archive, bundle, &staging_root, &maven_dir)?; staged_bundles.push((bundle.id.clone(), staged)); } install_jdbc_plugin_zip(&plugin_bytes, &plugins_root.join("jdbc"))?; for (bundle_id, staged_dir) in &staged_bundles { let target = maven_dir.join(bundle_id); if target.exists() { std::fs::remove_dir_all(&target).map_err(|err| err.to_string())?; } std::fs::rename(staged_dir, &target) .map_err(|err| format!("Failed to install offline JDBC bundle {bundle_id}: {err}"))?; } Ok(Some(OfflineJdbcImportResult { plugin_installed: true, bundles_installed: manifest.bundles.iter().map(|bundle| bundle.coordinate.clone()).collect(), })) })(); let _ = std::fs::remove_dir_all(&staging_root); import_result } // ---- JDBC Plugin ---- pub async fn get_jdbc_plugin_status(plugins_root: &Path) -> Result { jdbc_plugin_status_from_dir(&plugins_root.join("jdbc")).await } pub async fn install_jdbc_plugin(plugins_root: &Path) -> Result { install_jdbc_plugin_with_progress(plugins_root, |_| {}).await } pub async fn install_jdbc_plugin_with_progress( plugins_root: &Path, progress: impl Fn(AgentProgressEvent), ) -> Result { let bytes = download_jdbc_plugin_zip_with_progress(&progress).await?; let plugin_dir = plugins_root.join("jdbc"); let status_dir = plugin_dir.clone(); progress(AgentProgressEvent::transfer("jdbc-plugin-extract", 0, 0)); tokio::task::spawn_blocking(move || install_jdbc_plugin_zip(&bytes, &plugin_dir)) .await .map_err(|err| err.to_string())??; let status = jdbc_plugin_status_from_dir(&status_dir).await; progress(AgentProgressEvent::step("done")); status } pub async fn install_jdbc_plugin_from_file(plugins_root: &Path, file_path: &str) -> Result { let plugin_dir = plugins_root.join("jdbc"); let status_dir = plugin_dir.clone(); let file_path = file_path.to_string(); tokio::task::spawn_blocking(move || { let bytes = std::fs::read(file_path).map_err(|e| format!("Failed to read file: {e}"))?; install_jdbc_plugin_zip(&bytes, &plugin_dir) }) .await .map_err(|err| err.to_string())??; // Local install must stay fully offline: build status from the local // manifest only, do not check for a newer release over the network. jdbc_plugin_status_from_dir_local(&status_dir) } pub fn uninstall_jdbc_plugin(plugins_root: &Path) -> Result { let plugin_dir = plugins_root.join("jdbc"); for entry in ["manifest.json", "bin", "lib"] { let path = plugin_dir.join(entry); if !path.exists() { continue; } if path.is_dir() { std::fs::remove_dir_all(path).map_err(|err| err.to_string())?; } else { std::fs::remove_file(path).map_err(|err| err.to_string())?; } } jdbc_plugin_status_from_dir_local(&plugin_dir) } /// Builds plugin status from the local manifest only, no network call. /// Used by install/uninstall paths that must stay fully offline (uninstall, /// and installing a plugin package the user already has on disk). fn jdbc_plugin_status_from_dir_local(plugin_dir: &Path) -> Result { let manifest_path = plugin_dir.join("manifest.json"); let manifest = match std::fs::read_to_string(&manifest_path) { Ok(raw) => Some(serde_json::from_str::(&raw).map_err(|err| err.to_string())?), Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, Err(err) => return Err(err.to_string()), }; Ok(build_plugin_status(&manifest, None, plugin_dir)) } // ---- System Fonts ---- #[cfg(feature = "system-fonts")] pub fn list_system_fonts() -> Vec { let source = font_kit::source::SystemSource::new(); match source.all_families() { Ok(families) => { use std::collections::BTreeSet; families .into_iter() .map(|family| family.trim().to_string()) .filter(|family| !family.is_empty()) .collect::>() .into_iter() .collect() } Err(_) => vec![], } } #[cfg(not(feature = "system-fonts"))] pub fn list_system_fonts() -> Vec { vec![] } // ---- Internal helpers ---- fn jdbc_drivers_dir(plugins_root: &Path) -> PathBuf { plugins_root.join("jdbc").join("drivers") } fn jdbc_maven_drivers_dir(plugins_root: &Path) -> PathBuf { jdbc_drivers_dir(plugins_root).join("maven") } fn jdbc_local_drivers_dir(plugins_root: &Path) -> PathBuf { jdbc_drivers_dir(plugins_root).join("local") } fn jdbc_maven_resolver_executable(plugin_dir: &Path) -> PathBuf { #[cfg(windows)] { plugin_dir.join("bin").join("dbx-maven-resolver.bat") } #[cfg(not(windows))] { plugin_dir.join("bin").join("dbx-maven-resolver") } } async fn jdbc_plugin_status_from_dir(plugin_dir: &Path) -> Result { jdbc_plugin_status_from_dir_after(plugin_dir, latest_jdbc_plugin()).await } /// Waits on `latest` before reading the manifest, so a slow/offline network /// check can never race ahead of a concurrent local install and report a /// stale pre-install snapshot back to the caller. `latest` is a parameter /// (rather than calling `latest_jdbc_plugin()` directly) so tests can /// simulate a delayed network response without a real network call. async fn jdbc_plugin_status_from_dir_after( plugin_dir: &Path, latest: impl std::future::Future>, ) -> Result { let latest = latest.await; let manifest_path = plugin_dir.join("manifest.json"); let manifest = match std::fs::read_to_string(&manifest_path) { Ok(raw) => Some(serde_json::from_str::(&raw).map_err(|err| err.to_string())?), Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, Err(err) => return Err(err.to_string()), }; Ok(build_plugin_status(&manifest, latest.as_ref(), plugin_dir)) } fn build_plugin_status( manifest: &Option, latest: Option<&JdbcPluginLatest>, plugin_dir: &Path, ) -> JdbcPluginStatus { let version = manifest.as_ref().and_then(|m| (!m.version.is_empty()).then_some(m.version.clone())); let protocol_version = manifest.as_ref().map(|m| m.protocol_version); let compatible = match manifest.as_ref() { Some(m) => m.protocol_version == SUPPORTED_PLUGIN_PROTOCOL_VERSION, None => true, }; let latest_version = latest.map(|plugin| plugin.version.clone()); let latest_protocol_version = latest.map(|plugin| plugin.protocol_version); let update_available = match (version.as_deref(), latest) { (Some(current), Some(latest)) if manifest.is_some() => is_newer_version(&latest.version, current), (None, Some(_)) if manifest.is_some() => true, _ => false, }; JdbcPluginStatus { installed: manifest.is_some(), version, protocol_version, compatible, latest_version, latest_protocol_version, update_available, path: plugin_dir.to_string_lossy().to_string(), } } async fn latest_jdbc_plugin() -> Option { // 只需 jdbc_plugin,不关心 release notes;传中文 locale 跳过英文 notes 拉取 fetch_latest_release("zh-CN", crate::DownloadSource::Official).await.ok().and_then(|release| release.jdbc_plugin) } async fn download_jdbc_plugin_zip_with_progress(progress: &impl Fn(AgentProgressEvent)) -> Result, String> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(60)) .build() .map_err(|err| err.to_string())?; let mut resp = crate::race_download(&client, JDBC_PLUGIN_DOWNLOAD_URL, JDBC_PLUGIN_R2_PATH, "dbx-jdbc-plugin-installer") .await .map_err(|err| format!("Failed to download JDBC plugin: {err}"))?; let total = resp.content_length().unwrap_or(0); progress(AgentProgressEvent::transfer("jdbc-plugin", 0, total)); let mut downloaded = 0; let mut bytes = Vec::with_capacity(total.try_into().unwrap_or(0)); // Emit transfer progress at most once per whole percent (or 500ms on slow // links) instead of once per HTTP chunk; see TransferProgressGate. let mut transfer_gate = crate::agent_service::TransferProgressGate::new(total); while let Some(chunk) = resp.chunk().await.map_err(|err| err.to_string())? { downloaded += chunk.len() as u64; bytes.extend_from_slice(&chunk); if transfer_gate.record(downloaded, std::time::Instant::now()) { progress(AgentProgressEvent::transfer("jdbc-plugin", downloaded, total)); } } // The gate may have suppressed the final chunk; always publish the // completed transfer so the UI reaches 100% before extraction starts. progress(AgentProgressEvent::transfer("jdbc-plugin", downloaded, if total == 0 { downloaded } else { total })); Ok(bytes) } fn install_jdbc_plugin_zip(bytes: &[u8], plugin_dir: &Path) -> Result<(), String> { let cursor = std::io::Cursor::new(bytes); let mut archive = zip::ZipArchive::new(cursor).map_err(|err| err.to_string())?; let temp_dir = plugin_dir.with_extension("tmp"); if temp_dir.exists() { std::fs::remove_dir_all(&temp_dir).map_err(|err| err.to_string())?; } std::fs::create_dir_all(&temp_dir).map_err(|err| err.to_string())?; for index in 0..archive.len() { let mut file = archive.by_index(index).map_err(|err| err.to_string())?; if file.is_dir() { continue; } let Some(enclosed) = file.enclosed_name().map(|path| path.to_path_buf()) else { continue; }; let relative = strip_zip_root(&enclosed); if relative.as_os_str().is_empty() { continue; } let output = temp_dir.join(relative); if let Some(parent) = output.parent() { std::fs::create_dir_all(parent).map_err(|err| err.to_string())?; } let mut target = std::fs::File::create(&output).map_err(|err| err.to_string())?; std::io::copy(&mut file, &mut target).map_err(|err| err.to_string())?; } if !temp_dir.join("manifest.json").exists() { let _ = std::fs::remove_dir_all(&temp_dir); return Err(format!( "This ZIP is not a valid DBX JDBC plugin package (missing manifest.json). \ The correct package is available at: {JDBC_PLUGIN_DOWNLOAD_URL}" )); } let manifest_path = temp_dir.join("manifest.json"); let manifest = std::fs::read_to_string(&manifest_path) .map_err(|err| format!("Failed to read downloaded JDBC plugin manifest: {err}"))?; let manifest: PluginManifest = serde_json::from_str(&manifest) .map_err(|err| format!("Failed to parse downloaded JDBC plugin manifest: {err}"))?; if manifest.id != "jdbc" { let _ = std::fs::remove_dir_all(&temp_dir); return Err(format!("Downloaded plugin has unexpected id '{}'", manifest.id)); } if manifest.protocol_version != SUPPORTED_PLUGIN_PROTOCOL_VERSION { let _ = std::fs::remove_dir_all(&temp_dir); return Err(format!( "Downloaded JDBC plugin uses protocol version {}, but this DBX build supports protocol version {}", manifest.protocol_version, SUPPORTED_PLUGIN_PROTOCOL_VERSION )); } let drivers_dir = plugin_dir.join("drivers"); let temp_drivers_dir = temp_dir.join("drivers"); if drivers_dir.exists() && !temp_drivers_dir.exists() { copy_dir_all(&drivers_dir, &temp_drivers_dir)?; } if plugin_dir.exists() { std::fs::remove_dir_all(plugin_dir).map_err(|err| err.to_string())?; } std::fs::rename(&temp_dir, plugin_dir).map_err(|err| err.to_string())?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; for executable in ["dbx-jdbc-plugin", "dbx-maven-resolver"] { let executable = plugin_dir.join("bin").join(executable); if executable.exists() { let mut permissions = std::fs::metadata(&executable).map_err(|err| err.to_string())?.permissions(); permissions.set_mode(0o755); std::fs::set_permissions(executable, permissions).map_err(|err| err.to_string())?; } } } Ok(()) } fn strip_zip_root(path: &Path) -> PathBuf { let mut components = path.components(); let first = components.next(); if let (Some(std::path::Component::Normal(_)), Some(_)) = (first, components.clone().next()) { components.collect() } else { path.to_path_buf() } } fn copy_dir_all(source: &Path, target: &Path) -> Result<(), String> { std::fs::create_dir_all(target).map_err(|err| err.to_string())?; for entry in std::fs::read_dir(source).map_err(|err| err.to_string())? { let entry = entry.map_err(|err| err.to_string())?; let file_type = entry.file_type().map_err(|err| err.to_string())?; let dest = target.join(entry.file_name()); if file_type.is_dir() { copy_dir_all(&entry.path(), &dest)?; } else { std::fs::copy(entry.path(), dest).map_err(|err| err.to_string())?; } } Ok(()) } fn read_zip_entry( archive: &mut zip::ZipArchive, entry_name: &str, max_size: u64, ) -> Result, String> { let mut entry = archive.by_name(entry_name).map_err(|err| match err { zip::result::ZipError::FileNotFound => format!("Offline package entry not found: {entry_name}"), _ => format!("Failed to read offline package entry {entry_name}: {err}"), })?; if entry.size() > max_size { return Err(format!("Offline package entry is too large: {entry_name}")); } let mut bytes = Vec::with_capacity(entry.size().try_into().unwrap_or(0)); entry.read_to_end(&mut bytes).map_err(|err| format!("Failed to read offline package entry {entry_name}: {err}"))?; Ok(bytes) } fn validate_offline_jdbc_manifest(manifest: &OfflineJdbcManifest) -> Result<(), String> { if manifest.format_version != OFFLINE_JDBC_FORMAT_VERSION { return Err(format!("Unsupported offline JDBC format version: {}", manifest.format_version)); } if manifest.plugin_entry != OFFLINE_JDBC_PLUGIN_ENTRY { return Err(format!("Unexpected offline JDBC plugin entry: {}", manifest.plugin_entry)); } if manifest.bundles.is_empty() { return Err("Offline JDBC payload contains no Maven bundles".to_string()); } let mut ids = std::collections::HashSet::new(); let mut coordinates = std::collections::HashSet::new(); for bundle in &manifest.bundles { if !is_safe_bundle_id(&bundle.id) { return Err(format!("Invalid offline JDBC bundle id: {}", bundle.id)); } if bundle.id != maven_bundle_id(&bundle.coordinate) { return Err(format!("Offline JDBC bundle id does not match coordinate: {}", bundle.coordinate)); } if !ids.insert(bundle.id.as_str()) { return Err(format!("Duplicate offline JDBC bundle id: {}", bundle.id)); } if !coordinates.insert(bundle.coordinate.as_str()) { return Err(format!("Duplicate offline JDBC coordinate: {}", bundle.coordinate)); } } Ok(()) } fn stage_offline_jdbc_bundle( archive: &mut zip::ZipArchive, expected: &OfflineJdbcBundle, staging_root: &Path, final_maven_dir: &Path, ) -> Result { let prefix = format!("jdbc/maven/{}/", expected.id); let manifest_entry = format!("{prefix}manifest.json"); let manifest_raw = read_zip_entry(archive, &manifest_entry, 4 * 1024 * 1024)?; let portable: JdbcMavenBundleInfo = serde_json::from_slice(&manifest_raw) .map_err(|err| format!("Failed to parse offline JDBC bundle manifest {manifest_entry}: {err}"))?; if portable.id != expected.id || portable.coordinate != expected.coordinate { return Err(format!("Offline JDBC bundle manifest does not match {}", expected.coordinate)); } if portable.artifacts.is_empty() { return Err(format!("Offline JDBC bundle contains no JAR artifacts: {}", expected.coordinate)); } let staged_dir = staging_root.join(&expected.id); let jars_dir = staged_dir.join("jars"); std::fs::create_dir_all(&jars_dir).map_err(|err| err.to_string())?; let final_bundle_dir = final_maven_dir.join(&expected.id); let mut artifacts = Vec::with_capacity(portable.artifacts.len()); let mut file_names = std::collections::HashSet::new(); for mut artifact in portable.artifacts { let file_name_path = Path::new(&artifact.file_name); if artifact.extension != "jar" || file_name_path.file_name().and_then(|name| name.to_str()) != Some(artifact.file_name.as_str()) || !file_names.insert(artifact.file_name.clone()) { return Err(format!("Invalid offline JDBC artifact name: {}", artifact.file_name)); } let entry_name = format!("{prefix}jars/{}", artifact.file_name); let mut entry = archive.by_name(&entry_name).map_err(|err| match err { zip::result::ZipError::FileNotFound => format!("Offline package entry not found: {entry_name}"), _ => format!("Failed to read offline package entry {entry_name}: {err}"), })?; if entry.size() != artifact.size { return Err(format!("Offline JDBC artifact size mismatch: {entry_name}")); } let target = jars_dir.join(&artifact.file_name); let mut output = std::fs::File::create(&target).map_err(|err| err.to_string())?; std::io::copy(&mut entry, &mut output) .map_err(|err| format!("Failed to extract offline JDBC artifact {entry_name}: {err}"))?; drop(output); let sha256 = file_sha256(&target)?; if !sha256.eq_ignore_ascii_case(&artifact.sha256) { return Err(format!("Offline JDBC artifact checksum mismatch: {entry_name}")); } artifact.path = final_bundle_dir.join("jars").join(&artifact.file_name).to_string_lossy().to_string(); artifacts.push(artifact); } let installed = JdbcMavenBundleInfo { id: portable.id, coordinate: portable.coordinate, scope: portable.scope, repositories: portable.repositories, installed_at: chrono::Utc::now().to_rfc3339(), path: final_bundle_dir.to_string_lossy().to_string(), artifacts, }; std::fs::write( staged_dir.join("manifest.json"), serde_json::to_string_pretty(&installed).map_err(|err| err.to_string())?, ) .map_err(|err| err.to_string())?; Ok(staged_dir) } fn list_jdbc_drivers_from_dir(drivers_dir: &Path) -> Result, String> { let entries = match std::fs::read_dir(drivers_dir) { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), Err(err) => return Err(err.to_string()), }; let mut drivers = Vec::new(); for entry in entries { let entry = entry.map_err(|err| err.to_string())?; let path = entry.path(); if path.is_dir() { match path.file_name().and_then(|name| name.to_str()) { Some("maven") | Some("local") => { append_jdbc_bundle_jars(&mut drivers, &path)?; continue; } _ => {} } } if path.extension().and_then(|ext| ext.to_str()).map(|ext| ext.eq_ignore_ascii_case("jar")) != Some(true) { continue; } let metadata = entry.metadata().map_err(|err| err.to_string())?; drivers.push(JdbcDriverInfo { name: path.file_name().and_then(|name| name.to_str()).unwrap_or("driver.jar").to_string(), path: path.to_string_lossy().to_string(), size: metadata.len(), bundle_id: None, }); } drivers.sort_by(|a, b| a.name.cmp(&b.name)); Ok(drivers) } fn append_jdbc_bundle_jars(drivers: &mut Vec, bundles_dir: &Path) -> Result<(), String> { let entries = match std::fs::read_dir(bundles_dir) { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(err) => return Err(err.to_string()), }; for entry in entries { let entry = entry.map_err(|err| err.to_string())?; let bundle_dir = entry.path(); if !bundle_dir.is_dir() { continue; } let bundle_id = entry.file_name().to_string_lossy().to_string(); let jars_dir = bundle_dir.join("jars"); let jars = match std::fs::read_dir(&jars_dir) { Ok(jars) => jars, Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, Err(err) => return Err(err.to_string()), }; for jar in jars { let jar = jar.map_err(|err| err.to_string())?; let path = jar.path(); if path.extension().and_then(|ext| ext.to_str()).map(|ext| ext.eq_ignore_ascii_case("jar")) != Some(true) { continue; } let metadata = jar.metadata().map_err(|err| err.to_string())?; drivers.push(JdbcDriverInfo { name: path.file_name().and_then(|name| name.to_str()).unwrap_or("driver.jar").to_string(), path: path.to_string_lossy().to_string(), size: metadata.len(), bundle_id: Some(bundle_id.clone()), }); } } Ok(()) } fn list_jdbc_local_bundles_from_dir(local_dir: &Path) -> Result, String> { let entries = match std::fs::read_dir(local_dir) { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), Err(err) => return Err(err.to_string()), }; let mut bundles = Vec::new(); for entry in entries { let entry = entry.map_err(|err| err.to_string())?; let manifest_path = entry.path().join("manifest.json"); if !manifest_path.exists() { continue; } let raw = std::fs::read_to_string(&manifest_path).map_err(|err| err.to_string())?; bundles.push(serde_json::from_str::(&raw).map_err(|err| err.to_string())?); } bundles.sort_by(|a, b| a.name.cmp(&b.name)); Ok(bundles) } fn install_jdbc_local_bundle(plugins_root: &Path, paths: &[String]) -> Result<(), String> { let sources = paths .iter() .map(PathBuf::from) .map(|source| { if !source.exists() { return Err(format!("Driver JAR does not exist: {}", source.display())); } if source.extension().and_then(|ext| ext.to_str()).map(|ext| ext.eq_ignore_ascii_case("jar")) != Some(true) { return Err(format!("Only .jar files can be imported: {}", source.display())); } Ok(source) }) .collect::, String>>()?; let name = sources .first() .and_then(|path| path.file_stem()) .and_then(|name| name.to_str()) .filter(|name| !name.is_empty()) .unwrap_or("JDBC driver") .to_string(); let bundle_id = format!("local-{}", uuid::Uuid::new_v4().simple()); let bundle_dir = jdbc_local_drivers_dir(plugins_root).join(&bundle_id); let temp_dir = bundle_dir.with_extension("tmp"); std::fs::create_dir_all(temp_dir.join("jars")).map_err(|err| err.to_string())?; let mut artifacts = Vec::with_capacity(sources.len()); for source in sources { let file_name = source .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| format!("Invalid driver file path: {}", source.display()))?; let target = unique_target_path(&temp_dir.join("jars"), file_name); std::fs::copy(&source, &target) .map_err(|err| format!("Failed to import {} to {}: {err}", source.display(), target.display()))?; let metadata = std::fs::metadata(&target).map_err(|err| err.to_string())?; let final_path = bundle_dir.join("jars").join(target.file_name().unwrap_or_default()); artifacts.push(JdbcLocalArtifactInfo { file_name: target.file_name().and_then(|value| value.to_str()).unwrap_or(file_name).to_string(), path: final_path.to_string_lossy().to_string(), size: metadata.len(), sha256: file_sha256(&target)?, }); } let manifest = JdbcLocalBundleInfo { id: bundle_id, name, installed_at: chrono::Utc::now().to_rfc3339(), path: bundle_dir.to_string_lossy().to_string(), artifacts, }; std::fs::write( temp_dir.join("manifest.json"), serde_json::to_string_pretty(&manifest).map_err(|err| err.to_string())?, ) .map_err(|err| err.to_string())?; std::fs::rename(&temp_dir, &bundle_dir).map_err(|err| err.to_string()) } fn list_jdbc_maven_bundles_from_dir(maven_dir: &Path) -> Result, String> { let entries = match std::fs::read_dir(maven_dir) { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), Err(err) => return Err(err.to_string()), }; let mut bundles = Vec::new(); for entry in entries { let entry = entry.map_err(|err| err.to_string())?; let manifest_path = entry.path().join("manifest.json"); if !manifest_path.exists() { continue; } let raw = std::fs::read_to_string(&manifest_path).map_err(|err| err.to_string())?; bundles.push(serde_json::from_str::(&raw).map_err(|err| err.to_string())?); } bundles.sort_by(|a, b| a.coordinate.cmp(&b.coordinate)); Ok(bundles) } fn install_jdbc_maven_bundle( plugins_root: &Path, coordinate: &str, resolved: &MavenResolveOutput, ) -> Result<(), String> { if resolved.artifacts.is_empty() { return Err("Maven resolver did not return any JAR artifacts".to_string()); } let bundle_id = maven_bundle_id(coordinate); let bundle_dir = jdbc_maven_drivers_dir(plugins_root).join(&bundle_id); let temp_dir = bundle_dir.with_extension("tmp"); if temp_dir.exists() { std::fs::remove_dir_all(&temp_dir).map_err(|err| err.to_string())?; } std::fs::create_dir_all(temp_dir.join("jars")).map_err(|err| err.to_string())?; let mut artifacts = Vec::new(); for artifact in &resolved.artifacts { if !artifact.extension.eq_ignore_ascii_case("jar") { continue; } let source = PathBuf::from(&artifact.file); if !source.exists() { return Err(format!("Resolved artifact file does not exist: {}", source.display())); } let file_name = source .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| format!("Invalid resolved artifact path: {}", source.display()))?; let target = unique_target_path(&temp_dir.join("jars"), file_name); std::fs::copy(&source, &target) .map_err(|err| format!("Failed to copy {} to {}: {err}", source.display(), target.display()))?; let metadata = std::fs::metadata(&target).map_err(|err| err.to_string())?; let sha256 = file_sha256(&target)?; let final_path = bundle_dir.join("jars").join(target.file_name().unwrap_or_default()); artifacts.push(JdbcMavenArtifactInfo { group_id: artifact.group_id.clone(), artifact_id: artifact.artifact_id.clone(), version: artifact.version.clone(), classifier: artifact.classifier.clone(), extension: artifact.extension.clone(), file_name: target.file_name().and_then(|name| name.to_str()).unwrap_or(file_name).to_string(), path: final_path.to_string_lossy().to_string(), size: metadata.len(), sha256, }); } if artifacts.is_empty() { return Err("Maven resolver did not return any JAR artifacts".to_string()); } let manifest = JdbcMavenBundleInfo { id: bundle_id.clone(), coordinate: resolved.coordinate.clone(), scope: resolved.scope.clone(), repositories: resolved.repositories.clone(), installed_at: chrono::Utc::now().to_rfc3339(), path: bundle_dir.to_string_lossy().to_string(), artifacts, }; let manifest_json = serde_json::to_string_pretty(&manifest).map_err(|err| err.to_string())?; std::fs::write(temp_dir.join("manifest.json"), manifest_json).map_err(|err| err.to_string())?; if bundle_dir.exists() { std::fs::remove_dir_all(&bundle_dir).map_err(|err| err.to_string())?; } std::fs::rename(&temp_dir, &bundle_dir).map_err(|err| err.to_string())?; Ok(()) } fn install_single_jdbc_maven_artifact( plugins_root: &Path, coordinate: &str, group_id: &str, artifact_id: &str, version: &str, source: &Path, repositories: &[String], ) -> Result<(), String> { if !source.exists() { return Err(format!("Driver JAR does not exist: {}", source.display())); } let bundle_id = maven_bundle_id(coordinate); let bundle_dir = jdbc_maven_drivers_dir(plugins_root).join(&bundle_id); let temp_dir = bundle_dir.with_extension("tmp"); if temp_dir.exists() { std::fs::remove_dir_all(&temp_dir).map_err(|err| err.to_string())?; } std::fs::create_dir_all(temp_dir.join("jars")).map_err(|err| err.to_string())?; let file_name = source .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| format!("Invalid driver file path: {}", source.display()))?; let target = temp_dir.join("jars").join(file_name); std::fs::copy(source, &target) .map_err(|err| format!("Failed to copy {} to {}: {err}", source.display(), target.display()))?; let metadata = std::fs::metadata(&target).map_err(|err| err.to_string())?; let final_path = bundle_dir.join("jars").join(file_name); let manifest = JdbcMavenBundleInfo { id: bundle_id.clone(), coordinate: coordinate.to_string(), scope: "runtime".to_string(), repositories: repositories.to_vec(), installed_at: chrono::Utc::now().to_rfc3339(), path: bundle_dir.to_string_lossy().to_string(), artifacts: vec![JdbcMavenArtifactInfo { group_id: group_id.to_string(), artifact_id: artifact_id.to_string(), version: version.to_string(), classifier: String::new(), extension: "jar".to_string(), file_name: file_name.to_string(), path: final_path.to_string_lossy().to_string(), size: metadata.len(), sha256: file_sha256(&target)?, }], }; let manifest_json = serde_json::to_string_pretty(&manifest).map_err(|err| err.to_string())?; std::fs::write(temp_dir.join("manifest.json"), manifest_json).map_err(|err| err.to_string())?; if bundle_dir.exists() { std::fs::remove_dir_all(&bundle_dir).map_err(|err| err.to_string())?; } std::fs::rename(&temp_dir, &bundle_dir).map_err(|err| err.to_string())?; Ok(()) } fn maven_bundle_id(coordinate: &str) -> String { let mut id = String::with_capacity(coordinate.len()); for ch in coordinate.trim().chars() { if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-') { id.push(ch); } else { id.push('_'); } } if id.is_empty() { "maven-driver".to_string() } else { id } } fn is_safe_bundle_id(bundle_id: &str) -> bool { !bundle_id.is_empty() && bundle_id.chars().all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_')) } fn file_sha256(path: &Path) -> Result { let mut file = std::fs::File::open(path).map_err(|err| err.to_string())?; let mut hash = Sha256::new(); let mut buffer = [0_u8; 64 * 1024]; loop { let read = file.read(&mut buffer).map_err(|err| err.to_string())?; if read == 0 { break; } hash.update(&buffer[..read]); } let hash = hash.finalize(); Ok(format!("{hash:x}")) } #[derive(Debug, Deserialize)] struct MavenResolveOutput { coordinate: String, scope: String, repositories: Vec, artifacts: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct MavenResolvedArtifact { group_id: String, artifact_id: String, version: String, classifier: String, extension: String, file: String, } pub fn unique_target_path(dir: &Path, file_name: &str) -> PathBuf { let target = dir.join(file_name); if !target.exists() { return target; } let path = Path::new(file_name); let stem = path.file_stem().and_then(|value| value.to_str()).unwrap_or("driver"); let ext = path.extension().and_then(|value| value.to_str()).unwrap_or("jar"); for index in 1.. { let candidate = dir.join(format!("{stem}-{index}.{ext}")); if !candidate.exists() { return candidate; } } unreachable!() } fn paths_refer_to_same_file(left: &Path, right: &Path) -> bool { if left == right { return true; } matches!((left.canonicalize(), right.canonicalize()), (Ok(left), Ok(right)) if left == right) } #[cfg(test)] mod tests { use super::*; use crate::plugins::PluginRuntimeEnv; use std::io::Write; fn test_jdbc_plugin_zip() -> Vec { let cursor = std::io::Cursor::new(Vec::new()); let mut zip = zip::ZipWriter::new(cursor); let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); zip.start_file("dbx-jdbc-plugin-test/manifest.json", options).unwrap(); zip.write_all( br#"{ "id": "jdbc", "name": "DBX JDBC Plugin", "version": "0.1.30", "protocol_version": 1, "executable": "bin/dbx-jdbc-plugin", "drivers": [{"id":"jdbc","label":"JDBC","kind":"external","database_type":"jdbc"}] }"#, ) .unwrap(); zip.start_file("dbx-jdbc-plugin-test/bin/dbx-jdbc-plugin", options).unwrap(); zip.write_all(b"#!/bin/sh\n").unwrap(); zip.finish().unwrap().into_inner() } fn write_offline_jdbc_package(path: &Path, artifact_sha256: &str) { let coordinate = "com.example:demo-driver:1.0.0"; let bundle_id = maven_bundle_id(coordinate); let artifact = b"offline-driver"; let bundle = JdbcMavenBundleInfo { id: bundle_id.clone(), coordinate: coordinate.to_string(), scope: "runtime".to_string(), repositories: vec!["https://repo.maven.apache.org/maven2/".to_string()], installed_at: String::new(), path: String::new(), artifacts: vec![JdbcMavenArtifactInfo { group_id: "com.example".to_string(), artifact_id: "demo-driver".to_string(), version: "1.0.0".to_string(), classifier: String::new(), extension: "jar".to_string(), file_name: "demo-driver-1.0.0.jar".to_string(), path: String::new(), size: artifact.len() as u64, sha256: artifact_sha256.to_string(), }], }; let manifest = serde_json::json!({ "format_version": 1, "plugin_entry": "jdbc/plugin.zip", "bundles": [{"id": bundle_id, "coordinate": coordinate}], }); let file = std::fs::File::create(path).unwrap(); let mut zip = zip::ZipWriter::new(file); let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); zip.start_file(OFFLINE_JDBC_MANIFEST_ENTRY, options).unwrap(); zip.write_all(serde_json::to_string(&manifest).unwrap().as_bytes()).unwrap(); zip.start_file(OFFLINE_JDBC_PLUGIN_ENTRY, options).unwrap(); zip.write_all(&test_jdbc_plugin_zip()).unwrap(); zip.start_file(format!("jdbc/maven/{}/manifest.json", bundle.id), options).unwrap(); zip.write_all(serde_json::to_string(&bundle).unwrap().as_bytes()).unwrap(); zip.start_file(format!("jdbc/maven/{}/jars/demo-driver-1.0.0.jar", bundle.id), options).unwrap(); zip.write_all(artifact).unwrap(); zip.finish().unwrap(); } #[test] fn maven_bundle_install_lists_nested_jars() { let root = std::env::temp_dir().join(format!("dbx-jdbc-maven-test-{}", uuid::Uuid::new_v4())); let source_dir = root.join("source"); std::fs::create_dir_all(&source_dir).unwrap(); let source = source_dir.join("demo-driver.jar"); std::fs::write(&source, b"jar").unwrap(); let resolved = MavenResolveOutput { coordinate: "com.example:demo-driver:1.0.0".to_string(), scope: "runtime".to_string(), repositories: vec!["https://repo.maven.apache.org/maven2/".to_string()], artifacts: vec![MavenResolvedArtifact { group_id: "com.example".to_string(), artifact_id: "demo-driver".to_string(), version: "1.0.0".to_string(), classifier: String::new(), extension: "jar".to_string(), file: source.to_string_lossy().to_string(), }], }; install_jdbc_maven_bundle(&root, "com.example:demo-driver:1.0.0", &resolved).unwrap(); let drivers = list_jdbc_drivers(&root).unwrap(); assert_eq!(drivers.len(), 1); assert_eq!(drivers[0].name, "demo-driver.jar"); assert_eq!(drivers[0].bundle_id.as_deref(), Some("com.example_demo-driver_1.0.0")); let bundles = list_jdbc_maven_bundles(&root).unwrap(); assert_eq!(bundles.len(), 1); assert_eq!(bundles[0].artifacts.len(), 1); let _ = std::fs::remove_dir_all(root); } #[test] fn maven_bundle_ids_are_path_safe() { assert_eq!( maven_bundle_id("org.apache.hive:hive-jdbc:4.0.1:standalone"), "org.apache.hive_hive-jdbc_4.0.1_standalone" ); assert!(is_safe_bundle_id("org.apache.hive_hive-jdbc_4.0.1_standalone")); assert!(!is_safe_bundle_id("../hive")); } fn resolved_artifact(group_id: &str, artifact_id: &str, version: &str) -> MavenResolvedArtifact { MavenResolvedArtifact { group_id: group_id.to_string(), artifact_id: artifact_id.to_string(), version: version.to_string(), classifier: String::new(), extension: "jar".to_string(), file: format!("/tmp/{artifact_id}-{version}.jar"), } } fn resolved_with(artifacts: Vec) -> MavenResolveOutput { MavenResolveOutput { coordinate: String::new(), scope: "runtime".to_string(), repositories: vec!["https://repo.maven.apache.org/maven2/".to_string()], artifacts, } } #[test] fn oracle_ojdbc_resolves_matching_orai18n_companion() { let resolved = resolved_with(vec![resolved_artifact("com.oracle.database.jdbc", "ojdbc11", "21.9.0.0")]); assert_eq!(oracle_orai18n_companion(&resolved).as_deref(), Some("com.oracle.database.nls:orai18n:21.9.0.0")); } #[test] fn oracle_orai18n_companion_skips_existing_nls_artifact() { let resolved = resolved_with(vec![ resolved_artifact("com.oracle.database.jdbc", "ojdbc11", "21.9.0.0"), resolved_artifact("com.oracle.database.nls", "orai18n", "21.9.0.0"), ]); assert_eq!(oracle_orai18n_companion(&resolved), None); } #[test] fn oracle_orai18n_companion_ignores_non_oracle_drivers() { for coordinate in [("org.postgresql", "postgresql"), ("com.oracle.database.xml", "xdb6"), ("mysql", "mysql-connector-java")] { let resolved = resolved_with(vec![resolved_artifact(coordinate.0, coordinate.1, "8.0.33")]); assert_eq!(oracle_orai18n_companion(&resolved), None, "{}", coordinate.1); } } #[test] fn imports_offline_jdbc_plugin_and_rewrites_bundle_paths() { let root = std::env::temp_dir().join(format!("dbx-offline-jdbc-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&root).unwrap(); let package = root.join("offline.zip"); let artifact_sha256 = format!("{:x}", Sha256::digest(b"offline-driver")); write_offline_jdbc_package(&package, &artifact_sha256); let plugins_root = root.join("plugins"); let result = import_offline_jdbc_payload(&plugins_root, &package).unwrap().unwrap(); assert!(result.plugin_installed); assert_eq!(result.bundles_installed, vec!["com.example:demo-driver:1.0.0"]); assert!(plugins_root.join("jdbc/manifest.json").exists()); let bundles = list_jdbc_maven_bundles(&plugins_root).unwrap(); assert_eq!(bundles.len(), 1); assert_eq!( bundles[0].path, plugins_root.join("jdbc/drivers/maven/com.example_demo-driver_1.0.0").to_string_lossy() ); assert_eq!( bundles[0].artifacts[0].path, plugins_root .join("jdbc/drivers/maven/com.example_demo-driver_1.0.0/jars/demo-driver-1.0.0.jar") .to_string_lossy() ); assert_eq!(std::fs::read(&bundles[0].artifacts[0].path).unwrap(), b"offline-driver"); let _ = std::fs::remove_dir_all(root); } #[test] fn rejects_corrupt_offline_jdbc_artifact_before_installing_plugin() { let root = std::env::temp_dir().join(format!("dbx-offline-jdbc-corrupt-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&root).unwrap(); let package = root.join("offline.zip"); write_offline_jdbc_package(&package, &"0".repeat(64)); let plugins_root = root.join("plugins"); let error = import_offline_jdbc_payload(&plugins_root, &package).unwrap_err(); assert!(error.contains("checksum mismatch")); assert!(!plugins_root.join("jdbc/manifest.json").exists()); let _ = std::fs::remove_dir_all(root); } #[test] fn multiple_local_jars_are_imported_as_one_bundle() { let root = std::env::temp_dir().join(format!("dbx-jdbc-local-bundle-test-{}", uuid::Uuid::new_v4())); let source_dir = root.join("source"); std::fs::create_dir_all(&source_dir).unwrap(); let driver = source_dir.join("vendor-driver.jar"); let dependency = source_dir.join("vendor-codec.jar"); std::fs::write(&driver, b"driver").unwrap(); std::fs::write(&dependency, b"dependency").unwrap(); let paths = vec![driver.to_string_lossy().to_string(), dependency.to_string_lossy().to_string()]; let drivers = import_jdbc_drivers(&root, &paths).unwrap(); let bundles = list_jdbc_local_bundles(&root).unwrap(); assert_eq!(bundles.len(), 1); assert_eq!(bundles[0].name, "vendor-driver"); assert_eq!(bundles[0].artifacts.len(), 2); assert_eq!(drivers.len(), 2); assert!(drivers.iter().all(|item| item.bundle_id.as_deref() == Some(bundles[0].id.as_str()))); let remaining = delete_jdbc_local_bundle(&root, &bundles[0].id).unwrap(); assert!(remaining.is_empty()); assert!(list_jdbc_local_bundles(&root).unwrap().is_empty()); let _ = std::fs::remove_dir_all(root); } #[test] fn single_local_jar_keeps_legacy_standalone_behavior() { let root = std::env::temp_dir().join(format!("dbx-jdbc-single-jar-test-{}", uuid::Uuid::new_v4())); let source = root.join("source").join("standalone.jar"); std::fs::create_dir_all(source.parent().unwrap()).unwrap(); std::fs::write(&source, b"driver").unwrap(); let drivers = import_jdbc_drivers(&root, &[source.to_string_lossy().to_string()]).unwrap(); assert_eq!(drivers.len(), 1); assert_eq!(drivers[0].name, "standalone.jar"); assert_eq!(drivers[0].bundle_id, None); assert!(list_jdbc_local_bundles(&root).unwrap().is_empty()); let _ = std::fs::remove_dir_all(root); } #[test] fn single_local_jar_already_in_drivers_dir_is_not_duplicated() { let root = std::env::temp_dir().join(format!("dbx-jdbc-managed-jar-test-{}", uuid::Uuid::new_v4())); let source = root.join("jdbc").join("drivers").join("standalone.jar"); std::fs::create_dir_all(source.parent().unwrap()).unwrap(); std::fs::write(&source, b"driver").unwrap(); let drivers = import_jdbc_drivers(&root, &[source.to_string_lossy().to_string()]).unwrap(); assert_eq!(drivers.len(), 1); assert_eq!(drivers[0].name, "standalone.jar"); assert!(!source.with_file_name("standalone-1.jar").exists()); let _ = std::fs::remove_dir_all(root); } #[test] fn single_local_jar_with_existing_name_keeps_unique_import_behavior() { let root = std::env::temp_dir().join(format!("dbx-jdbc-duplicate-name-test-{}", uuid::Uuid::new_v4())); let installed = root.join("jdbc").join("drivers").join("standalone.jar"); let source = root.join("source").join("standalone.jar"); std::fs::create_dir_all(installed.parent().unwrap()).unwrap(); std::fs::create_dir_all(source.parent().unwrap()).unwrap(); std::fs::write(&installed, b"installed").unwrap(); std::fs::write(&source, b"new").unwrap(); let drivers = import_jdbc_drivers(&root, &[source.to_string_lossy().to_string()]).unwrap(); assert_eq!(drivers.len(), 2); assert!(drivers.iter().any(|driver| driver.name == "standalone.jar")); assert!(drivers.iter().any(|driver| driver.name == "standalone-1.jar")); assert_eq!(std::fs::read(root.join("jdbc/drivers/standalone-1.jar")).unwrap(), b"new"); let _ = std::fs::remove_dir_all(root); } #[test] fn installs_prestosql_direct_driver_as_builtin_bundle() { let root = std::env::temp_dir().join(format!("dbx-prestosql-direct-driver-test-{}", uuid::Uuid::new_v4())); let source_dir = root.join("source"); std::fs::create_dir_all(&source_dir).unwrap(); let source = source_dir.join("presto-jdbc-350.jar"); std::fs::write(&source, b"presto").unwrap(); install_single_jdbc_maven_artifact( &root, PRESTOSQL_JDBC_DRIVER_COORDINATE, "io.prestosql", "presto-jdbc", PRESTOSQL_JDBC_DRIVER_VERSION, &source, &[PRESTOSQL_JDBC_DRIVER_REPOSITORY.to_string()], ) .unwrap(); let bundles = list_jdbc_maven_bundles(&root).unwrap(); assert_eq!(bundles.len(), 1); assert_eq!(bundles[0].id, "io.prestosql_presto-jdbc_350"); assert_eq!(bundles[0].coordinate, PRESTOSQL_JDBC_DRIVER_COORDINATE); assert_eq!(bundles[0].artifacts[0].file_name, "presto-jdbc-350.jar"); let drivers = list_jdbc_drivers(&root).unwrap(); assert_eq!(drivers.len(), 1); assert_eq!(drivers[0].bundle_id.as_deref(), Some("io.prestosql_presto-jdbc_350")); let _ = std::fs::remove_dir_all(root); } #[tokio::test] async fn maven_resolver_receives_runtime_env() { let root = std::env::temp_dir().join(format!("dbx-jdbc-maven-env-test-{}", uuid::Uuid::new_v4())); let plugin_dir = root.join("jdbc"); let bin_dir = plugin_dir.join("bin"); let source_dir = root.join("source"); std::fs::create_dir_all(&bin_dir).unwrap(); std::fs::create_dir_all(&source_dir).unwrap(); let source = source_dir.join("env-driver.jar"); std::fs::write(&source, b"jar").unwrap(); let output = serde_json::json!({ "coordinate": "com.example:env-driver:1.0.0", "scope": "runtime", "repositories": ["https://repo.maven.apache.org/maven2/"], "artifacts": [{ "groupId": "com.example", "artifactId": "env-driver", "version": "1.0.0", "classifier": "", "extension": "jar", "file": source.to_string_lossy(), }], }) .to_string(); let resolver = jdbc_maven_resolver_executable(&plugin_dir); if cfg!(windows) { std::fs::write( &resolver, format!( "@echo off\r\nif \"%DBX_JAVA_BIN%\"==\"\" (\r\n echo missing DBX_JAVA_BIN 1>&2\r\n exit /b 2\r\n)\r\necho {output}\r\n" ), ) .unwrap(); } else { std::fs::write( &resolver, format!( "#!/usr/bin/env sh\nif [ -z \"${{DBX_JAVA_BIN:-}}\" ]; then\n echo missing DBX_JAVA_BIN >&2\n exit 2\nfi\necho '{}'\n", output.replace('\'', "'\\''") ), ) .unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&resolver, std::fs::Permissions::from_mode(0o755)).unwrap(); } } let drivers = install_jdbc_driver_from_maven( &root, JdbcMavenInstallRequest { coordinate: "com.example:env-driver:1.0.0".to_string(), repositories: vec![] }, PluginRuntimeEnv::default().with_var("DBX_JAVA_BIN", "java-from-managed-jre"), ) .await .unwrap(); assert_eq!(drivers.len(), 1); assert_eq!(drivers[0].name, "env-driver.jar"); let _ = std::fs::remove_dir_all(root); } #[tokio::test] async fn delayed_network_status_check_does_not_revert_a_concurrent_local_install() { let root = std::env::temp_dir().join(format!("dbx-jdbc-status-race-test-{}", uuid::Uuid::new_v4())); let plugin_dir = root.join("jdbc"); std::fs::create_dir_all(&plugin_dir).unwrap(); // Mirrors DriverStoreDialog's mount-time status check: it starts // before the local install and only finishes its (simulated) // network wait after the local install has already written the // new manifest to disk. let status_plugin_dir = plugin_dir.clone(); let status_task = tokio::spawn(async move { jdbc_plugin_status_from_dir_after(&status_plugin_dir, async { tokio::time::sleep(std::time::Duration::from_millis(80)).await; None }) .await }); tokio::time::sleep(std::time::Duration::from_millis(20)).await; // Local install completes fully offline while the status check // above is still waiting on its simulated network round-trip. std::fs::write( plugin_dir.join("manifest.json"), r#"{"id":"jdbc","name":"DBX JDBC Plugin","version":"0.1.30","protocol_version":1,"executable":"bin/dbx-jdbc-plugin","drivers":[]}"#, ) .unwrap(); let status = status_task.await.unwrap().unwrap(); assert!(status.installed, "stale pre-install snapshot must not overwrite the completed local install"); assert_eq!(status.version.as_deref(), Some("0.1.30")); let _ = std::fs::remove_dir_all(root); } }