diff --git a/.github/scripts/offline-jdbc-payload.test.mjs b/.github/scripts/offline-jdbc-payload.test.mjs new file mode 100644 index 0000000000..5624bf554d --- /dev/null +++ b/.github/scripts/offline-jdbc-payload.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const BUILDER = join(REPO_ROOT, "agents/scripts/build_offline_jdbc_payload.mjs"); +const ZIP_BUILDER = join(REPO_ROOT, "agents/scripts/build_offline_zip.sh"); +const RELEASE_VERIFIER = join(REPO_ROOT, "agents/scripts/verify_offline_jdbc_release.mjs"); +const ASSET_MANIFEST = join(REPO_ROOT, "apps/desktop/src/lib/database/managedJdbcAssets.json"); +const PLATFORMS = ["macos-aarch64", "macos-x64", "linux-x64", "linux-aarch64", "windows-x64", "windows-aarch64"]; + +function flattenBundles(manifest) { + return Object.values(manifest.drivers).flatMap((driver) => Object.values(driver.bundles || {})); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { encoding: "utf8", ...options }); + assert.equal(result.status, 0, result.stderr || result.stdout); + return result; +} + +test("builds and verifies all platform ZIPs from the managed JDBC asset manifest", () => { + const root = mkdtempSync(join(tmpdir(), "dbx-offline-jdbc-builder-test-")); + try { + const releaseDir = join(root, "release"); + const pluginZip = join(root, "plugin.zip"); + const jarRoot = join(root, "jar"); + const artifact = join(root, "driver.jar"); + const resolver = join(root, "resolver.mjs"); + const testManifestPath = join(root, "managed-jdbc-assets.json"); + mkdirSync(releaseDir, { recursive: true }); + mkdirSync(join(jarRoot, "META-INF"), { recursive: true }); + writeFileSync(pluginZip, "plugin"); + writeFileSync(join(jarRoot, "META-INF/LICENSE"), "Apache License 2.0\n"); + writeFileSync(join(jarRoot, "META-INF/NOTICE"), "Test notice\n"); + run("zip", ["-qr", artifact, "."], { cwd: jarRoot }); + + const digest = createHash("sha256").update(readFileSync(artifact)).digest("hex"); + const productionManifest = JSON.parse(readFileSync(ASSET_MANIFEST, "utf8")); + const testManifest = structuredClone(productionManifest); + const resolverBundles = {}; + for (const bundle of flattenBundles(testManifest)) { + bundle.artifact.file_name = "driver.jar"; + bundle.artifact.size = statSync(artifact).size; + bundle.artifact.sha256 = digest; + bundle.redistribution.license_entries = ["META-INF/LICENSE"]; + bundle.redistribution.notice_entries = ["META-INF/NOTICE"]; + resolverBundles[bundle.coordinate] = { + coordinate: bundle.coordinate, + scope: "runtime", + repositories: [testManifest.repository], + artifacts: [ + { + groupId: bundle.artifact.group_id, + artifactId: bundle.artifact.artifact_id, + version: bundle.artifact.version, + classifier: bundle.artifact.classifier, + extension: bundle.artifact.extension, + file: artifact, + size: bundle.artifact.size, + sha256: digest, + }, + ], + }; + } + writeFileSync(testManifestPath, `${JSON.stringify(testManifest, null, 2)}\n`); + writeFileSync( + resolver, + `#!/usr/bin/env node +const bundles = ${JSON.stringify(resolverBundles)}; +const coordinate = process.argv[process.argv.indexOf("--coordinate") + 1]; +if (!bundles[coordinate]) process.exit(2); +process.stdout.write(JSON.stringify(bundles[coordinate])); +`, + ); + chmodSync(resolver, 0o755); + + run(process.execPath, [BUILDER, releaseDir, pluginZip, resolver, testManifestPath], { + env: { ...process.env, DBX_OFFLINE_JDBC_MAVEN_CACHE: join(root, "cache") }, + }); + + const payloadDir = join(releaseDir, "offline-jdbc/jdbc"); + const offlineManifest = JSON.parse(readFileSync(join(payloadDir, "offline-manifest.json"), "utf8")); + assert.equal(offlineManifest.format_version, 1); + assert.equal(offlineManifest.plugin_entry, "jdbc/plugin.zip"); + assert.equal(offlineManifest.third_party_entry, "jdbc/third-party-notices.json"); + assert.deepEqual( + offlineManifest.bundles.map((bundle) => bundle.coordinate), + flattenBundles(productionManifest).map((bundle) => bundle.coordinate), + ); + for (const bundle of offlineManifest.bundles) { + const bundleManifest = JSON.parse(readFileSync(join(payloadDir, "maven", bundle.id, "manifest.json"), "utf8")); + assert.equal(bundleManifest.coordinate, bundle.coordinate); + assert.equal(bundleManifest.artifacts.length, 1); + assert.equal(bundleManifest.artifacts[0].sha256, digest); + } + + writeFileSync(join(releaseDir, "agent-registry.json"), "{}\n"); + for (const platform of PLATFORMS) writeFileSync(join(releaseDir, `dbx-jre-21-${platform}.tar.zst`), platform); + run("bash", [ZIP_BUILDER, releaseDir]); + run(process.execPath, [RELEASE_VERIFIER, releaseDir, testManifestPath]); + for (const platform of PLATFORMS) assert.equal(existsSync(join(releaseDir, `dbx-agents-offline-${platform}.zip`)), true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/.github/workflows/agents-release.yml b/.github/workflows/agents-release.yml index b655d8e7a2..16aa1aaefa 100644 --- a/.github/workflows/agents-release.yml +++ b/.github/workflows/agents-release.yml @@ -969,6 +969,11 @@ jobs: with: token: ${{ steps.release-token.outputs.token }} fetch-depth: 0 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + - uses: gradle/actions/setup-gradle@v4 - uses: actions/download-artifact@v4 with: path: artifacts @@ -1172,9 +1177,22 @@ jobs: echo "=== agent-registry.json ===" cat release/agent-registry.json + - name: Build managed JDBC offline payload + shell: bash + run: | + ./plugins/jdbc/package.sh + JDBC_VERSION="$(sed -nE "s/^version[[:space:]]*=[[:space:]]*'([^']+)'.*/\1/p" plugins/jdbc/build.gradle | head -n 1)" + node agents/scripts/build_offline_jdbc_payload.mjs \ + release \ + "plugins/jdbc/dist/dbx-jdbc-plugin-${JDBC_VERSION}.zip" \ + "plugins/jdbc/dist/dbx-jdbc-plugin-${JDBC_VERSION}/bin/dbx-maven-resolver" + - name: Build offline ZIP bundles run: bash agents/scripts/build_offline_zip.sh release + - name: Verify managed JDBC assets in all offline ZIPs + run: node agents/scripts/verify_offline_jdbc_release.mjs release + - name: Build single-driver packages run: | python3 agents/scripts/build_driver_zips.py release --cleanup-sources diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00df6c7fa7..bf7a36797c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -526,6 +526,46 @@ jobs: - name: JDBC plugin package check run: ./plugins/jdbc/package.sh + offline-jdbc-release: + needs: changes + if: needs.changes.outputs.offline_jdbc == 'true' + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v5 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Cache approved Maven assets + uses: actions/cache@v4 + with: + path: ~/.cache/dbx-offline-jdbc-maven + key: offline-jdbc-${{ hashFiles('apps/desktop/src/lib/database/managedJdbcAssets.json') }} + + - name: Build real managed JDBC payload + env: + DBX_OFFLINE_JDBC_MAVEN_CACHE: ~/.cache/dbx-offline-jdbc-maven + run: | + ./plugins/jdbc/package.sh + JDBC_VERSION="$(sed -nE "s/^version[[:space:]]*=[[:space:]]*'([^']+)'.*/\1/p" plugins/jdbc/build.gradle | head -n 1)" + node agents/scripts/build_offline_jdbc_payload.mjs \ + release \ + "plugins/jdbc/dist/dbx-jdbc-plugin-${JDBC_VERSION}.zip" \ + "plugins/jdbc/dist/dbx-jdbc-plugin-${JDBC_VERSION}/bin/dbx-maven-resolver" + + - name: Build and unpack all six platform ZIPs + run: | + printf '{}\n' > release/agent-registry.json + for platform in macos-aarch64 macos-x64 linux-x64 linux-aarch64 windows-x64 windows-aarch64; do + printf '%s\n' "$platform" > "release/dbx-jre-21-${platform}.tar.zst" + done + bash agents/scripts/build_offline_zip.sh release + node agents/scripts/verify_offline_jdbc_release.mjs release + nix-packaging: needs: changes if: needs.changes.outputs.nix == 'true' @@ -548,6 +588,7 @@ jobs: rust: ${{ steps.filter.outputs.rust }} rust_full: ${{ steps.rust-mode.outputs.full }} jdbc: ${{ steps.filter.outputs.jdbc }} + offline_jdbc: ${{ steps.filter.outputs.offline_jdbc }} agents: ${{ steps.filter.outputs.agents }} duckdb_windows: ${{ steps.filter.outputs.duckdb_windows }} nix: ${{ steps.filter.outputs.nix }} @@ -595,6 +636,15 @@ jobs: - '.github/workflows/ci.yml' jdbc: - 'plugins/jdbc/**' + offline_jdbc: + - 'plugins/jdbc/**' + - 'agents/scripts/build_offline_jdbc_payload.mjs' + - 'agents/scripts/build_offline_zip.sh' + - 'agents/scripts/verify_offline_jdbc_release.mjs' + - 'apps/desktop/src/lib/database/managedJdbcAssets.json' + - '.github/scripts/offline-jdbc-payload.test.mjs' + - '.github/workflows/agents-release.yml' + - '.github/workflows/ci.yml' agents: - 'agents/**' - 'crates/dbx-core/Cargo.toml' diff --git a/agents/scripts/build_offline_jdbc_payload.mjs b/agents/scripts/build_offline_jdbc_payload.mjs new file mode 100644 index 0000000000..e728af6828 --- /dev/null +++ b/agents/scripts/build_offline_jdbc_payload.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFileSync, createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const DEFAULT_ASSET_MANIFEST = join(REPO_ROOT, "apps/desktop/src/lib/database/managedJdbcAssets.json"); + +function fail(message) { + throw new Error(message); +} + +async function sha256(path) { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +} + +function loadAssetManifest(path) { + const manifest = JSON.parse(readFileSync(path, "utf8")); + if (manifest.format_version !== 1) fail(`Unsupported managed JDBC asset manifest version: ${manifest.format_version}`); + if (!manifest.repository || !manifest.drivers || typeof manifest.drivers !== "object") fail("Invalid managed JDBC asset manifest"); + + const bundles = Object.values(manifest.drivers).flatMap((driver) => Object.values(driver.bundles || {})); + if (bundles.length === 0) fail("Managed JDBC asset manifest contains no bundles"); + const ids = new Set(); + const coordinates = new Set(); + for (const bundle of bundles) { + if (!bundle.id || !bundle.coordinate || !bundle.artifact || !bundle.redistribution) fail("Managed JDBC bundle is incomplete"); + if (ids.has(bundle.id)) fail(`Duplicate managed JDBC bundle id: ${bundle.id}`); + if (coordinates.has(bundle.coordinate)) fail(`Duplicate managed JDBC coordinate: ${bundle.coordinate}`); + ids.add(bundle.id); + coordinates.add(bundle.coordinate); + } + return { manifest, bundles }; +} + +function resolveBundle(resolver, coordinate, localRepository, repositories) { + const javaToolOptions = [ + process.env.JAVA_TOOL_OPTIONS, + "-Djava.net.preferIPv4Stack=true", + "-Daether.connector.connectTimeout=30000", + "-Daether.connector.requestTimeout=300000", + ] + .filter(Boolean) + .join(" "); + const args = ["resolve", "--coordinate", coordinate, "--local-repo", localRepository]; + for (const repository of repositories) args.push("--repo", repository); + const result = spawnSync(resolver, args, { + encoding: "utf8", + maxBuffer: 128 * 1024 * 1024, + env: { ...process.env, JAVA_TOOL_OPTIONS: javaToolOptions }, + }); + if (result.error) fail(`Failed to start JDBC Maven resolver for ${coordinate}: ${result.error.message}`); + if (result.status !== 0) fail(`Failed to resolve ${coordinate}: ${(result.stderr || result.stdout).trim()}`); + try { + return JSON.parse(result.stdout); + } catch (error) { + fail(`Failed to parse Maven resolver output for ${coordinate}: ${error.message}`); + } +} + +function artifactIdentity(artifact) { + return [artifact.groupId, artifact.artifactId, artifact.version, artifact.classifier || "", artifact.extension].join(":"); +} + +function expectedArtifactIdentity(artifact) { + return [artifact.group_id, artifact.artifact_id, artifact.version, artifact.classifier || "", artifact.extension].join(":"); +} + +function jarEntries(path) { + const result = spawnSync("unzip", ["-Z1", path], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }); + if (result.error) fail(`Failed to inspect JDBC JAR ${path}: ${result.error.message}`); + if (result.status !== 0) fail(`Failed to inspect JDBC JAR ${path}: ${(result.stderr || result.stdout).trim()}`); + return new Set(result.stdout.split(/\r?\n/).filter(Boolean)); +} + +async function validateAndCopyBundle(bundle, resolved, mavenDir) { + const resolvedArtifacts = (resolved.artifacts || []).filter((artifact) => String(artifact.extension).toLowerCase() === "jar"); + const identities = new Set(resolvedArtifacts.map(artifactIdentity)); + if (identities.size !== resolvedArtifacts.length) fail(`Maven resolver returned duplicate JAR artifacts for ${bundle.coordinate}`); + if (resolvedArtifacts.length !== 1) { + fail(`Resolved dependency set changed for ${bundle.coordinate}: expected 1 approved shaded JAR, received ${resolvedArtifacts.length}`); + } + + const resolvedArtifact = resolvedArtifacts[0]; + const approved = bundle.artifact; + if (artifactIdentity(resolvedArtifact) !== expectedArtifactIdentity(approved)) { + fail(`Resolved artifact identity does not match the approved asset for ${bundle.coordinate}`); + } + if (!existsSync(resolvedArtifact.file)) fail(`Resolved artifact does not exist: ${resolvedArtifact.file}`); + if (basename(resolvedArtifact.file) !== approved.file_name) fail(`Resolved artifact file name does not match the approved asset for ${bundle.coordinate}`); + + const actualSize = statSync(resolvedArtifact.file).size; + const actualDigest = await sha256(resolvedArtifact.file); + if (Number(resolvedArtifact.size) !== actualSize || String(resolvedArtifact.sha256).toLowerCase() !== actualDigest) { + fail(`Maven resolver metadata mismatch: ${resolvedArtifact.file}`); + } + if (approved.size !== actualSize || approved.sha256.toLowerCase() !== actualDigest) { + fail(`Resolved artifact checksum is not approved for redistribution: ${bundle.coordinate}`); + } + + const entries = jarEntries(resolvedArtifact.file); + for (const entry of [...bundle.redistribution.license_entries, ...bundle.redistribution.notice_entries]) { + if (!entries.has(entry)) fail(`Approved license/notice entry is missing from ${bundle.coordinate}: ${entry}`); + } + + const bundleDir = join(mavenDir, bundle.id); + const jarsDir = join(bundleDir, "jars"); + mkdirSync(jarsDir, { recursive: true }); + copyFileSync(resolvedArtifact.file, join(jarsDir, approved.file_name)); + const artifact = { + group_id: approved.group_id, + artifact_id: approved.artifact_id, + version: approved.version, + classifier: approved.classifier || "", + extension: approved.extension, + file_name: approved.file_name, + path: "", + size: actualSize, + sha256: actualDigest, + }; + const bundleManifest = { + id: bundle.id, + coordinate: bundle.coordinate, + scope: resolved.scope || "runtime", + repositories: resolved.repositories?.length ? resolved.repositories : [], + installed_at: "", + path: "", + artifacts: [artifact], + }; + writeFileSync(join(bundleDir, "manifest.json"), `${JSON.stringify(bundleManifest, null, 2)}\n`); + return artifact; +} + +async function main() { + const [releaseArg, pluginZipArg, resolverArg, assetManifestArg] = process.argv.slice(2); + if (!releaseArg || !pluginZipArg || !resolverArg) { + fail("Usage: build_offline_jdbc_payload.mjs [asset-manifest]"); + } + + const releaseDir = resolve(releaseArg); + const pluginZip = resolve(pluginZipArg); + const resolver = resolve(resolverArg); + const assetManifestPath = resolve(assetManifestArg || DEFAULT_ASSET_MANIFEST); + if (!existsSync(pluginZip)) fail(`JDBC plugin ZIP not found: ${pluginZip}`); + if (!existsSync(resolver)) fail(`JDBC Maven resolver not found: ${resolver}`); + if (!existsSync(assetManifestPath)) fail(`Managed JDBC asset manifest not found: ${assetManifestPath}`); + const { manifest: assetManifest, bundles } = loadAssetManifest(assetManifestPath); + + const payloadDir = join(releaseDir, "offline-jdbc", "jdbc"); + const mavenDir = join(payloadDir, "maven"); + rmSync(payloadDir, { recursive: true, force: true }); + mkdirSync(mavenDir, { recursive: true }); + copyFileSync(pluginZip, join(payloadDir, "plugin.zip")); + + const externalCache = process.env.DBX_OFFLINE_JDBC_MAVEN_CACHE?.trim(); + const resolverRepository = process.env.DBX_OFFLINE_JDBC_MAVEN_REPOSITORY?.trim() || assetManifest.repository; + const workDir = externalCache ? null : mkdtempSync(join(tmpdir(), "dbx-offline-jdbc-")); + const localRepository = externalCache ? resolve(externalCache) : join(workDir, "maven-cache"); + mkdirSync(localRepository, { recursive: true }); + const manifestBundles = []; + const notices = []; + try { + for (const bundle of bundles) { + console.log(`Resolving offline JDBC bundle ${bundle.coordinate}`); + const resolved = resolveBundle(resolver, bundle.coordinate, localRepository, [resolverRepository]); + const artifact = await validateAndCopyBundle(bundle, resolved, mavenDir); + manifestBundles.push({ id: bundle.id, coordinate: bundle.coordinate }); + notices.push({ id: bundle.id, coordinate: bundle.coordinate, artifact, redistribution: bundle.redistribution }); + console.log(`Prepared ${bundle.coordinate} (approved SHA-256 ${artifact.sha256})`); + } + } finally { + if (workDir) rmSync(workDir, { recursive: true, force: true }); + } + + writeFileSync( + join(payloadDir, "third-party-notices.json"), + `${JSON.stringify({ format_version: 1, bundles: notices }, null, 2)}\n`, + ); + writeFileSync( + join(payloadDir, "offline-manifest.json"), + `${JSON.stringify({ format_version: 1, plugin_entry: "jdbc/plugin.zip", third_party_entry: "jdbc/third-party-notices.json", bundles: manifestBundles }, null, 2)}\n`, + ); + console.log(`Prepared offline JDBC payload in ${payloadDir}`); +} + +main().catch((error) => { + console.error(`ERROR: ${error?.stack || error?.message || String(error)}`); + process.exitCode = 1; +}); diff --git a/agents/scripts/build_offline_zip.sh b/agents/scripts/build_offline_zip.sh index 919240c3f0..7b099de1ba 100755 --- a/agents/scripts/build_offline_zip.sh +++ b/agents/scripts/build_offline_zip.sh @@ -3,7 +3,8 @@ set -euo pipefail # Build offline ZIP bundles for each platform. # Usage: ./scripts/build_offline_zip.sh -# The release-dir must contain agent-registry.json, raw driver artifacts, and dbx-jre-*.tar.zst +# The release-dir must contain agent-registry.json, raw driver artifacts, and dbx-jre-*.tar.zst. +# If offline-jdbc/jdbc is present, it is included as the managed JDBC payload. RELEASE_DIR="$(cd "${1:?Usage: build_offline_zip.sh }" && pwd)" @@ -30,6 +31,10 @@ for platform in "${PLATFORMS[@]}"; do cp "$RELEASE_DIR/agent-registry.json" "$WORK/" + if [ -d "$RELEASE_DIR/offline-jdbc/jdbc" ]; then + cp -R "$RELEASE_DIR/offline-jdbc/jdbc" "$WORK/" + fi + # Copy all JRE archives for this platform JRE_COUNT=0 for jre_file in "$RELEASE_DIR"/dbx-jre-*-"$platform".tar.zst; do @@ -56,7 +61,9 @@ for platform in "${PLATFORMS[@]}"; do done ZIP_NAME="dbx-agents-offline-${platform}.zip" - (cd "$WORK" && zip -r "$RELEASE_DIR/$ZIP_NAME" agent-registry.json jre/ drivers/) + ZIP_ENTRIES=(agent-registry.json jre/ drivers/) + [ -d "$WORK/jdbc" ] && ZIP_ENTRIES+=(jdbc/) + (cd "$WORK" && zip -r "$RELEASE_DIR/$ZIP_NAME" "${ZIP_ENTRIES[@]}") SIZE=$(du -h "$RELEASE_DIR/$ZIP_NAME" | cut -f1) echo "Created $ZIP_NAME ($SIZE)" done diff --git a/agents/scripts/verify_offline_jdbc_release.mjs b/agents/scripts/verify_offline_jdbc_release.mjs new file mode 100644 index 0000000000..3ecd943b4c --- /dev/null +++ b/agents/scripts/verify_offline_jdbc_release.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createReadStream, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const DEFAULT_ASSET_MANIFEST = join(REPO_ROOT, "apps/desktop/src/lib/database/managedJdbcAssets.json"); +const PLATFORMS = ["macos-aarch64", "macos-x64", "linux-x64", "linux-aarch64", "windows-x64", "windows-aarch64"]; + +function fail(message) { + throw new Error(message); +} + +async function sha256(path) { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +} + +function filesBelow(root) { + const files = []; + function visit(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) visit(path); + else if (entry.isFile()) files.push(relative(root, path).replaceAll("\\", "/")); + } + } + visit(root); + return files.sort(); +} + +function flattenBundles(manifest) { + return Object.values(manifest.drivers).flatMap((driver) => Object.values(driver.bundles || {})); +} + +async function validatePayload(payloadDir, assetManifestPath) { + const assets = JSON.parse(readFileSync(assetManifestPath, "utf8")); + const approved = flattenBundles(assets); + const offline = JSON.parse(readFileSync(join(payloadDir, "offline-manifest.json"), "utf8")); + const notices = JSON.parse(readFileSync(join(payloadDir, "third-party-notices.json"), "utf8")); + if (offline.third_party_entry !== "jdbc/third-party-notices.json") fail("Offline JDBC third-party notice entry is missing"); + if (JSON.stringify(offline.bundles) !== JSON.stringify(approved.map(({ id, coordinate }) => ({ id, coordinate })))) { + fail("Offline JDBC bundle list differs from the managed JDBC asset manifest"); + } + if (notices.bundles.length !== approved.length) fail("Offline JDBC third-party notice list is incomplete"); + + for (const bundle of approved) { + const manifest = JSON.parse(readFileSync(join(payloadDir, "maven", bundle.id, "manifest.json"), "utf8")); + if (manifest.coordinate !== bundle.coordinate || manifest.artifacts.length !== 1) fail(`Invalid offline JDBC bundle: ${bundle.coordinate}`); + const artifact = manifest.artifacts[0]; + const jar = join(payloadDir, "maven", bundle.id, "jars", artifact.file_name); + if (artifact.size !== statSync(jar).size || artifact.sha256 !== (await sha256(jar))) fail(`Invalid bundled JDBC artifact: ${bundle.coordinate}`); + if (artifact.sha256 !== bundle.artifact.sha256 || artifact.size !== bundle.artifact.size) fail(`Unapproved bundled JDBC artifact: ${bundle.coordinate}`); + const notice = notices.bundles.find((candidate) => candidate.id === bundle.id); + if (!notice || JSON.stringify(notice.redistribution) !== JSON.stringify(bundle.redistribution)) fail(`Missing redistribution metadata: ${bundle.coordinate}`); + } +} + +async function main() { + const [releaseArg, assetManifestArg] = process.argv.slice(2); + if (!releaseArg) fail("Usage: verify_offline_jdbc_release.mjs [asset-manifest]"); + const releaseDir = resolve(releaseArg); + const payloadDir = join(releaseDir, "offline-jdbc", "jdbc"); + const assetManifestPath = resolve(assetManifestArg || DEFAULT_ASSET_MANIFEST); + if (!existsSync(payloadDir)) fail(`Offline JDBC payload not found: ${payloadDir}`); + await validatePayload(payloadDir, assetManifestPath); + + const expectedFiles = filesBelow(payloadDir); + const expectedHashes = new Map(); + for (const file of expectedFiles) expectedHashes.set(file, await sha256(join(payloadDir, file))); + + for (const platform of PLATFORMS) { + const zip = join(releaseDir, `dbx-agents-offline-${platform}.zip`); + if (!existsSync(zip)) fail(`Offline ZIP not found: ${zip}`); + const extractionRoot = mkdtempSync(join(tmpdir(), `dbx-offline-verify-${platform}-`)); + try { + const result = spawnSync("unzip", ["-q", zip, "-d", extractionRoot], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }); + if (result.error) fail(`Failed to unpack ${zip}: ${result.error.message}`); + if (result.status !== 0) fail(`Failed to unpack ${zip}: ${(result.stderr || result.stdout).trim()}`); + const extractedPayload = join(extractionRoot, "jdbc"); + const actualFiles = filesBelow(extractedPayload); + if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) fail(`Incomplete jdbc/ directory in ${zip}`); + for (const file of actualFiles) { + if ((await sha256(join(extractedPayload, file))) !== expectedHashes.get(file)) fail(`JDBC payload checksum mismatch in ${zip}: ${file}`); + } + } finally { + rmSync(extractionRoot, { recursive: true, force: true }); + } + console.log(`Verified complete jdbc/ payload in dbx-agents-offline-${platform}.zip`); + } +} + +main().catch((error) => { + console.error(`ERROR: ${error?.stack || error?.message || String(error)}`); + process.exitCode = 1; +}); diff --git a/apps/desktop/src/components/config/DriverStoreDialog.vue b/apps/desktop/src/components/config/DriverStoreDialog.vue index ef0241b8db..7811aa4ef0 100644 --- a/apps/desktop/src/components/config/DriverStoreDialog.vue +++ b/apps/desktop/src/components/config/DriverStoreDialog.vue @@ -628,7 +628,7 @@ async function importOfflineZip() { resetAgentInstallProgress(); try { const count = await api.importAgentsFromZip(selected, activeAgentOperationId.value); - await refreshAgents(); + await Promise.all([refreshAgents(), loadJdbcDrivers(), loadJdbcPluginStatus()]); toast(t("driverStore.offlineImportSuccess", { count })); } catch (e: any) { toast(t("driverStore.offlineImportFailed", { error: backendError(e) })); @@ -660,7 +660,7 @@ async function importDriverFile(driver: AgentDriverInfo) { resetAgentInstallProgress(); try { const count = await api.importAgentsFromZip(selected, activeAgentOperationId.value); - await refreshAgents(); + await Promise.all([refreshAgents(), loadJdbcDrivers(), loadJdbcPluginStatus()]); toast(t("driverStore.offlineImportSuccess", { count })); } finally { activeAgentOperationId.value = null; diff --git a/apps/desktop/src/lib/__tests__/database/managedJdbcDrivers.spec.ts b/apps/desktop/src/lib/__tests__/database/managedJdbcDrivers.spec.ts index 3df05e1fef..49ccc415a1 100644 --- a/apps/desktop/src/lib/__tests__/database/managedJdbcDrivers.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/managedJdbcDrivers.spec.ts @@ -24,6 +24,26 @@ describe("managedJdbcDrivers", () => { expect(managedJdbcDriverRows([], pluginStatus()).map(({ db_type }) => db_type)).toEqual(["prestosql", "phoenix"]); }); + it("marks both managed drivers installed when the offline payload contains every bundle", () => { + const coordinates = [PRESTOSQL_JDBC_DRIVER_COORDINATE, ...PHOENIX_MANAGED_MAVEN_COORDINATES]; + const bundles = coordinates.map( + (coordinate): JdbcMavenBundleInfo => ({ + id: coordinate.replace(/[^A-Za-z0-9.-]/g, "_"), + coordinate, + scope: "runtime", + repositories: ["https://repo.maven.apache.org/maven2/"], + installed_at: "2026-08-14T00:00:00Z", + path: `/plugins/jdbc/drivers/maven/${coordinate}`, + artifacts: [], + }), + ); + + expect(managedJdbcDriverRows(bundles, pluginStatus()).map(({ db_type, installed }) => ({ db_type, installed }))).toEqual([ + { db_type: "prestosql", installed: true }, + { db_type: "phoenix", installed: true }, + ]); + }); + it("installs PrestoSQL through the generic Maven resolver", async () => { const installed: JdbcMavenBundleInfo = { id: "io.prestosql_presto-jdbc_350", diff --git a/apps/desktop/src/lib/database/managedJdbcAssets.json b/apps/desktop/src/lib/database/managedJdbcAssets.json new file mode 100644 index 0000000000..38ab29bf2a --- /dev/null +++ b/apps/desktop/src/lib/database/managedJdbcAssets.json @@ -0,0 +1,98 @@ +{ + "format_version": 1, + "repository": "https://repo.maven.apache.org/maven2/", + "drivers": { + "prestosql": { + "bundles": { + "driver": { + "id": "io.prestosql_presto-jdbc_350", + "coordinate": "io.prestosql:presto-jdbc:350", + "artifact": { + "group_id": "io.prestosql", + "artifact_id": "presto-jdbc", + "version": "350", + "classifier": "", + "extension": "jar", + "file_name": "presto-jdbc-350.jar", + "size": 6656599, + "sha256": "664a23e517c120c1d5a9bb88b215612b117415b9f1649bc22c48298eaf3f979b" + }, + "redistribution": { + "project_url": "https://prestosql.io/", + "source_url": "https://github.com/prestosql/presto/tree/350", + "root_license_expression": "Apache-2.0", + "license_entries": ["META-INF/LICENSE", "META-INF/LICENSE.txt"], + "notice_entries": ["META-INF/NOTICE", "META-INF/NOTICE.txt"] + } + } + } + }, + "phoenix": { + "bundles": { + "direct": { + "id": "org.apache.phoenix_phoenix-client-embedded-hbase-2.5_5.2.1", + "coordinate": "org.apache.phoenix:phoenix-client-embedded-hbase-2.5:5.2.1", + "artifact": { + "group_id": "org.apache.phoenix", + "artifact_id": "phoenix-client-embedded-hbase-2.5", + "version": "5.2.1", + "classifier": "", + "extension": "jar", + "file_name": "phoenix-client-embedded-hbase-2.5-5.2.1.jar", + "size": 158572775, + "sha256": "c698a81f4250dbfe0af4b656b6de9c72bd3fee26e7945ccb893f741fbe05c4ca" + }, + "redistribution": { + "project_url": "https://phoenix.apache.org/", + "source_url": "https://github.com/apache/phoenix/tree/5.2.1", + "root_license_expression": "Apache-2.0", + "license_entries": ["META-INF/LICENSE", "META-INF/LICENSE.txt", "LICENSE", "META-INF/licenses-binary/LICENSE.protobuf.txt"], + "notice_entries": ["META-INF/DEPENDENCIES", "META-INF/NOTICE.txt", "META-INF/NOTICE-binary", "META-INF/NOTICE.md", "META-INF/NOTICE.markdown"] + } + }, + "logging": { + "id": "ch.qos.reload4j_reload4j_1.2.26", + "coordinate": "ch.qos.reload4j:reload4j:1.2.26", + "artifact": { + "group_id": "ch.qos.reload4j", + "artifact_id": "reload4j", + "version": "1.2.26", + "classifier": "", + "extension": "jar", + "file_name": "reload4j-1.2.26.jar", + "size": 338688, + "sha256": "a7e86ae144541d04d24d1b7123d7425415477d50f387bb868e5c955aeaedd96f" + }, + "redistribution": { + "project_url": "https://reload4j.qos.ch/", + "source_url": "https://github.com/qos-ch/reload4j/tree/v_1.2.26", + "root_license_expression": "Apache-2.0", + "license_entries": ["META-INF/LICENSE"], + "notice_entries": ["META-INF/NOTICE"] + } + }, + "query_server": { + "id": "org.apache.phoenix_phoenix-queryserver-client_6.0.0", + "coordinate": "org.apache.phoenix:phoenix-queryserver-client:6.0.0", + "artifact": { + "group_id": "org.apache.phoenix", + "artifact_id": "phoenix-queryserver-client", + "version": "6.0.0", + "classifier": "", + "extension": "jar", + "file_name": "phoenix-queryserver-client-6.0.0.jar", + "size": 6294972, + "sha256": "94e634ded958525f1dee090999c77ab1d0e7e022ea2767b0c73416cc67c8ebcf" + }, + "redistribution": { + "project_url": "https://phoenix.apache.org/", + "source_url": "https://github.com/apache/phoenix-queryserver/tree/6.0.0", + "root_license_expression": "Apache-2.0", + "license_entries": ["META-INF/LICENSE"], + "notice_entries": ["META-INF/DEPENDENCIES", "META-INF/NOTICE"] + } + } + } + } + } +} diff --git a/apps/desktop/src/lib/database/phoenixConnection.ts b/apps/desktop/src/lib/database/phoenixConnection.ts index b094ab7b93..2a078d4f42 100644 --- a/apps/desktop/src/lib/database/phoenixConnection.ts +++ b/apps/desktop/src/lib/database/phoenixConnection.ts @@ -1,15 +1,17 @@ import type { ConnectionConfig } from "@/types/database"; import type { JdbcProductProfileDefinition } from "@/lib/database/jdbcProductProfile"; +import managedJdbcAssets from "@/lib/database/managedJdbcAssets.json"; export const PHOENIX_DRIVER_PROFILE = "phoenix"; export const PHOENIX_DIRECT_JDBC_URL = "jdbc:phoenix:localhost"; export const PHOENIX_DIRECT_JDBC_DRIVER_CLASS = "org.apache.phoenix.jdbc.PhoenixDriver"; export const PHOENIX_QUERY_SERVER_JDBC_URL = "jdbc:phoenix:thin:url=http://127.0.0.1:8765;serialization=PROTOBUF"; export const PHOENIX_QUERY_SERVER_JDBC_DRIVER_CLASS = "org.apache.phoenix.queryserver.client.Driver"; -export const PHOENIX_MAVEN_REPOSITORY = "https://repo.maven.apache.org/maven2/"; -export const PHOENIX_DIRECT_MAVEN_COORDINATE = "org.apache.phoenix:phoenix-client-embedded-hbase-2.5:5.2.1"; -export const PHOENIX_QUERY_SERVER_MAVEN_COORDINATE = "org.apache.phoenix:phoenix-queryserver-client:6.0.0"; -export const PHOENIX_DIRECT_LOGGING_MAVEN_COORDINATE = "ch.qos.reload4j:reload4j:1.2.26"; +const PHOENIX_JDBC_ASSETS = managedJdbcAssets.drivers.phoenix.bundles; +export const PHOENIX_MAVEN_REPOSITORY = managedJdbcAssets.repository; +export const PHOENIX_DIRECT_MAVEN_COORDINATE = PHOENIX_JDBC_ASSETS.direct.coordinate; +export const PHOENIX_QUERY_SERVER_MAVEN_COORDINATE = PHOENIX_JDBC_ASSETS.query_server.coordinate; +export const PHOENIX_DIRECT_LOGGING_MAVEN_COORDINATE = PHOENIX_JDBC_ASSETS.logging.coordinate; export const PHOENIX_DRIVER_NOT_INSTALLED_ERROR = "Apache Phoenix JDBC driver is not installed. Install it from the Driver Manager, then retry."; export const PHOENIX_JDBC_PLUGIN_NOT_INSTALLED_ERROR = "DBX JDBC plugin is not installed. Install Apache Phoenix JDBC from the Driver Manager, then retry."; diff --git a/apps/desktop/src/lib/database/prestoSqlBuiltinDriver.ts b/apps/desktop/src/lib/database/prestoSqlBuiltinDriver.ts index 90a69ba944..1160b52a72 100644 --- a/apps/desktop/src/lib/database/prestoSqlBuiltinDriver.ts +++ b/apps/desktop/src/lib/database/prestoSqlBuiltinDriver.ts @@ -1,10 +1,12 @@ import type { JdbcMavenBundleInfo } from "@/types/database"; import { managedJdbcDriverPaths, type ManagedJdbcDriverDefinition } from "@/lib/database/managedJdbcDriver"; +import managedJdbcAssets from "@/lib/database/managedJdbcAssets.json"; export const PRESTOSQL_DRIVER_DB_TYPE = "prestosql"; -export const PRESTOSQL_JDBC_DRIVER_VERSION = "350"; -export const PRESTOSQL_JDBC_DRIVER_COORDINATE = `io.prestosql:presto-jdbc:${PRESTOSQL_JDBC_DRIVER_VERSION}`; -export const PRESTOSQL_JDBC_DRIVER_REPOSITORY = "https://repo.maven.apache.org/maven2/"; +const PRESTOSQL_JDBC_ASSET = managedJdbcAssets.drivers.prestosql.bundles.driver; +export const PRESTOSQL_JDBC_DRIVER_VERSION = PRESTOSQL_JDBC_ASSET.artifact.version; +export const PRESTOSQL_JDBC_DRIVER_COORDINATE = PRESTOSQL_JDBC_ASSET.coordinate; +export const PRESTOSQL_JDBC_DRIVER_REPOSITORY = managedJdbcAssets.repository; export const PRESTOSQL_MANAGED_JDBC_DRIVER: ManagedJdbcDriverDefinition = { id: PRESTOSQL_DRIVER_DB_TYPE, label: "PrestoSQL", diff --git a/crates/dbx-core/src/jdbc.rs b/crates/dbx-core/src/jdbc.rs index f2d21b4035..f4c27c885c 100644 --- a/crates/dbx-core/src/jdbc.rs +++ b/crates/dbx-core/src/jdbc.rs @@ -4,11 +4,15 @@ use crate::plugins::{PluginManifest, SUPPORTED_PLUGIN_PROTOCOL_VERSION}; use crate::update::{fetch_latest_release, is_newer_version, JdbcPluginLatest}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::io::Read; use std::path::{Path, PathBuf}; const JDBC_PLUGIN_DOWNLOAD_URL: &str = "https://github.com/t8y2/dbx/releases/latest/download/dbx-jdbc-plugin-latest.zip"; const JDBC_PLUGIN_R2_PATH: &str = "releases/latest/dbx-jdbc-plugin-latest.zip"; +const OFFLINE_JDBC_MANIFEST_ENTRY: &str = "jdbc/offline-manifest.json"; +const OFFLINE_JDBC_FORMAT_VERSION: u32 = 1; +const OFFLINE_JDBC_PLUGIN_ENTRY: &str = "jdbc/plugin.zip"; pub const PRESTOSQL_JDBC_DRIVER_VERSION: &str = "350"; pub const PRESTOSQL_JDBC_DRIVER_COORDINATE: &str = "io.prestosql:presto-jdbc:350"; pub const PRESTOSQL_JDBC_DRIVER_REPOSITORY: &str = "https://repo.maven.apache.org/maven2/"; @@ -83,6 +87,25 @@ pub struct JdbcPluginStatus { pub path: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OfflineJdbcImportResult { + pub plugin_installed: bool, + pub bundles_installed: Vec, +} + +#[derive(Debug, Deserialize)] +struct OfflineJdbcManifest { + format_version: u32, + plugin_entry: String, + bundles: Vec, +} + +#[derive(Debug, Deserialize)] +struct OfflineJdbcBundle { + id: String, + coordinate: String, +} + // ---- JDBC Drivers ---- pub fn list_jdbc_drivers(plugins_root: &Path) -> Result, String> { @@ -255,6 +278,66 @@ pub async fn install_prestosql_jdbc_driver(plugins_root: &Path) -> Result Result, String> { + if !zip_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.to_ascii_lowercase().ends_with(".zip")) + { + return Ok(None); + } + let file = std::fs::File::open(zip_path).map_err(|err| format!("Failed to open offline package: {err}"))?; + let mut archive = zip::ZipArchive::new(file).map_err(|err| format!("Invalid offline package ZIP: {err}"))?; + let manifest_raw = match read_zip_entry(&mut archive, OFFLINE_JDBC_MANIFEST_ENTRY, 1024 * 1024) { + Ok(raw) => raw, + Err(error) if error == format!("Offline package entry not found: {OFFLINE_JDBC_MANIFEST_ENTRY}") => { + return Ok(None); + } + Err(error) => return Err(error), + }; + let manifest: OfflineJdbcManifest = + serde_json::from_slice(&manifest_raw).map_err(|err| format!("Failed to parse offline JDBC manifest: {err}"))?; + validate_offline_jdbc_manifest(&manifest)?; + + let plugin_bytes = read_zip_entry(&mut archive, &manifest.plugin_entry, 512 * 1024 * 1024)?; + let maven_dir = jdbc_maven_drivers_dir(plugins_root); + std::fs::create_dir_all(&maven_dir).map_err(|err| err.to_string())?; + let staging_root = maven_dir.join(format!(".offline-import-{}", uuid::Uuid::new_v4())); + + let import_result = (|| { + std::fs::create_dir_all(&staging_root).map_err(|err| err.to_string())?; + let mut staged_bundles = Vec::with_capacity(manifest.bundles.len()); + for bundle in &manifest.bundles { + let staged = stage_offline_jdbc_bundle(&mut archive, bundle, &staging_root, &maven_dir)?; + staged_bundles.push((bundle.id.clone(), staged)); + } + + install_jdbc_plugin_zip(&plugin_bytes, &plugins_root.join("jdbc"))?; + + for (bundle_id, staged_dir) in &staged_bundles { + let target = maven_dir.join(bundle_id); + if target.exists() { + std::fs::remove_dir_all(&target).map_err(|err| err.to_string())?; + } + std::fs::rename(staged_dir, &target) + .map_err(|err| format!("Failed to install offline JDBC bundle {bundle_id}: {err}"))?; + } + + Ok(Some(OfflineJdbcImportResult { + plugin_installed: true, + bundles_installed: manifest.bundles.iter().map(|bundle| bundle.coordinate.clone()).collect(), + })) + })(); + + let _ = std::fs::remove_dir_all(&staging_root); + import_result +} + // ---- JDBC Plugin ---- pub async fn get_jdbc_plugin_status(plugins_root: &Path) -> Result { @@ -540,6 +623,123 @@ fn copy_dir_all(source: &Path, target: &Path) -> Result<(), String> { Ok(()) } +fn read_zip_entry( + archive: &mut zip::ZipArchive, + entry_name: &str, + max_size: u64, +) -> Result, String> { + let mut entry = archive.by_name(entry_name).map_err(|err| match err { + zip::result::ZipError::FileNotFound => format!("Offline package entry not found: {entry_name}"), + _ => format!("Failed to read offline package entry {entry_name}: {err}"), + })?; + if entry.size() > max_size { + return Err(format!("Offline package entry is too large: {entry_name}")); + } + let mut bytes = Vec::with_capacity(entry.size().try_into().unwrap_or(0)); + entry.read_to_end(&mut bytes).map_err(|err| format!("Failed to read offline package entry {entry_name}: {err}"))?; + Ok(bytes) +} + +fn validate_offline_jdbc_manifest(manifest: &OfflineJdbcManifest) -> Result<(), String> { + if manifest.format_version != OFFLINE_JDBC_FORMAT_VERSION { + return Err(format!("Unsupported offline JDBC format version: {}", manifest.format_version)); + } + if manifest.plugin_entry != OFFLINE_JDBC_PLUGIN_ENTRY { + return Err(format!("Unexpected offline JDBC plugin entry: {}", manifest.plugin_entry)); + } + if manifest.bundles.is_empty() { + return Err("Offline JDBC payload contains no Maven bundles".to_string()); + } + let mut ids = std::collections::HashSet::new(); + let mut coordinates = std::collections::HashSet::new(); + for bundle in &manifest.bundles { + if !is_safe_bundle_id(&bundle.id) { + return Err(format!("Invalid offline JDBC bundle id: {}", bundle.id)); + } + if bundle.id != maven_bundle_id(&bundle.coordinate) { + return Err(format!("Offline JDBC bundle id does not match coordinate: {}", bundle.coordinate)); + } + if !ids.insert(bundle.id.as_str()) { + return Err(format!("Duplicate offline JDBC bundle id: {}", bundle.id)); + } + if !coordinates.insert(bundle.coordinate.as_str()) { + return Err(format!("Duplicate offline JDBC coordinate: {}", bundle.coordinate)); + } + } + Ok(()) +} + +fn stage_offline_jdbc_bundle( + archive: &mut zip::ZipArchive, + expected: &OfflineJdbcBundle, + staging_root: &Path, + final_maven_dir: &Path, +) -> Result { + let prefix = format!("jdbc/maven/{}/", expected.id); + let manifest_entry = format!("{prefix}manifest.json"); + let manifest_raw = read_zip_entry(archive, &manifest_entry, 4 * 1024 * 1024)?; + let portable: JdbcMavenBundleInfo = serde_json::from_slice(&manifest_raw) + .map_err(|err| format!("Failed to parse offline JDBC bundle manifest {manifest_entry}: {err}"))?; + if portable.id != expected.id || portable.coordinate != expected.coordinate { + return Err(format!("Offline JDBC bundle manifest does not match {}", expected.coordinate)); + } + if portable.artifacts.is_empty() { + return Err(format!("Offline JDBC bundle contains no JAR artifacts: {}", expected.coordinate)); + } + + let staged_dir = staging_root.join(&expected.id); + let jars_dir = staged_dir.join("jars"); + std::fs::create_dir_all(&jars_dir).map_err(|err| err.to_string())?; + let final_bundle_dir = final_maven_dir.join(&expected.id); + let mut artifacts = Vec::with_capacity(portable.artifacts.len()); + let mut file_names = std::collections::HashSet::new(); + + for mut artifact in portable.artifacts { + let file_name_path = Path::new(&artifact.file_name); + if artifact.extension != "jar" + || file_name_path.file_name().and_then(|name| name.to_str()) != Some(artifact.file_name.as_str()) + || !file_names.insert(artifact.file_name.clone()) + { + return Err(format!("Invalid offline JDBC artifact name: {}", artifact.file_name)); + } + let entry_name = format!("{prefix}jars/{}", artifact.file_name); + let mut entry = archive.by_name(&entry_name).map_err(|err| match err { + zip::result::ZipError::FileNotFound => format!("Offline package entry not found: {entry_name}"), + _ => format!("Failed to read offline package entry {entry_name}: {err}"), + })?; + if entry.size() != artifact.size { + return Err(format!("Offline JDBC artifact size mismatch: {entry_name}")); + } + let target = jars_dir.join(&artifact.file_name); + let mut output = std::fs::File::create(&target).map_err(|err| err.to_string())?; + std::io::copy(&mut entry, &mut output) + .map_err(|err| format!("Failed to extract offline JDBC artifact {entry_name}: {err}"))?; + drop(output); + let sha256 = file_sha256(&target)?; + if !sha256.eq_ignore_ascii_case(&artifact.sha256) { + return Err(format!("Offline JDBC artifact checksum mismatch: {entry_name}")); + } + artifact.path = final_bundle_dir.join("jars").join(&artifact.file_name).to_string_lossy().to_string(); + artifacts.push(artifact); + } + + let installed = JdbcMavenBundleInfo { + id: portable.id, + coordinate: portable.coordinate, + scope: portable.scope, + repositories: portable.repositories, + installed_at: chrono::Utc::now().to_rfc3339(), + path: final_bundle_dir.to_string_lossy().to_string(), + artifacts, + }; + std::fs::write( + staged_dir.join("manifest.json"), + serde_json::to_string_pretty(&installed).map_err(|err| err.to_string())?, + ) + .map_err(|err| err.to_string())?; + Ok(staged_dir) +} + fn list_jdbc_drivers_from_dir(drivers_dir: &Path) -> Result, String> { let entries = match std::fs::read_dir(drivers_dir) { Ok(entries) => entries, @@ -858,8 +1058,17 @@ fn is_safe_bundle_id(bundle_id: &str) -> bool { } fn file_sha256(path: &Path) -> Result { - let bytes = std::fs::read(path).map_err(|err| err.to_string())?; - let hash = Sha256::digest(bytes); + let mut file = std::fs::File::open(path).map_err(|err| err.to_string())?; + let mut hash = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer).map_err(|err| err.to_string())?; + if read == 0 { + break; + } + hash.update(&buffer[..read]); + } + let hash = hash.finalize(); Ok(format!("{hash:x}")) } @@ -911,6 +1120,71 @@ fn paths_refer_to_same_file(left: &Path, right: &Path) -> bool { mod tests { use super::*; use crate::plugins::PluginRuntimeEnv; + use std::io::Write; + + fn test_jdbc_plugin_zip() -> Vec { + let cursor = std::io::Cursor::new(Vec::new()); + let mut zip = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + zip.start_file("dbx-jdbc-plugin-test/manifest.json", options).unwrap(); + zip.write_all( + br#"{ + "id": "jdbc", + "name": "DBX JDBC Plugin", + "version": "0.1.30", + "protocol_version": 1, + "executable": "bin/dbx-jdbc-plugin", + "drivers": [{"id":"jdbc","label":"JDBC","kind":"external","database_type":"jdbc"}] + }"#, + ) + .unwrap(); + zip.start_file("dbx-jdbc-plugin-test/bin/dbx-jdbc-plugin", options).unwrap(); + zip.write_all(b"#!/bin/sh\n").unwrap(); + zip.finish().unwrap().into_inner() + } + + fn write_offline_jdbc_package(path: &Path, artifact_sha256: &str) { + let coordinate = "com.example:demo-driver:1.0.0"; + let bundle_id = maven_bundle_id(coordinate); + let artifact = b"offline-driver"; + let bundle = JdbcMavenBundleInfo { + id: bundle_id.clone(), + coordinate: coordinate.to_string(), + scope: "runtime".to_string(), + repositories: vec!["https://repo.maven.apache.org/maven2/".to_string()], + installed_at: String::new(), + path: String::new(), + artifacts: vec![JdbcMavenArtifactInfo { + group_id: "com.example".to_string(), + artifact_id: "demo-driver".to_string(), + version: "1.0.0".to_string(), + classifier: String::new(), + extension: "jar".to_string(), + file_name: "demo-driver-1.0.0.jar".to_string(), + path: String::new(), + size: artifact.len() as u64, + sha256: artifact_sha256.to_string(), + }], + }; + let manifest = serde_json::json!({ + "format_version": 1, + "plugin_entry": "jdbc/plugin.zip", + "bundles": [{"id": bundle_id, "coordinate": coordinate}], + }); + + let file = std::fs::File::create(path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + zip.start_file(OFFLINE_JDBC_MANIFEST_ENTRY, options).unwrap(); + zip.write_all(serde_json::to_string(&manifest).unwrap().as_bytes()).unwrap(); + zip.start_file(OFFLINE_JDBC_PLUGIN_ENTRY, options).unwrap(); + zip.write_all(&test_jdbc_plugin_zip()).unwrap(); + zip.start_file(format!("jdbc/maven/{}/manifest.json", bundle.id), options).unwrap(); + zip.write_all(serde_json::to_string(&bundle).unwrap().as_bytes()).unwrap(); + zip.start_file(format!("jdbc/maven/{}/jars/demo-driver-1.0.0.jar", bundle.id), options).unwrap(); + zip.write_all(artifact).unwrap(); + zip.finish().unwrap(); + } #[test] fn maven_bundle_install_lists_nested_jars() { @@ -957,6 +1231,51 @@ mod tests { assert!(!is_safe_bundle_id("../hive")); } + #[test] + fn imports_offline_jdbc_plugin_and_rewrites_bundle_paths() { + let root = std::env::temp_dir().join(format!("dbx-offline-jdbc-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let package = root.join("offline.zip"); + let artifact_sha256 = format!("{:x}", Sha256::digest(b"offline-driver")); + write_offline_jdbc_package(&package, &artifact_sha256); + + let plugins_root = root.join("plugins"); + let result = import_offline_jdbc_payload(&plugins_root, &package).unwrap().unwrap(); + + assert!(result.plugin_installed); + assert_eq!(result.bundles_installed, vec!["com.example:demo-driver:1.0.0"]); + assert!(plugins_root.join("jdbc/manifest.json").exists()); + let bundles = list_jdbc_maven_bundles(&plugins_root).unwrap(); + assert_eq!(bundles.len(), 1); + assert_eq!( + bundles[0].path, + plugins_root.join("jdbc/drivers/maven/com.example_demo-driver_1.0.0").to_string_lossy() + ); + assert_eq!( + bundles[0].artifacts[0].path, + plugins_root + .join("jdbc/drivers/maven/com.example_demo-driver_1.0.0/jars/demo-driver-1.0.0.jar") + .to_string_lossy() + ); + assert_eq!(std::fs::read(&bundles[0].artifacts[0].path).unwrap(), b"offline-driver"); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn rejects_corrupt_offline_jdbc_artifact_before_installing_plugin() { + let root = std::env::temp_dir().join(format!("dbx-offline-jdbc-corrupt-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let package = root.join("offline.zip"); + write_offline_jdbc_package(&package, &"0".repeat(64)); + + let plugins_root = root.join("plugins"); + let error = import_offline_jdbc_payload(&plugins_root, &package).unwrap_err(); + + assert!(error.contains("checksum mismatch")); + assert!(!plugins_root.join("jdbc/manifest.json").exists()); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn multiple_local_jars_are_imported_as_one_bundle() { let root = std::env::temp_dir().join(format!("dbx-jdbc-local-bundle-test-{}", uuid::Uuid::new_v4())); diff --git a/crates/dbx-web/src/routes/agents.rs b/crates/dbx-web/src/routes/agents.rs index e50cda5559..1a833cc2a8 100644 --- a/crates/dbx-web/src/routes/agents.rs +++ b/crates/dbx-web/src/routes/agents.rs @@ -236,11 +236,14 @@ pub async fn import_agents_from_zip( let plan = inspect_offline_package(&package_path).map_err(AppError::from)?; ensure_no_offline_import_blockers(&state.app, &plan).await.map_err(AppError::from)?; - import_agents_from_package_core(&state.app.agent_manager, &package_path, |event| { + let import_result = import_agents_from_package_core(&state.app.agent_manager, &package_path, |event| { send_progress_event(&tx, event.with_operation_id(&operation_id)) }) .await - .map_err(AppError::from) + .map_err(AppError::from)?; + dbx_core::jdbc::import_offline_jdbc_payload(state.app.plugins.root_dir(), &package_path) + .map_err(AppError::from)?; + Ok::<_, AppError>(import_result) } .await; let _ = std::fs::remove_file(&package_path); diff --git a/src-tauri/src/commands/agents.rs b/src-tauri/src/commands/agents.rs index 1ec64e9645..b3e774c4fe 100644 --- a/src-tauri/src/commands/agents.rs +++ b/src-tauri/src/commands/agents.rs @@ -176,6 +176,7 @@ pub async fn import_agents_from_zip( emit_agent_progress(&app_handle, &operation_id, event) }) .await?; + dbx_core::jdbc::import_offline_jdbc_payload(state.plugins.root_dir(), &package_path)?; let count = result.drivers_installed.len() as u32; emit_agent_progress(&app, &operation_id, AgentProgressEvent::step("done")); Ok(count)