feat(plugins): add interactive prompts and richer connection forms
This commit is contained in:
1 parent
fac270fc8a
commit
e4bda693fb
42 files changed
+3922
-200
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user