diff --git a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue index cde4f400cf..cc594c1495 100644 --- a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue +++ b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue @@ -24,6 +24,7 @@ import { useQueryStore } from "@/stores/queryStore"; import type { InstalledPlugin, PluginInstallResult, PluginRepository, PluginRepositoryCatalogResult, PluginTrustedKey } from "@/types/database"; import { useI18n } from "vue-i18n"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; +import { translateBackendError } from "@/i18n/backend-errors"; const props = defineProps<{ focusTarget?: PluginCenterFocus | null; @@ -389,7 +390,7 @@ async function installPlugin(source: string | File) { const result = await api.installPluginPackage(source, allowUnsigned.value); await finishInstall(result); } catch (cause) { - toast(cause instanceof Error ? cause.message : String(cause), 8000); + toast(translateBackendError(t, cause), 8000); } finally { installing.value = false; } @@ -422,7 +423,7 @@ async function installPluginFromUrl() { installUrl.value = ""; await finishInstall(result); } catch (cause) { - toast(cause instanceof Error ? cause.message : String(cause), 8000); + toast(translateBackendError(t, cause), 8000); } finally { unlisten?.(); urlInstalling.value = false; diff --git a/apps/desktop/src/i18n/__tests__/backendErrors.spec.ts b/apps/desktop/src/i18n/__tests__/backendErrors.spec.ts index 745b18becd..1fd255b40c 100644 --- a/apps/desktop/src/i18n/__tests__/backendErrors.spec.ts +++ b/apps/desktop/src/i18n/__tests__/backendErrors.spec.ts @@ -454,6 +454,26 @@ describe("backend error translation", () => { expect(translateBackendError(t, error, "ClickHouse error: table analytics.events does not exist")).toBe(`${t("backendErrors.legacy")}\n\nClickHouse error: table analytics.events does not exist`); }); + test("renders plugin signature failures without exposing the JSON error envelope", () => { + const detail = "Plugin package is signed by untrusted key 'dbx-store-release-2026'"; + const error = JSON.stringify({ + version: 1, + code: "DBX-LEGACY-0001", + messageKey: "backendErrors.legacy", + messageParams: {}, + source: "legacyBackend", + origin: { subsystem: "backend", adapter: "legacy" }, + operationOutcome: "unknown", + detail, + }); + + for (const locale of ["zh-CN", "en"] as const) { + const t = translatorFor(locale); + expect(translateBackendError(t, error)).toBe(`${t("backendErrors.legacy")}\n\n${detail}`); + expect(translateBackendError(t, new Error(error))).toBe(`${t("backendErrors.legacy")}\n\n${detail}`); + } + }); + test("does not append the generic transport fallback to a structured error", () => { const t = translatorFor("zh-CN"); const error = { diff --git a/apps/desktop/src/lib/__tests__/plugins/pluginCenterIntegration.spec.ts b/apps/desktop/src/lib/__tests__/plugins/pluginCenterIntegration.spec.ts index 5d0c9256da..f7ac7720c1 100644 --- a/apps/desktop/src/lib/__tests__/plugins/pluginCenterIntegration.spec.ts +++ b/apps/desktop/src/lib/__tests__/plugins/pluginCenterIntegration.spec.ts @@ -26,6 +26,16 @@ describe("plugin center integration", () => { expect(driverStoreSource).not.toContain("PluginContributionsPanel"); }); + it("formats structured backend errors for local and URL package installs", () => { + const localInstall = pluginCenterSource.slice(pluginCenterSource.indexOf("async function installPlugin("), pluginCenterSource.indexOf("function isHttpPackageUrl(")); + const urlInstall = pluginCenterSource.slice(pluginCenterSource.indexOf("async function installPluginFromUrl("), pluginCenterSource.indexOf("const urlProgressPercent")); + + for (const installSource of [localInstall, urlInstall]) { + expect(installSource).toContain("toast(translateBackendError(t, cause), 8000)"); + expect(installSource).not.toContain("String(cause)"); + } + }); + it("creates and edits plugin connections in the unified connection dialog", () => { expect(appDialogsSource).toContain(':edit-config="editConfig"'); expect(appDialogsSource).toContain(':plugin-provider="connectionPluginProvider"'); diff --git a/crates/dbx-core/src/plugins/installer.rs b/crates/dbx-core/src/plugins/installer.rs index 47c73d89ab..bbd461e525 100644 --- a/crates/dbx-core/src/plugins/installer.rs +++ b/crates/dbx-core/src/plugins/installer.rs @@ -255,7 +255,7 @@ fn read_trust_document(root_dir: &Path) -> Result { impl PluginPackageInstaller { pub fn new(root_dir: PathBuf, app_version: impl Into) -> Result { - let trust_store = PluginTrustStore::load(&root_dir)?; + let trust_store = super::marketplace::package_install_trust_store(&root_dir)?; Ok(Self { root_dir, app_version: app_version.into(), trust_store: Arc::new(trust_store) }) } @@ -1111,6 +1111,102 @@ mod tests { assert_eq!(result.signature, PluginSignatureStatus::Trusted { key_id: "sample-key".to_string() }); } + #[test] + fn local_installer_loads_official_keys_without_persisting_them() { + let root = tempfile::tempdir().unwrap(); + let installer = PluginPackageInstaller::new(root.path().to_path_buf(), "0.5.67").unwrap(); + + assert!(installer.trust_store.keys.contains_key("dbx-store-release-2026")); + assert!(installer.trust_store.keys.contains_key("dbx-store-preview-2026")); + assert!(PluginTrustStore::list_base64_keys(root.path()).unwrap().is_empty()); + assert!(!root.path().join(".trust").exists()); + } + + #[test] + fn local_installer_rejects_forged_official_signatures() { + let root = tempfile::tempdir().unwrap(); + let installer = PluginPackageInstaller::new(root.path().to_path_buf(), "0.5.67").unwrap(); + let forged_key = SigningKey::from_bytes(&[11u8; 32]); + let package_path = root.path().join("forged.dbxp"); + std::fs::write(&package_path, package("1.0.0", Some((&forged_key, "dbx-store-release-2026")), false)).unwrap(); + + for policy in [PluginInstallPolicy::LocalSigned, PluginInstallPolicy::LocalDevelopment] { + let error = installer.install_file(&package_path, policy).unwrap_err(); + assert_eq!(error, "Plugin package signature verification failed"); + } + assert!(!root.path().join("sample.hello").exists()); + } + + #[test] + fn local_installer_preserves_custom_key_trust() { + let root = tempfile::tempdir().unwrap(); + let signing_key = SigningKey::from_bytes(&[9u8; 32]); + let public_key = base64::engine::general_purpose::STANDARD.encode(signing_key.verifying_key().as_bytes()); + PluginTrustStore::save_base64_key(root.path(), "sample-repository", &public_key).unwrap(); + let installer = PluginPackageInstaller::new(root.path().to_path_buf(), "0.5.67").unwrap(); + let package_path = root.path().join("custom.dbxp"); + std::fs::write(&package_path, package("1.0.0", Some((&signing_key, "sample-repository")), false)).unwrap(); + + let installed = installer.install_file(&package_path, PluginInstallPolicy::LocalSigned).unwrap(); + + assert_eq!(installed.signature, PluginSignatureStatus::Trusted { key_id: "sample-repository".to_string() }); + assert!(installer.trust_store.keys.contains_key("dbx-store-release-2026")); + assert_eq!(PluginTrustStore::list_base64_keys(root.path()).unwrap().len(), 1); + } + + #[test] + fn local_installer_rejects_unknown_signed_packages() { + let root = tempfile::tempdir().unwrap(); + let installer = PluginPackageInstaller::new(root.path().to_path_buf(), "0.5.67").unwrap(); + let signing_key = SigningKey::from_bytes(&[9u8; 32]); + let package = package("1.0.0", Some((&signing_key, "unknown-release")), false); + + for policy in [PluginInstallPolicy::LocalSigned, PluginInstallPolicy::LocalDevelopment] { + let error = installer.install_bytes(&package, policy).unwrap_err(); + assert_eq!(error, "Plugin package is signed by untrusted key 'unknown-release'"); + } + assert!(!root.path().join("sample.hello").exists()); + } + + #[test] + fn local_installer_rejects_official_key_conflicts() { + let root = tempfile::tempdir().unwrap(); + let forged_key = SigningKey::from_bytes(&[11u8; 32]); + let public_key = base64::engine::general_purpose::STANDARD.encode(forged_key.verifying_key().as_bytes()); + PluginTrustStore::save_base64_key(root.path(), "dbx-store-release-2026", &public_key).unwrap(); + + let error = PluginPackageInstaller::new(root.path().to_path_buf(), "0.5.67") + .err() + .expect("a custom key must not override an official signing key"); + + assert!(error.contains("'dbx-store-release-2026' already exists with a different public key")); + } + + #[test] + fn local_installer_accepts_matching_official_keys() { + let root = tempfile::tempdir().unwrap(); + let installer = PluginPackageInstaller::new(root.path().to_path_buf(), "0.5.67").unwrap(); + let official_key = installer.trust_store.keys.get("dbx-store-release-2026").unwrap(); + let public_key = base64::engine::general_purpose::STANDARD.encode(official_key.as_bytes()); + PluginTrustStore::save_base64_key(root.path(), "dbx-store-release-2026", &public_key).unwrap(); + + let reloaded = PluginPackageInstaller::new(root.path().to_path_buf(), "0.5.67").unwrap(); + + assert_eq!(reloaded.trust_store.keys.get("dbx-store-release-2026"), Some(official_key)); + assert_eq!(PluginTrustStore::list_base64_keys(root.path()).unwrap().len(), 1); + } + + #[test] + fn local_installer_enforces_unsigned_policy() { + let root = tempfile::tempdir().unwrap(); + let installer = PluginPackageInstaller::new(root.path().to_path_buf(), "0.5.67").unwrap(); + let unsigned = package("1.0.0", None, false); + + assert!(installer.install_bytes(&unsigned, PluginInstallPolicy::LocalSigned).is_err()); + let installed = installer.install_bytes(&unsigned, PluginInstallPolicy::LocalDevelopment).unwrap(); + assert_eq!(installed.signature, PluginSignatureStatus::Unsigned); + } + #[test] fn marketplace_expectation_binds_manifest_and_signing_identity() { let manifest: PluginManifest = serde_json::from_value(serde_json::json!({ diff --git a/crates/dbx-core/src/plugins/marketplace.rs b/crates/dbx-core/src/plugins/marketplace.rs index 4e5c7ad5b6..5428ce8c51 100644 --- a/crates/dbx-core/src/plugins/marketplace.rs +++ b/crates/dbx-core/src/plugins/marketplace.rs @@ -445,9 +445,7 @@ impl PluginMarketplace { bytes.extend_from_slice(&chunk); on_progress(bytes.len() as u64, total); } - let trust_store = url_install_trust_store(&self.root_dir)?; - PluginPackageInstaller::with_trust_store(self.root_dir.clone(), self.app_version.clone(), trust_store) - .install_bytes(&bytes, policy) + PluginPackageInstaller::new(self.root_dir.clone(), self.app_version.clone())?.install_bytes(&bytes, policy) } async fn download_limited(&self, url: Url, max_bytes: usize, label: &str) -> Result, String> { @@ -719,11 +717,11 @@ fn marketplace_trust_store(root_dir: &Path, kind: PluginRepositoryKind) -> Resul Ok(store) } -/// Trust store for direct URL installs: the user's trusted keys plus the +/// Trust store for local file and direct URL installs: the user's trusted keys plus the /// built-in official DBX Marketplace keys. A user-saved key that collides with /// a builtin key id but carries a different public key is a rotation conflict /// and fails the install instead of silently overriding the builtin key. -pub fn url_install_trust_store(root_dir: &Path) -> Result { +pub(super) fn package_install_trust_store(root_dir: &Path) -> Result { let mut keys = PluginTrustStore::list_base64_keys(root_dir)? .into_iter() .map(|key| (key.key_id, key.public_key)) @@ -732,7 +730,7 @@ pub fn url_install_trust_store(root_dir: &Path) -> Result Result Result { + package_install_trust_store(root_dir) +} + fn parse_http_url(raw: &str, label: &str) -> Result { let url = Url::parse(raw).map_err(|error| format!("Invalid {label}: {error}"))?; ensure_http_url(url, label) @@ -1013,6 +1015,14 @@ mod tests { assert!(!marketplace_trust_store(root.path(), PluginRepositoryKind::Official).unwrap().is_empty()); } + #[test] + fn custom_repositories_do_not_implicitly_trust_official_keys() { + let root = tempfile::tempdir().unwrap(); + for kind in [PluginRepositoryKind::Custom, PluginRepositoryKind::Enterprise] { + assert!(marketplace_trust_store(root.path(), kind).unwrap().is_empty()); + } + } + #[tokio::test] async fn isolates_repository_fetch_failures() { use tokio::io::{AsyncReadExt, AsyncWriteExt};