From 79cd12d13a217d039ae110b3387a2a262ce454b0 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 21 Jul 2026 13:42:39 +0800 Subject: [PATCH] feat(agents): add versioned offline driver packages --- .github/workflows/agents-release.yml | 66 ++- agents/scripts/build_driver_zips.py | 69 +++ agents/scripts/build_offline_zip.sh | 4 +- .../scripts/driver_release_packages_test.py | 103 ++++ agents/scripts/version_agent_artifacts.py | 64 +++ .../components/config/DriverStoreDialog.vue | 42 +- apps/desktop/src/i18n/locales/en.ts | 4 +- apps/desktop/src/i18n/locales/es.ts | 4 +- apps/desktop/src/i18n/locales/it.ts | 4 +- apps/desktop/src/i18n/locales/ja.ts | 4 +- apps/desktop/src/i18n/locales/pt-BR.ts | 4 +- apps/desktop/src/i18n/locales/zh-CN.ts | 4 +- apps/desktop/src/i18n/locales/zh-TW.ts | 4 +- apps/desktop/src/lib/backend/api.ts | 3 +- apps/desktop/src/lib/backend/http.ts | 8 +- apps/desktop/src/lib/backend/tauri.ts | 8 +- .../driverStore/driverImportSelection.spec.ts | 16 + .../lib/driverStore/driverImportSelection.ts | 11 + crates/dbx-core/src/agent_service.rs | 444 ++++++++++++++++-- crates/dbx-core/tests/agent_service.rs | 409 +++++++++++++++- crates/dbx-web/src/main.rs | 3 +- crates/dbx-web/src/routes/agents.rs | 78 ++- docs/app/[lang]/drivers/DriversClient.tsx | 8 +- docs/content/docs/driver-management.cn.mdx | 12 +- docs/content/docs/driver-management.mdx | 12 +- docs/lib/agentRegistry.test.ts | 53 ++- docs/lib/agentRegistry.ts | 56 ++- src-tauri/src/commands/agents.rs | 36 +- src-tauri/src/lib.rs | 1 + 29 files changed, 1372 insertions(+), 162 deletions(-) create mode 100644 agents/scripts/build_driver_zips.py create mode 100644 agents/scripts/driver_release_packages_test.py create mode 100644 agents/scripts/version_agent_artifacts.py create mode 100644 apps/desktop/src/lib/driverStore/driverImportSelection.spec.ts create mode 100644 apps/desktop/src/lib/driverStore/driverImportSelection.ts diff --git a/.github/workflows/agents-release.yml b/.github/workflows/agents-release.yml index 8408ea1f9..764e477f9 100644 --- a/.github/workflows/agents-release.yml +++ b/.github/workflows/agents-release.yml @@ -298,6 +298,11 @@ jobs: find artifacts -name 'dbx-jre-*.tar.gz' -exec cp {} release/ \; ls -lh release/ + - name: Add versions to agent artifact filenames + env: + MODULE_VERSIONS: ${{ needs.bump-versions.outputs.versions }} + run: python3 agents/scripts/version_agent_artifacts.py release "$MODULE_VERSIONS" + - name: Generate agent-registry.json env: MODULE_VERSIONS: ${{ needs.bump-versions.outputs.versions }} @@ -311,6 +316,10 @@ jobs: echo "$MODULE_VERSIONS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('$name','${RELEASE_VERSION}'))" } + module_names() { + echo "$MODULE_VERSIONS" | python3 -c 'import sys,json; print("\n".join(sorted(json.load(sys.stdin))))' + } + generate_jar_entry() { local name="$1" label="$2" file="$3" jre_key="$4" version="$5" external_driver="$6" native_json="$7" local sha256=$(sha256sum "$file" | cut -d' ' -f1) @@ -376,12 +385,13 @@ jobs: } generate_native_platforms() { - local name="$1" + local name="$1" version="$2" local PLATFORMS="" - for f in release/dbx-agent-${name}-*; do + for p in macos-aarch64 macos-x64 linux-aarch64 linux-x64 windows-aarch64 windows-x64; do + extension="" + [[ "$p" == windows-* ]] && extension=".exe" + f="release/dbx-agent-${name}-${version}-${p}${extension}" [ ! -f "$f" ] && continue - [[ "$f" != *.jar ]] || continue - p=$(basename "$f" | sed "s/dbx-agent-${name}-//; s/\\.exe$//") sha256=$(sha256sum "$f" | cut -d' ' -f1) size=$(stat -c%s "$f") url="https://github.com/${REPO}/releases/download/${TAG}/$(basename $f)" @@ -401,25 +411,26 @@ jobs: JRE21_PLATFORMS=$(generate_jre_platforms "21") DRIVERS="" - for f in release/dbx-agent-*.jar; do - name=$(basename "$f" .jar | sed 's/dbx-agent-//') + for name in $(module_names); do + version=$(get_module_version "$name") + f="release/dbx-agent-${name}-${version}.jar" + [ -f "$f" ] || continue label=$(unzip -p "$f" META-INF/MANIFEST.MF | awk -F': ' 'BEGIN{IGNORECASE=1} /^Agent-Label:/ {sub(/\r$/, "", $2); print $2; exit}' || echo "$name") [ -z "$label" ] && label="$name" external_driver=$(unzip -p "$f" META-INF/MANIFEST.MF | awk -F': ' 'BEGIN{IGNORECASE=1} /^Agent-External-Driver:/ {sub(/\r$/, "", $2); print tolower($2); exit}' || true) [ "$external_driver" = "true" ] || external_driver="false" jre_key=$(detect_jre_key "$name") - version=$(get_module_version "$name") - native_json=$(generate_native_platforms "$name") + native_json=$(generate_native_platforms "$name" "$version") [ -n "$DRIVERS" ] && DRIVERS="${DRIVERS},"$'\n' DRIVERS="${DRIVERS}$(generate_jar_entry "$name" "$label" "$f" "$jre_key" "$version" "$external_driver" "$native_json")" done for name in oracle xugu kingbase; do - [ -f "release/dbx-agent-${name}.jar" ] && continue - native_json=$(generate_native_platforms "$name") + version=$(get_module_version "$name") + [ -f "release/dbx-agent-${name}-${version}.jar" ] && continue + native_json=$(generate_native_platforms "$name" "$version") [ -z "$native_json" ] && continue label=$(native_only_label "$name") jre_key=$(detect_jre_key "$name") - version=$(get_module_version "$name") [ -n "$DRIVERS" ] && DRIVERS="${DRIVERS},"$'\n' DRIVERS="${DRIVERS}$(generate_native_entry "$name" "$label" "$jre_key" "$version" "$native_json")" done @@ -444,6 +455,9 @@ jobs: echo "=== agent-registry.json ===" cat release/agent-registry.json + - name: Build single-driver ZIP bundles + run: python3 agents/scripts/build_driver_zips.py release + - name: Build offline ZIP bundles run: bash agents/scripts/build_offline_zip.sh release @@ -455,8 +469,12 @@ jobs: run: | NOTES="" DRIVER_NAMES=() - for f in release/dbx-agent-*.jar; do - DRIVER_NAMES+=("$(basename "$f" .jar | sed 's/dbx-agent-//')") + module_names() { + echo "$MODULE_VERSIONS" | python3 -c 'import sys,json; print("\n".join(sorted(json.load(sys.stdin))))' + } + for name in $(module_names); do + version=$(echo "$MODULE_VERSIONS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('$name',''))") + [ -f "release/dbx-agent-${name}-${version}.jar" ] && DRIVER_NAMES+=("$name") done for name in oracle xugu kingbase; do [ -f "release/dbx-agent-${name}.jar" ] && continue @@ -478,7 +496,7 @@ jobs: new_ver=$(echo "$MODULE_VERSIONS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('$name',''))") [ "$old_ver" = "$new_ver" ] && continue - jar_file="release/dbx-agent-${name}.jar" + jar_file="release/dbx-agent-${name}-${new_ver}.jar" if [ -f "$jar_file" ]; then label=$(unzip -p "$jar_file" META-INF/MANIFEST.MF | awk -F': ' 'BEGIN{IGNORECASE=1} /^Agent-Label:/ {sub(/\r$/, "", $2); print $2; exit}' || echo "$name") [ -z "$label" ] && label="$name" @@ -531,6 +549,16 @@ jobs: git push origin refs/tags/agents-latest --force if gh release view agents-latest --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + # Versioned driver assets change names every release, so remove the + # previous set before uploading to keep agents-latest unambiguous. + gh release view agents-latest --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' | + while IFS= read -r asset; do + case "$asset" in + dbx-agent-*.jar|dbx-agent-*.zip|dbx-agent-oracle-*|dbx-agent-xugu-*|dbx-agent-kingbase-*) + gh release delete-asset agents-latest "$asset" --repo "$GITHUB_REPOSITORY" --yes + ;; + esac + done gh release upload agents-latest release/* --repo "$GITHUB_REPOSITORY" --clobber gh release edit agents-latest \ --repo "$GITHUB_REPOSITORY" \ @@ -563,11 +591,14 @@ jobs: # Upload only changed driver JARs UPLOADED=0 SKIPPED=0 - for f in release/dbx-agent-*.jar; do - [ ! -f "$f" ] && continue - name=$(basename "$f" .jar | sed 's/dbx-agent-//') + module_names() { + echo "$MODULE_VERSIONS" | python3 -c 'import sys,json; print("\n".join(sorted(json.load(sys.stdin))))' + } + for name in $(module_names); do old_ver=$(echo "$PREV_VERSIONS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('$name',''))" 2>/dev/null) new_ver=$(echo "$MODULE_VERSIONS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('$name',''))" 2>/dev/null) + f="release/dbx-agent-${name}-${new_ver}.jar" + [ ! -f "$f" ] && continue if [ "$old_ver" = "$new_ver" ] && [ -n "$old_ver" ]; then echo "Skip $(basename $f) (unchanged $old_ver)" SKIPPED=$((SKIPPED + 1)) @@ -581,6 +612,7 @@ jobs: done echo "Drivers: $UPLOADED uploaded, $SKIPPED skipped" + # Upload versioned native binaries and all single-driver ZIP packages. for f in release/dbx-agent-*; do [ -f "$f" ] || continue [[ "$f" != *.jar ]] || continue diff --git a/agents/scripts/build_driver_zips.py b/agents/scripts/build_driver_zips.py new file mode 100644 index 000000000..ccaa1c2c8 --- /dev/null +++ b/agents/scripts/build_driver_zips.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +import argparse +import copy +import json +import zipfile +from pathlib import Path +from urllib.parse import urlparse + + +def artifact_filename(url: str) -> str: + return Path(urlparse(url).path).name + + +def write_driver_zip(output: Path, registry: dict, source: Path) -> None: + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + archive.writestr("agent-registry.json", json.dumps(registry, ensure_ascii=False, indent=2) + "\n") + archive.write(source, f"drivers/{source.name}") + + +def build_driver_zips(release_dir: Path) -> list[Path]: + registry_path = release_dir / "agent-registry.json" + registry = json.loads(registry_path.read_text(encoding="utf-8")) + outputs: list[Path] = [] + + for driver_name, driver in registry.get("drivers", {}).items(): + version = driver["version"] + jar_artifact = driver.get("jar") + if jar_artifact and jar_artifact.get("size", 0) > 0: + filename = artifact_filename(jar_artifact["url"]) + source = release_dir / filename + if not source.is_file(): + raise FileNotFoundError(f"Java agent artifact missing for {driver_name}: {source}") + + package_driver = copy.deepcopy(driver) + package_driver.pop("native", None) + package_driver["jar"] = {"url": source.name, "size": source.stat().st_size} + package_registry = {"jres": {}, "drivers": {driver_name: package_driver}} + output = release_dir / f"dbx-agent-{driver_name}-{version}.zip" + write_driver_zip(output, package_registry, source) + outputs.append(output) + + for platform, artifact in driver.get("native", {}).items(): + filename = artifact_filename(artifact["url"]) + source = release_dir / filename + if not source.is_file(): + raise FileNotFoundError(f"Native agent artifact missing for {driver_name}/{platform}: {source}") + + package_driver = copy.deepcopy(driver) + package_driver.pop("jar", None) + package_driver["native"] = {platform: {"url": source.name, "size": source.stat().st_size}} + package_registry = {"jres": {}, "drivers": {driver_name: package_driver}} + output = release_dir / f"dbx-agent-{driver_name}-{version}-{platform}.zip" + write_driver_zip(output, package_registry, source) + outputs.append(output) + + return outputs + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build offline ZIPs for individual DBX agents") + parser.add_argument("release_dir", type=Path) + args = parser.parse_args() + + for path in build_driver_zips(args.release_dir): + print(f"Created {path.name} ({path.stat().st_size} bytes)") + + +if __name__ == "__main__": + main() diff --git a/agents/scripts/build_offline_zip.sh b/agents/scripts/build_offline_zip.sh index 527b5a290..1e7495f8d 100755 --- a/agents/scripts/build_offline_zip.sh +++ b/agents/scripts/build_offline_zip.sh @@ -48,7 +48,9 @@ for platform in "${PLATFORMS[@]}"; do for jar_file in "$RELEASE_DIR"/dbx-agent-*.jar; do [ -f "$jar_file" ] || continue # Kingbase is distributed only as a native agent; keep legacy JDBC builds out of offline bundles. - [ "$(basename "$jar_file")" = "dbx-agent-kingbase.jar" ] && continue + case "$(basename "$jar_file")" in + dbx-agent-kingbase.jar|dbx-agent-kingbase-*.jar) continue ;; + esac cp "$jar_file" "$WORK/drivers/" done diff --git a/agents/scripts/driver_release_packages_test.py b/agents/scripts/driver_release_packages_test.py new file mode 100644 index 000000000..2a5a6ffd7 --- /dev/null +++ b/agents/scripts/driver_release_packages_test.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +import json +import subprocess +import tempfile +import unittest +import zipfile +from pathlib import Path + +from build_driver_zips import build_driver_zips +from version_agent_artifacts import version_agent_artifacts + + +class DriverReleasePackagesTest(unittest.TestCase): + def test_builds_java_and_platform_specific_native_driver_zips(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + release_dir = Path(temp_dir) + native_source = release_dir / "dbx-agent-kingbase-windows-x64.exe" + native_source.write_bytes(b"MZtest-agent") + java_source = release_dir / "dbx-agent-h2.jar" + java_source.write_bytes(b"test-jar") + versions = {"h2": "0.2.5", "oracle": "0.1.10", "xugu": "0.1.20", "kingbase": "0.1.34"} + + renamed = version_agent_artifacts(release_dir, versions) + versioned_java = release_dir / "dbx-agent-h2-0.2.5.jar" + versioned_native = release_dir / "dbx-agent-kingbase-0.1.34-windows-x64.exe" + self.assertEqual(renamed, [versioned_java, versioned_native]) + + registry = { + "jres": {"21": {"version": "21", "platforms": {}}}, + "drivers": { + "h2": { + "version": "0.2.5", + "label": "H2", + "min_app_version": "0.6.0", + "jre": "21", + "jar": {"url": f"https://example.com/{versioned_java.name}", "size": versioned_java.stat().st_size}, + }, + "kingbase": { + "version": "0.1.34", + "label": "人大金仓 KingbaseES", + "min_app_version": "0.6.0", + "jre": "21", + "jar": {"url": "https://example.com/legacy-placeholder.jar", "size": 0}, + "native": { + "windows-x64": { + "url": f"https://example.com/{versioned_native.name}", + "size": versioned_native.stat().st_size, + } + }, + }, + }, + } + (release_dir / "agent-registry.json").write_text(json.dumps(registry), encoding="utf-8") + + outputs = build_driver_zips(release_dir) + + self.assertEqual( + outputs, + [ + release_dir / "dbx-agent-h2-0.2.5.zip", + release_dir / "dbx-agent-kingbase-0.1.34-windows-x64.zip", + ], + ) + with zipfile.ZipFile(outputs[0]) as archive: + self.assertEqual(set(archive.namelist()), {"agent-registry.json", f"drivers/{versioned_java.name}"}) + package_registry = json.loads(archive.read("agent-registry.json")) + self.assertEqual(set(package_registry["drivers"]), {"h2"}) + self.assertNotIn("native", package_registry["drivers"]["h2"]) + self.assertEqual(package_registry["drivers"]["h2"]["jar"], {"url": versioned_java.name, "size": 8}) + with zipfile.ZipFile(outputs[1]) as archive: + self.assertEqual(set(archive.namelist()), {"agent-registry.json", f"drivers/{versioned_native.name}"}) + package_registry = json.loads(archive.read("agent-registry.json")) + kingbase = package_registry["drivers"]["kingbase"] + self.assertNotIn("jar", kingbase) + self.assertEqual(set(kingbase["native"]), {"windows-x64"}) + self.assertEqual( + kingbase["native"]["windows-x64"], + {"url": versioned_native.name, "size": versioned_native.stat().st_size}, + ) + + def test_full_offline_bundle_includes_versioned_native_artifact(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + release_dir = Path(temp_dir) + filename = "dbx-agent-kingbase-0.1.34-windows-x64.exe" + (release_dir / filename).write_bytes(b"MZtest-agent") + (release_dir / "dbx-jre-21-windows-x64.tar.gz").write_bytes(b"test-jre") + (release_dir / "agent-registry.json").write_text('{"jres":{},"drivers":{}}', encoding="utf-8") + + subprocess.run( + ["bash", str(Path(__file__).with_name("build_offline_zip.sh")), str(release_dir)], + check=True, + capture_output=True, + text=True, + ) + + bundle = release_dir / "dbx-agents-offline-windows-x64.zip" + self.assertTrue(bundle.is_file()) + with zipfile.ZipFile(bundle) as archive: + self.assertIn(f"drivers/{filename}", archive.namelist()) + + +if __name__ == "__main__": + unittest.main() diff --git a/agents/scripts/version_agent_artifacts.py b/agents/scripts/version_agent_artifacts.py new file mode 100644 index 000000000..80948d354 --- /dev/null +++ b/agents/scripts/version_agent_artifacts.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +import argparse +import json +from pathlib import Path + + +NATIVE_DRIVERS = ("oracle", "xugu", "kingbase") +PLATFORMS = ( + "macos-aarch64", + "macos-x64", + "linux-aarch64", + "linux-x64", + "windows-aarch64", + "windows-x64", +) + + +def rename_artifact(source: Path, target: Path) -> Path | None: + if not source.exists(): + return None + if target.exists(): + raise FileExistsError(f"Versioned agent artifact already exists: {target}") + source.rename(target) + return target + + +def version_agent_artifacts(release_dir: Path, versions: dict[str, str]) -> list[Path]: + renamed: list[Path] = [] + for driver, version in sorted(versions.items()): + jar = rename_artifact( + release_dir / f"dbx-agent-{driver}.jar", + release_dir / f"dbx-agent-{driver}-{version}.jar", + ) + if jar: + renamed.append(jar) + + for driver in NATIVE_DRIVERS: + version = versions.get(driver) + if not version: + raise ValueError(f"Missing version for native driver: {driver}") + for platform in PLATFORMS: + extension = ".exe" if platform.startswith("windows-") else "" + artifact = rename_artifact( + release_dir / f"dbx-agent-{driver}-{platform}{extension}", + release_dir / f"dbx-agent-{driver}-{version}-{platform}{extension}", + ) + if artifact: + renamed.append(artifact) + return renamed + + +def main() -> None: + parser = argparse.ArgumentParser(description="Add module versions to DBX agent release filenames") + parser.add_argument("release_dir", type=Path) + parser.add_argument("versions_json") + args = parser.parse_args() + + versions = json.loads(args.versions_json) + for path in version_agent_artifacts(args.release_dir, versions): + print(f"Versioned {path.name}") + + +if __name__ == "__main__": + main() diff --git a/apps/desktop/src/components/config/DriverStoreDialog.vue b/apps/desktop/src/components/config/DriverStoreDialog.vue index 56c731ed3..f84610554 100644 --- a/apps/desktop/src/components/config/DriverStoreDialog.vue +++ b/apps/desktop/src/components/config/DriverStoreDialog.vue @@ -21,6 +21,7 @@ import { formatRuntimeBytes, formatRuntimeCpu, formatRuntimeUptime, runtimeHealt import { addDriverInstallQueue, driverInstallProgressPercent, isDriverInstallProgressTarget, removeDriverInstallQueue, takeNextDriverInstallQueue, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi"; import { PRESTOSQL_DRIVER_DB_TYPE, prestoSqlBuiltinDriverRow, prestoSqlMavenBundle } from "@/lib/database/prestoSqlBuiltinDriver"; import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint"; +import { isOfflineDriverPackage, webDriverImportAccept } from "@/lib/driverStore/driverImportSelection"; const { t } = useI18n(); const { toast } = useToast(); @@ -550,19 +551,38 @@ async function importOfflineZip() { } } -async function importDriverJar(dbType: string) { +async function importDriverFile(driver: AgentDriverInfo) { + const dbType = driver.db_type; if (isPrestoSqlBuiltinDriver(dbType)) { await importJdbcDrivers(); return; } + const blockers = await api.checkAgentUpdateBlockers([dbType]); + if (blockers.length > 0) { + toast(t("driverStore.driverUpdateBlocked", { labels: blockers.map((blocker) => blocker.label).join(", ") })); + return; + } const label = driverLabel(dbType); - if (isWeb) { - const file = await chooseWebFile(".jar"); - if (!file) return; - try { - await api.importAgentJar(dbType, file); + const requiresJavaRuntime = driverRequiresJavaRuntime(driver); + const isWindows = navigator.userAgent.toLowerCase().includes("windows"); + const installSelectedFile = async (selected: string | File) => { + if (isOfflineDriverPackage(selected)) { + const count = await api.importAgentsFromZip(selected); + await refreshAgents(); + toast(t("driverStore.offlineImportSuccess", { count })); + } else { + await api.importAgentDriver(dbType, selected); await refreshAgents(); toast(t("driverStore.driverImportSuccess", { label })); + } + }; + if (isWeb) { + // Native release assets have no extension on macOS/Linux, so do not apply + // a browser accept filter that would make the correct file unselectable. + const file = await chooseWebFile(webDriverImportAccept(requiresJavaRuntime, isWindows)); + if (!file) return; + try { + await installSelectedFile(file); } catch (e: any) { toast(t("driverStore.driverImportFailed", { label, error: e })); } @@ -572,13 +592,11 @@ async function importDriverJar(dbType: string) { const selected = await open({ title: t("driverStore.chooseDriverJar"), multiple: false, - filters: [{ name: "JAR", extensions: ["jar"] }], + filters: requiresJavaRuntime ? [{ name: "Driver package or JAR", extensions: ["zip", "jar"] }] : isWindows ? [{ name: "Driver package or executable", extensions: ["zip", "exe"] }] : undefined, }); if (typeof selected !== "string") return; try { - await api.importAgentJar(dbType, selected); - await refreshAgents(); - toast(t("driverStore.driverImportSuccess", { label })); + await installSelectedFile(selected); } catch (e: any) { toast(t("driverStore.driverImportFailed", { label, error: e })); } @@ -1293,7 +1311,7 @@ watch(driverStoreTab, (tab) => { class="h-7 w-7 rounded-[6px] text-xs text-muted-foreground" :title="t('driverStore.importLocalJar')" :disabled="upgradingAll || installing !== null" - @click="importDriverJar(driver.db_type)" + @click="importDriverFile(driver)" > @@ -1359,7 +1377,7 @@ watch(driverStoreTab, (tab) => { class="h-7 w-7 rounded-[6px] text-xs text-muted-foreground" :title="t('driverStore.importLocalJar')" :disabled="upgradingAll || installing !== null" - @click="importDriverJar(driver.db_type)" + @click="importDriverFile(driver)" > diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 70c9739f6..2fc6a432a 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -4075,7 +4075,7 @@ export default { offlineImportFailed: "Offline import failed: {error}", driverImportSuccess: "{label} driver imported", driverImportFailed: "Failed to import {label} driver: {error}", - chooseDriverJar: "Choose driver JAR file", + chooseDriverJar: "Choose driver file", jreReinstallSuccess: "JRE {jre} reinstalled", jreReinstallFailed: "Failed to reinstall JRE {jre}: {error}", jreUninstallSuccess: "JRE {jre} uninstalled", @@ -4109,7 +4109,7 @@ export default { upgradingProgress: "Upgrading ({current}/{total})", upgradeAll: "Upgrade all", queued: "Queued", - importLocalJar: "Import local JAR", + importLocalJar: "Import local driver file", updating: "Updating", update: "Update", localInstall: "Local install", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index ecf5b4f76..cb76351da 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3841,7 +3841,7 @@ export default withEnglishFallback({ offlineImportFailed: "Error en la importación offline: {error}", driverImportSuccess: "Driver {label} importado", driverImportFailed: "Error al importar el driver {label}: {error}", - chooseDriverJar: "Seleccionar archivo JAR del driver", + chooseDriverJar: "Seleccionar archivo del driver", jreReinstallSuccess: "JRE {jre} reinstalado", jreReinstallFailed: "Error al reinstalar JRE {jre}: {error}", jreUninstallSuccess: "JRE {jre} desinstalado", @@ -3875,7 +3875,7 @@ export default withEnglishFallback({ upgradingProgress: "Actualizando ({current}/{total})", upgradeAll: "Actualizar todo", queued: "En cola", - importLocalJar: "Importar JAR local", + importLocalJar: "Importar archivo de driver local", updating: "Actualizando", update: "Actualizar", localInstall: "Instalación local", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index e2d377e58..b89e76612 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3839,7 +3839,7 @@ export default withEnglishFallback({ offlineImportFailed: "Importazione offline non riuscita: {error}", driverImportSuccess: "Driver {label} importato", driverImportFailed: "Importazione del driver {label} non riuscita: {error}", - chooseDriverJar: "Scegli il file JAR del driver", + chooseDriverJar: "Scegli il file del driver", jreReinstallSuccess: "JRE {jre} reinstallato", jreReinstallFailed: "Reinstallazione del JRE {jre} non riuscita: {error}", jreUninstallSuccess: "JRE {jre} disinstallato", @@ -3873,7 +3873,7 @@ export default withEnglishFallback({ upgradingProgress: "Aggiornamento in corso ({current}/{total})", upgradeAll: "Aggiorna tutti", queued: "In coda", - importLocalJar: "Importa JAR locale", + importLocalJar: "Importa file driver locale", updating: "Aggiornamento in corso", update: "Aggiorna", localInstall: "Installazione locale", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 1b587dfcc..388a897d1 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3839,7 +3839,7 @@ export default withEnglishFallback({ offlineImportFailed: "オフラインインポートに失敗しました: {error}", driverImportSuccess: "{label} ドライバーをインポートしました", driverImportFailed: "{label} ドライバーのインポートに失敗しました: {error}", - chooseDriverJar: "ドライバーJARファイルを選択", + chooseDriverJar: "ドライバーファイルを選択", jreReinstallSuccess: "JRE {jre} を再インストールしました", jreReinstallFailed: "JRE {jre} の再インストールに失敗しました: {error}", jreUninstallSuccess: "JRE {jre} をアンインストールしました", @@ -3873,7 +3873,7 @@ export default withEnglishFallback({ upgradingProgress: "アップグレード中 ({current}/{total})", upgradeAll: "すべてアップグレード", queued: "待機中", - importLocalJar: "ローカルJARをインポート", + importLocalJar: "ローカルドライバーファイルをインポート", updating: "更新中", update: "更新", localInstall: "ローカルインストール", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index b4b0c9de3..02a87fb76 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3841,7 +3841,7 @@ export default withEnglishFallback({ offlineImportFailed: "Falha na importação offline: {error}", driverImportSuccess: "Driver {label} importado", driverImportFailed: "Falha ao importar o driver {label}: {error}", - chooseDriverJar: "Escolher arquivo JAR do driver", + chooseDriverJar: "Escolher arquivo do driver", jreReinstallSuccess: "JRE {jre} reinstalada", jreReinstallFailed: "Falha ao reinstalar a JRE {jre}: {error}", jreUninstallSuccess: "JRE {jre} desinstalada", @@ -3875,7 +3875,7 @@ export default withEnglishFallback({ upgradingProgress: "Atualizando ({current}/{total})", upgradeAll: "Atualizar todos", queued: "Na fila", - importLocalJar: "Importar JAR local", + importLocalJar: "Importar arquivo de driver local", updating: "Atualizando", update: "Atualizar", localInstall: "Instalação local", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index e9a60d445..aacb42fab 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -4065,7 +4065,7 @@ export default withEnglishFallback({ offlineImportFailed: "离线导入失败: {error}", driverImportSuccess: "{label} 驱动导入成功", driverImportFailed: "{label} 驱动导入失败: {error}", - chooseDriverJar: "选择驱动 JAR 文件", + chooseDriverJar: "选择驱动文件", jreReinstallSuccess: "JRE {jre} 重新安装成功", jreReinstallFailed: "JRE {jre} 重新安装失败: {error}", jreUninstallSuccess: "JRE {jre} 已卸载", @@ -4099,7 +4099,7 @@ export default withEnglishFallback({ upgradingProgress: "升级中 ({current}/{total})", upgradeAll: "全部升级", queued: "排队中", - importLocalJar: "导入本地 JAR", + importLocalJar: "导入本地驱动文件", updating: "更新中", update: "更新", localInstall: "本地安装", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 85d017aaa..7811ea927 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3645,7 +3645,7 @@ export default withEnglishFallback({ offlineImportFailed: "離線匯入失敗: {error}", driverImportSuccess: "{label} 驅動程式匯入成功", driverImportFailed: "{label} 驅動程式匯入失敗: {error}", - chooseDriverJar: "選擇驅動程式 JAR 檔案", + chooseDriverJar: "選擇驅動程式檔案", jreReinstallSuccess: "JRE {jre} 重新安裝成功", jreReinstallFailed: "JRE {jre} 重新安裝失敗: {error}", jreUninstallSuccess: "JRE {jre} 已解除安裝", @@ -3679,7 +3679,7 @@ export default withEnglishFallback({ upgradingProgress: "更新中 ({current}/{total})", upgradeAll: "全部更新", queued: "排隊中", - importLocalJar: "匯入本機 JAR", + importLocalJar: "匯入本機驅動程式檔案", updating: "更新中", update: "更新", localInstall: "本機安裝", diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index f2c18773a..9b7ddfbe6 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -109,7 +109,8 @@ export const getAgentJavaRuntimeConfig = forward("getAgentJavaRuntimeConfig"); export const setAgentJavaRuntimeConfig = forward("setAgentJavaRuntimeConfig"); export const invalidateAgentRegistryCache = forward("invalidateAgentRegistryCache"); export const importAgentsFromZip = forward("importAgentsFromZip"); -export const importAgentJar = forward("importAgentJar"); +export const importAgentDriver = forward("importAgentDriver"); +export const importAgentJar = importAgentDriver; export async function reinstallJre(jreKey?: string) { const backend = await getBackend(); return backend.reinstallJre(jreKey, useSettingsStore().editorSettings.updateDownloadSource); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 6065d7feb..0266be20d 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -457,23 +457,25 @@ export async function importAgentsFromZip(fileOrPath: string | File): Promise { +export async function importAgentDriver(dbType: string, pathOrFile: string | File): Promise { let blob: Blob; let fileName: string; if (pathOrFile instanceof File) { blob = pathOrFile; fileName = pathOrFile.name; } else { - fileName = pathOrFile.split("/").pop() || "driver.jar"; + fileName = pathOrFile.split("/").pop() || "agent"; blob = await (await fetch(pathOrFile)).blob(); } const formData = new FormData(); formData.append("dbType", dbType); formData.append("file", blob, fileName); - const uploadRes = await fetch(apiUrl("/api/agents/import-jar"), { method: "POST", body: formData }); + const uploadRes = await fetch(apiUrl("/api/agents/import-driver"), { method: "POST", body: formData }); if (!uploadRes.ok) throw new Error(await uploadRes.text()); } +export const importAgentJar = importAgentDriver; + export async function reinstallJre(jreKey?: string, _source?: UpdateDownloadSource): Promise { await post("/api/agents/reinstall-jre", { jreKey }); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index f7f9c1be9..c66d3ec79 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1341,13 +1341,15 @@ export async function importAgentsFromZip(path: string | File): Promise return invoke("import_agents_from_zip", { path }); } -export async function importAgentJar(dbType: string, path: string | File): Promise { +export async function importAgentDriver(dbType: string, path: string | File): Promise { if (typeof path !== "string") { - throw new Error("Desktop driver JAR import requires a local file path"); + throw new Error("Desktop driver import requires a local file path"); } - return invoke("import_agent_jar_cmd", { dbType, path }); + return invoke("import_agent_driver_cmd", { dbType, path }); } +export const importAgentJar = importAgentDriver; + export async function reinstallJre(jreKey?: string, source?: UpdateDownloadSource): Promise { return invoke("reinstall_jre", { jreKey, source }); } diff --git a/apps/desktop/src/lib/driverStore/driverImportSelection.spec.ts b/apps/desktop/src/lib/driverStore/driverImportSelection.spec.ts new file mode 100644 index 000000000..22317afa1 --- /dev/null +++ b/apps/desktop/src/lib/driverStore/driverImportSelection.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { isOfflineDriverPackage, webDriverImportAccept } from "./driverImportSelection"; + +describe("driver import selection", () => { + it("recognizes ZIP paths and uploaded files case-insensitively", () => { + expect(isOfflineDriverPackage("C:\\Downloads\\dbx-agent-h2-0.2.5.ZIP")).toBe(true); + expect(isOfflineDriverPackage({ name: "dbx-agent-kingbase-0.1.34-macos-aarch64.zip" })).toBe(true); + expect(isOfflineDriverPackage({ name: "dbx-agent-h2-0.2.5.jar" })).toBe(false); + }); + + it("allows ZIP alongside the platform raw artifact", () => { + expect(webDriverImportAccept(true, false)).toBe(".zip,.jar"); + expect(webDriverImportAccept(false, true)).toBe(".zip,.exe"); + expect(webDriverImportAccept(false, false)).toBe(""); + }); +}); diff --git a/apps/desktop/src/lib/driverStore/driverImportSelection.ts b/apps/desktop/src/lib/driverStore/driverImportSelection.ts new file mode 100644 index 000000000..c11d70b26 --- /dev/null +++ b/apps/desktop/src/lib/driverStore/driverImportSelection.ts @@ -0,0 +1,11 @@ +export type DriverImportSelection = string | { name: string }; + +export function isOfflineDriverPackage(selection: DriverImportSelection): boolean { + const name = typeof selection === "string" ? selection : selection.name; + return name.toLowerCase().endsWith(".zip"); +} + +export function webDriverImportAccept(requiresJavaRuntime: boolean, isWindows: boolean): string { + if (requiresJavaRuntime) return ".zip,.jar"; + return isWindows ? ".zip,.exe" : ""; +} diff --git a/crates/dbx-core/src/agent_service.rs b/crates/dbx-core/src/agent_service.rs index 7e19717d4..07ecd547b 100644 --- a/crates/dbx-core/src/agent_service.rs +++ b/crates/dbx-core/src/agent_service.rs @@ -1,5 +1,5 @@ use std::hash::{Hash, Hasher}; -use std::io::Read; +use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -306,11 +306,13 @@ pub fn install_local_agent(am: &AgentManager, db_type: &str, source: PathBuf) -> let jar_path = am.driver_jar_path(db_type); let parent = jar_path.parent().ok_or_else(|| format!("Invalid driver path: {}", jar_path.display()))?; std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - std::fs::copy(&source, &jar_path).map_err(|e| format!("Failed to copy local agent jar: {e}"))?; - if !am.is_driver_jar_valid(db_type) { - std::fs::remove_file(&jar_path).ok(); + let staging_path = parent.join(format!(".agent-jar-import-{}", uuid::Uuid::new_v4())); + std::fs::copy(&source, &staging_path).map_err(|e| format!("Failed to copy local agent jar: {e}"))?; + if !is_valid_agent_jar(&staging_path) { + std::fs::remove_file(&staging_path).ok(); return Err(format!("Local agent jar is invalid or corrupt: {}", source.display())); } + replace_imported_agent_file(&staging_path, &jar_path)?; let mut local_state = am.load_state(); local_state.installed_drivers.insert( @@ -324,6 +326,20 @@ pub fn install_local_agent(am: &AgentManager, db_type: &str, source: PathBuf) -> am.save_state(&local_state) } +fn is_valid_agent_jar(path: &Path) -> bool { + let Ok(file) = std::fs::File::open(path) else { + return false; + }; + let Ok(mut archive) = zip::ZipArchive::new(file) else { + return false; + }; + let Ok(mut manifest) = archive.by_name("META-INF/MANIFEST.MF") else { + return false; + }; + let mut manifest_text = String::new(); + manifest.read_to_string(&mut manifest_text).is_ok() && manifest_text.contains("Main-Class:") +} + pub async fn fetch_registry() -> Result { fetch_registry_from(DownloadSource::Official).await } @@ -1228,6 +1244,25 @@ pub struct OfflineImportResult { pub drivers_skipped: Vec, } +#[derive(Debug, Clone)] +pub struct OfflineImportPlan { + pub driver_keys: Vec, + pub includes_jre: bool, +} + +type OfflineDriverEntry = (String, String, bool); + +pub fn inspect_offline_zip(zip_path: &Path) -> Result { + let file = std::fs::File::open(zip_path).map_err(|e| format!("Failed to open ZIP file: {e}"))?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid ZIP file: {e}"))?; + let registry = read_registry_from_zip(&mut archive)?; + let (jre_entries, driver_entries) = collect_offline_entries(&mut archive, ®istry)?; + Ok(OfflineImportPlan { + driver_keys: driver_entries.into_iter().map(|(db_type, _, _)| db_type).collect(), + includes_jre: !jre_entries.is_empty(), + }) +} + pub fn import_offline_zip( am: &AgentManager, zip_path: &Path, @@ -1244,36 +1279,13 @@ pub fn import_offline_zip( let mut result = OfflineImportResult { jre_installed: Vec::new(), drivers_installed: Vec::new(), drivers_skipped: Vec::new() }; - let jre_entries: Vec<(String, String)> = (0..archive.len()) - .filter_map(|i| { - let entry = archive.by_index(i).ok()?; - let name = entry.name().to_string(); - if name.starts_with("jre/") && name.ends_with(".tar.gz") && name.contains(platform) { - let jre_key = extract_jre_key_from_filename(&name)?; - Some((jre_key, name)) - } else { - None - } - }) - .collect(); - - let driver_entries: Vec<(String, String, bool)> = (0..archive.len()) - .filter_map(|i| { - let entry = archive.by_index(i).ok()?; - let name = entry.name().to_string(); - if name.starts_with("drivers/") && name.ends_with(".jar") { - let db_type = extract_db_type_from_filename(&name)?; - Some((db_type, name, false)) - } else if name.starts_with("drivers/") { - let db_type = db_type_for_native_offline_entry(®istry, platform, &name)?; - Some((db_type, name, true)) - } else { - None - } - }) - .collect(); + let (jre_entries, driver_entries) = collect_offline_entries(&mut archive, ®istry)?; let total = (jre_entries.len() + driver_entries.len()) as u32; + if total == 0 { + return Err(format!("Offline package contains no drivers compatible with platform: {platform}")); + } + validate_offline_driver_entries(am, &mut archive, &driver_entries)?; let mut current: u32 = 0; for (jre_key, entry_name) in &jre_entries { @@ -1295,13 +1307,22 @@ pub fn import_offline_zip( } let jre_dir = am.jre_dir(jre_key); - // Daemons cannot be stopped from a sync function safely; the retry + - // Windows rename fallback in replace_old_jre_dir still handles a - // locked directory. Daemon shutdown for the foreground install paths - // happens in `reinstall_agent_jre` and `install_agent_driver_*`. - replace_old_jre_dir(am, &jre_dir)?; - extract_tar_gz(&tmp_archive, &jre_dir)?; + let staging_dir = am.base_dir().join(format!(".jre-offline-import-{}", uuid::Uuid::new_v4())); + if let Err(error) = extract_tar_gz(&tmp_archive, &staging_dir) { + std::fs::remove_dir_all(&staging_dir).ok(); + std::fs::remove_file(&tmp_archive).ok(); + return Err(error); + } + if !jre_dir_contains_java(&staging_dir) { + std::fs::remove_dir_all(&staging_dir).ok(); + std::fs::remove_file(&tmp_archive).ok(); + return Err(format!("Offline JRE archive does not contain a Java executable: {entry_name}")); + } + let pending_cleanup = replace_imported_jre_dir(&staging_dir, &jre_dir)?; std::fs::remove_file(&tmp_archive).ok(); + if let Some(path) = pending_cleanup { + local_state.pending_jre_cleanup.push(path); + } if let Some(ver) = jre_version { local_state.jre_versions.insert(jre_key.clone(), ver); @@ -1336,17 +1357,29 @@ pub fn import_offline_zip( std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } let mut entry = archive.by_name(entry_name).map_err(|e| format!("Failed to read {entry_name}: {e}"))?; - let mut out = std::fs::File::create(&driver_path).map_err(|e| format!("Failed to write driver: {e}"))?; + let parent = driver_path.parent().ok_or_else(|| format!("Invalid driver path: {}", driver_path.display()))?; + let staging_path = parent.join(format!(".offline-agent-import-{}", uuid::Uuid::new_v4())); + let mut out = std::fs::File::create(&staging_path).map_err(|e| format!("Failed to write driver: {e}"))?; std::io::copy(&mut entry, &mut out).map_err(|e| format!("Failed to copy driver: {e}"))?; + drop(out); if *is_native { - mark_executable(&driver_path)?; - std::fs::remove_file(am.driver_jar_path(db_type)).ok(); + if let Err(error) = validate_native_agent_binary(&staging_path) { + std::fs::remove_file(&staging_path).ok(); + return Err(error); + } + mark_executable(&staging_path)?; } else { - // Offline bundles are user-supplied; reject corrupt JARs before state says the driver is installed. - if !am.is_driver_jar_valid(db_type) { - std::fs::remove_file(&driver_path).ok(); + // Validate the staged JAR before replacing a working driver so a + // corrupt offline package cannot destroy the previous installation. + if !is_valid_agent_jar(&staging_path) { + std::fs::remove_file(&staging_path).ok(); return Err(format!("Offline agent jar is invalid or corrupt: {entry_name}")); } + } + replace_imported_agent_file(&staging_path, &driver_path)?; + if *is_native { + std::fs::remove_file(am.driver_jar_path(db_type)).ok(); + } else { std::fs::remove_file(am.driver_native_path(db_type)).ok(); } @@ -1365,6 +1398,88 @@ pub fn import_offline_zip( Ok(result) } +fn collect_offline_entries( + archive: &mut zip::ZipArchive, + registry: &AgentRegistry, +) -> Result<(Vec<(String, String)>, Vec), String> { + let platform = AgentManager::current_platform(); + let mut jre_entries = Vec::new(); + let mut drivers = std::collections::BTreeMap::::new(); + + for index in 0..archive.len() { + let entry = archive.by_index(index).map_err(|e| format!("Failed to inspect ZIP entry: {e}"))?; + let Some(path) = entry.enclosed_name() else { + return Err(format!("Offline package contains an unsafe path: {}", entry.name())); + }; + let name = path.to_string_lossy().replace('\\', "/"); + if name.starts_with("jre/") && name.ends_with(".tar.gz") && name.contains(platform) { + let jre_key = extract_jre_key_from_filename(&name) + .ok_or_else(|| format!("Invalid JRE filename in offline package: {name}"))?; + validate_offline_identifier(&jre_key, "JRE")?; + jre_entries.push((jre_key, name)); + } else if name.starts_with("drivers/") && name.ends_with(".jar") { + let db_type = db_type_for_jar_offline_entry(registry, &name) + .or_else(|| extract_db_type_from_filename(&name)) + .ok_or_else(|| format!("Unable to identify offline driver: {name}"))?; + validate_offline_driver_key(&db_type)?; + drivers.entry(db_type).or_insert((name, false)); + } else if name.starts_with("drivers/") { + if let Some(db_type) = db_type_for_native_offline_entry(registry, platform, &name) { + validate_offline_driver_key(&db_type)?; + // Prefer the native artifact when a package contains both the + // platform executable and a Java fallback for the same driver. + drivers.insert(db_type, (name, true)); + } + } + } + + Ok((jre_entries, drivers.into_iter().map(|(db_type, (name, is_native))| (db_type, name, is_native)).collect())) +} + +fn validate_offline_driver_entries( + am: &AgentManager, + archive: &mut zip::ZipArchive, + driver_entries: &[OfflineDriverEntry], +) -> Result<(), String> { + for (_, entry_name, is_native) in driver_entries { + let staging_path = am.base_dir().join(format!(".offline-agent-validation-{}", uuid::Uuid::new_v4())); + let result = (|| { + let mut entry = archive.by_name(entry_name).map_err(|e| format!("Failed to read {entry_name}: {e}"))?; + let mut out = std::fs::File::create(&staging_path).map_err(|e| format!("Failed to write driver: {e}"))?; + std::io::copy(&mut entry, &mut out).map_err(|e| format!("Failed to copy driver: {e}"))?; + drop(out); + if *is_native { + validate_native_agent_binary(&staging_path) + } else if is_valid_agent_jar(&staging_path) { + Ok(()) + } else { + Err(format!("Offline agent jar is invalid or corrupt: {entry_name}")) + } + })(); + std::fs::remove_file(&staging_path).ok(); + result?; + } + Ok(()) +} + +fn validate_offline_driver_key(db_type: &str) -> Result<(), String> { + validate_offline_identifier(db_type, "driver")?; + if agent_catalog::label_for_key(db_type).is_none() { + return Err(format!("Offline package contains an unknown driver type: {db_type}")); + } + Ok(()) +} + +fn validate_offline_identifier(value: &str, kind: &str) -> Result<(), String> { + if value.is_empty() + || matches!(value, "." | "..") + || !value.chars().all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_')) + { + return Err(format!("Offline package contains an invalid {kind} identifier: {value}")); + } + Ok(()) +} + fn read_registry_from_zip(archive: &mut zip::ZipArchive) -> Result { let mut entry = archive .by_name("agent-registry.json") @@ -1403,6 +1518,15 @@ fn db_type_for_native_offline_entry(registry: &AgentRegistry, platform: &str, na }) } +fn db_type_for_jar_offline_entry(registry: &AgentRegistry, name: &str) -> Option { + let filename = name.rsplit('/').next()?; + registry.drivers.iter().find_map(|(db_type, driver)| { + let artifact = driver.jar.as_ref()?; + let artifact_filename = artifact.url.rsplit('/').next()?; + (artifact_filename == filename).then(|| db_type.clone()) + }) +} + 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") @@ -1415,11 +1539,210 @@ fn extract_tar_gz(archive: &Path, dest: &Path) -> Result<(), String> { Ok(()) } -pub fn import_agent_jar(am: &AgentManager, db_type: &str, jar_path: &Path) -> Result<(), String> { - if !jar_path.exists() { - return Err(format!("File not found: {}", jar_path.display())); +pub fn import_agent_driver(am: &AgentManager, db_type: &str, source_path: &Path) -> Result<(), String> { + if !source_path.is_file() { + return Err(format!("File not found: {}", source_path.display())); } - install_local_agent(am, db_type, jar_path.to_path_buf()) + + if source_path.extension().is_some_and(|extension| extension.eq_ignore_ascii_case("jar")) { + install_local_agent(am, db_type, source_path.to_path_buf())?; + std::fs::remove_file(am.driver_native_path(db_type)).ok(); + return Ok(()); + } + + validate_native_agent_binary(source_path)?; + let native_path = am.driver_native_path(db_type); + let parent = native_path.parent().ok_or_else(|| format!("Invalid driver path: {}", native_path.display()))?; + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let staging_path = parent.join(format!(".agent-import-{}", uuid::Uuid::new_v4())); + std::fs::copy(source_path, &staging_path).map_err(|e| format!("Failed to copy native agent: {e}"))?; + mark_executable(&staging_path)?; + replace_imported_agent_file(&staging_path, &native_path)?; + std::fs::remove_file(am.driver_jar_path(db_type)).ok(); + + let mut local_state = am.load_state(); + local_state.installed_drivers.insert( + db_type.to_string(), + InstalledDriver { + version: "0.1.0-local".to_string(), + installed_at: chrono::Utc::now().to_rfc3339(), + jre: DEFAULT_JRE_KEY.to_string(), + }, + ); + am.save_state(&local_state) +} + +pub fn import_agent_jar(am: &AgentManager, db_type: &str, jar_path: &Path) -> Result<(), String> { + import_agent_driver(am, db_type, jar_path) +} + +fn replace_imported_agent_file(staging_path: &Path, target_path: &Path) -> Result<(), String> { + let backup_path = target_path.with_file_name(format!( + ".{}-backup-{}", + target_path.file_name().and_then(|name| name.to_str()).unwrap_or("agent"), + uuid::Uuid::new_v4() + )); + let had_existing = target_path.exists(); + if had_existing { + std::fs::rename(target_path, &backup_path).map_err(|e| format!("Failed to replace existing agent: {e}"))?; + } + if let Err(error) = std::fs::rename(staging_path, target_path) { + if had_existing { + let _ = std::fs::rename(&backup_path, target_path); + } + let _ = std::fs::remove_file(staging_path); + return Err(format!("Failed to install agent: {error}")); + } + if had_existing { + std::fs::remove_file(backup_path).ok(); + } + Ok(()) +} + +fn replace_imported_jre_dir(staging_dir: &Path, target_dir: &Path) -> Result, String> { + let backup_dir = target_dir.with_file_name(format!( + ".{}-backup-{}", + target_dir.file_name().and_then(|name| name.to_str()).unwrap_or("jre"), + uuid::Uuid::new_v4() + )); + let had_existing = target_dir.exists(); + if had_existing { + std::fs::rename(target_dir, &backup_dir).map_err(|error| { + let _ = std::fs::remove_dir_all(staging_dir); + format!("Failed to replace existing JRE: {error}") + })?; + } + if let Err(error) = std::fs::rename(staging_dir, target_dir) { + if had_existing { + let _ = std::fs::rename(&backup_dir, target_dir); + } + let _ = std::fs::remove_dir_all(staging_dir); + return Err(format!("Failed to install JRE: {error}")); + } + if had_existing && remove_jre_dir_with_retry(&backup_dir).is_err() { + // The new runtime is already installed. Keep the old directory for + // startup cleanup rather than turning a successful import into an error. + return Ok(Some(backup_dir)); + } + Ok(None) +} + +fn jre_dir_contains_java(path: &Path) -> bool { + let java_name = if cfg!(windows) { "java.exe" } else { "java" }; + path.join("bin").join(java_name).is_file() + || path.join("Contents").join("Home").join("bin").join(java_name).is_file() +} + +fn validate_native_agent_binary(path: &Path) -> Result<(), String> { + let mut file = std::fs::File::open(path).map_err(|e| format!("Failed to read native agent: {e}"))?; + let mut magic = [0_u8; 4]; + file.read_exact(&mut magic).map_err(|e| format!("Failed to read native agent header: {e}"))?; + let valid = if cfg!(target_os = "windows") { + is_windows_binary_for_current_arch(&mut file, &magic) + } else if cfg!(target_os = "linux") { + is_elf_binary_for_current_arch(&mut file, &magic) + } else if cfg!(target_os = "macos") { + is_macho_binary_for_current_arch(&mut file, &magic) + } else { + false + }; + if valid { + Ok(()) + } else { + Err(format!("The selected file is not a {} native agent for this platform", AgentManager::current_platform())) + } +} + +fn is_elf_binary_for_current_arch(file: &mut std::fs::File, magic: &[u8; 4]) -> bool { + if magic != b"\x7fELF" || file.seek(SeekFrom::Start(4)).is_err() { + return false; + } + let mut header = [0_u8; 16]; + if file.read_exact(&mut header).is_err() || header[0] != 2 { + return false; + } + let machine = match header[1] { + 1 => u16::from_le_bytes([header[14], header[15]]), + 2 => u16::from_be_bytes([header[14], header[15]]), + _ => return false, + }; + (cfg!(target_arch = "x86_64") && machine == 62) || (cfg!(target_arch = "aarch64") && machine == 183) +} + +fn is_macho_binary_for_current_arch(file: &mut std::fs::File, magic: &[u8; 4]) -> bool { + const CPU_TYPE_X86_64: u32 = 0x0100_0007; + const CPU_TYPE_ARM64: u32 = 0x0100_000c; + let expected = if cfg!(target_arch = "aarch64") { CPU_TYPE_ARM64 } else { CPU_TYPE_X86_64 }; + + let thin_endian = match magic { + [0xce, 0xfa, 0xed, 0xfe] | [0xcf, 0xfa, 0xed, 0xfe] => Some(true), + [0xfe, 0xed, 0xfa, 0xce] | [0xfe, 0xed, 0xfa, 0xcf] => Some(false), + _ => None, + }; + if let Some(little_endian) = thin_endian { + if file.seek(SeekFrom::Start(4)).is_err() { + return false; + } + let mut cpu_type = [0_u8; 4]; + if file.read_exact(&mut cpu_type).is_err() { + return false; + } + let cpu_type = if little_endian { u32::from_le_bytes(cpu_type) } else { u32::from_be_bytes(cpu_type) }; + return cpu_type == expected; + } + + let (little_endian, arch_size) = match magic { + [0xca, 0xfe, 0xba, 0xbe] => (false, 20_u64), + [0xbe, 0xba, 0xfe, 0xca] => (true, 20_u64), + [0xca, 0xfe, 0xba, 0xbf] => (false, 32_u64), + [0xbf, 0xba, 0xfe, 0xca] => (true, 32_u64), + _ => return false, + }; + if file.seek(SeekFrom::Start(4)).is_err() { + return false; + } + let mut count = [0_u8; 4]; + if file.read_exact(&mut count).is_err() { + return false; + } + let count = if little_endian { u32::from_le_bytes(count) } else { u32::from_be_bytes(count) }; + // A real universal binary has only a handful of slices; cap the count so + // a malformed header cannot trigger unbounded seeks during import. + if count == 0 || count > 64 { + return false; + } + for index in 0..count { + if file.seek(SeekFrom::Start(8 + u64::from(index) * arch_size)).is_err() { + return false; + } + let mut cpu_type = [0_u8; 4]; + if file.read_exact(&mut cpu_type).is_err() { + return false; + } + let cpu_type = if little_endian { u32::from_le_bytes(cpu_type) } else { u32::from_be_bytes(cpu_type) }; + if cpu_type == expected { + return true; + } + } + false +} + +fn is_windows_binary_for_current_arch(file: &mut std::fs::File, magic: &[u8; 4]) -> bool { + if &magic[..2] != b"MZ" || file.seek(SeekFrom::Start(0x3c)).is_err() { + return false; + } + let mut pe_offset = [0_u8; 4]; + if file.read_exact(&mut pe_offset).is_err() + || file.seek(SeekFrom::Start(u32::from_le_bytes(pe_offset) as u64)).is_err() + { + return false; + } + let mut pe_header = [0_u8; 6]; + if file.read_exact(&mut pe_header).is_err() || &pe_header[..4] != b"PE\0\0" { + return false; + } + let machine = u16::from_le_bytes([pe_header[4], pe_header[5]]); + (cfg!(target_arch = "x86_64") && machine == 0x8664) || (cfg!(target_arch = "aarch64") && machine == 0xaa64) } // ──────────── Tests ──────────── @@ -1449,6 +1772,31 @@ mod agent_download_url_tests { assert_eq!(extract_jre_key_from_filename("jre/dbx-jre-21-macos-aarch64.tar.gz").as_deref(), Some("21")); assert_eq!(extract_jre_key_from_filename("jre/jre-21-macos-aarch64.tar.gz").as_deref(), Some("21")); } + + #[test] + fn windows_native_header_validator_checks_cpu_architecture() { + let path = std::env::temp_dir().join(format!("dbx-agent-pe-test-{}", uuid::Uuid::new_v4())); + let expected_machine = if cfg!(target_arch = "aarch64") { 0xaa64_u16 } else { 0x8664_u16 }; + let wrong_machine = if expected_machine == 0xaa64 { 0x8664_u16 } else { 0xaa64_u16 }; + + std::fs::write(&path, test_pe_binary(expected_machine)).unwrap(); + let mut file = std::fs::File::open(&path).unwrap(); + assert!(is_windows_binary_for_current_arch(&mut file, b"MZ\0\0")); + + std::fs::write(&path, test_pe_binary(wrong_machine)).unwrap(); + let mut file = std::fs::File::open(&path).unwrap(); + assert!(!is_windows_binary_for_current_arch(&mut file, b"MZ\0\0")); + std::fs::remove_file(path).ok(); + } + + fn test_pe_binary(machine: u16) -> Vec { + let mut bytes = vec![0_u8; 0x48]; + bytes[..2].copy_from_slice(b"MZ"); + bytes[0x3c..0x40].copy_from_slice(&(0x40_u32).to_le_bytes()); + bytes[0x40..0x44].copy_from_slice(b"PE\0\0"); + bytes[0x44..0x46].copy_from_slice(&machine.to_le_bytes()); + bytes + } } #[cfg(test)] diff --git a/crates/dbx-core/tests/agent_service.rs b/crates/dbx-core/tests/agent_service.rs index 953f9bd06..5c5deb0ae 100644 --- a/crates/dbx-core/tests/agent_service.rs +++ b/crates/dbx-core/tests/agent_service.rs @@ -3,9 +3,9 @@ use dbx_core::agent_manager::{ JreInfo, DEFAULT_JRE_KEY, }; use dbx_core::agent_service::{ - build_agent_list, clear_agent_download_cache, github_url_to_r2_path, import_agent_jar, import_agents_from_zip, - is_app_version_compatible, jre_needs_install, local_agent_jar_candidates, replace_download, uninstall_agent_driver, - AgentProgressEvent, + build_agent_list, clear_agent_download_cache, github_url_to_r2_path, import_agent_driver, import_agent_jar, + import_agents_from_zip, inspect_offline_zip, is_app_version_compatible, jre_needs_install, + local_agent_jar_candidates, replace_download, uninstall_agent_driver, AgentProgressEvent, }; fn test_manager(name: &str) -> AgentManager { @@ -406,6 +406,51 @@ fn local_jar_import_rejects_corrupt_jar() { assert!(!manager.load_state().installed_drivers.contains_key("h2")); } +#[test] +fn local_native_import_installs_current_platform_executable() { + let manager = test_manager("local-native-import"); + let source = test_path("local-native-import-source").join(if cfg!(windows) { + "dbx-agent-kingbase-windows.exe" + } else { + "dbx-agent-kingbase" + }); + std::fs::create_dir_all(source.parent().unwrap()).unwrap(); + std::fs::write(&source, current_platform_native_binary()).unwrap(); + + import_agent_driver(&manager, "kingbase", &source).unwrap(); + + assert_eq!(std::fs::read(manager.driver_native_path("kingbase")).unwrap(), std::fs::read(&source).unwrap()); + assert!(!manager.driver_jar_path("kingbase").exists()); + assert_eq!(manager.load_state().installed_drivers["kingbase"].version, "0.1.0-local"); +} + +#[test] +fn local_native_import_rejects_wrong_platform_binary() { + let manager = test_manager("local-native-import-invalid"); + let source = test_path("local-native-import-invalid-source").join("dbx-agent-kingbase"); + std::fs::create_dir_all(source.parent().unwrap()).unwrap(); + std::fs::write(&source, b"not-an-executable").unwrap(); + + let err = import_agent_driver(&manager, "kingbase", &source).unwrap_err(); + + assert!(err.contains(AgentManager::current_platform())); + assert!(!manager.driver_native_path("kingbase").exists()); + assert!(!manager.load_state().installed_drivers.contains_key("kingbase")); +} + +#[test] +fn local_native_import_rejects_wrong_arch_binary() { + let manager = test_manager("local-native-wrong-arch"); + let native_path = test_path("local-native-wrong-arch-file").join("agent"); + std::fs::create_dir_all(native_path.parent().unwrap()).unwrap(); + std::fs::write(&native_path, native_binary_for_arch(!cfg!(target_arch = "aarch64"))).unwrap(); + + let err = import_agent_driver(&manager, "kingbase", &native_path).unwrap_err(); + + assert!(err.contains("not a")); + assert!(!manager.driver_native_path("kingbase").exists()); +} + #[tokio::test] async fn uninstall_driver_removes_artifact_and_state() { let manager = test_manager("uninstall"); @@ -511,6 +556,53 @@ fn offline_zip_import_installs_release_named_jre() { assert!(events.lock().unwrap().iter().any(|event| event.step == "jre-extract")); } +#[test] +fn offline_zip_import_preserves_existing_jre_when_archive_is_corrupt() { + let manager = test_manager("offline-corrupt-jre-preserves-existing"); + let root = test_path("offline-corrupt-jre-preserves-existing-zip"); + let valid_zip = root.join("valid.zip"); + let corrupt_zip = root.join("corrupt.zip"); + std::fs::create_dir_all(&root).unwrap(); + write_offline_driver_zip_with_jre(&valid_zip, "h2", "0.2.0", "21.0.12"); + import_agents_from_zip(&manager, &valid_zip, |_| {}).unwrap(); + let java_path = manager.jre_java_path(DEFAULT_JRE_KEY); + let original_java = std::fs::read(&java_path).unwrap(); + + write_offline_driver_zip_with_jre_bytes(&corrupt_zip, "h2", "0.3.0", "21.0.13", b"not-a-tar-gz".to_vec()); + let err = import_agents_from_zip(&manager, &corrupt_zip, |_| {}).unwrap_err(); + + assert!(err.contains("Failed to extract JRE archive")); + assert_eq!(std::fs::read(java_path).unwrap(), original_java); + assert_eq!(manager.load_state().jre_versions.get(DEFAULT_JRE_KEY).map(String::as_str), Some("21.0.12")); +} + +#[test] +fn offline_zip_import_preserves_existing_jre_when_driver_is_corrupt() { + let manager = test_manager("offline-corrupt-driver-preserves-jre"); + let root = test_path("offline-corrupt-driver-preserves-jre-zip"); + let valid_zip = root.join("valid.zip"); + let corrupt_zip = root.join("corrupt.zip"); + std::fs::create_dir_all(&root).unwrap(); + write_offline_driver_zip_with_jre(&valid_zip, "h2", "0.2.0", "21.0.12"); + import_agents_from_zip(&manager, &valid_zip, |_| {}).unwrap(); + let java_path = manager.jre_java_path(DEFAULT_JRE_KEY); + let original_java = std::fs::read(&java_path).unwrap(); + + write_offline_driver_zip_with_jre_and_jar_bytes( + &corrupt_zip, + "h2", + "0.3.0", + "21.0.13", + test_jre_archive_bytes(), + b"jar".to_vec(), + ); + let err = import_agents_from_zip(&manager, &corrupt_zip, |_| {}).unwrap_err(); + + assert!(err.contains("invalid or corrupt")); + assert_eq!(std::fs::read(java_path).unwrap(), original_java); + assert_eq!(manager.load_state().jre_versions.get(DEFAULT_JRE_KEY).map(String::as_str), Some("21.0.12")); +} + #[test] fn offline_zip_import_rejects_corrupt_jar() { let manager = test_manager("offline-corrupt-driver"); @@ -525,16 +617,284 @@ fn offline_zip_import_rejects_corrupt_jar() { assert!(!manager.load_state().installed_drivers.contains_key("h2")); } +#[test] +fn offline_zip_import_preserves_existing_driver_when_jar_is_corrupt() { + let manager = test_manager("offline-corrupt-driver-preserves-existing"); + let root = test_path("offline-corrupt-driver-preserves-existing-zip"); + let valid_zip = root.join("valid.zip"); + let corrupt_zip = root.join("corrupt.zip"); + std::fs::create_dir_all(&root).unwrap(); + write_offline_driver_zip(&valid_zip, "h2", "0.2.0"); + import_agents_from_zip(&manager, &valid_zip, |_| {}).unwrap(); + let original = std::fs::read(manager.driver_jar_path("h2")).unwrap(); + + write_offline_driver_zip_with_jar(&corrupt_zip, "h2", "0.3.0", b"jar".to_vec()); + let err = import_agents_from_zip(&manager, &corrupt_zip, |_| {}).unwrap_err(); + + assert!(err.contains("invalid or corrupt")); + assert_eq!(std::fs::read(manager.driver_jar_path("h2")).unwrap(), original); + assert_eq!(manager.load_state().installed_drivers["h2"].version, "0.2.0"); +} + +#[test] +fn offline_zip_import_keeps_legacy_unversioned_jar_compatibility() { + let manager = test_manager("offline-legacy-jar-zip"); + let zip_path = test_path("offline-legacy-jar-zip").join("h2.zip"); + std::fs::create_dir_all(zip_path.parent().unwrap()).unwrap(); + let file = std::fs::File::create(&zip_path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let jar = test_agent_jar_bytes(); + let registry = serde_json::json!({ + "drivers": { + "h2": { + "version": "0.1.9", + "label": "H2", + "min_app_version": "0.1.0", + "jre": DEFAULT_JRE_KEY, + "jar": { "url": "https://example.com/dbx-agent-h2.jar", "size": jar.len() } + } + } + }); + zip.start_file("agent-registry.json", options).unwrap(); + std::io::Write::write_all(&mut zip, registry.to_string().as_bytes()).unwrap(); + zip.start_file("drivers/dbx-agent-h2.jar", options).unwrap(); + std::io::Write::write_all(&mut zip, &jar).unwrap(); + zip.finish().unwrap(); + + import_agents_from_zip(&manager, &zip_path, |_| {}).unwrap(); + + assert_eq!(manager.load_state().installed_drivers["h2"].version, "0.1.9"); +} + +#[test] +fn offline_zip_import_installs_versioned_native_driver_package() { + let manager = test_manager("offline-native-driver-zip"); + let zip_path = test_path("offline-native-driver-zip").join("kingbase.zip"); + std::fs::create_dir_all(zip_path.parent().unwrap()).unwrap(); + write_offline_native_driver_zip(&zip_path, "kingbase", "0.1.34"); + + let result = import_agents_from_zip(&manager, &zip_path, |_| {}).unwrap(); + + assert_eq!(result.drivers_installed, vec!["kingbase"]); + assert_eq!(manager.load_state().installed_drivers["kingbase"].version, "0.1.34"); + assert_eq!(std::fs::read(manager.driver_native_path("kingbase")).unwrap(), current_platform_native_binary()); + assert!(!manager.driver_jar_path("kingbase").exists()); +} + +#[test] +fn offline_zip_import_rejects_native_driver_for_another_platform() { + let manager = test_manager("offline-wrong-platform-native-driver-zip"); + let zip_path = test_path("offline-wrong-platform-native-driver-zip").join("kingbase.zip"); + std::fs::create_dir_all(zip_path.parent().unwrap()).unwrap(); + let other_platform = if AgentManager::current_platform() == "windows-x64" { "linux-x64" } else { "windows-x64" }; + write_offline_native_driver_zip_for_platform(&zip_path, "kingbase", "0.1.34", other_platform); + + let err = import_agents_from_zip(&manager, &zip_path, |_| {}).unwrap_err(); + + assert!(err.contains("no drivers compatible")); + assert!(err.contains(AgentManager::current_platform())); + assert!(!manager.is_driver_installed("kingbase")); +} + +#[test] +fn offline_zip_import_preserves_existing_native_driver_when_binary_is_invalid() { + let manager = test_manager("offline-invalid-native-preserves-existing"); + let root = test_path("offline-invalid-native-preserves-existing-zip"); + let valid_zip = root.join("valid.zip"); + let invalid_zip = root.join("invalid.zip"); + std::fs::create_dir_all(&root).unwrap(); + write_offline_native_driver_zip(&valid_zip, "kingbase", "0.1.34"); + import_agents_from_zip(&manager, &valid_zip, |_| {}).unwrap(); + let original = std::fs::read(manager.driver_native_path("kingbase")).unwrap(); + + write_offline_native_driver_zip_with_bytes( + &invalid_zip, + "kingbase", + "0.1.35", + AgentManager::current_platform(), + b"not-a-native-agent".to_vec(), + ); + let err = import_agents_from_zip(&manager, &invalid_zip, |_| {}).unwrap_err(); + + assert!(err.contains("not a")); + assert_eq!(std::fs::read(manager.driver_native_path("kingbase")).unwrap(), original); + assert_eq!(manager.load_state().installed_drivers["kingbase"].version, "0.1.34"); +} + +#[test] +fn offline_zip_inspection_reports_drivers_and_jre() { + let zip_path = test_path("offline-inspection").join("agents.zip"); + std::fs::create_dir_all(zip_path.parent().unwrap()).unwrap(); + write_offline_driver_zip_with_jre(&zip_path, "h2", "0.2.0", "21.0.12"); + + let plan = inspect_offline_zip(&zip_path).unwrap(); + + assert_eq!(plan.driver_keys, vec!["h2"]); + assert!(plan.includes_jre); +} + +#[test] +fn offline_zip_import_rejects_unknown_driver_type() { + let zip_path = test_path("offline-unknown-driver").join("agents.zip"); + std::fs::create_dir_all(zip_path.parent().unwrap()).unwrap(); + write_offline_driver_zip_with_jar(&zip_path, "unknown-driver", "0.1.0", test_agent_jar_bytes()); + + let err = inspect_offline_zip(&zip_path).unwrap_err(); + + assert!(err.contains("unknown driver type")); +} + +#[test] +fn offline_zip_import_rejects_unsafe_entry_path() { + let zip_path = test_path("offline-unsafe-entry").join("agents.zip"); + std::fs::create_dir_all(zip_path.parent().unwrap()).unwrap(); + let file = std::fs::File::create(&zip_path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + zip.start_file("agent-registry.json", options).unwrap(); + std::io::Write::write_all(&mut zip, br#"{"drivers":{}}"#).unwrap(); + zip.start_file("../escape", options).unwrap(); + std::io::Write::write_all(&mut zip, b"escape").unwrap(); + zip.finish().unwrap(); + + let err = inspect_offline_zip(&zip_path).unwrap_err(); + + assert!(err.contains("unsafe path")); +} + +#[test] +fn offline_zip_import_prefers_native_artifact_over_java_fallback() { + let manager = test_manager("offline-native-preferred"); + let zip_path = test_path("offline-native-preferred-zip").join("kingbase.zip"); + std::fs::create_dir_all(zip_path.parent().unwrap()).unwrap(); + write_offline_hybrid_driver_zip(&zip_path, "kingbase", "0.1.34"); + + let plan = inspect_offline_zip(&zip_path).unwrap(); + let result = import_agents_from_zip(&manager, &zip_path, |_| {}).unwrap(); + + assert_eq!(plan.driver_keys, vec!["kingbase"]); + assert_eq!(result.drivers_installed, vec!["kingbase"]); + assert!(manager.driver_native_path("kingbase").exists()); + assert!(!manager.driver_jar_path("kingbase").exists()); +} + fn write_offline_driver_zip(path: &std::path::Path, db_type: &str, version: &str) { write_offline_driver_zip_with_jar(path, db_type, version, test_agent_jar_bytes()); } -fn write_offline_driver_zip_with_jre(path: &std::path::Path, db_type: &str, version: &str, jre_version: &str) { +fn write_offline_native_driver_zip(path: &std::path::Path, db_type: &str, version: &str) { + write_offline_native_driver_zip_for_platform(path, db_type, version, AgentManager::current_platform()); +} + +fn write_offline_native_driver_zip_for_platform(path: &std::path::Path, db_type: &str, version: &str, platform: &str) { + write_offline_native_driver_zip_with_bytes(path, db_type, version, platform, current_platform_native_binary()); +} + +fn write_offline_native_driver_zip_with_bytes( + path: &std::path::Path, + db_type: &str, + version: &str, + platform: &str, + native: Vec, +) { + 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); + let extension = if platform.starts_with("windows-") { ".exe" } else { "" }; + let filename = format!("dbx-agent-{db_type}-{version}-{platform}{extension}"); + let registry = serde_json::json!({ + "jres": {}, + "drivers": { + db_type: { + "version": version, + "label": db_type, + "min_app_version": "0.6.0", + "jre": DEFAULT_JRE_KEY, + "native": { + platform: { + "url": format!("https://example.com/{filename}"), + "size": native.len() + } + } + } + } + }); + + zip.start_file("agent-registry.json", options).unwrap(); + std::io::Write::write_all(&mut zip, registry.to_string().as_bytes()).unwrap(); + zip.start_file(format!("drivers/{filename}"), options).unwrap(); + std::io::Write::write_all(&mut zip, &native).unwrap(); + zip.finish().unwrap(); +} + +fn write_offline_hybrid_driver_zip(path: &std::path::Path, db_type: &str, version: &str) { 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); let jar = test_agent_jar_bytes(); - let jre_archive = test_jre_archive_bytes(); + let native = current_platform_native_binary(); + let platform = AgentManager::current_platform(); + let extension = if platform.starts_with("windows-") { ".exe" } else { "" }; + let jar_filename = format!("dbx-agent-{db_type}-{version}.jar"); + let native_filename = format!("dbx-agent-{db_type}-{version}-{platform}{extension}"); + let registry = serde_json::json!({ + "jres": {}, + "drivers": { + db_type: { + "version": version, + "label": db_type, + "min_app_version": "0.1.0", + "jre": DEFAULT_JRE_KEY, + "jar": { "url": format!("https://example.com/{jar_filename}"), "size": jar.len() }, + "native": { + platform: { "url": format!("https://example.com/{native_filename}"), "size": native.len() } + } + } + } + }); + + zip.start_file("agent-registry.json", options).unwrap(); + std::io::Write::write_all(&mut zip, registry.to_string().as_bytes()).unwrap(); + zip.start_file(format!("drivers/{jar_filename}"), options).unwrap(); + std::io::Write::write_all(&mut zip, &jar).unwrap(); + zip.start_file(format!("drivers/{native_filename}"), options).unwrap(); + std::io::Write::write_all(&mut zip, &native).unwrap(); + zip.finish().unwrap(); +} + +fn write_offline_driver_zip_with_jre(path: &std::path::Path, db_type: &str, version: &str, jre_version: &str) { + write_offline_driver_zip_with_jre_bytes(path, db_type, version, jre_version, test_jre_archive_bytes()); +} + +fn write_offline_driver_zip_with_jre_bytes( + path: &std::path::Path, + db_type: &str, + version: &str, + jre_version: &str, + jre_archive: Vec, +) { + write_offline_driver_zip_with_jre_and_jar_bytes( + path, + db_type, + version, + jre_version, + jre_archive, + test_agent_jar_bytes(), + ); +} + +fn write_offline_driver_zip_with_jre_and_jar_bytes( + path: &std::path::Path, + db_type: &str, + version: &str, + jre_version: &str, + jre_archive: Vec, + jar: Vec, +) { + 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); let registry = serde_json::json!({ "jres": { DEFAULT_JRE_KEY: { @@ -548,7 +908,7 @@ fn write_offline_driver_zip_with_jre(path: &std::path::Path, db_type: &str, vers "label": db_type, "min_app_version": "0.1.0", "jre": DEFAULT_JRE_KEY, - "jar": { "url": format!("https://example.com/dbx-agent-{db_type}.jar"), "size": jar.len() } + "jar": { "url": format!("https://example.com/dbx-agent-{db_type}-{version}.jar"), "size": jar.len() } } } }); @@ -558,7 +918,7 @@ fn write_offline_driver_zip_with_jre(path: &std::path::Path, db_type: &str, vers zip.start_file(format!("jre/dbx-jre-{DEFAULT_JRE_KEY}-{}.tar.gz", AgentManager::current_platform()), options) .unwrap(); std::io::Write::write_all(&mut zip, &jre_archive).unwrap(); - zip.start_file(format!("drivers/dbx-agent-{db_type}.jar"), options).unwrap(); + zip.start_file(format!("drivers/dbx-agent-{db_type}-{version}.jar"), options).unwrap(); std::io::Write::write_all(&mut zip, &jar).unwrap(); zip.finish().unwrap(); } @@ -598,14 +958,14 @@ fn write_offline_driver_zip_with_jar(path: &std::path::Path, db_type: &str, vers "label": db_type, "min_app_version": "0.1.0", "jre": DEFAULT_JRE_KEY, - "jar": { "url": format!("https://example.com/dbx-agent-{db_type}.jar"), "size": jar.len() } + "jar": { "url": format!("https://example.com/dbx-agent-{db_type}-{version}.jar"), "size": jar.len() } } } }); zip.start_file("agent-registry.json", options).unwrap(); std::io::Write::write_all(&mut zip, registry.to_string().as_bytes()).unwrap(); - zip.start_file(format!("drivers/dbx-agent-{db_type}.jar"), options).unwrap(); + zip.start_file(format!("drivers/dbx-agent-{db_type}-{version}.jar"), options).unwrap(); std::io::Write::write_all(&mut zip, &jar).unwrap(); zip.finish().unwrap(); } @@ -623,3 +983,34 @@ fn test_agent_jar_bytes() -> Vec { std::io::Write::write_all(&mut zip, b"Manifest-Version: 1.0\nMain-Class: com.dbx.agent.TestAgent\n\n").unwrap(); zip.finish().unwrap().into_inner() } + +fn current_platform_native_binary() -> Vec { + native_binary_for_arch(cfg!(target_arch = "aarch64")) +} + +fn native_binary_for_arch(aarch64: bool) -> Vec { + if cfg!(windows) { + let mut bytes = vec![0_u8; 0x48]; + bytes[..2].copy_from_slice(b"MZ"); + bytes[0x3c..0x40].copy_from_slice(&(0x40_u32).to_le_bytes()); + bytes[0x40..0x44].copy_from_slice(b"PE\0\0"); + let machine = if aarch64 { 0xaa64_u16 } else { 0x8664_u16 }; + bytes[0x44..0x46].copy_from_slice(&machine.to_le_bytes()); + bytes + } else if cfg!(target_os = "linux") { + let mut bytes = vec![0_u8; 20]; + bytes[..4].copy_from_slice(b"\x7fELF"); + bytes[4] = 2; + bytes[5] = 1; + let machine = if aarch64 { 183_u16 } else { 62_u16 }; + bytes[18..20].copy_from_slice(&machine.to_le_bytes()); + bytes + } else if cfg!(target_os = "macos") { + let mut bytes = vec![0xcf, 0xfa, 0xed, 0xfe]; + let cpu_type = if aarch64 { 0x0100_000c_u32 } else { 0x0100_0007_u32 }; + bytes.extend_from_slice(&cpu_type.to_le_bytes()); + bytes + } else { + Vec::new() + } +} diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 5fdd6198e..2828d4f21 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -245,7 +245,8 @@ async fn main() { .route("/agents/upgrade-all", post(routes::agents::upgrade_all_agents)) .route("/agents/uninstall", post(routes::agents::uninstall_agent)) .route("/agents/import-offline", post(routes::agents::import_agents_from_zip)) - .route("/agents/import-jar", post(routes::agents::import_agent_jar)) + .route("/agents/import-driver", post(routes::agents::import_agent_driver_file)) + .route("/agents/import-jar", post(routes::agents::import_agent_driver_file)) .route( "/agents/java-runtime", get(routes::agents::get_agent_java_runtime_config).post(routes::agents::set_agent_java_runtime_config), diff --git a/crates/dbx-web/src/routes/agents.rs b/crates/dbx-web/src/routes/agents.rs index cf5a96b72..d18c96e8a 100644 --- a/crates/dbx-web/src/routes/agents.rs +++ b/crates/dbx-web/src/routes/agents.rs @@ -7,9 +7,10 @@ use dbx_core::agent_manager::{ AgentDriverInfo, AgentState, DriverStoreUsage, JavaRuntimeConfig, JavaRuntimeMode, DEFAULT_JRE_KEY, }; use dbx_core::agent_service::{ - build_agent_list, clear_agent_download_cache, fetch_registry, - import_agents_from_zip as import_agents_from_zip_core, install_agent_driver, invalidate_registry_cache, - reinstall_agent_jre, uninstall_agent_driver, uninstall_agent_jre, upgrade_all_agent_drivers, AgentProgressEvent, + build_agent_list, clear_agent_download_cache, fetch_registry, import_agent_driver, + import_agents_from_zip as import_agents_from_zip_core, inspect_offline_zip, install_agent_driver, + invalidate_registry_cache, reinstall_agent_jre, uninstall_agent_driver, uninstall_agent_jre, + upgrade_all_agent_drivers, AgentProgressEvent, OfflineImportPlan, }; use dbx_core::driver_runtime::DriverRuntimeSummary; use futures::Stream; @@ -177,18 +178,22 @@ pub async fn import_agents_from_zip( } let zip_path = tmp_dir.join(format!("agent-offline-{}.zip", uuid::Uuid::new_v4())); - let mut upload = tokio::fs::File::create(&zip_path).await.map_err(|err| AppError(err.to_string()))?; - let mut field = field; - while let Some(chunk) = field.chunk().await.map_err(|err| AppError(err.to_string()))? { - upload.write_all(&chunk).await.map_err(|err| AppError(err.to_string()))?; - } - upload.flush().await.map_err(|err| AppError(err.to_string()))?; - drop(upload); - let tx = progress_sender(&state, "global").await; - let result = + let result = async { + let mut upload = tokio::fs::File::create(&zip_path).await.map_err(|err| AppError(err.to_string()))?; + let mut field = field; + while let Some(chunk) = field.chunk().await.map_err(|err| AppError(err.to_string()))? { + upload.write_all(&chunk).await.map_err(|err| AppError(err.to_string()))?; + } + upload.flush().await.map_err(|err| AppError(err.to_string()))?; + drop(upload); + + let plan = inspect_offline_zip(&zip_path).map_err(AppError)?; + ensure_no_offline_import_blockers(&state.app, &plan).await.map_err(AppError)?; import_agents_from_zip_core(&state.app.agent_manager, &zip_path, |event| send_progress_event(&tx, event)) - .map_err(AppError); + .map_err(AppError) + } + .await; let _ = std::fs::remove_file(&zip_path); let result = result?; @@ -199,37 +204,44 @@ pub async fn import_agents_from_zip( Err(AppError("No file uploaded".to_string())) } -pub async fn import_agent_jar( +pub async fn import_agent_driver_file( State(state): State>, mut multipart: Multipart, ) -> Result, AppError> { let mut db_type: Option = None; - let mut jar_data: Option> = None; - let mut jar_name = String::new(); + let mut driver_data: Option> = None; + let mut driver_name = String::new(); while let Ok(Some(field)) = multipart.next_field().await { let name = field.name().unwrap_or("").to_string(); if name == "dbType" { db_type = Some(field.text().await.map_err(|e| AppError(e.to_string()))?); } else if name == "file" { - jar_name = field.file_name().unwrap_or("driver.jar").to_string(); - if !jar_name.to_lowercase().ends_with(".jar") { - return Err(AppError("Only .jar files can be imported".to_string())); - } - jar_data = Some(field.bytes().await.map_err(|e| AppError(e.to_string()))?.to_vec()); + driver_name = field.file_name().unwrap_or("agent").to_string(); + driver_data = Some(field.bytes().await.map_err(|e| AppError(e.to_string()))?.to_vec()); } } let db_type = db_type.ok_or_else(|| AppError("Missing dbType field".to_string()))?; - let data = jar_data.ok_or_else(|| AppError("No file uploaded".to_string()))?; + let data = driver_data.ok_or_else(|| AppError("No file uploaded".to_string()))?; - let temp_dir = state.app.plugins.root_dir().join("jar_upload_tmp"); + let temp_dir = state.app.plugins.root_dir().join("agent_upload_tmp"); std::fs::create_dir_all(&temp_dir).map_err(|e| AppError(e.to_string()))?; - let tmp_path = temp_dir.join(&jar_name); + let suffix = std::path::Path::new(&driver_name) + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| format!(".{extension}")) + .unwrap_or_default(); + let tmp_path = temp_dir.join(format!("agent-{}{}", uuid::Uuid::new_v4(), suffix)); std::fs::write(&tmp_path, &data).map_err(|e| AppError(e.to_string()))?; - dbx_core::agent_service::import_agent_jar(&state.app.agent_manager, &db_type, &tmp_path).map_err(AppError::from)?; + let result = async { + ensure_no_agent_update_blockers(&state.app, std::slice::from_ref(&db_type)).await.map_err(AppError)?; + import_agent_driver(&state.app.agent_manager, &db_type, &tmp_path).map_err(AppError::from) + } + .await; let _ = std::fs::remove_file(&tmp_path); + result?; Ok(Json(serde_json::json!({ "success": true }))) } @@ -302,3 +314,19 @@ async fn ensure_no_agent_update_blockers( Err(format!("请先关闭以下数据库连接后再更新驱动: {}", blockers.join(", "))) } } + +async fn ensure_no_offline_import_blockers( + state: &dbx_core::connection::AppState, + plan: &OfflineImportPlan, +) -> Result<(), String> { + let mut driver_keys = plan.driver_keys.clone(); + if plan.includes_jre { + // Replacing a managed JRE affects every running Java Agent, so include + // all active runtimes in the same connection-aware update preflight. + driver_keys.extend(state.agent_manager.active_daemon_keys().await); + driver_keys.extend(state.active_agent_connection_driver_keys().await); + driver_keys.sort(); + driver_keys.dedup(); + } + ensure_no_agent_update_blockers(state, &driver_keys).await +} diff --git a/docs/app/[lang]/drivers/DriversClient.tsx b/docs/app/[lang]/drivers/DriversClient.tsx index ba8e1a1f6..05f742c69 100644 --- a/docs/app/[lang]/drivers/DriversClient.tsx +++ b/docs/app/[lang]/drivers/DriversClient.tsx @@ -17,9 +17,9 @@ const i18n = { bundles: "Offline Bundles", bundlesDesc: "Platform-specific ZIP packages that include the agent registry, database drivers, native agents, and the matching JRE.", drivers: "Database Drivers", - driversDesc: "JDBC driver JAR files for each supported database type.", + driversDesc: "Single-driver offline ZIPs for Java agents. Install the matching JRE separately when it is not already available.", nativeAgents: "Native Agents", - nativeAgentsDesc: "Go-based native agents for Oracle, KingBase, and XuguDB. Download the executable that matches the offline machine.", + nativeAgentsDesc: "Platform-specific offline ZIPs for Oracle, KingBase, and XuguDB. Import the ZIP directly in Driver Manager.", jre: "Java Runtime (JRE)", jreDesc: "JRE packages used by agent-based database drivers. Required for Oracle, SQL Server, and other agent-managed connections.", loading: "Loading driver catalog...", @@ -56,9 +56,9 @@ const i18n = { bundles: "整包下载", bundlesDesc: "按平台提供的 ZIP 离线包,包含 Agent registry、数据库驱动、原生 Agent 和匹配的 JRE。", drivers: "数据库驱动", - driversDesc: "每种支持的数据库类型对应的 JDBC 驱动 JAR 文件。", + driversDesc: "Java Agent 的单驱动离线 ZIP;目标机器尚未安装 JRE 时需要另外安装一次对应 JRE。", nativeAgents: "原生 Agent", - nativeAgentsDesc: "Oracle、人大金仓和虚谷使用 Go 原生 Agent,请下载与内网机器平台匹配的可执行文件。", + nativeAgentsDesc: "Oracle、人大金仓和虚谷的按平台单驱动离线包,可直接在驱动管理中导入 ZIP。", jre: "Java 运行时 (JRE)", jreDesc: "Agent 驱动所需的 JRE 环境,Oracle、SQL Server 等数据库通过 Agent 连接时需要。", loading: "正在加载驱动列表...", diff --git a/docs/content/docs/driver-management.cn.mdx b/docs/content/docs/driver-management.cn.mdx index ba411f856..9ba76bc62 100644 --- a/docs/content/docs/driver-management.cn.mdx +++ b/docs/content/docs/driver-management.cn.mdx @@ -55,12 +55,22 @@ DBX 会定期检查驱动更新: 对于无法访问互联网的环境,DBX 支持离线驱动安装: -1. 从另一台机器下载 JDBC 驱动 ZIP 包 +1. 从另一台机器下载与目标操作系统和 CPU 架构匹配的 DBX 离线驱动 ZIP 包 2. 将其传输到离线机器 3. 在驱动商店中使用**从 ZIP 导入**安装驱动包 这适用于气隙网络、严格防火墙环境或企业中预先批准的驱动版本。 +所有 Agent 都提供单驱动 ZIP,可直接使用**导入离线包**安装。Java Agent 的单驱动包跨平台,但不重复携带 JRE;Kingbase、Oracle、Xugu 等原生 Agent 的单驱动包按操作系统和 CPU 架构区分。 + +如果目标机器尚未安装 Java Agent 所需的托管 JRE,请另外导入一次对应平台的 JRE,或者直接使用包含全部驱动和 JRE 的完整离线包。 + +### Windows 导入 Kingbase + +Windows x64 下载 `dbx-agent-kingbase--windows-x64.zip`,Windows ARM64 下载 `dbx-agent-kingbase--windows-aarch64.zip`,然后在驱动管理中选择**导入离线包**。请勿导入 Linux 或 macOS 包。 + +ZIP 内包含版本信息和对应平台的原生 Agent。DBX 会将其安装为 `%USERPROFILE%\.dbx\agents\drivers\kingbase\agent.exe`,不需要手动解除浏览器下载文件的阻止状态或复制文件。 + ## 插件更新 对于由 [JDBC 插件](/cn/docs/plugins)支持的数据库,当有新插件版本可用时,DBX 会显示更新通知。插件更新遵循与内置 Agent 驱动相同的安装流程。 diff --git a/docs/content/docs/driver-management.mdx b/docs/content/docs/driver-management.mdx index 174bf19e6..ea6e5568e 100644 --- a/docs/content/docs/driver-management.mdx +++ b/docs/content/docs/driver-management.mdx @@ -55,12 +55,22 @@ DBX checks for driver updates periodically: For environments without internet access, DBX supports offline driver installation: -1. Download the JDBC driver ZIP bundle from another machine +1. Download the DBX offline driver ZIP matching the target OS and CPU architecture on another machine 2. Transfer it to the offline machine 3. In Driver Store, use **Import from ZIP** to install the driver bundle This is useful for air-gapped networks, strict firewall environments, or pre-approved driver versions in enterprise settings. +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 Kingbase, 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. + +### Importing Kingbase on Windows + +Download `dbx-agent-kingbase--windows-x64.zip` for Windows x64 or `dbx-agent-kingbase--windows-aarch64.zip` for Windows ARM64, then choose **Import offline package** in Driver Manager. Do not import a Linux or macOS package. + +The ZIP contains the version metadata and native agent for that platform. DBX installs it as `%USERPROFILE%\.dbx\agents\drivers\kingbase\agent.exe`, without requiring users to unblock or manually copy the executable. + ## Plugin Updates For databases supported by the [JDBC Plugin](/en/docs/plugins), DBX shows update notices when a new plugin version is available. Plugin updates follow the same install flow as built-in agent drivers. diff --git a/docs/lib/agentRegistry.test.ts b/docs/lib/agentRegistry.test.ts index ce30b2512..87bf0facf 100644 --- a/docs/lib/agentRegistry.test.ts +++ b/docs/lib/agentRegistry.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { afterEach, test, vi } from "vitest"; -import { buildAgentDownloadCatalog, buildNativeAgentEntries, downloadLinksFor, fetchAgentDownloadCatalog, formatSize } from "./agentRegistry"; +import driverVersions from "../../agents/versions.json"; +import { buildAgentDownloadCatalog, buildDriverEntries, buildNativeAgentEntries, downloadLinksFor, fetchAgentDownloadCatalog, formatSize } from "./agentRegistry"; afterEach(() => { vi.restoreAllMocks(); @@ -67,7 +68,26 @@ test("unknown fallback asset sizes render as unavailable", () => { assert.equal(formatSize(0), "—"); }); -test("KingBase release executables are listed as native agents", () => { +test("Java agent ZIPs are preferred over raw JARs", () => { + const accessVersion = driverVersions.access; + const entries = buildDriverEntries([ + { + name: `dbx-agent-access-${accessVersion}.jar`, + browser_download_url: `https://example.com/dbx-agent-access-${accessVersion}.jar`, + size: 1024, + }, + { + name: `dbx-agent-access-${accessVersion}.zip`, + browser_download_url: `https://example.com/dbx-agent-access-${accessVersion}.zip`, + size: 2048, + }, + ]); + + assert.equal(entries[0]?.key, "access"); + assert.equal(entries[0]?.jar.url, `https://example.com/dbx-agent-access-${accessVersion}.zip`); +}); + +test("KingBase native ZIPs are preferred over raw release executables", () => { const entries = buildNativeAgentEntries([ { name: "dbx-agent-kingbase-windows-x64.exe", @@ -75,22 +95,37 @@ test("KingBase release executables are listed as native agents", () => { size: 1024, }, { - name: "dbx-agent-kingbase-linux-x64", - browser_download_url: "https://example.com/dbx-agent-kingbase-linux-x64", + name: "dbx-agent-kingbase-0.1.34-windows-x64.exe", + browser_download_url: "https://example.com/dbx-agent-kingbase-0.1.34-windows-x64.exe", size: 2048, }, { - name: "dbx-agent-kingbase.jar", - browser_download_url: "https://example.com/dbx-agent-kingbase.jar", + name: "dbx-agent-kingbase-0.1.34-windows-x64.zip", + browser_download_url: "https://example.com/dbx-agent-kingbase-0.1.34-windows-x64.zip", size: 4096, }, + { + name: "dbx-agent-kingbase-0.1.34-linux-x64.zip", + browser_download_url: "https://example.com/dbx-agent-kingbase-0.1.34-linux-x64.zip", + size: 3072, + }, ]); assert.deepEqual( - entries.map(({ key, platformKey, filename }) => ({ key, platformKey, filename })), + entries.map(({ key, version, platformKey, filename }) => ({ key, version, platformKey, filename })), [ - { key: "kingbase", platformKey: "linux-x64", filename: "dbx-agent-kingbase-linux-x64" }, - { key: "kingbase", platformKey: "windows-x64", filename: "dbx-agent-kingbase-windows-x64.exe" }, + { + key: "kingbase", + version: "0.1.34", + platformKey: "linux-x64", + filename: "dbx-agent-kingbase-0.1.34-linux-x64.zip", + }, + { + key: "kingbase", + version: "0.1.34", + platformKey: "windows-x64", + filename: "dbx-agent-kingbase-0.1.34-windows-x64.zip", + }, ], ); }); diff --git a/docs/lib/agentRegistry.ts b/docs/lib/agentRegistry.ts index 6a5007f04..9d455c0fa 100644 --- a/docs/lib/agentRegistry.ts +++ b/docs/lib/agentRegistry.ts @@ -28,6 +28,7 @@ interface AgentRegistryArtifact { } interface AgentRegistryDriver { + version?: string; jar?: AgentRegistryArtifact; native?: Record; } @@ -169,6 +170,11 @@ function releaseAssetName(url: string, fallback: string): string { } } +function siblingReleaseAssetUrl(url: string, filename: string): string { + const separator = url.lastIndexOf("/"); + return separator >= 0 ? `${url.slice(0, separator + 1)}${filename}` : filename; +} + function githubReleaseAsset(name: string, size: number, tag = "agents-latest"): GitHubReleaseAsset { return { name, @@ -193,8 +199,25 @@ function registryReleaseAssets(registry: AgentRegistry): GitHubReleaseAsset[] { for (const [driverKey, driver] of Object.entries(registry.drivers ?? {})) { addArtifact(driver.jar, `dbx-agent-${driverKey}.jar`); + if (driver.version && driver.jar?.url && driver.jar.size > 0) { + const packageName = `dbx-agent-${driverKey}-${driver.version}.zip`; + assets.set(packageName, { + name: packageName, + size: 0, + browser_download_url: siblingReleaseAssetUrl(driver.jar.url, packageName), + }); + } for (const [platformKey, artifact] of Object.entries(driver.native ?? {})) { addArtifact(artifact, `dbx-agent-${driverKey}-${platformKey}`); + const filename = releaseAssetName(artifact.url, ""); + if (driver.version && filename.includes(`-${driver.version}-${platformKey}`)) { + const packageName = `${filename.replace(/\.exe$/, "")}.zip`; + assets.set(packageName, { + name: packageName, + size: 0, + browser_download_url: siblingReleaseAssetUrl(artifact.url, packageName), + }); + } } } @@ -292,13 +315,17 @@ export function buildDriverEntries(assets: GitHubReleaseAsset[]): DriverDisplayE return currentJavaDriverKeys .map((key) => { - const asset = byName.get(`dbx-agent-${key}.jar`); + const version = driverVersionMap[key] ?? ""; + const asset = + byName.get(`dbx-agent-${key}-${version}.zip`) ?? + byName.get(`dbx-agent-${key}-${version}.jar`) ?? + byName.get(`dbx-agent-${key}.jar`); if (!asset) return null; return { key, label: labelForDriver(key), - version: driverVersionMap[key] ?? "", + version, minAppVersion: MIN_APP_VERSION, jar: assetInfo(asset), jre: "21", @@ -308,26 +335,39 @@ export function buildDriverEntries(assets: GitHubReleaseAsset[]): DriverDisplayE } export function buildNativeAgentEntries(assets: GitHubReleaseAsset[]): NativeAgentDisplayEntry[] { - const entries: NativeAgentDisplayEntry[] = []; + const entries = new Map(); + const platforms = "macos-aarch64|macos-x64|linux-aarch64|linux-x64|windows-aarch64|windows-x64"; for (const asset of assets) { - const match = /^dbx-agent-(.+?)-(macos-aarch64|macos-x64|linux-aarch64|linux-x64|windows-aarch64|windows-x64)(?:\.exe)?$/.exec(asset.name); + const packageMatch = new RegExp(`^dbx-agent-(oracle|kingbase|xugu)-(.+)-(${platforms})\\.zip$`).exec(asset.name); + const versionedMatch = new RegExp(`^dbx-agent-(oracle|kingbase|xugu)-(.+)-(${platforms})(?:\\.exe)?$`).exec(asset.name); + const legacyMatch = new RegExp(`^dbx-agent-(oracle|kingbase|xugu)-(${platforms})(?:\\.exe)?$`).exec(asset.name); + const match = packageMatch ?? versionedMatch ?? legacyMatch; if (!match) continue; - const [, key, platformKey] = match; + const packaged = packageMatch !== null; + const key = match[1]; + const version = legacyMatch ? (driverVersionMap[key] ?? "") : match[2]; + const platformKey = legacyMatch ? match[2] : match[3]; if (!nativeDriverKeys.has(key)) continue; - entries.push({ + const entryKey = `${key}:${platformKey}`; + const existing = entries.get(entryKey); + if (existing?.packaged && !packaged) continue; + entries.set(entryKey, { key, label: labelForDriver(key), - version: driverVersionMap[key] ?? "", + version, platformKey, platformLabel: platformLabels[platformKey] ?? platformKey, filename: asset.name, info: assetInfo(asset), + packaged, }); } - return entries.sort((a, b) => a.label.localeCompare(b.label) || a.platformLabel.localeCompare(b.platformLabel)); + return Array.from(entries.values()) + .map(({ packaged: _, ...entry }) => entry) + .sort((a, b) => a.label.localeCompare(b.label) || a.platformLabel.localeCompare(b.platformLabel)); } export function buildOfflineBundleEntries(assets: GitHubReleaseAsset[]): OfflineBundleEntry[] { diff --git a/src-tauri/src/commands/agents.rs b/src-tauri/src/commands/agents.rs index 539b22c71..b73ead530 100644 --- a/src-tauri/src/commands/agents.rs +++ b/src-tauri/src/commands/agents.rs @@ -4,10 +4,10 @@ use tauri::{Emitter, State}; use dbx_core::agent_manager::{AgentDriverInfo, DriverStoreUsage, JavaRuntimeConfig, JavaRuntimeMode, DEFAULT_JRE_KEY}; use dbx_core::agent_service::{ - build_agent_list, clear_agent_download_cache, fetch_registry_from, import_agent_jar, - import_agents_from_zip as import_agents_from_zip_core, install_agent_driver_from, invalidate_registry_cache, - reinstall_agent_jre_from, uninstall_agent_driver, uninstall_agent_jre, upgrade_all_agent_drivers_from, - AgentProgressEvent, UpgradeAllAgentDriversResult, + build_agent_list, clear_agent_download_cache, fetch_registry_from, import_agent_driver, + import_agents_from_zip as import_agents_from_zip_core, inspect_offline_zip, install_agent_driver_from, + invalidate_registry_cache, reinstall_agent_jre_from, uninstall_agent_driver, uninstall_agent_jre, + upgrade_all_agent_drivers_from, AgentProgressEvent, OfflineImportPlan, UpgradeAllAgentDriversResult, }; use dbx_core::connection::AppState; use dbx_core::driver_runtime::DriverRuntimeSummary; @@ -162,6 +162,8 @@ pub async fn import_agents_from_zip( ) -> Result { let am = &state.agent_manager; let zip_path = std::path::PathBuf::from(&path); + let plan = inspect_offline_zip(&zip_path)?; + ensure_no_offline_import_blockers(state.inner().as_ref(), &plan).await?; let app_handle = app.clone(); let result = import_agents_from_zip_core(am, &zip_path, |event| emit_agent_progress(&app_handle, event))?; let count = result.drivers_installed.len() as u32; @@ -169,13 +171,24 @@ pub async fn import_agents_from_zip( Ok(count) } +#[tauri::command] +pub async fn import_agent_driver_cmd( + state: State<'_, Arc>, + db_type: String, + path: String, +) -> Result<(), String> { + ensure_no_agent_update_blockers(state.inner().as_ref(), std::slice::from_ref(&db_type)).await?; + import_agent_driver(&state.agent_manager, &db_type, std::path::Path::new(&path)) +} + #[tauri::command] pub async fn import_agent_jar_cmd( state: State<'_, Arc>, db_type: String, path: String, ) -> Result<(), String> { - import_agent_jar(&state.agent_manager, &db_type, std::path::Path::new(&path)) + ensure_no_agent_update_blockers(state.inner().as_ref(), std::slice::from_ref(&db_type)).await?; + import_agent_driver(&state.agent_manager, &db_type, std::path::Path::new(&path)) } #[tauri::command] @@ -206,6 +219,19 @@ async fn ensure_no_agent_update_blockers(state: &AppState, db_types: &[String]) Err(format!("请先关闭以下数据库连接后再更新驱动: {labels}")) } +async fn ensure_no_offline_import_blockers(state: &AppState, plan: &OfflineImportPlan) -> Result<(), String> { + let mut driver_keys = plan.driver_keys.clone(); + if plan.includes_jre { + // Replacing a managed JRE affects every running Java Agent, so include + // all active runtimes in the same connection-aware update preflight. + driver_keys.extend(state.agent_manager.active_daemon_keys().await); + driver_keys.extend(state.active_agent_connection_driver_keys().await); + driver_keys.sort(); + driver_keys.dedup(); + } + ensure_no_agent_update_blockers(state, &driver_keys).await +} + async fn agent_update_blockers(state: &AppState, db_types: &[String]) -> Vec { update_blockers_from_keys(state.active_agent_connection_driver_keys().await, db_types) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ed998612f..16d864f11 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1471,6 +1471,7 @@ pub fn run() { commands::agents::reinstall_jre, commands::agents::invalidate_agent_registry_cache, commands::agents::import_agents_from_zip, + commands::agents::import_agent_driver_cmd, commands::agents::import_agent_jar_cmd, commands::system_fonts::list_system_fonts, commands::ssh_config::list_ssh_config_hosts,