feat(plugins): add interactive prompts and richer connection forms

This commit is contained in:
Jinpy authored and GitHub committed 2026-09-17 19:29:28 +08:00
1 parent fac270fc8a
commit e4bda693fb
42 files changed
+3922 -200

No files matched your search

+56
View File
@@ -1606,4 +1606,60 @@ mod tests {
let loaded = load_connections_from_file(&path, &store).unwrap();
assert_eq!(loaded[0].connection_secrets.get("access_token").map(String::as_str), Some("plugin-secret"));
}
#[test]
fn load_connections_preserves_opaque_null_plugin_secrets() {
let path = temp_connections_file("plugin-null-secret");
let store = MemorySecretStore::default();
let mut config = connection("plugin-connection", "", "");
config.db_type = DatabaseType::Plugin;
config.plugin_id = Some("io.dbx.ssh".to_string());
config.plugin_connection_provider = Some("ssh.connection".to_string());
config.plugin_connection_type = Some("ssh".to_string());
config.external_config = Some(serde_json::json!({ "sudo_source": "custom" }));
config.connection_secrets.insert("sudo_password".to_string(), "null".to_string());
config.connection_secrets.insert("totp_secret".to_string(), "".to_string());
config.connection_secrets.insert("private_key_passphrase".to_string(), "real-passphrase".to_string());
save_connections_to_file(&path, &[config], &store).unwrap();
store.set_existing("plugin-connection", &format!("{PLUGIN_CONNECTION_SECRET_PREFIX}totp_secret"), "null");
let stored = store.values.borrow().clone();
let deleted = store.deleted.borrow().clone();
let raw_config = std::fs::read(&path).unwrap();
let loaded = load_connections_from_file(&path, &store).unwrap();
let secrets = &loaded[0].connection_secrets;
assert_eq!(secrets.get("sudo_password").map(String::as_str), Some("null"));
assert_eq!(secrets.get("totp_secret").map(String::as_str), Some("null"));
assert_eq!(secrets.get("private_key_passphrase").map(String::as_str), Some("real-passphrase"));
assert_eq!(load_connections_from_file(&path, &store).unwrap()[0].connection_secrets, *secrets);
assert_eq!(*store.values.borrow(), stored);
assert_eq!(*store.deleted.borrow(), deleted);
assert_eq!(std::fs::read(&path).unwrap(), raw_config);
save_connections_to_file(&path, &loaded, &store).unwrap();
assert_eq!(load_connections_from_file(&path, &store).unwrap()[0].connection_secrets, *secrets);
assert_eq!(*store.values.borrow(), stored);
}
#[test]
fn file_secret_store_preserves_opaque_null_credentials() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("connections.json");
let secret_path = dir.path().join("secrets.json");
let store = super::FileSecretStore::new(secret_path.clone());
let mut config = connection("plugin-connection", "", "");
config.db_type = DatabaseType::Plugin;
config.connection_secrets.insert("credential".to_string(), "null".to_string());
save_connections_to_file(&path, &[config], &store).unwrap();
let raw_config = std::fs::read(&path).unwrap();
let raw_secrets = std::fs::read(&secret_path).unwrap();
for _ in 0..2 {
let reopened_store = super::FileSecretStore::new(secret_path.clone());
let loaded = load_connections_from_file(&path, &reopened_store).unwrap();
assert_eq!(loaded[0].connection_secrets.get("credential").map(String::as_str), Some("null"));
assert_eq!(std::fs::read(&path).unwrap(), raw_config);
assert_eq!(std::fs::read(&secret_path).unwrap(), raw_secrets);
}
}
}
+4
View File
@@ -437,6 +437,10 @@ async fn ensure_worker_consent(data_dir: &Path, identity: &str, dest: &str, dige
previous_fingerprint: None,
prompt: Some(dest.to_string()),
echo: false,
source: None,
title: None,
default_value: None,
options: Vec::new(),
};
let Some(rx) = ssh_prompt::request_ssh_prompt(request) else {
return Err("Uploading a SQLite worker requires explicit consent in the Desktop UI".to_string());
+143 -2
View File
@@ -8,6 +8,12 @@
//! suspend on a `oneshot` while the Tauri layer forwards the request to the UI
//! and the UI answers through a command.
//!
//! The same gateway carries generic [`SshPromptKind::UserInput`] questions that
//! are not part of a host-owned SSH transport, e.g. a plugin backend asking for
//! a bastion MFA code through the `host/requestUserInput` Host API method. The
//! channel is intentionally transport-agnostic: the host only relays a question
//! and the typed answer, and it never answers on the user's behalf.
//!
//! The gateway is installed once at app startup by the Tauri layer
//! (`install_ssh_prompt_gateway`). In headless / test contexts where no
//! gateway is installed, `request_ssh_prompt` returns `None` and callers MUST
@@ -30,6 +36,20 @@ pub enum SshPromptKind {
SecretInput,
/// Confirm uploading the SQLite worker binary onto the file host.
WorkerUploadConsent,
/// A caller that is not a host-owned transport (today: a plugin backend
/// through the `host/requestUserInput` Host API method) needs a value typed
/// by the user. Unlike [`SshPromptKind::SecretInput`] the host never
/// synthesizes or auto-answers it: without a user there is no answer.
UserInput,
}
/// A fixed answer a [`SshPromptKind::UserInput`] request offers.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SshPromptOption {
/// Value returned to the caller when the user picks this option.
pub value: String,
/// Text the dialog shows.
pub label: String,
}
/// A request for user input, sent from the backend to the UI.
@@ -56,19 +76,52 @@ pub struct SshPromptRequest {
/// Passwords and verification codes normally set this to false.
#[serde(default)]
pub echo: bool,
/// UserInput: who is asking (e.g. the plugin display name), shown so the
/// user can tell a bastion login prompt from a host-owned one.
#[serde(default)]
pub source: Option<String>,
/// UserInput: heading above `prompt`, already localized by the caller.
#[serde(default)]
pub title: Option<String>,
/// UserInput: preset answer offered in the input.
#[serde(default)]
pub default_value: Option<String>,
/// UserInput: fixed answers; empty means free-form input.
#[serde(default)]
pub options: Vec<SshPromptOption>,
}
/// The user's answer, sent from the UI back to the backend.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SshPromptAnswer {
/// Accept and (for HostKeyVerify) optionally persist the key.
Accept { remember: bool },
/// Reject.
Reject,
/// A typed secret (SecretInput).
/// A typed value (SecretInput / UserInput).
Secret(String),
}
impl Default for SshPromptRequest {
fn default() -> Self {
Self {
id: String::new(),
kind: SshPromptKind::HostKeyVerify,
host: String::new(),
port: 0,
key_type: None,
fingerprint: None,
previous_fingerprint: None,
prompt: None,
echo: false,
source: None,
title: None,
default_value: None,
options: Vec::new(),
}
}
}
/// Internal envelope: the request plus the channel to deliver the answer on.
pub struct SshPromptEnvelope {
pub request: SshPromptRequest,
@@ -88,6 +141,18 @@ pub fn clear_ssh_prompt_gateway() {
*PROMPT_GATEWAY.lock().unwrap() = None;
}
/// Serializes every test that mutates the process-global prompt gateway.
///
/// One gateway serves the host's SSH transports, the SQLite worker consent
/// prompt and plugin Host API questions, so tests in any module must hold this
/// lock while a gateway is installed — otherwise one test's gateway (or the
/// fail-closed checks that rely on *no* gateway) clobbers another's.
#[cfg(test)]
pub fn prompt_gateway_test_lock() -> &'static tokio::sync::Mutex<()> {
static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
&LOCK
}
/// Request input from the UI. Returns the receiver the caller should await, or
/// `None` if no gateway is installed (caller must fail closed).
pub fn request_ssh_prompt(request: SshPromptRequest) -> Option<oneshot::Receiver<SshPromptAnswer>> {
@@ -117,6 +182,10 @@ pub fn host_key_verify_request(
previous_fingerprint: None,
prompt: None,
echo: false,
source: None,
title: None,
default_value: None,
options: Vec::new(),
}
}
@@ -138,6 +207,10 @@ pub fn host_key_changed_request(
previous_fingerprint,
prompt: None,
echo: false,
source: None,
title: None,
default_value: None,
options: Vec::new(),
}
}
@@ -153,6 +226,74 @@ pub fn secret_input_request(host: &str, port: u16, prompt: String, echo: bool) -
previous_fingerprint: None,
prompt: Some(prompt),
echo,
source: None,
title: None,
default_value: None,
options: Vec::new(),
}
}
/// A generic question from a caller the host does not own (today: a plugin
/// backend). Built through [`UserInputRequest::into_request`], which allocates
/// the prompt id the UI answers with.
#[derive(Debug, Clone)]
pub struct UserInputRequest {
pub prompt: String,
pub echo: bool,
pub title: Option<String>,
pub source: Option<String>,
pub default_value: Option<String>,
pub options: Vec<SshPromptOption>,
}
impl UserInputRequest {
pub fn new(prompt: impl Into<String>) -> Self {
Self { prompt: prompt.into(), echo: false, title: None, source: None, default_value: None, options: Vec::new() }
}
pub fn with_title(mut self, title: Option<String>) -> Self {
self.title = title;
self
}
pub fn with_source(mut self, source: Option<String>) -> Self {
self.source = source;
self
}
pub fn with_default(mut self, default_value: Option<String>) -> Self {
self.default_value = default_value;
self
}
pub fn with_options(mut self, options: Vec<SshPromptOption>) -> Self {
self.options = options;
self
}
pub fn with_echo(mut self, echo: bool) -> Self {
self.echo = echo;
self
}
/// Turn the question into a prompt request. `host`/`port` are display-only
/// context a caller outside the host's own transports usually leaves empty.
pub fn into_request(self, host: &str, port: u16) -> SshPromptRequest {
SshPromptRequest {
id: Uuid::new_v4().to_string(),
kind: SshPromptKind::UserInput,
host: host.to_string(),
port,
key_type: None,
fingerprint: None,
previous_fingerprint: None,
prompt: Some(self.prompt),
echo: self.echo,
source: self.source,
title: self.title,
default_value: self.default_value,
options: self.options,
}
}
}
+26 -24
View File
@@ -1877,11 +1877,13 @@ pub(crate) fn effective_hop_timeout(hop: &SshTunnelConfig) -> u64 {
}
}
/// Serializes every test that mutates the process-global SSH prompt gateway so
/// they cannot clobber each other's gateway (or the MITM fail-closed test,
/// which relies on *no* gateway being installed). Test-only.
/// Serializes every test that mutates the process-global prompt gateway. The
/// lock lives in `db::ssh_prompt` because the gateway is shared with the plugin
/// Host API, so prompt tests in other modules must take the same lock. Test-only.
#[cfg(test)]
static PROMPT_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
fn prompt_test_lock() -> &'static tokio::sync::Mutex<()> {
crate::db::ssh_prompt::prompt_gateway_test_lock()
}
#[cfg(test)]
fn plan_chain(
@@ -1918,10 +1920,10 @@ fn plan_chain(
#[cfg(test)]
mod tests {
use super::prompt_test_lock;
#[cfg(unix)]
use super::resolve_ssh_agent_socket_path;
use super::SshClient;
use super::PROMPT_TEST_LOCK;
use super::{
bind_tunnel_listener, connect_and_authenticate, describe_terminal_auth_failure, effective_hop_timeout,
netcat_proxy_command, openssh_padding_len, plan_chain, read_ssh_string,
@@ -2404,7 +2406,7 @@ mod tests {
#[tokio::test]
async fn unknown_host_prompt_accept_learns_key() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
let dir = tempdir().unwrap();
let path = dir.path().join("known_hosts");
let verifier = HostKeyVerifier::new(path.clone());
@@ -2428,7 +2430,7 @@ mod tests {
#[tokio::test]
async fn unknown_host_prompt_accept_without_remember_is_session_only() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
let dir = tempdir().unwrap();
let path = dir.path().join("known_hosts");
let verifier = HostKeyVerifier::new(path.clone());
@@ -2454,7 +2456,7 @@ mod tests {
#[tokio::test]
async fn unknown_host_prompt_reject_aborts_handshake() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
let dir = tempdir().unwrap();
let path = dir.path().join("known_hosts");
let verifier = HostKeyVerifier::new(path.clone());
@@ -2480,7 +2482,7 @@ mod tests {
#[tokio::test]
async fn unknown_host_without_gateway_fails_closed() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
// Ensure no gateway is installed so request_ssh_prompt returns None.
ssh_prompt::clear_ssh_prompt_gateway();
let dir = tempdir().unwrap();
@@ -2501,7 +2503,7 @@ mod tests {
#[tokio::test]
async fn trusted_host_skips_prompt() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
let dir = tempdir().unwrap();
let path = dir.path().join("known_hosts");
let verifier = HostKeyVerifier::new(path);
@@ -2531,7 +2533,7 @@ mod tests {
#[tokio::test]
async fn changed_host_prompt_update_replaces_key() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
let dir = tempdir().unwrap();
let path = dir.path().join("known_hosts");
seed_changed_host_key(&path);
@@ -2559,7 +2561,7 @@ mod tests {
#[tokio::test]
async fn changed_host_prompt_continue_is_session_only() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
let dir = tempdir().unwrap();
let path = dir.path().join("known_hosts");
seed_changed_host_key(&path);
@@ -2587,7 +2589,7 @@ mod tests {
#[tokio::test]
async fn changed_host_prompt_reject_aborts_handshake() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
let dir = tempdir().unwrap();
let path = dir.path().join("known_hosts");
seed_changed_host_key(&path);
@@ -2735,7 +2737,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU=
#[tokio::test]
async fn password_auth_continues_with_keyboard_interactive_totp() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
ssh_prompt::clear_ssh_prompt_gateway();
let (port, server_task) = start_password_then_totp_server().await;
let dir = tempdir().unwrap();
@@ -2781,7 +2783,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU=
#[tokio::test]
async fn public_key_auth_continues_with_keyboard_interactive_totp() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
ssh_prompt::clear_ssh_prompt_gateway();
let (port, server_task) = start_password_then_totp_server().await;
let dir = tempdir().unwrap();
@@ -2901,7 +2903,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU=
#[tokio::test]
async fn socks5_proxy_forwards_requested_target_over_ssh() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
ssh_prompt::clear_ssh_prompt_gateway();
let server_key = decode_secret_key(TEST_SERVER_KEY_PEM, None).expect("decode test server key");
let server_config = server::Config { keys: vec![server_key], ..Default::default() };
@@ -3052,7 +3054,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU=
#[tokio::test]
async fn tunnel_falls_back_to_netcat_when_direct_tcpip_is_prohibited() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
ssh_prompt::clear_ssh_prompt_gateway();
let server_key = decode_secret_key(TEST_SERVER_KEY_PEM, None).expect("decode test server key");
let server_config = server::Config { keys: vec![server_key], ..Default::default() };
@@ -3107,7 +3109,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU=
#[tokio::test]
async fn prohibited_direct_tcpip_does_not_run_netcat_by_default() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
ssh_prompt::clear_ssh_prompt_gateway();
let server_key = decode_secret_key(TEST_SERVER_KEY_PEM, None).expect("decode test server key");
let server_config = server::Config { keys: vec![server_key], ..Default::default() };
@@ -3157,7 +3159,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU=
#[tokio::test]
async fn forwarded_connection_checks_logical_host_identity() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
ssh_prompt::clear_ssh_prompt_gateway();
let (connect_port, server_task) = start_accept_none_server().await;
let dir = tempdir().unwrap();
@@ -3205,7 +3207,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU=
/// wait must not be charged against `connect_timeout_secs`.
#[tokio::test]
async fn slow_host_key_acceptance_does_not_time_out_the_connection() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
ssh_prompt::clear_ssh_prompt_gateway();
let (connect_port, server_task) = start_accept_none_server().await;
let dir = tempdir().unwrap();
@@ -3248,7 +3250,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU=
#[tokio::test]
async fn concurrent_tunnel_starts_share_one_handshake() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
ssh_prompt::clear_ssh_prompt_gateway();
let (connect_port, server_task) = start_accept_none_server().await;
let dir = tempdir().unwrap();
@@ -3342,7 +3344,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU=
#[tokio::test]
async fn unknown_host_without_persist_permission_does_not_leak_password() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
// This test proves the client never sends a password to an untrusted
// host. It must run with NO prompt gateway installed so the handshake
// fails closed (no UI to confirm the key -> reject before auth).
@@ -3479,7 +3481,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU=
#[tokio::test]
async fn reconnect_auth_failure_is_reported_instead_of_a_dead_local_port() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
ssh_prompt::clear_ssh_prompt_gateway();
let publickey_attempts = Arc::new(AtomicUsize::new(0));
@@ -3573,7 +3575,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU=
#[tokio::test]
async fn rejected_public_key_reports_what_the_server_refused() {
let _guard = PROMPT_TEST_LOCK.lock().await;
let _guard = prompt_test_lock().lock().await;
ssh_prompt::clear_ssh_prompt_gateway();
// Every publickey attempt is refused, so this exercises the initial
+6 -5
View File
@@ -40,16 +40,17 @@ pub use manifest::{
current_plugin_target, resolve_safe_plugin_path, PluginBackendEntrypoint, PluginBackendTransport,
PluginCompatibility, PluginConnectionActionContribution, PluginConnectionActionVariant, PluginConnectionActionWhen,
PluginConnectionCapability, PluginConnectionProviderContribution, PluginContribution, PluginDriverManifest,
PluginEngines, PluginEntrypoints, PluginFieldCondition, PluginFilesystemCapability,
PluginFilesystemProviderContribution, PluginFormFieldBinding, PluginFormFieldDefinition, PluginFormFieldOption,
PluginEngines, PluginEntrypoints, PluginFieldCondition, PluginFieldConditionClause, PluginFieldConditionLiteral,
PluginFilesystemCapability, PluginFilesystemProviderContribution, PluginFormFieldBinding,
PluginFormFieldDefinition, PluginFormFieldOption, PluginFormFieldPicker, PluginFormFieldPickerKind,
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,
PLUGIN_CONNECTION_TEST_METHOD, SUPPORTED_PLUGIN_HOST_API_VERSION, SUPPORTED_PLUGIN_HOST_FEATURES,
SUPPORTED_PLUGIN_MANIFEST_VERSION, SUPPORTED_PLUGIN_PERMISSIONS, SUPPORTED_PLUGIN_PROTOCOL_VERSION,
};
pub use runtime::{
PluginBinaryMessage, PluginEvent, PluginHandshake, PluginHandshakeIdentity, PluginSessionState,
PluginSessionStatus, PluginSidecarSession, PLUGIN_REQUEST_TIMEOUT,
PluginSessionStatus, PluginSidecarSession, PLUGIN_REQUEST_TIMEOUT, PLUGIN_REQUEST_USER_INPUT_METHOD,
};
#[derive(Debug, Clone)]
+255 -31
View File
@@ -546,39 +546,36 @@ fn plugin_field_value_is_empty(value: Option<&serde_json::Value>) -> bool {
}
}
/// Resolves the stored value of one plugin form field (a condition's operand).
fn plugin_condition_field_value(
key: &str,
config: &ConnectionConfig,
provider: &PluginConnectionProviderContribution,
) -> Option<serde_json::Value> {
let sibling = provider.fields.iter().find(|field| field.key == key)?;
plugin_connection_field_value(config, sibling)
}
/// Evaluates a plugin manifest field condition (`visible_when` / `required_when`,
/// Host API 1.1) against the stored connection values. Mirrors the frontend
/// `pluginFieldConditions.ts` semantics: the condition matches when the current
/// value of the referenced sibling field is listed in `one_of`; a missing
/// sibling field or an unset/empty value never matches.
/// `pluginFieldConditions.ts` semantics: a leaf matches when the current value
/// of the referenced sibling field is listed in `one_of`, and a missing sibling
/// field or an unset/empty value never matches; `all_of` / `any_of` / `not`
/// compose those clauses.
fn plugin_field_condition_matches(
condition: &super::PluginFieldCondition,
config: &ConnectionConfig,
provider: &PluginConnectionProviderContribution,
) -> bool {
let Some(sibling) = provider.fields.iter().find(|field| field.key == condition.field) else {
return false;
};
let Some(value) = plugin_connection_field_value(config, sibling) else {
return false;
};
let text = match value {
serde_json::Value::Null => return false,
serde_json::Value::String(text) => text,
other => other.to_string(),
};
if text.trim().is_empty() {
return false;
}
condition.one_of.iter().any(|option| option == &text)
condition.matches(&|key| plugin_condition_field_value(key, config, provider))
}
/// A field participates in the form when its `visible_when` (if any) matches.
/// Mirrors the frontend cascade (`pluginFieldConditions.ts`): once the raw
/// condition matches, the referenced sibling must itself be visible — a hidden
/// container field's stored default (e.g. `krb_credential_type: "password"`
/// while auth is simple) must not mark grandchild fields visible + required,
/// otherwise non-dialog write paths reject connections the dialog accepts.
/// Mirrors the frontend cascade (`pluginFieldConditions.ts`): a clause only
/// counts while the sibling it reads is itself visible — a hidden container
/// field's stored default (e.g. `krb_credential_type: "password"` while auth is
/// simple) must not mark grandchild fields visible + required, otherwise
/// non-dialog write paths reject connections the dialog accepts.
fn plugin_field_is_visible(
field: &PluginFormFieldDefinition,
config: &ConnectionConfig,
@@ -598,19 +595,59 @@ fn plugin_field_is_visible_cached(
let Some(condition) = &field.visible_when else {
return true;
};
if !plugin_field_condition_matches(condition, config, provider) {
return false;
plugin_field_condition_is_visible(condition, config, provider, seen)
}
/// Visibility-aware evaluation of one condition expression, mirroring the
/// frontend `conditionExpressionVisible`: a clause only counts while the field
/// it reads is itself visible, `any_of`/`all_of` compose those verdicts, and
/// `not` additionally requires every operand to be visible (an inverted hidden
/// value is not a usable answer).
fn plugin_field_condition_is_visible(
condition: &super::PluginFieldCondition,
config: &ConnectionConfig,
provider: &PluginConnectionProviderContribution,
seen: &mut HashSet<String>,
) -> bool {
match condition {
super::PluginFieldCondition::Field(clause) => {
if !plugin_field_condition_matches(condition, config, provider) {
return false;
}
plugin_field_key_is_visible(&clause.field, config, provider, seen)
}
super::PluginFieldCondition::AllOf { all_of } => {
all_of.iter().all(|child| plugin_field_condition_is_visible(child, config, provider, seen))
}
super::PluginFieldCondition::AnyOf { any_of } => {
any_of.iter().any(|child| plugin_field_condition_is_visible(child, config, provider, seen))
}
super::PluginFieldCondition::Not { not } => {
let operands_visible =
not.referenced_fields().iter().all(|key| plugin_field_key_is_visible(key, config, provider, seen));
operands_visible && !plugin_field_condition_matches(not, config, provider)
}
}
// The condition matched, so the sibling exists (its value was readable);
// guard cycles anyway and fall through to visible, mirroring the frontend.
let Some(sibling) = provider.fields.iter().find(|candidate| candidate.key == condition.field) else {
}
/// Whether the sibling field behind one referenced key is itself rendered.
/// Cycles count as visible, mirroring the frontend's `seen` guard.
fn plugin_field_key_is_visible(
key: &str,
config: &ConnectionConfig,
provider: &PluginConnectionProviderContribution,
seen: &mut HashSet<String>,
) -> bool {
let Some(sibling) = provider.fields.iter().find(|candidate| candidate.key == key) else {
return true;
};
if seen.contains(&sibling.key) {
return true;
}
seen.insert(sibling.key.clone());
plugin_field_is_visible_cached(sibling, config, provider, seen)
let visible = plugin_field_is_visible_cached(sibling, config, provider, seen);
seen.remove(&sibling.key);
visible
}
/// Effective required = static `required` OR a matching `required_when`
@@ -695,8 +732,8 @@ fn ensure_permission(plugin: &super::InstalledPlugin, required_permission: Optio
#[cfg(test)]
mod tests {
use super::{
plugin_connection_action_result, plugin_invoke_connection_action, validate_plugin_connection_values,
validate_plugin_connection_values_for_action,
plugin_connection_action_result, plugin_field_is_visible, plugin_invoke_connection_action,
validate_plugin_connection_values, validate_plugin_connection_values_for_action,
};
use crate::models::connection::ConnectionConfig;
use crate::plugins::PluginConnectionProviderContribution;
@@ -1117,4 +1154,191 @@ mod tests {
let cyclic_config = config_for(serde_json::json!({ "a": "x", "b": "x" }));
assert!(validate_plugin_connection_values(&cyclic_config, &cyclic).is_ok());
}
#[test]
fn condition_sibling_branches_keep_cycle_detection_path_local() {
let mut provider: PluginConnectionProviderContribution = serde_json::from_value(serde_json::json!({
"id": "test.connection",
"database_type": "test",
"fields": [
{ "key": "mode", "label": "Mode", "type": "text" },
{
"key": "auth", "label": "Auth", "type": "text",
"visible_when": { "field": "mode", "one_of": ["enabled"] }
},
{ "key": "flag", "label": "Flag", "type": "boolean" },
{ "key": "target", "label": "Target", "type": "text", "required": true }
]
}))
.unwrap();
let mut config: ConnectionConfig = serde_json::from_value(serde_json::json!({
"id": "plugin-connection", "name": "Test", "db_type": "plugin",
"host": "", "port": 0, "username": "", "password": ""
}))
.unwrap();
let clause = serde_json::json!({ "field": "auth", "one_of": ["password"] });
let branches = [
serde_json::json!({ "all_of": [clause, { "field": "flag", "one_of": [true] }] }),
serde_json::json!({ "all_of": [{ "field": "flag", "one_of": [false] }, clause] }),
];
for condition in [
clause.clone(),
serde_json::json!({ "any_of": [clause, clause] }),
serde_json::json!({ "all_of": [clause, clause] }),
serde_json::json!({ "any_of": [branches[0], branches[1]] }),
serde_json::json!({ "any_of": [branches[1], branches[0]] }),
] {
provider.fields[3].visible_when = Some(serde_json::from_value(condition.clone()).unwrap());
for flag in [false, true] {
for (mode, expected) in [("disabled", false), ("enabled", true)] {
config.external_config =
Some(serde_json::json!({ "mode": mode, "auth": "password", "flag": flag }));
assert_eq!(
plugin_field_is_visible(&provider.fields[3], &config, &provider),
expected,
"{condition}"
);
assert_eq!(validate_plugin_connection_values(&config, &provider).is_err(), expected, "{condition}");
}
}
}
provider.fields[3].visible_when = Some(
serde_json::from_value(serde_json::json!({
"not": { "any_of": [clause, clause] }
}))
.unwrap(),
);
for (mode, expected) in [("disabled", false), ("enabled", true)] {
config.external_config = Some(serde_json::json!({ "mode": mode, "auth": "token" }));
assert_eq!(plugin_field_is_visible(&provider.fields[3], &config, &provider), expected);
}
}
#[test]
fn condition_cycles_still_require_matching_values() {
let provider: PluginConnectionProviderContribution = serde_json::from_value(serde_json::json!({
"id": "test.connection",
"database_type": "test",
"fields": [
{ "key": "first", "label": "First", "type": "text", "visible_when": { "field": "second", "one_of": ["on"] } },
{ "key": "second", "label": "Second", "type": "text", "visible_when": { "field": "first", "one_of": ["on"] } },
{ "key": "self", "label": "Self", "type": "text", "visible_when": { "field": "self", "one_of": ["on"] } },
{ "key": "unknown", "label": "Unknown", "type": "text", "visible_when": { "field": "missing", "one_of": ["on"] } }
]
}))
.unwrap();
let mut config: ConnectionConfig = serde_json::from_value(serde_json::json!({
"id": "plugin-connection", "name": "Test", "db_type": "plugin",
"host": "", "port": 0, "username": "", "password": ""
}))
.unwrap();
for (value, expected) in [("on", true), ("off", false)] {
config.external_config =
Some(serde_json::json!({ "first": value, "second": "on", "self": value, "missing": value }));
for field in &provider.fields {
assert_eq!(
plugin_field_is_visible(field, &config, &provider),
expected && field.key != "unknown",
"{}",
field.key
);
}
}
}
/// Mirrors the SSH plugin shape the composite contract was added for:
/// `sudo_command` is required only while `sudo_source = custom` **and**
/// `read_only = false`, which the single-clause contract could not express.
#[test]
fn composite_condition_gates_required_fields_like_the_dialog() {
let provider: PluginConnectionProviderContribution = serde_json::from_value(serde_json::json!({
"id": "ssh.connection",
"label": "SSH",
"database_type": "ssh",
"fields": [
{
"key": "read_only",
"label": "Read only",
"type": "boolean",
"binding": "config",
"default": false
},
{
"key": "sudo_source",
"label": "Sudo source",
"type": "select",
"binding": "config",
"default": "none",
"options": [
{ "value": "none", "label": "None" },
{ "value": "custom", "label": "Custom" }
]
},
{
"key": "sudo_command",
"label": "Sudo command",
"type": "text",
"binding": "config",
"visible_when": {
"all_of": [
{ "field": "sudo_source", "one_of": ["custom"] },
{ "field": "read_only", "one_of": [false] }
]
},
"required_when": {
"all_of": [
{ "field": "sudo_source", "one_of": ["custom"] },
{ "not": { "field": "read_only", "one_of": [true] } }
]
}
}
]
}))
.unwrap();
let config_for = |external: serde_json::Value| -> ConnectionConfig {
serde_json::from_value(serde_json::json!({
"id": "plugin-connection",
"name": "Prod SSH",
"db_type": "plugin",
"host": "ssh.example.com",
"port": 22,
"username": "root",
"password": "",
"database": null,
"plugin_id": "io.dbx.ssh",
"plugin_connection_provider": "ssh.connection",
"plugin_connection_type": "ssh",
"external_config": external
}))
.unwrap()
};
// Read-only sessions ignore the sudo block: nothing is required.
assert!(validate_plugin_connection_values(
&config_for(serde_json::json!({ "sudo_source": "custom", "read_only": true })),
&provider
)
.is_ok());
// Sudo stays off: nothing is required either.
assert!(validate_plugin_connection_values(
&config_for(serde_json::json!({ "sudo_source": "none", "read_only": false })),
&provider
)
.is_ok());
// Custom sudo on a writable connection: the command becomes required.
assert!(validate_plugin_connection_values(
&config_for(serde_json::json!({ "sudo_source": "custom", "read_only": false })),
&provider
)
.unwrap_err()
.contains("Sudo command' is required"));
// ...and passes once it is filled in.
assert!(validate_plugin_connection_values(
&config_for(
serde_json::json!({ "sudo_source": "custom", "read_only": false, "sudo_command": "sudo -n true" })
),
&provider
)
.is_ok());
}
}
+618 -43
View File
@@ -5,7 +5,15 @@ use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
pub const SUPPORTED_PLUGIN_MANIFEST_VERSION: u32 = 1;
pub const SUPPORTED_PLUGIN_HOST_API_VERSION: &str = "1.0.0";
/// Host API version the host advertises at `plugin/initialize`.
///
/// 1.1 adds the plugin-initiated `host/requestUserInput` method (see
/// `plugins/runtime.rs`). It is additive: 1.0 plugins keep working, and a
/// plugin that wants the capability must check the advertised version (or the
/// `host.requestUserInput` entry in `host.features`) before calling it.
pub const SUPPORTED_PLUGIN_HOST_API_VERSION: &str = "1.1.0";
/// Capabilities the host advertises to a plugin backend at `plugin/initialize`.
pub const SUPPORTED_PLUGIN_HOST_FEATURES: &[&str] = &["host.requestUserInput"];
pub const SUPPORTED_PLUGIN_PROTOCOL_VERSION: u32 = 1;
pub const PLUGIN_CONNECTION_TEST_METHOD: &str = "connection/test";
pub const PLUGIN_CONNECTION_CONNECT_METHOD: &str = "connection/connect";
@@ -16,6 +24,13 @@ pub const SUPPORTED_PLUGIN_PERMISSIONS: &[&str] = &["host.events", "host.binary"
/// Cap the number of `host.network:<origin>` entries so a manifest cannot bloat
/// the sandbox CSP or enumerate large origin lists.
pub const MAX_PLUGIN_NETWORK_ORIGINS: usize = 8;
/// Bound the `visible_when` / `required_when` expression tree so a hostile
/// manifest cannot make the host or the dialog evaluator do unbounded work.
pub const MAX_PLUGIN_FIELD_CONDITION_DEPTH: usize = 8;
pub const MAX_PLUGIN_FIELD_CONDITION_NODES: usize = 64;
/// Cap the file filters a picker may declare so one manifest cannot bloat the
/// native dialog or the browser `accept` attribute.
pub const MAX_PLUGIN_PICKER_FILTERS: usize = 16;
const HOST_NETWORK_PERMISSION_PREFIX: &str = "host.network:";
/// Parse a `host.network:https://host[:port]` permission into the origin that
@@ -104,9 +119,9 @@ pub struct PluginEngines {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginEntrypoints {
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend: Option<PluginBackendEntrypoint>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui: Option<PluginUiEntrypoint>,
}
@@ -157,7 +172,7 @@ pub struct PluginDriverManifest {
pub id: String,
pub label: String,
pub kind: String,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database_type: Option<String>,
}
@@ -236,18 +251,27 @@ pub struct PluginFormFieldDefinition {
pub label: String,
#[serde(rename = "type")]
pub field_type: PluginFormFieldType,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<PluginFormFieldOption>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binding: Option<PluginFormFieldBinding>,
/// Optional local-file action on a text/password/textarea field.
///
/// Desktop (client-side) hosts open a native picker and store the chosen
/// **absolute path** in this field. Browser hosts cannot read a client
/// path, so the same action becomes an **upload**: the host reads the file
/// and stores its content in `content_field` instead. Contract documented
/// in `plugins/README.md`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub picker: Option<PluginFormFieldPicker>,
/// Plugin method returning `{ options: [{ value, label }] }`; the host
/// connection form fetches it and renders the field as a dynamic select.
/// Optional and forward/backward compatible: older hosts reject the
@@ -260,11 +284,189 @@ pub struct PluginFormFieldDefinition {
pub required_when: Option<PluginFieldCondition>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
/// A `visible_when` / `required_when` expression.
///
/// The legacy single-field form `{ "field": "mode", "one_of": ["custom"] }`
/// keeps its exact meaning. Composite forms (`all_of`, `any_of`, `not`) let a
/// manifest express combinations such as
/// `sudo_source = custom AND read_only = false`, which a single-field
/// condition cannot. Composite nodes nest arbitrarily; [`MAX_PLUGIN_FIELD_CONDITION_DEPTH`]
/// bounds the evaluation cost of a hostile manifest.
///
/// Semantics are shared with the frontend evaluator
/// (`apps/desktop/src/lib/plugins/pluginFieldConditions.ts`): a leaf matches
/// when the referenced sibling field holds a non-empty value that equals one of
/// the listed literals (compared by canonical string form, so the boolean
/// `false` matches both the literal `false` and the literal `"false"`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged, deny_unknown_fields)]
pub enum PluginFieldCondition {
/// Legacy single-field clause: `{ "field": "...", "one_of": [...] }`.
Field(PluginFieldConditionClause),
/// Every nested condition must match.
AllOf { all_of: Vec<PluginFieldCondition> },
/// At least one nested condition must match.
AnyOf { any_of: Vec<PluginFieldCondition> },
/// Inverts the nested condition.
Not { not: Box<PluginFieldCondition> },
}
/// The legacy single-field clause of a [`PluginFieldCondition`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginFieldCondition {
pub struct PluginFieldConditionClause {
pub field: String,
pub one_of: Vec<String>,
pub one_of: Vec<PluginFieldConditionLiteral>,
}
/// A value a manifest may list in `one_of`. Plugins mostly compare strings,
/// but booleans and numbers are allowed so a condition can be written exactly
/// like the value the form produces (`"read_only": [false]` instead of
/// `["false"]`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PluginFieldConditionLiteral {
String(String),
Bool(bool),
Number(serde_json::Number),
}
impl PluginFieldConditionLiteral {
/// Canonical string form used for comparison. This is the rule the
/// frontend has always applied (`String(value)`), so every manifest written
/// against the string-only contract keeps matching.
pub fn canonical(&self) -> String {
match self {
Self::String(value) => value.clone(),
Self::Bool(value) => value.to_string(),
// `serde_json::Number` prints `22.0` for a float that JavaScript
// renders as `22`; normalize integral floats so the two sides agree.
Self::Number(value) => normalize_condition_number(value),
}
}
}
fn normalize_condition_number(number: &serde_json::Number) -> String {
if let Some(value) = number.as_i64() {
return value.to_string();
}
if let Some(value) = number.as_u64() {
return value.to_string();
}
if let Some(value) = number.as_f64() {
if value.fract() == 0.0 && value.abs() < 9_007_199_254_740_992.0 {
return format!("{}", value as i64);
}
}
number.to_string()
}
impl PluginFieldCondition {
/// Every sibling field key the expression reads, in declaration order.
/// Duplicates are preserved; callers de-duplicate when they need to.
pub fn referenced_fields(&self) -> Vec<&str> {
let mut fields = Vec::new();
self.collect_referenced_fields(&mut fields);
fields
}
fn collect_referenced_fields<'a>(&'a self, fields: &mut Vec<&'a str>) {
match self {
Self::Field(clause) => fields.push(clause.field.as_str()),
Self::AllOf { all_of } => all_of.iter().for_each(|child| child.collect_referenced_fields(fields)),
Self::AnyOf { any_of } => any_of.iter().for_each(|child| child.collect_referenced_fields(fields)),
Self::Not { not } => not.collect_referenced_fields(fields),
}
}
/// Structural validation shared by every condition site. Returns
/// human-readable problems without a location prefix.
pub fn validate(&self) -> Vec<String> {
let mut errors = Vec::new();
if self.node_count() > MAX_PLUGIN_FIELD_CONDITION_NODES {
errors.push(format!("condition uses more than {MAX_PLUGIN_FIELD_CONDITION_NODES} nodes"));
return errors;
}
self.validate_at_depth(0, &mut errors);
errors
}
fn validate_at_depth(&self, depth: usize, errors: &mut Vec<String>) {
if depth > MAX_PLUGIN_FIELD_CONDITION_DEPTH {
errors.push(format!("condition nesting exceeds {MAX_PLUGIN_FIELD_CONDITION_DEPTH} levels"));
return;
}
match self {
Self::Field(clause) => {
if clause.field.trim().is_empty() {
errors.push("condition field cannot be empty".to_string());
}
if clause.one_of.is_empty() {
errors.push("condition one_of cannot be empty".to_string());
}
}
Self::AllOf { all_of } => {
if all_of.is_empty() {
errors.push("condition all_of cannot be empty".to_string());
}
for child in all_of {
child.validate_at_depth(depth + 1, errors);
}
}
Self::AnyOf { any_of } => {
if any_of.is_empty() {
errors.push("condition any_of cannot be empty".to_string());
}
for child in any_of {
child.validate_at_depth(depth + 1, errors);
}
}
Self::Not { not } => not.validate_at_depth(depth + 1, errors),
}
}
/// Number of nodes in this expression tree (including this one).
pub fn node_count(&self) -> usize {
match self {
Self::Field(_) => 1,
Self::AllOf { all_of } => 1 + all_of.iter().map(Self::node_count).sum::<usize>(),
Self::AnyOf { any_of } => 1 + any_of.iter().map(Self::node_count).sum::<usize>(),
Self::Not { not } => 1 + not.node_count(),
}
}
/// Raw expression evaluation against a field-value reader. Visibility
/// cascade handling (a referenced field that is itself hidden) is applied
/// by callers, exactly like the single-field contract behaved.
pub fn matches(&self, read: &impl Fn(&str) -> Option<serde_json::Value>) -> bool {
match self {
Self::Field(clause) => {
let Some(value) = read(&clause.field) else {
return false;
};
let text = condition_value_text(&value);
if text.trim().is_empty() {
return false;
}
clause.one_of.iter().any(|literal| literal.canonical() == text)
}
Self::AllOf { all_of } => all_of.iter().all(|child| child.matches(read)),
Self::AnyOf { any_of } => any_of.iter().any(|child| child.matches(read)),
Self::Not { not } => !not.matches(read),
}
}
}
/// Canonical string form of a stored field value, mirroring the frontend
/// (`String(value)`) so a boolean `false` and the literal `"false"` agree.
pub fn condition_value_text(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => String::new(),
serde_json::Value::String(text) => text.clone(),
serde_json::Value::Bool(value) => value.to_string(),
serde_json::Value::Number(value) => normalize_condition_number(value),
other => other.to_string(),
}
}
impl PluginFormFieldDefinition {
@@ -309,22 +511,50 @@ pub struct PluginFormFieldOption {
pub value: String,
}
/// A file action on a plugin connection field (see
/// [`PluginFormFieldDefinition::picker`]).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginFormFieldPicker {
pub kind: PluginFormFieldPickerKind,
/// File filters offered by the picker, e.g. `[".pem", ".key"]`. Entries are
/// extensions (`.ext`) or MIME types (`text/plain`); non-empty entries only.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub accept: Vec<String>,
/// Field that receives the file **content** on hosts without a client
/// filesystem (the browser build), which cannot produce a usable path.
/// Required for the picker to appear in the browser; on desktop the chosen
/// path goes into the declaring field and this field is cleared so the two
/// sources cannot disagree.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_field: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PluginFormFieldPickerKind {
/// Pick one existing file.
File,
/// Pick one existing directory (desktop only; no browser equivalent).
Directory,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginConnectionProviderContribution {
pub id: String,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
pub database_type: String,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub fields: Vec<PluginFormFieldDefinition>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workbench: Option<String>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filesystem_provider: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<PluginConnectionCapability>,
@@ -351,17 +581,17 @@ pub enum PluginConnectionCapability {
pub struct PluginConnectionActionContribution {
pub id: String,
pub label: String,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant: Option<PluginConnectionActionVariant>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub when: Option<PluginConnectionActionWhen>,
#[serde(default, skip_serializing_if = "is_false")]
pub close_on_success: bool,
#[serde(default = "default_action_requires_valid_form", skip_serializing_if = "is_true")]
pub requires_valid_form: bool,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
}
@@ -400,9 +630,9 @@ pub enum PluginConnectionActionWhen {
pub struct PluginWorkbenchContribution {
pub id: String,
pub label: String,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
}
@@ -414,9 +644,9 @@ pub struct PluginWorkbenchContribution {
pub struct PluginContextMenuContribution {
pub id: String,
pub label: String,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// Menu surface the item belongs to; currently only `connection`.
#[serde(default)]
@@ -430,9 +660,9 @@ pub struct PluginContextMenuContribution {
pub struct PluginResultViewContribution {
pub id: String,
pub label: String,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
}
@@ -442,13 +672,13 @@ pub struct PluginFilesystemProviderContribution {
pub id: String,
pub label: String,
pub schemes: Vec<String>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default)]
pub capabilities: Vec<PluginFilesystemCapability>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root_uri: Option<String>,
}
@@ -979,6 +1209,78 @@ fn validate_connection_actions(
}
}
/// A picker action only makes sense on a field the user can type into, and the
/// field that receives uploaded content must be a declared sibling.
fn validate_form_field_picker(
field: &PluginFormFieldDefinition,
fields: &[PluginFormFieldDefinition],
contribution_index: usize,
field_index: usize,
errors: &mut Vec<String>,
) {
let Some(picker) = &field.picker else {
return;
};
let location = format!("Contribution at index {contribution_index} field {field_index} picker");
if !matches!(
field.field_type,
PluginFormFieldType::Text | PluginFormFieldType::Password | PluginFormFieldType::Textarea
) {
errors.push(format!("{location} is only supported on text, password, or textarea fields"));
}
if picker.accept.len() > MAX_PLUGIN_PICKER_FILTERS {
errors.push(format!("{location} declares more than {MAX_PLUGIN_PICKER_FILTERS} filters"));
}
for filter in &picker.accept {
if !valid_picker_filter(filter) {
errors.push(format!("{location} filter '{filter}' must look like '.pem' or 'text/plain'"));
}
}
if picker.kind == PluginFormFieldPickerKind::Directory && picker.content_field.is_some() {
errors.push(format!("{location} cannot upload a directory into a content field"));
}
let Some(content_key) = &picker.content_field else {
return;
};
match fields.iter().find(|candidate| candidate.key == *content_key) {
None => errors.push(format!("{location} content_field references unknown field '{content_key}'")),
Some(content) => {
if content.key == field.key {
errors.push(format!("{location} content_field cannot be the declaring field"));
}
if !matches!(
content.field_type,
PluginFormFieldType::Text | PluginFormFieldType::Password | PluginFormFieldType::Textarea
) {
errors.push(format!("{location} content_field '{content_key}' must be a text or textarea field"));
}
}
}
}
/// Accept entries are file extensions (`.pem`) or MIME types (`text/plain`), so
/// the browser `<input accept>` attribute and the native dialog filters can use
/// them directly. Anything else is rejected instead of silently ignored.
fn valid_picker_filter(filter: &str) -> bool {
if let Some(extension) = filter.strip_prefix('.') {
return !extension.is_empty()
&& extension.len() <= 16
&& extension.chars().all(|character| character.is_ascii_alphanumeric());
}
let mut parts = filter.split('/');
let (Some(kind), Some(subtype), None) = (parts.next(), parts.next(), parts.next()) else {
return false;
};
let valid_part = |part: &str| {
!part.is_empty()
&& part.len() <= 64
&& part
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.' | '_' | '*'))
};
valid_part(kind) && valid_part(subtype)
}
fn validate_form_fields(fields: &[PluginFormFieldDefinition], contribution_index: usize, errors: &mut Vec<String>) {
let mut seen_keys = HashSet::new();
let field_keys = fields.iter().map(|field| field.key.as_str()).collect::<HashSet<_>>();
@@ -993,6 +1295,7 @@ fn validate_form_fields(fields: &[PluginFormFieldDefinition], contribution_index
&format!("Contribution at index {contribution_index} field {field_index} label"),
errors,
);
validate_form_field_picker(field, fields, contribution_index, field_index, errors);
for (name, condition) in
[("visible_when", field.visible_when.as_ref()), ("required_when", field.required_when.as_ref())]
@@ -1000,16 +1303,15 @@ fn validate_form_fields(fields: &[PluginFormFieldDefinition], contribution_index
let Some(condition) = condition else {
continue;
};
if condition.one_of.is_empty() {
errors.push(format!(
"Contribution at index {contribution_index} field {field_index} {name} one_of cannot be empty"
));
}
if !field_keys.contains(condition.field.as_str()) {
errors.push(format!(
"Contribution at index {contribution_index} field {field_index} {name} references unknown field '{}'",
condition.field
));
let location = format!("Contribution at index {contribution_index} field {field_index} {name}");
errors.extend(condition.validate().into_iter().map(|error| format!("{location} {error}")));
for referenced in condition.referenced_fields() {
// Self-references are tolerated for backward compatibility (a
// single-field manifest could always point a field at itself);
// unknown targets were already rejected by the v1 contract.
if !field_keys.contains(referenced) {
errors.push(format!("{location} references unknown field '{referenced}'"));
}
}
}
@@ -1713,6 +2015,55 @@ mod tests {
assert!(errors.iter().any(|error| error.contains("timeout_ms must be between")));
}
/// The manifest the frontend receives is a re-serialization of the parsed
/// manifest, so an absent optional must stay absent. Emitting `null`
/// instead made the connection form treat "no default" as a real `null`
/// value: untouched fields showed a literal "null" and a save persisted the
/// four-character string "null" into `connection_secrets`.
#[test]
fn serialized_form_fields_omit_absent_optionals() {
let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
"manifest_version": 1,
"id": "io.dbx.nulls",
"name": "Nulls",
"version": "1.0.0",
"publisher": "example",
"engines": { "dbx": ">=0.1.0", "host_api": "^1.0" },
"contributions": [{
"type": "connection-provider",
"id": "nulls.connection",
"label": "Nulls",
"database_type": "nulls",
"fields": [
{ "key": "sudo_password", "label": "Sudo password", "type": "password", "binding": "secret" },
{ "key": "with_default", "label": "With default", "type": "text", "default": "root" },
{ "key": "explicit_null", "label": "Explicit null", "type": "text", "default": null },
{ "key": "mode", "label": "Mode", "type": "text" }
]
}]
}))
.unwrap();
let serialized = serde_json::to_value(&manifest).unwrap();
let fields = serialized["contributions"][0]["fields"].as_array().unwrap();
// A field that declares nothing optional carries none of those keys, so
// the frontend can never mistake "absent" for a `null` value.
for key in ["default", "binding", "description", "placeholder"] {
assert!(fields[3].get(key).is_none(), "bare field must not serialize `{key}`: {}", fields[3]);
}
// Declared values survive the round trip; an explicit `null` default
// means "no default" and normalizes to an absent key rather than a
// value the form would try to render.
assert_eq!(fields[0]["binding"], "secret");
assert_eq!(fields[1]["default"], "root");
assert!(fields[2].get("default").is_none());
// Provider-level optionals follow the same rule.
assert!(serialized["contributions"][0].get("icon").is_none());
assert!(serialized["contributions"][0].get("workbench").is_none());
assert!(serialized["contributions"][0].get("filesystem_provider").is_none());
}
#[test]
fn parses_form_field_conditions() {
let dir = tempfile::tempdir().unwrap();
@@ -1754,9 +2105,21 @@ mod tests {
assert!(compatibility.compatible, "{:?}", compatibility.errors);
let visible_when = provider.fields[1].visible_when.as_ref().unwrap();
assert_eq!(visible_when.field, "mode");
assert_eq!(visible_when.one_of, vec!["custom".to_string()]);
assert_eq!(provider.fields[1].required_when.as_ref().unwrap().one_of, vec!["custom".to_string()]);
let super::PluginFieldCondition::Field(clause) = visible_when else {
panic!("legacy single-field condition must deserialize into the field clause");
};
assert_eq!(clause.field, "mode");
assert_eq!(clause.one_of, vec![super::PluginFieldConditionLiteral::String("custom".to_string())]);
// Serializing a legacy clause keeps the exact v1 manifest shape.
assert_eq!(
serde_json::to_value(visible_when).unwrap(),
serde_json::json!({ "field": "mode", "one_of": ["custom"] })
);
let required = provider.fields[1].required_when.as_ref().unwrap();
let super::PluginFieldCondition::Field(required_clause) = required else {
panic!("legacy single-field condition must deserialize into the field clause");
};
assert_eq!(required_clause.one_of, vec![super::PluginFieldConditionLiteral::String("custom".to_string())]);
}
#[test]
@@ -1815,10 +2178,222 @@ mod tests {
let compatibility = manifest.compatibility(dir.path(), "0.5.68");
assert!(!compatibility.compatible);
assert!(compatibility.errors.iter().any(|error| error.contains("visible_when one_of cannot be empty")));
assert!(compatibility
.errors
.iter()
.any(|error| error.contains("visible_when condition one_of cannot be empty")));
assert!(compatibility
.errors
.iter()
.any(|error| error.contains("visible_when references unknown field 'missing'")));
}
#[test]
fn parses_composite_form_field_conditions() {
let dir = tempfile::tempdir().unwrap();
let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
"manifest_version": 1,
"id": "io.dbx.conditions",
"name": "Conditions",
"version": "1.0.0",
"publisher": "example",
"engines": { "dbx": ">=0.1.0", "host_api": "^1.0" },
"contributions": [{
"type": "connection-provider",
"id": "conditions.connection",
"database_type": "conditions",
"fields": [
{ "key": "authentication", "label": "Auth", "type": "text" },
{ "key": "read_only", "label": "Read only", "type": "boolean" },
{ "key": "sudo_source", "label": "Sudo source", "type": "text" },
{
"key": "sudo_command",
"label": "Sudo command",
"type": "text",
"visible_when": {
"all_of": [
{ "field": "sudo_source", "one_of": ["custom"] },
{ "field": "read_only", "one_of": [false] }
]
},
"required_when": {
"all_of": [
{ "field": "sudo_source", "one_of": ["custom"] },
{ "not": { "field": "read_only", "one_of": [true] } }
]
}
}
]
}]
}))
.unwrap();
let compatibility = manifest.compatibility(dir.path(), "0.6.14");
assert!(compatibility.compatible, "{:?}", compatibility.errors);
let provider = manifest.connection_provider("conditions.connection").unwrap().unwrap();
let visible_when = provider.fields[3].visible_when.as_ref().unwrap();
assert_eq!(visible_when.referenced_fields(), vec!["sudo_source", "read_only"]);
assert_eq!(visible_when.node_count(), 3);
assert!(matches!(visible_when, super::PluginFieldCondition::AllOf { .. }));
}
#[test]
fn rejects_malformed_composite_form_field_conditions() {
let dir = tempfile::tempdir().unwrap();
let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
"manifest_version": 1,
"id": "io.dbx.conditions",
"name": "Conditions",
"version": "1.0.0",
"publisher": "example",
"engines": { "dbx": ">=0.1.0", "host_api": "^1.0" },
"contributions": [{
"type": "connection-provider",
"id": "conditions.connection",
"database_type": "conditions",
"fields": [
{ "key": "mode", "label": "Mode", "type": "text" },
{
"key": "empty_all_of",
"label": "Empty",
"type": "text",
"visible_when": { "all_of": [] }
},
{
"key": "unknown_nested",
"label": "Unknown",
"type": "text",
"required_when": { "any_of": [{ "field": "ghost", "one_of": ["x"] }] }
}
]
}]
}))
.unwrap();
let compatibility = manifest.compatibility(dir.path(), "0.6.14");
assert!(!compatibility.compatible);
assert!(compatibility.errors.iter().any(|error| error.contains("condition all_of cannot be empty")));
assert!(compatibility
.errors
.iter()
.any(|error| error.contains("required_when references unknown field 'ghost'")));
}
/// The SSH plugin asks the host for a key file: desktop hosts store the
/// client path, browser hosts upload the content into a paired field.
#[test]
fn parses_and_validates_file_pickers() {
let dir = tempfile::tempdir().unwrap();
let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
"manifest_version": 1,
"id": "io.dbx.ssh",
"name": "SSH",
"version": "1.0.0",
"publisher": "example",
"engines": { "dbx": ">=0.1.0", "host_api": "^1.0" },
"contributions": [{
"type": "connection-provider",
"id": "ssh.connection",
"label": "SSH",
"database_type": "ssh",
"fields": [
{
"key": "private_key_path",
"label": "Private key path",
"type": "text",
"binding": "config",
"picker": {
"kind": "file",
"accept": [".pem", ".key", "text/plain"],
"content_field": "private_key"
}
},
{ "key": "private_key", "label": "Private key", "type": "textarea", "binding": "secret" }
]
}]
}))
.unwrap();
let compatibility = manifest.compatibility(dir.path(), "0.6.15");
assert!(compatibility.compatible, "{:?}", compatibility.errors);
let provider = manifest.connection_provider("ssh.connection").unwrap().unwrap();
let picker = provider.fields[0].picker.as_ref().unwrap();
assert_eq!(picker.kind, super::PluginFormFieldPickerKind::File);
assert_eq!(picker.accept, vec![".pem", ".key", "text/plain"]);
assert_eq!(picker.content_field.as_deref(), Some("private_key"));
// The picker round-trips through the manifest the UI receives.
let serialized = serde_json::to_value(&manifest).unwrap();
assert_eq!(serialized["contributions"][0]["fields"][0]["picker"]["kind"], "file");
}
#[test]
fn rejects_invalid_file_pickers() {
let dir = tempfile::tempdir().unwrap();
let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
"manifest_version": 1,
"id": "io.dbx.ssh",
"name": "SSH",
"version": "1.0.0",
"publisher": "example",
"engines": { "dbx": ">=0.1.0", "host_api": "^1.0" },
"contributions": [{
"type": "connection-provider",
"id": "ssh.connection",
"label": "SSH",
"database_type": "ssh",
"fields": [
{ "key": "port", "label": "Port", "type": "number", "picker": { "kind": "file" } },
{
"key": "private_key_path",
"label": "Private key path",
"type": "text",
"picker": {
"kind": "file",
"accept": ["pem", ".", "text/"],
"content_field": "missing"
}
},
{
"key": "self_reference",
"label": "Self",
"type": "text",
"picker": { "kind": "file", "content_field": "self_reference" }
},
{
"key": "folder",
"label": "Folder",
"type": "text",
"picker": { "kind": "directory", "content_field": "private_key_path" }
},
{ "key": "private_key", "label": "Private key", "type": "textarea", "binding": "secret" }
]
}]
}))
.unwrap();
let compatibility = manifest.compatibility(dir.path(), "0.6.15");
assert!(!compatibility.compatible);
let errors = compatibility.errors.join("\n");
assert!(errors.contains("picker is only supported on text, password, or textarea fields"), "{errors}");
assert!(errors.contains("filter 'pem' must look like '.pem' or 'text/plain'"), "{errors}");
assert!(errors.contains("filter 'text/' must look like '.pem' or 'text/plain'"), "{errors}");
assert!(errors.contains("content_field references unknown field 'missing'"), "{errors}");
assert!(errors.contains("content_field cannot be the declaring field"), "{errors}");
assert!(errors.contains("cannot upload a directory into a content field"), "{errors}");
}
#[test]
fn rejects_unknown_keys_and_mixed_condition_shapes() {
// A clause may not smuggle composite keys, and a composite node may not
// smuggle `field`/`one_of`; both must fail to deserialize.
assert!(serde_json::from_value::<super::PluginFieldCondition>(serde_json::json!({
"field": "mode",
"one_of": ["custom"],
"all_of": [{ "field": "mode", "one_of": ["custom"] }]
}))
.is_err());
assert!(serde_json::from_value::<super::PluginFieldCondition>(serde_json::json!({})).is_err());
assert!(serde_json::from_value::<super::PluginFieldCondition>(serde_json::json!({ "not": 1 })).is_err());
}
}
File diff suppressed because it is too large. Load diff
+52
View File
@@ -6730,6 +6730,58 @@ mod tests {
assert_eq!(loaded[0].connection_secrets.get("access_token").map(String::as_str), Some("plugin-secret"));
}
#[tokio::test]
async fn load_connections_preserves_opaque_null_plugin_secrets() {
let path = temp_db_path("plugin-null-secret");
let storage = Storage::open(&path).await.unwrap();
let mut config = mq_connection("plugin-connection", "");
config.name = "SSH plugin".to_string();
config.db_type = DatabaseType::Plugin;
config.driver_profile = Some("plugin".to_string());
config.plugin_id = Some("io.dbx.ssh".to_string());
config.plugin_connection_provider = Some("io.dbx.ssh.connection".to_string());
config.plugin_connection_type = Some("ssh".to_string());
config.connection_secrets.insert("sudo_password".to_string(), "null".to_string());
config.connection_secrets.insert("totp_secret".to_string(), "".to_string());
config.connection_secrets.insert("private_key_passphrase".to_string(), "real-passphrase".to_string());
storage.save_connections(std::slice::from_ref(&config)).await.unwrap();
storage
.set_secret("plugin-connection", &plugin_connection_secret_key("totp_secret").unwrap(), "null")
.await
.unwrap();
let loaded = storage.load_connections().await.unwrap();
let secrets = &loaded[0].connection_secrets;
assert_eq!(secrets.len(), 3);
assert_eq!(secrets.get("sudo_password").map(String::as_str), Some("null"));
assert_eq!(secrets.get("totp_secret").map(String::as_str), Some("null"));
assert_eq!(secrets.get("private_key_passphrase").map(String::as_str), Some("real-passphrase"));
assert_eq!(
storage
.get_secret("plugin-connection", &plugin_connection_secret_key("sudo_password").unwrap())
.await
.unwrap()
.as_deref(),
Some("null")
);
assert_eq!(
storage
.get_secret("plugin-connection", &plugin_connection_secret_key("totp_secret").unwrap())
.await
.unwrap()
.as_deref(),
Some("null")
);
assert_eq!(storage.load_connections().await.unwrap()[0].connection_secrets, *secrets);
storage.save_connections(&loaded).await.unwrap();
assert_eq!(storage.load_connections().await.unwrap()[0].connection_secrets, *secrets);
drop(storage);
let reopened = Storage::open(&path).await.unwrap();
assert_eq!(reopened.load_connections().await.unwrap()[0].connection_secrets, *secrets);
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn metadata_save_scrubs_mq_auth_token_and_preserves_existing_secret() {
let path = temp_db_path("mq-token-metadata");
+1 -3
View File
@@ -132,9 +132,7 @@ mod tests {
port: 22,
key_type: Some("ssh-ed25519".to_string()),
fingerprint: Some("SHA256:test".to_string()),
previous_fingerprint: None,
prompt: None,
echo: false,
..Default::default()
}
}