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