diff --git a/apps/desktop/src/components/config/DriverStoreDialog.vue b/apps/desktop/src/components/config/DriverStoreDialog.vue index 3277edc2a1..3362e25d2a 100644 --- a/apps/desktop/src/components/config/DriverStoreDialog.vue +++ b/apps/desktop/src/components/config/DriverStoreDialog.vue @@ -744,9 +744,9 @@ async function importOfflineZip() { activeAgentOperationId.value = uuid(); resetAgentInstallProgress(); try { - const count = await api.importAgentsFromZip(selected, activeAgentOperationId.value); + const result = await api.importAgentsFromZip(selected, activeAgentOperationId.value); await Promise.all([refreshAgents(), loadJdbcDrivers(), loadJdbcPluginStatus()]); - toast(t("driverStore.offlineImportSuccess", { count })); + toast(t(result.jreCount > 0 ? (result.count > 0 ? "driverStore.offlineImportWithJreSuccess" : "driverStore.offlineJreImportSuccess") : "driverStore.offlineImportSuccess", { count: result.count, jreCount: result.jreCount })); } catch (e: any) { toast(t("driverStore.offlineImportFailed", { error: backendError(e) })); } finally { @@ -776,9 +776,9 @@ async function importDriverFile(driver: AgentDriverInfo) { activeAgentOperationId.value = uuid(); resetAgentInstallProgress(); try { - const count = await api.importAgentsFromZip(selected, activeAgentOperationId.value); + const result = await api.importAgentsFromZip(selected, activeAgentOperationId.value); await Promise.all([refreshAgents(), loadJdbcDrivers(), loadJdbcPluginStatus()]); - toast(t("driverStore.offlineImportSuccess", { count })); + toast(t(result.jreCount > 0 ? (result.count > 0 ? "driverStore.offlineImportWithJreSuccess" : "driverStore.offlineJreImportSuccess") : "driverStore.offlineImportSuccess", { count: result.count, jreCount: result.jreCount })); } finally { activeAgentOperationId.value = null; resetAgentInstallProgress(); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index e043852433..9e162c6033 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -7861,6 +7861,8 @@ export default withEnglishFallback({ driverUninstallFailed: "{label} drayverini silmək mümkün olmadı: {error}", chooseOfflineDriverPackage: "Oflayn drayver paketini seç", offlineImportSuccess: "Oflayn idxal tamamlandı. {count} drayver quraşdırıldı.", + offlineJreImportSuccess: "Oflayn idxal tamamlandı. {jreCount} JRE quraşdırıldı.", + offlineImportWithJreSuccess: "Oflayn idxal tamamlandı. {jreCount} JRE və {count} drayver quraşdırıldı.", offlineImportFailed: "Oflayn idxal uğursuz oldu: {error}", driverImportSuccess: "{label} drayveri idxal edildi", driverImportFailed: "{label} drayverini idxal etmək mümkün olmadı: {error}", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 85c0897cea..36eff94317 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -8051,6 +8051,8 @@ export default { driverUninstallFailed: "Failed to uninstall {label} driver: {error}", chooseOfflineDriverPackage: "Choose offline driver package", offlineImportSuccess: "Offline import complete. Installed {count} driver(s).", + offlineJreImportSuccess: "Offline import complete. Installed {jreCount} JRE(s).", + offlineImportWithJreSuccess: "Offline import complete. Installed {jreCount} JRE(s) and {count} driver(s).", offlineImportFailed: "Offline import failed: {error}", driverImportSuccess: "{label} driver imported", driverImportFailed: "Failed to import {label} driver: {error}", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 27c48089a2..677e6bf8f5 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -7599,6 +7599,8 @@ export default withEnglishFallback({ driverUninstallFailed: "Error al desinstalar el driver {label}: {error}", chooseOfflineDriverPackage: "Seleccionar paquete offline de drivers", offlineImportSuccess: "Importación offline completada. Se instalaron {count} driver(s).", + offlineJreImportSuccess: "Importación offline completada. Se instalaron {jreCount} JRE(s).", + offlineImportWithJreSuccess: "Importación offline completada. Se instalaron {jreCount} JRE(s) y {count} driver(s).", offlineImportFailed: "Error en la importación offline: {error}", driverImportSuccess: "Driver {label} importado", driverImportFailed: "Error al importar el driver {label}: {error}", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index aa8a5c3ed0..a9f3ace671 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -7599,6 +7599,8 @@ export default withEnglishFallback({ driverUninstallFailed: "Disinstillazione del driver {label} non riuscita: {error}", chooseOfflineDriverPackage: "Scegli pacchetto driver offline", offlineImportSuccess: "Importazione offline completata. Installato/i {count} driver.", + offlineJreImportSuccess: "Importazione offline completata. Installato/i {jreCount} JRE.", + offlineImportWithJreSuccess: "Importazione offline completata. Installato/i {jreCount} JRE e {count} driver.", offlineImportFailed: "Importazione offline non riuscita: {error}", driverImportSuccess: "Driver {label} importato", driverImportFailed: "Importazione del driver {label} non riuscita: {error}", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index ee1f5130b7..2a826d69cc 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -7651,6 +7651,8 @@ export default withEnglishFallback({ driverUninstallFailed: "{label} ドライバーのアンインストールに失敗しました: {error}", chooseOfflineDriverPackage: "オフラインドライバーパッケージを選択", offlineImportSuccess: "オフラインインポートが完了しました。{count}件のドライバーをインストールしました。", + offlineJreImportSuccess: "オフラインインポートが完了しました。{jreCount}件のJREをインストールしました。", + offlineImportWithJreSuccess: "オフラインインポートが完了しました。{jreCount}件のJREと{count}件のドライバーをインストールしました。", offlineImportFailed: "オフラインインポートに失敗しました: {error}", driverImportSuccess: "{label} ドライバーをインポートしました", driverImportFailed: "{label} ドライバーのインポートに失敗しました: {error}", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 93eeb985ce..acb03698d8 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -7331,6 +7331,8 @@ export default withEnglishFallback({ driverUninstallFailed: "{label} 드라이버 제거 실패: {error}", chooseOfflineDriverPackage: "오프라인 드라이버 패키지 선택", offlineImportSuccess: "오프라인 가져오기 완료. 드라이버 {count}개 설치.", + offlineJreImportSuccess: "오프라인 가져오기 완료. JRE {jreCount}개 설치.", + offlineImportWithJreSuccess: "오프라인 가져오기 완료. JRE {jreCount}개와 드라이버 {count}개 설치.", offlineImportFailed: "오프라인 가져오기 실패: {error}", driverImportSuccess: "{label} 드라이버를 가져왔습니다", driverImportFailed: "{label} 드라이버 가져오기 실패: {error}", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 7127826014..9f26665b59 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -7601,6 +7601,8 @@ export default withEnglishFallback({ driverUninstallFailed: "Falha ao desinstalar o driver {label}: {error}", chooseOfflineDriverPackage: "Escolher pacote de driver offline", offlineImportSuccess: "Importação offline concluída. {count} driver(s) instalado(s).", + offlineJreImportSuccess: "Importação offline concluída. {jreCount} JRE(s) instalado(s).", + offlineImportWithJreSuccess: "Importação offline concluída. {jreCount} JRE(s) e {count} driver(s) instalado(s).", offlineImportFailed: "Falha na importação offline: {error}", driverImportSuccess: "Driver {label} importado", driverImportFailed: "Falha ao importar o driver {label}: {error}", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 8ba7562ab8..4ea9051462 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -7713,6 +7713,8 @@ export default withEnglishFallback({ driverUninstallFailed: "{label} sürücüsü kaldırılamadı: {error}", chooseOfflineDriverPackage: "Çevrimdışı sürücü paketini seçin", offlineImportSuccess: "Çevrimdışı içe aktarma tamamlandı. {count} sürücü kuruldu.", + offlineJreImportSuccess: "Çevrimdışı içe aktarma tamamlandı. {jreCount} JRE kuruldu.", + offlineImportWithJreSuccess: "Çevrimdışı içe aktarma tamamlandı. {jreCount} JRE ve {count} sürücü kuruldu.", offlineImportFailed: "Çevrimdışı içe aktarma başarısız: {error}", driverImportSuccess: "{label} sürücüsü içe aktarıldı", driverImportFailed: "{label} sürücüsü içe aktarılamadı: {error}", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 6f13c43f02..e6f23ae47d 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -8023,6 +8023,8 @@ export default withEnglishFallback({ driverUninstallFailed: "{label} 驱动卸载失败: {error}", chooseOfflineDriverPackage: "选择离线驱动包", offlineImportSuccess: "离线导入完成,已安装 {count} 个驱动", + offlineJreImportSuccess: "离线导入完成,已安装 {jreCount} 个 JRE", + offlineImportWithJreSuccess: "离线导入完成,已安装 {jreCount} 个 JRE 和 {count} 个驱动", offlineImportFailed: "离线导入失败: {error}", driverImportSuccess: "{label} 驱动导入成功", driverImportFailed: "{label} 驱动导入失败: {error}", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 2823cf6432..1ce6f5fbee 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -6903,6 +6903,8 @@ export default withEnglishFallback({ driverUninstallFailed: "{label} 驅動程式解除安裝失敗: {error}", chooseOfflineDriverPackage: "選擇離線驅動程式包", offlineImportSuccess: "離線匯入完成,已安裝 {count} 個驅動程式", + offlineJreImportSuccess: "離線匯入完成,已安裝 {jreCount} 個 JRE", + offlineImportWithJreSuccess: "離線匯入完成,已安裝 {jreCount} 個 JRE 和 {count} 個驅動程式", offlineImportFailed: "離線匯入失敗: {error}", driverImportSuccess: "{label} 驅動程式匯入成功", driverImportFailed: "{label} 驅動程式匯入失敗: {error}", diff --git a/apps/desktop/src/lib/backend/__tests__/agentOfflineImport.spec.ts b/apps/desktop/src/lib/backend/__tests__/agentOfflineImport.spec.ts new file mode 100644 index 0000000000..4cd6c163a4 --- /dev/null +++ b/apps/desktop/src/lib/backend/__tests__/agentOfflineImport.spec.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("offline Agent and standalone JRE import", () => { + it("returns separate driver and JRE counts from the desktop command", async () => { + const { importAgentsFromZip } = await import("../tauri"); + mocks.invoke.mockResolvedValue({ count: 0, jreCount: 1 }); + expect(await importAgentsFromZip("/tmp/renamed.tar.zst", "import-1")).toEqual({ count: 0, jreCount: 1 }); + expect(mocks.invoke).toHaveBeenCalledWith("import_agents_from_zip", { path: "/tmp/renamed.tar.zst", operationId: "import-1" }); + }); + + it("uploads standalone JRE packages and preserves both result counts", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ count: 0, jreCount: 1 }))); + vi.stubGlobal("fetch", fetchMock); + const { importAgentsFromZip } = await import("../http"); + const file = new File(["archive"], "renamed.tar.zst"); + expect(await importAgentsFromZip(file, "import-2")).toEqual({ count: 0, jreCount: 1 }); + const [url, request] = fetchMock.mock.calls[0]; + expect(url).toContain("/api/agents/import-offline"); + expect(request.method).toBe("POST"); + expect(request.body.get("operationId")).toBe("import-2"); + expect(request.body.get("file").name).toBe(file.name); + }); + + it("remains compatible with web backends that only return a driver count", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ count: 2 })))); + const { importAgentsFromZip } = await import("../http"); + expect(await importAgentsFromZip(new File(["archive"], "drivers.zip"))).toEqual({ count: 2, jreCount: 0 }); + }); +}); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index df1a316646..337a2d208e 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -69,6 +69,7 @@ import type { DriverStoreUsage, DriverRuntimeSummary, UpgradeAllAgentDriversResult, + AgentOfflineImportResult, AgentUpdateBlocker, AgentOfflineExportPreview, AgentOfflineExportResult, @@ -861,7 +862,7 @@ export async function invalidateAgentRegistryCache(): Promise { await post("/api/agents/invalidate-registry-cache", {}); } -export async function importAgentsFromZip(fileOrPath: string | File, operationId?: string): Promise { +export async function importAgentsFromZip(fileOrPath: string | File, operationId?: string): Promise { if (typeof fileOrPath === "string") { throw new Error("Offline package import in web mode requires a File object, not a file path"); } @@ -873,8 +874,8 @@ export async function importAgentsFromZip(fileOrPath: string | File, operationId body: formData, }); if (!res.ok) throw await backendResponseError(res); - const result: { count: number } = await res.json(); - return result.count; + const result: AgentOfflineImportResult = await res.json(); + return { count: result.count, jreCount: result.jreCount ?? 0 }; } export async function previewAgentOfflineExport(): Promise { diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 2a3552c4f7..ac427c2340 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -200,6 +200,11 @@ export interface AgentOfflineExportResult { bytes: number; } +export interface AgentOfflineImportResult { + count: number; + jreCount: number; +} + export type JavaRuntimeMode = "managed" | "system" | "custom"; export interface JavaRuntimeConfig { @@ -2511,7 +2516,7 @@ export async function invalidateAgentRegistryCache(): Promise { return invoke("invalidate_agent_registry_cache"); } -export async function importAgentsFromZip(path: string | File, operationId?: string): Promise { +export async function importAgentsFromZip(path: string | File, operationId?: string): Promise { if (typeof path !== "string") { throw new Error("Desktop offline package import requires a local file path"); } diff --git a/crates/dbx-core/src/agent_service.rs b/crates/dbx-core/src/agent_service.rs index 21a54e4ce8..c84a64cced 100644 --- a/crates/dbx-core/src/agent_service.rs +++ b/crates/dbx-core/src/agent_service.rs @@ -1065,6 +1065,11 @@ pub async fn import_agents_from_zip( pub fn inspect_offline_package(package_path: &Path) -> Result { if is_tar_zstd_package(package_path) { + if let Some(info) = tar_zstd_jre_package_info(package_path)? { + let staging = tempfile::tempdir().map_err(|error| error.to_string())?; + extract_and_validate_standalone_jre(package_path, staging.path(), &info)?; + return Ok(OfflineImportPlan { driver_keys: Vec::new(), includes_jre: true }); + } inspect_tar_zstd_driver_package(package_path) } else { inspect_offline_zip(package_path) @@ -1077,6 +1082,9 @@ pub async fn import_agents_from_package( progress: impl Fn(AgentProgressEvent), ) -> Result { if is_tar_zstd_package(package_path) { + if let Some(info) = tar_zstd_jre_package_info(package_path)? { + return import_tar_zstd_jre_package(am, package_path, &info, &progress).await; + } import_tar_zstd_driver_package(am, package_path, |event| { progress(AgentProgressEvent { operation_id: None, @@ -2467,6 +2475,125 @@ pub struct OfflineImportResult { pub drivers_skipped: Vec, } +#[derive(Debug)] +struct TarZstdJrePackageInfo { + key: String, + version: String, +} + +fn tar_zstd_jre_package_info(package_path: &Path) -> Result, String> { + let file = std::fs::File::open(package_path).map_err(|error| error.to_string())?; + let decoder = zstd::stream::read::Decoder::new(file).map_err(|error| error.to_string())?; + let mut archive = tar::Archive::new(decoder); + let mut release = None; + let mut has_registry = false; + let mut invalid_jre_entry = false; + for entry in archive.entries().map_err(|error| error.to_string())? { + let mut entry = entry.map_err(|error| error.to_string())?; + let name = safe_archive_entry_name(&entry.path().map_err(|error| error.to_string())?)?; + has_registry |= name == "agent-registry.json"; + invalid_jre_entry |= !(name == "dbx-jre" || name.starts_with("dbx-jre/")) + || !(entry.header().entry_type().is_file() + || entry.header().entry_type().is_dir() + || (entry.header().entry_type().is_symlink() && standalone_jre_link_is_safe(&entry, &name)?)); + if name == "dbx-jre/release" { + if release.is_some() || !entry.header().entry_type().is_file() || entry.size() > 64 * 1024 { + return Err("Invalid offline JRE release metadata".to_string()); + } + let mut text = String::new(); + entry.read_to_string(&mut text).map_err(|error| error.to_string())?; + release = Some(text); + } + } + if has_registry { + return Ok(None); + } + let Some(release) = release else { return Ok(None) }; + if invalid_jre_entry { + return Err("Offline JRE package contains an unexpected or non-regular entry".to_string()); + } + let version = release + .lines() + .find_map(|line| line.strip_prefix("JAVA_VERSION=").map(|value| value.trim().trim_matches('"'))) + .ok_or("Offline JRE package is missing JAVA_VERSION")?; + let key = version.split('.').next().unwrap_or(""); + if key.is_empty() || !key.bytes().all(|byte| byte.is_ascii_digit()) || key == "0" { + return Err("Invalid offline JRE JAVA_VERSION".to_string()); + } + validate_offline_identifier(version, "JRE version")?; + Ok(Some(TarZstdJrePackageInfo { key: key.to_string(), version: version.to_string() })) +} + +fn standalone_jre_link_is_safe(entry: &tar::Entry<'_, R>, name: &str) -> Result { + let Some(target) = entry.link_name().map_err(|error| error.to_string())? else { return Ok(false) }; + let mut parts: Vec<_> = name.split('/').collect(); + parts.pop(); + for component in target.components() { + match component { + std::path::Component::Normal(value) => { + let Some(value) = value.to_str() else { return Ok(false) }; + parts.push(value); + } + std::path::Component::CurDir => {} + std::path::Component::ParentDir if parts.len() > 1 => { + parts.pop(); + } + _ => return Ok(false), + } + } + Ok(parts.first() == Some(&"dbx-jre")) +} + +fn extract_and_validate_standalone_jre( + package_path: &Path, + staging: &Path, + info: &TarZstdJrePackageInfo, +) -> Result<(), String> { + extract_jre_archive(package_path, staging, Some(ArtifactFormat::TarZstd))?; + let java = staging.join("bin").join(if cfg!(windows) { "java.exe" } else { "java" }); + if !java.is_file() { + return Err(format!( + "Offline JRE {} package does not contain a Java executable for {}", + info.key, + AgentManager::current_platform() + )); + } + validate_native_agent_binary(&java).map_err(|_| { + format!("Offline JRE {} package does not support platform: {}", info.key, AgentManager::current_platform()) + })?; + mark_executable(&java) +} + +async fn import_tar_zstd_jre_package( + am: &AgentManager, + package_path: &Path, + info: &TarZstdJrePackageInfo, + progress: &impl Fn(AgentProgressEvent), +) -> Result { + let _installation_guard = am.installation_operation_lock.write().await; + std::fs::create_dir_all(am.base_dir()).map_err(|error| error.to_string())?; + let staging = tempfile::Builder::new() + .prefix(".jre-offline-import-") + .tempdir_in(am.base_dir()) + .map_err(|error| error.to_string())?; + progress(AgentProgressEvent::step("jre-extract")); + extract_and_validate_standalone_jre(package_path, staging.path(), info)?; + // Validate before stopping active daemons or replacing a working runtime. + am.stop_daemons().await; + let pending_cleanup = replace_imported_jre_dir(staging.path(), &am.jre_dir(&info.key))?; + am.mutate_state(|state| { + state.jre_versions.insert(info.key.clone(), info.version.clone()); + if let Some(path) = pending_cleanup { + state.pending_jre_cleanup.push(path); + } + })?; + Ok(OfflineImportResult { + jre_installed: vec![info.key.clone()], + drivers_installed: Vec::new(), + drivers_skipped: Vec::new(), + }) +} + #[derive(Debug, Clone)] pub struct OfflineImportPlan { pub driver_keys: Vec, diff --git a/crates/dbx-core/tests/agent_service.rs b/crates/dbx-core/tests/agent_service.rs index 470e2cc832..c953710cc3 100644 --- a/crates/dbx-core/tests/agent_service.rs +++ b/crates/dbx-core/tests/agent_service.rs @@ -14,6 +14,106 @@ fn test_manager(name: &str) -> AgentManager { AgentManager::new_with_base_dir(dir) } +fn write_standalone_jre(path: &std::path::Path, version: &str, java: Option<&[u8]>) { + let encoder = zstd::stream::write::Encoder::new(std::fs::File::create(path).unwrap(), 0).unwrap(); + let mut archive = tar::Builder::new(encoder); + let mut append = |name: &str, bytes: &[u8]| { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + archive.append_data(&mut header, name, bytes).unwrap(); + }; + append("dbx-jre/release", format!("JAVA_VERSION=\"{version}\"\n").as_bytes()); + if let Some(java) = java { + append(if cfg!(windows) { "dbx-jre/bin/java.exe" } else { "dbx-jre/bin/java" }, java); + } + archive.into_inner().unwrap().finish().unwrap(); +} + +#[tokio::test] +async fn standalone_jre_import_works_without_registry_and_with_renamed_upload() { + let dir = tempfile::tempdir().unwrap(); + let package = dir.path().join("agent-offline-upload.tar.zst"); + let binary = current_platform_native_binary(); + write_standalone_jre(&package, "21.0.12.1", Some(&binary)); + let plan = inspect_offline_package(&package).unwrap(); + assert!(plan.includes_jre); + assert!(plan.driver_keys.is_empty()); + let manager = AgentManager::new_with_base_dir(dir.path().join("agents")); + let result = import_agents_from_package(&manager, &package, |_| {}).await.unwrap(); + assert_eq!(result.jre_installed, vec!["21"]); + assert!(result.drivers_installed.is_empty()); + assert!(manager.is_jre_installed("21")); + assert_eq!(manager.load_state().jre_versions["21"], "21.0.12.1"); +} + +#[tokio::test] +async fn standalone_jre_invalid_package_preserves_existing_runtime() { + let dir = tempfile::tempdir().unwrap(); + let manager = AgentManager::new_with_base_dir(dir.path().join("agents")); + let package = dir.path().join("upload.tar.zst"); + write_standalone_jre(&package, "21.0.12.1", Some(¤t_platform_native_binary())); + import_agents_from_package(&manager, &package, |_| {}).await.unwrap(); + for (version, java) in + [("21.0.13", None), ("../../outside", Some(b"bad".as_slice())), ("21.0.13", Some(b"bad".as_slice()))] + { + write_standalone_jre(&package, version, java); + assert!(inspect_offline_package(&package).is_err()); + assert!(import_agents_from_package(&manager, &package, |_| {}).await.is_err()); + assert_eq!(manager.load_state().jre_versions["21"], "21.0.12.1"); + assert!(manager.is_jre_installed("21")); + } + write_standalone_jre(&package, "21.0.13", Some(¤t_platform_native_binary())); + import_agents_from_package(&manager, &package, |_| {}).await.unwrap(); + assert_eq!(manager.load_state().jre_versions["21"], "21.0.13"); +} + +#[test] +fn standalone_jre_rejects_wrong_cpu_architecture() { + let dir = tempfile::tempdir().unwrap(); + let package = dir.path().join("upload.tar.zst"); + write_standalone_jre(&package, "21.0.12.1", Some(&native_binary_for_arch(!cfg!(target_arch = "aarch64")))); + assert!(inspect_offline_package(&package).unwrap_err().contains("does not support platform")); +} + +#[test] +fn standalone_jre_rejects_links_outside_archive() { + let dir = tempfile::tempdir().unwrap(); + let package = dir.path().join("upload.tar.zst"); + let encoder = zstd::stream::write::Encoder::new(std::fs::File::create(&package).unwrap(), 0).unwrap(); + let mut archive = tar::Builder::new(encoder); + let release = b"JAVA_VERSION=\"21.0.12.1\"\n"; + let mut header = tar::Header::new_gnu(); + header.set_size(release.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive.append_data(&mut header, "dbx-jre/release", release.as_slice()).unwrap(); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + archive.append_link(&mut header, "dbx-jre/lib/escape", "../../outside").unwrap(); + archive.into_inner().unwrap().finish().unwrap(); + assert!(inspect_offline_package(&package).unwrap_err().contains("non-regular entry")); + assert!(!dir.path().join("outside").exists()); +} + +#[tokio::test] +#[ignore = "requires DBX_TEST_JRE_PACKAGE containing an official package for the current platform"] +async fn standalone_jre_official_package_runs_java_after_import() { + let package = std::path::PathBuf::from(std::env::var("DBX_TEST_JRE_PACKAGE").unwrap()); + let dir = tempfile::tempdir().unwrap(); + let manager = AgentManager::new_with_base_dir(dir.path().join("agents")); + assert!(inspect_offline_package(&package).unwrap().includes_jre); + let result = import_agents_from_package(&manager, &package, |_| {}).await.unwrap(); + let key = &result.jre_installed[0]; + let java = manager.jre_dir(key).join("bin").join(if cfg!(windows) { "java.exe" } else { "java" }); + let output = std::process::Command::new(java).arg("-version").output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + println!("{}", String::from_utf8_lossy(&output.stderr)); +} + fn registry_with_driver(db_type: &str, version: &str, jre: &str) -> AgentRegistry { let mut drivers = std::collections::HashMap::new(); drivers.insert( diff --git a/crates/dbx-web/src/routes/agents.rs b/crates/dbx-web/src/routes/agents.rs index 3fce5f406a..90fcd00017 100644 --- a/crates/dbx-web/src/routes/agents.rs +++ b/crates/dbx-web/src/routes/agents.rs @@ -304,7 +304,10 @@ pub async fn import_agents_from_zip( let result = result?; send_progress_event(&tx, AgentProgressEvent::step("done").with_operation_id(&operation_id)); - return Ok(Json(serde_json::json!({ "count": result.drivers_installed.len() as u32 }))); + return Ok(Json(serde_json::json!({ + "count": result.drivers_installed.len() as u32, + "jreCount": result.jre_installed.len(), + }))); } Err(AppError::from("No file uploaded".to_string())) diff --git a/docs/content/docs/driver-management.cn.mdx b/docs/content/docs/driver-management.cn.mdx index 57bf82a882..455c95a5d5 100644 --- a/docs/content/docs/driver-management.cn.mdx +++ b/docs/content/docs/driver-management.cn.mdx @@ -85,7 +85,7 @@ RabbitMQ 等消息队列 Agent 还可能同时使用业务协议和管理 API。 所有 Agent 都提供单驱动 ZIP,可直接使用**导入离线包**安装。Java Agent 的单驱动包跨平台,但不重复携带 JRE;KingbaseES、Oracle、Xugu 等原生 Agent 的单驱动包按操作系统和 CPU 架构区分。 -如果目标机器尚未安装 Java Agent 所需的托管 JRE,请另外导入一次对应平台的 JRE,或者直接使用包含全部驱动和 JRE 的完整离线包。 +如果目标机器尚未安装 Java Agent 所需的托管 JRE,可在**导入离线包**中选择对应平台的 `dbx-jre-21-.tar.zst`,或者直接使用包含全部驱动和 JRE 的完整离线 ZIP 包。独立 JRE 包仅包含 Java 运行时,不包含数据库驱动,也不需要 `agent-registry.json`。DBX 会验证 Java 的操作系统和 CPU 架构后安装到托管 JRE 目录。 ### Windows 导入 KingbaseES diff --git a/docs/content/docs/driver-management.mdx b/docs/content/docs/driver-management.mdx index 8bc4ba4c2f..8a98744632 100644 --- a/docs/content/docs/driver-management.mdx +++ b/docs/content/docs/driver-management.mdx @@ -85,7 +85,7 @@ This is useful for air-gapped networks, strict firewall environments, or pre-app Every agent provides a single-driver ZIP that can be installed with **Import offline package**. Java agent packages are platform-independent and do not duplicate the JRE, while native agents such as KingbaseES, Oracle, and Xugu provide separate packages for each OS and CPU architecture. -If the target machine does not already have the managed JRE required by a Java agent, import the matching JRE package once or use the full offline bundle containing every driver and the JRE. +If the target machine does not already have the managed JRE required by a Java agent, use **Import offline package** to select the matching platform's `dbx-jre--.tar.zst`, or use the full offline bundle containing every driver and the JRE. Standalone JRE packages do not require `agent-registry.json`; DBX validates the Java executable's operating system and CPU architecture before installation. ### Importing KingbaseES on Windows diff --git a/src-tauri/src/commands/agents.rs b/src-tauri/src/commands/agents.rs index 65240f735a..674ce11948 100644 --- a/src-tauri/src/commands/agents.rs +++ b/src-tauri/src/commands/agents.rs @@ -219,7 +219,7 @@ pub async fn import_agents_from_zip( state: State<'_, Arc>, path: String, operation_id: Option, -) -> Result { +) -> Result { let am = &state.agent_manager; let package_path = std::path::PathBuf::from(&path); let plan = inspect_offline_package(&package_path)?; @@ -233,7 +233,7 @@ pub async fn import_agents_from_zip( dbx_core::jdbc::import_offline_jdbc_payload(state.plugins.root_dir(), &package_path)?; let count = result.drivers_installed.len() as u32; emit_agent_progress(&app, &operation_id, AgentProgressEvent::step("done")); - Ok(count) + Ok(serde_json::json!({ "count": count, "jreCount": result.jre_installed.len() })) } #[tauri::command]