Files
dbx/crates/dbx-core/src/plugins.rs
T

541 lines
20 KiB
Rust

use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use serde::de::DeserializeOwned;
use serde::Serialize;
use tokio::process::Command;
mod assets;
mod filesystem;
mod host;
mod installer;
mod manifest;
mod marketplace;
mod runtime;
pub use assets::PluginUiAsset;
pub use filesystem::{
PluginFilesystemEntry, PluginFilesystemEntryKind, PluginFilesystemListResult, PluginFilesystemMutationResult,
PluginFilesystemReadResult, DEFAULT_PLUGIN_FILESYSTEM_PAGE_SIZE, DEFAULT_PLUGIN_FILESYSTEM_PREVIEW_BYTES,
MAX_PLUGIN_FILESYSTEM_INLINE_WRITE_BYTES, MAX_PLUGIN_FILESYSTEM_PAGE_SIZE, MAX_PLUGIN_FILESYSTEM_PREVIEW_BYTES,
PLUGIN_FILESYSTEM_CREATE_DIRECTORY_METHOD, PLUGIN_FILESYSTEM_DELETE_METHOD, PLUGIN_FILESYSTEM_LIST_METHOD,
PLUGIN_FILESYSTEM_READ_METHOD, PLUGIN_FILESYSTEM_RENAME_METHOD, PLUGIN_FILESYSTEM_WRITE_METHOD,
};
pub use host::{ActivePluginSession, PluginConnectionActionResult, PluginConnectionHandle, PluginHost};
pub use installer::{
PluginInstallPolicy, PluginInstallResponse, PluginInstallResult, PluginPackageInstaller, PluginRollbackResponse,
PluginRollbackResult, PluginSignatureStatus, PluginTrustStore, PluginTrustedKey, DBXP_EXTENSION,
MAX_PLUGIN_PACKAGE_BYTES, PLUGIN_CHECKSUMS_FILE, PLUGIN_SIGNATURE_FILE,
};
pub use marketplace::{
PluginMarketplace, PluginMarketplaceArtifact, PluginMarketplaceCatalog, PluginMarketplaceInstallRequest,
PluginMarketplaceLocalization, PluginMarketplacePlugin, PluginMarketplaceRepositoryMetadata,
PluginMarketplaceVersion, PluginRepository, PluginRepositoryCatalogResult, PluginRepositoryKind,
PluginRepositoryStore, MAX_PLUGIN_CATALOG_BYTES, OFFICIAL_PLUGIN_REPOSITORY_ID, SUPPORTED_PLUGIN_CATALOG_VERSION,
};
pub use manifest::{
current_plugin_target, resolve_safe_plugin_path, PluginBackendEntrypoint, PluginBackendTransport,
PluginCompatibility, PluginConnectionActionContribution, PluginConnectionActionVariant, PluginConnectionActionWhen,
PluginConnectionCapability, PluginConnectionProviderContribution, PluginContribution, PluginDriverManifest,
PluginEngines, PluginEntrypoints, PluginFilesystemCapability, PluginFilesystemProviderContribution,
PluginFormFieldBinding, PluginFormFieldDefinition, PluginFormFieldOption, PluginFormFieldType, PluginManifest,
PluginUiEntrypoint, PluginWorkbenchContribution, PLUGIN_CONNECTION_ACTION_METHOD, PLUGIN_CONNECTION_CONNECT_METHOD,
PLUGIN_CONNECTION_DISCONNECT_METHOD, PLUGIN_CONNECTION_TEST_METHOD, SUPPORTED_PLUGIN_HOST_API_VERSION,
SUPPORTED_PLUGIN_MANIFEST_VERSION, SUPPORTED_PLUGIN_PERMISSIONS, SUPPORTED_PLUGIN_PROTOCOL_VERSION,
};
pub use runtime::{
PluginBinaryMessage, PluginEvent, PluginHandshake, PluginHandshakeIdentity, PluginSessionState,
PluginSessionStatus, PluginSidecarSession, PLUGIN_REQUEST_TIMEOUT,
};
#[derive(Debug, Clone)]
pub struct InstalledPlugin {
pub manifest: PluginManifest,
pub path: PathBuf,
pub compatibility: PluginCompatibility,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InstalledPluginInfo {
pub manifest: PluginManifest,
pub compatibility: PluginCompatibilityInfo,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginCompatibilityInfo {
pub compatible: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
}
impl InstalledPlugin {
pub fn new(manifest: PluginManifest, path: PathBuf, app_version: &str) -> Self {
let compatibility = manifest.compatibility(&path, app_version);
Self { manifest, path, compatibility }
}
pub fn info(&self) -> InstalledPluginInfo {
InstalledPluginInfo {
manifest: self.manifest.clone(),
compatibility: PluginCompatibilityInfo {
compatible: self.compatibility.compatible,
errors: self.compatibility.errors.clone(),
warnings: self.compatibility.warnings.clone(),
target: self.compatibility.target.clone(),
},
}
}
}
#[derive(Debug, Clone, Default)]
pub struct PluginRuntimeEnv {
vars: Vec<(String, String)>,
}
impl PluginRuntimeEnv {
pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.vars.push((key.into(), value.into()));
self
}
pub fn get(&self, key: &str) -> Option<&str> {
self.vars.iter().find_map(|(name, value)| (name == key).then_some(value.as_str()))
}
pub fn apply_to(&self, command: &mut Command) {
for (key, value) in &self.vars {
command.env(key, value);
}
}
}
#[derive(Debug, Clone)]
pub struct PluginRegistry {
root_dir: PathBuf,
app_version: String,
}
impl PluginRegistry {
pub fn new(root_dir: PathBuf) -> Self {
Self::new_with_app_version(root_dir, env!("CARGO_PKG_VERSION"))
}
pub fn new_with_app_version(root_dir: PathBuf, app_version: impl Into<String>) -> Self {
Self { root_dir, app_version: app_version.into() }
}
pub fn root_dir(&self) -> &Path {
&self.root_dir
}
pub fn app_version(&self) -> &str {
&self.app_version
}
pub fn list_installed(&self) -> Result<Vec<InstalledPlugin>, String> {
let entries = match std::fs::read_dir(&self.root_dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
Err(err) => return Err(err.to_string()),
};
let mut plugins = Vec::new();
for entry in entries {
let entry = entry.map_err(|err| err.to_string())?;
let container_path = entry.path();
if !container_path.is_dir() {
continue;
}
let path = match installer::resolve_active_plugin_dir(&container_path) {
Ok(Some(path)) => path,
Ok(None) => continue,
Err(error) => {
plugins.push(unavailable_plugin(container_path, error));
continue;
}
};
let manifest_path = path.join("manifest.json");
if !manifest_path.exists() {
continue;
}
let raw = match std::fs::read_to_string(&manifest_path) {
Ok(raw) => raw,
Err(error) => {
plugins.push(unavailable_plugin(
container_path,
format!("Failed to read plugin manifest {}: {error}", manifest_path.display()),
));
continue;
}
};
let manifest: PluginManifest = match serde_json::from_str(&raw) {
Ok(manifest) => manifest,
Err(error) => {
plugins.push(unavailable_plugin(
container_path,
format!("Failed to parse plugin manifest {}: {error}", manifest_path.display()),
));
continue;
}
};
plugins.push(InstalledPlugin::new(manifest, path, &self.app_version));
}
plugins.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id));
Ok(plugins)
}
pub fn find_driver(&self, driver_id: &str) -> Result<Option<InstalledPlugin>, String> {
Ok(self.list_installed()?.into_iter().find(|plugin| {
plugin.compatibility.compatible
&& plugin
.manifest
.drivers
.iter()
.any(|driver| driver.id == driver_id || driver.database_type.as_deref() == Some(driver_id))
}))
}
pub fn find_plugin(&self, plugin_id: &str) -> Result<Option<InstalledPlugin>, String> {
Ok(self.list_installed()?.into_iter().find(|plugin| plugin.manifest.id == plugin_id))
}
pub async fn invoke_driver<T>(&self, driver_id: &str, method: &str, params: serde_json::Value) -> Result<T, String>
where
T: DeserializeOwned,
{
self.invoke_driver_with_env(driver_id, method, params, PluginRuntimeEnv::default()).await
}
pub async fn invoke_driver_with_env<T>(
&self,
driver_id: &str,
method: &str,
params: serde_json::Value,
env: PluginRuntimeEnv,
) -> Result<T, String>
where
T: DeserializeOwned,
{
self.invoke_driver_with_env_and_timeout(driver_id, method, params, env, Some(PLUGIN_REQUEST_TIMEOUT)).await
}
pub async fn invoke_driver_with_env_and_timeout<T>(
&self,
driver_id: &str,
method: &str,
params: serde_json::Value,
env: PluginRuntimeEnv,
timeout_duration: Option<Duration>,
) -> Result<T, String>
where
T: DeserializeOwned,
{
let plugin =
self.find_driver(driver_id)?.ok_or_else(|| format!("Plugin driver '{driver_id}' is not installed"))?;
ensure_plugin_compatible(&plugin)?;
let session = PluginSidecarSession::start(plugin, self.app_version.clone(), env).await?;
let result = session.invoke_with_timeout(method, params, Some(driver_id), timeout_duration).await;
session.shutdown().await;
result
}
pub async fn start_driver_session(&self, driver_id: &str) -> Result<Arc<PluginDriverSession>, String> {
self.start_driver_session_with_env(driver_id, PluginRuntimeEnv::default()).await
}
pub async fn start_driver_session_with_env(
&self,
driver_id: &str,
env: PluginRuntimeEnv,
) -> Result<Arc<PluginDriverSession>, String> {
let plugin =
self.find_driver(driver_id)?.ok_or_else(|| format!("Plugin driver '{driver_id}' is not installed"))?;
ensure_plugin_compatible(&plugin)?;
PluginDriverSession::start(plugin, driver_id.to_string(), self.app_version.clone(), env).await.map(Arc::new)
}
}
fn unavailable_plugin(path: PathBuf, error: String) -> InstalledPlugin {
let id = path
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or("invalid-plugin")
.to_string();
InstalledPlugin {
manifest: PluginManifest {
id: id.clone(),
name: id,
version: "0.0.0-invalid".to_string(),
description: error.clone(),
..PluginManifest::default()
},
path,
compatibility: PluginCompatibility {
compatible: false,
errors: vec![error],
target: Some(current_plugin_target()),
..PluginCompatibility::default()
},
}
}
fn ensure_plugin_compatible(plugin: &InstalledPlugin) -> Result<(), String> {
if plugin.compatibility.compatible {
return Ok(());
}
Err(format!("Plugin '{}' is incompatible: {}", plugin.manifest.id, plugin.compatibility.errors.join("; ")))
}
pub struct PluginDriverSession {
sidecar: Arc<PluginSidecarSession>,
driver_id: String,
}
impl PluginDriverSession {
async fn start(
plugin: InstalledPlugin,
driver_id: String,
app_version: String,
env: PluginRuntimeEnv,
) -> Result<Self, String> {
let sidecar = PluginSidecarSession::start(plugin, app_version, env).await?;
Ok(Self { sidecar, driver_id })
}
pub async fn invoke<T>(&self, method: &str, params: serde_json::Value) -> Result<T, String>
where
T: DeserializeOwned,
{
self.invoke_with_timeout(method, params, Some(PLUGIN_REQUEST_TIMEOUT)).await
}
pub async fn invoke_with_timeout<T>(
&self,
method: &str,
params: serde_json::Value,
timeout_duration: Option<Duration>,
) -> Result<T, String>
where
T: DeserializeOwned,
{
self.sidecar.invoke_with_timeout(method, params, Some(&self.driver_id), timeout_duration).await
}
pub async fn shutdown(&self) {
self.sidecar.shutdown().await;
}
pub async fn pid(&self) -> Option<u32> {
self.sidecar.pid().await
}
pub fn subscribe_events(&self) -> tokio::sync::broadcast::Receiver<PluginEvent> {
self.sidecar.subscribe_events()
}
pub fn subscribe_binary(&self) -> tokio::sync::broadcast::Receiver<PluginBinaryMessage> {
self.sidecar.subscribe_binary()
}
pub async fn send_binary(&self, channel: &str, data: &[u8]) -> Result<(), String> {
self.sidecar.send_binary(channel, data).await
}
#[cfg(all(test, unix))]
pub(crate) async fn start_for_test(
plugin: InstalledPlugin,
driver_id: String,
env: PluginRuntimeEnv,
) -> Result<Self, String> {
Self::start(plugin, driver_id, env!("CARGO_PKG_VERSION").to_string(), env).await
}
}
#[cfg(test)]
mod tests {
use super::{InstalledPlugin, PluginContribution, PluginManifest, PluginRegistry};
#[cfg(unix)]
use super::{PluginDriverManifest, PluginDriverSession, PluginRuntimeEnv};
#[cfg(unix)]
#[tokio::test]
async fn shutdown_kills_plugin_child_process() {
let dir = std::env::temp_dir().join(format!("dbx-plugin-shutdown-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let executable = dir.join("plugin.sh");
std::fs::write(&executable, "#!/bin/sh\nsleep 30\n").unwrap();
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(&executable).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&executable, permissions).unwrap();
}
let plugin = InstalledPlugin::new(
PluginManifest {
id: "jdbc".to_string(),
name: "JDBC".to_string(),
version: "test".to_string(),
protocol_version: 1,
description: String::new(),
executable: Some("plugin.sh".to_string()),
drivers: vec![PluginDriverManifest {
id: "jdbc".to_string(),
label: "JDBC".to_string(),
kind: "external".to_string(),
database_type: Some("jdbc".to_string()),
}],
contributions: Vec::new(),
..PluginManifest::default()
},
dir.clone(),
env!("CARGO_PKG_VERSION"),
);
let session = PluginDriverSession::start(
plugin,
"jdbc".to_string(),
env!("CARGO_PKG_VERSION").to_string(),
PluginRuntimeEnv::default(),
)
.await
.expect("session should start");
let pid = session.pid().await.expect("child should have a pid");
session.shutdown().await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(!process_exists(pid));
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(unix)]
fn process_exists(pid: u32) -> bool {
std::process::Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.stderr(std::process::Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
#[test]
fn preserves_typed_contributions_in_manifest_round_trip() {
let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
"manifest_version": 1,
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"publisher": "example",
"engines": { "host_api": "1" },
"contributions": [{
"type": "connection-provider",
"id": "sample.connection",
"database_type": "sample",
"fields": []
}]
}))
.expect("manifest should parse");
assert!(matches!(
&manifest.contributions[0],
PluginContribution::ConnectionProvider(provider) if provider.id == "sample.connection"
));
let serialized = serde_json::to_value(manifest).expect("manifest should serialize");
assert_eq!(serialized["contributions"][0]["database_type"], "sample");
}
#[test]
fn registry_lists_frontend_contributions_from_disk() {
let root = std::env::temp_dir().join(format!("dbx-plugin-registry-test-{}", uuid::Uuid::new_v4()));
let plugin_dir = root.join("sample");
std::fs::create_dir_all(&plugin_dir).expect("plugin directory should be created");
std::fs::write(
plugin_dir.join("manifest.json"),
serde_json::json!({
"manifest_version": 1,
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"publisher": "example",
"engines": { "host_api": "1" },
"contributions": [{
"type": "connection-provider",
"id": "sample.connection",
"database_type": "sample",
"fields": []
}]
})
.to_string(),
)
.expect("manifest should be written");
let plugins = PluginRegistry::new(root.clone()).list_installed().expect("plugins should be listed");
assert_eq!(plugins.len(), 1);
assert!(matches!(
&plugins[0].manifest.contributions[0],
PluginContribution::ConnectionProvider(provider) if provider.id == "sample.connection"
));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn registry_keeps_other_plugins_visible_when_one_manifest_is_corrupt() {
let root = std::env::temp_dir().join(format!("dbx-plugin-corrupt-registry-test-{}", uuid::Uuid::new_v4()));
let valid = root.join("valid");
let corrupt = root.join("corrupt");
std::fs::create_dir_all(&valid).unwrap();
std::fs::create_dir_all(&corrupt).unwrap();
std::fs::write(
valid.join("manifest.json"),
serde_json::json!({ "id": "valid", "name": "Valid", "drivers": [] }).to_string(),
)
.unwrap();
std::fs::write(corrupt.join("manifest.json"), "{").unwrap();
let plugins = PluginRegistry::new(root.clone()).list_installed().unwrap();
assert_eq!(plugins.len(), 2);
assert!(plugins.iter().any(|plugin| plugin.manifest.id == "valid" && plugin.compatibility.compatible));
assert!(plugins.iter().any(|plugin| {
plugin.manifest.id == "corrupt"
&& !plugin.compatibility.compatible
&& plugin.compatibility.errors.iter().any(|error| error.contains("Failed to parse"))
}));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn installed_plugin_info_hides_runtime_paths() {
let plugin = InstalledPlugin::new(
PluginManifest {
manifest_version: 1,
id: "sample".to_string(),
name: "Sample".to_string(),
version: "1.0.0".to_string(),
publisher: "example".to_string(),
..PluginManifest::default()
},
std::path::PathBuf::from("/private/plugin/install"),
env!("CARGO_PKG_VERSION"),
);
let serialized = serde_json::to_value(plugin.info()).unwrap();
assert!(serialized.get("path").is_none());
assert!(serialized["compatibility"].get("backend_executable").is_none());
assert!(serialized["compatibility"].get("ui_entry").is_none());
assert!(serialized["compatibility"].get("ui_root").is_none());
}
}