feat: add SSH config aliases and PostgreSQL extensions

This commit is contained in:
zergmk2 authored and GitHub committed 2026-07-05 20:29:49 +08:00
1 parent 8b035d0989
commit f800d80f90
35 files changed
+1191 -33

No files matched your search

+1
View File
@@ -911,6 +911,7 @@ mod tests {
expose_lan: false,
use_ssh_agent: false,
ssh_agent_sock_path: String::new(),
auth_method: "password".to_string(),
}),
TransportLayerConfig::HttpTunnel(crate::models::connection::HttpTunnelConfig {
id: "http".to_string(),
@@ -780,6 +780,7 @@ mod tests {
expose_lan: false,
use_ssh_agent: false,
ssh_agent_sock_path: String::new(),
auth_method: "key".to_string(),
}
}
+52 -2
View File
@@ -26,8 +26,8 @@ use crate::sql::starts_with_executable_sql_keyword;
use crate::types::{
ColumnInfo, CompletionAssistantCandidate, CompletionAssistantCandidateKind, CompletionAssistantMatchMode,
CompletionAssistantObjectKind, CompletionAssistantRequest, CompletionAssistantResponse, DatabaseInfo,
ForeignKeyInfo, FunctionInfo, IndexInfo, ObjectInfo, ObjectStatistics, OwnerInfo, QueryResult, RuleInfo,
SchemaInfo, SequenceInfo, TableInfo, TriggerInfo,
ExtensionInfo, ForeignKeyInfo, FunctionInfo, IndexInfo, ObjectInfo, ObjectStatistics, OwnerInfo, QueryResult,
RuleInfo, SchemaInfo, SequenceInfo, TableInfo, TriggerInfo,
};
fn pg_temporal_to_json_value(row: &Row, idx: usize) -> Option<serde_json::Value> {
@@ -2845,6 +2845,56 @@ pub async fn list_rules(pool: &Pool, schema: &str) -> Result<Vec<RuleInfo>, Stri
.collect())
}
pub async fn list_extensions(pool: &Pool, schema: &str) -> Result<Vec<ExtensionInfo>, String> {
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
let stmt = client
.prepare_cached(
"SELECT e.extname, COALESCE(e.extversion, '') AS extversion, d.description, n.nspname \
FROM pg_catalog.pg_extension e \
JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace \
LEFT JOIN pg_catalog.pg_description d ON d.objoid = e.oid AND d.classoid = 'pg_extension'::regclass \
WHERE n.nspname = $1 \
ORDER BY e.extname",
)
.await
.map_err(|e| e.to_string())?;
let rows = client.query(&stmt, &[&schema]).await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| ExtensionInfo {
name: row.get::<_, String>(0),
version: row.get::<_, String>(1),
comment: row.try_get::<_, Option<String>>(2).ok().flatten().filter(|s| !s.is_empty()),
schema: Some(schema.to_string()),
})
.collect())
}
pub async fn list_available_extensions(pool: &Pool) -> Result<Vec<ExtensionInfo>, String> {
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
let stmt = client
.prepare_cached(
"SELECT name, default_version, comment \
FROM pg_catalog.pg_available_extensions \
WHERE installed_version IS NULL \
ORDER BY name",
)
.await
.map_err(|e| e.to_string())?;
let rows = client.query(&stmt, &[]).await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| ExtensionInfo {
name: row.get::<_, String>(0),
version: row.get::<_, String>(1),
comment: row.try_get::<_, Option<String>>(2).ok().flatten().filter(|s| !s.is_empty()),
schema: None,
})
.collect())
}
pub async fn list_owners(pool: &Pool, schema: &str) -> Result<Vec<OwnerInfo>, String> {
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
let stmt = client.prepare_cached(POSTGRES_OWNERS_SQL).await.map_err(|e| e.to_string())?;
+28 -4
View File
@@ -1,6 +1,8 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::fs;
use crate::path_utils::expand_tilde;
use std::sync::Arc;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
@@ -66,6 +68,7 @@ async fn connect_and_authenticate(
ssh_key_passphrase: &str,
use_ssh_agent: bool,
ssh_agent_sock_path: &str,
auth_method: &str,
connect_timeout_secs: u64,
) -> Result<Handle<SshClient>, String> {
let config = Arc::new(ssh_client_config());
@@ -88,8 +91,19 @@ async fn connect_and_authenticate(
return Ok(session);
}
// When auth_method is "none" and the probe was rejected, fail early
// instead of falling back to other credential methods.
if auth_method == "none" {
return Err("SSH authentication failed: server rejected the connection without credentials".to_string());
}
// "none" was rejected — fall back to the configured credential method.
if !ssh_key_path.is_empty() {
// When auth_method is set, only try the matching method.
let try_key = auth_method.is_empty() && !ssh_key_path.is_empty() || auth_method == "key";
let try_password = auth_method.is_empty() && !ssh_password.is_empty() || auth_method == "password";
let try_agent = auth_method.is_empty() && use_ssh_agent || auth_method == "agent";
if try_key {
// Validate SSH key file path
validate_file_path(ssh_key_path, |_| false)?;
@@ -112,7 +126,7 @@ async fn connect_and_authenticate(
if !auth_res.success() {
return Err("SSH public key authentication failed".to_string());
}
} else if !ssh_password.is_empty() {
} else if try_password {
let auth_res = tokio::time::timeout(connect_timeout, session.authenticate_password(ssh_user, ssh_password))
.await
.map_err(|_| format!("SSH password auth timed out ({connect_timeout_secs}s)"))?
@@ -120,7 +134,7 @@ async fn connect_and_authenticate(
if !auth_res.success() {
return Err("SSH password authentication failed".to_string());
}
} else if use_ssh_agent {
} else if try_agent {
match try_authenticate_with_agent(&mut session, ssh_user, ssh_agent_sock_path, &connect_timeout).await {
Ok(()) => {}
Err(agent_err) => return Err(agent_err),
@@ -215,7 +229,8 @@ async fn try_authenticate_with_agent(
}
fn load_ssh_private_key(path: &str, passphrase: Option<&str>) -> Result<PrivateKey, String> {
let secret = fs::read_to_string(path).map_err(|e| e.to_string())?;
let expanded = expand_tilde(path);
let secret = fs::read_to_string(&expanded).map_err(|e| e.to_string())?;
match decode_secret_key(&secret, passphrase) {
Ok(key) => Ok(key),
Err(err) if is_ssh_key_character_encoding_error(&err.to_string()) => {
@@ -460,6 +475,7 @@ async fn tunnel_reconnect_loop(
ssh_key_passphrase: String,
use_ssh_agent: bool,
ssh_agent_sock_path: String,
auth_method: String,
connect_timeout_secs: u64,
listener: TcpListener,
remote_host: String,
@@ -495,6 +511,7 @@ async fn tunnel_reconnect_loop(
&ssh_key_passphrase,
use_ssh_agent,
&ssh_agent_sock_path,
&auth_method,
connect_timeout_secs,
)
.await
@@ -565,6 +582,7 @@ impl TunnelManager {
ssh_key_passphrase: &str,
use_ssh_agent: bool,
ssh_agent_sock_path: &str,
auth_method: &str,
connect_timeout_secs: u64,
remote_host: &str,
remote_port: u16,
@@ -588,6 +606,7 @@ impl TunnelManager {
ssh_key_passphrase,
use_ssh_agent,
ssh_agent_sock_path,
auth_method,
connect_timeout_secs,
remote_host,
remote_port,
@@ -658,6 +677,7 @@ impl TunnelManager {
&hop.key_passphrase,
hop.use_ssh_agent,
&hop.ssh_agent_sock_path,
&hop.auth_method,
effective_hop_timeout(hop),
&target_host,
target_port,
@@ -718,6 +738,7 @@ async fn spawn_tunnel(
ssh_key_passphrase: &str,
use_ssh_agent: bool,
ssh_agent_sock_path: &str,
auth_method: &str,
connect_timeout_secs: u64,
remote_host: &str,
remote_port: u16,
@@ -739,6 +760,7 @@ async fn spawn_tunnel(
ssh_key_passphrase,
use_ssh_agent,
ssh_agent_sock_path,
auth_method,
connect_timeout_secs,
)
.await?;
@@ -753,6 +775,7 @@ async fn spawn_tunnel(
ssh_key_passphrase.to_string(),
use_ssh_agent,
ssh_agent_sock_path.to_string(),
auth_method.to_string(),
connect_timeout_secs,
listener,
remote_host.to_string(),
@@ -851,6 +874,7 @@ mod tests {
expose_lan: false,
use_ssh_agent: false,
ssh_agent_sock_path: String::new(),
auth_method: "password".to_string(),
}
}
+118 -10
View File
@@ -16,6 +16,25 @@ impl LayerEndpoint {
}
}
/// Resolves any `~/.ssh/config` aliases on SSH layers before endpoints are
/// computed. Both `.endpoint()` read sites below (the current layer's own
/// connect target, and an earlier layer's forward target when this layer is
/// the *next* one in the chain) must see the resolved host/port rather than
/// a literal alias string, so resolution happens once up front instead of
/// only at the `start_tunnel` call site.
fn resolve_ssh_layers(
layers: &[TransportLayerConfig],
resolve: impl Fn(&crate::models::connection::SshTunnelConfig) -> crate::models::connection::SshTunnelConfig,
) -> Vec<TransportLayerConfig> {
layers
.iter()
.map(|layer| match layer {
TransportLayerConfig::Ssh(ssh) => TransportLayerConfig::Ssh(resolve(ssh)),
other => other.clone(),
})
.collect()
}
/// Starts an ordered transport layer chain and returns the final local port.
///
/// Each layer listens on a local port. The next layer connects to that local
@@ -34,6 +53,9 @@ pub async fn start_transport_layers(
}
validate_transport_layers(layers)?;
let layers = resolve_ssh_layers(layers, crate::ssh_config::resolve_ssh_tunnel_config);
let layers = layers.as_slice();
let mut next_connect_endpoint: Option<LayerEndpoint> = None;
let mut final_local_port = 0;
@@ -52,21 +74,22 @@ pub async fn start_transport_layers(
};
let local_port = match layer {
TransportLayerConfig::Ssh(ssh) => ssh_tunnels
TransportLayerConfig::Ssh(resolved) => ssh_tunnels
.start_tunnel(
&layer_id,
&connect_endpoint.host,
connect_endpoint.port,
&ssh.user,
&ssh.password,
&ssh.key_path,
&ssh.key_passphrase,
ssh.use_ssh_agent,
&ssh.ssh_agent_sock_path,
effective_ssh_connect_timeout_secs(ssh.connect_timeout_secs),
&resolved.user,
&resolved.password,
&resolved.key_path,
&resolved.key_passphrase,
resolved.use_ssh_agent,
&resolved.ssh_agent_sock_path,
&resolved.auth_method,
effective_ssh_connect_timeout_secs(resolved.connect_timeout_secs),
&target_endpoint.host,
target_endpoint.port,
is_last && ssh.expose_lan,
is_last && resolved.expose_lan,
)
.await
.map_err(|err| format!("SSH layer {} failed: {err}", index + 1))?,
@@ -164,6 +187,22 @@ fn plan_transport_layers(
remote_port: u16,
local_ports: &[u16],
) -> Vec<PlannedTransportLayer> {
plan_transport_layers_with_resolver(layers, remote_host, remote_port, local_ports, |ssh| ssh.clone())
}
/// Same as `plan_transport_layers`, but takes an explicit SSH-alias resolver
/// so tests can exercise `~/.ssh/config` resolution without touching the real
/// filesystem (mirrors `start_transport_layers`'s use of `resolve_ssh_layers`).
#[cfg(test)]
fn plan_transport_layers_with_resolver(
layers: &[TransportLayerConfig],
remote_host: &str,
remote_port: u16,
local_ports: &[u16],
resolve: impl Fn(&crate::models::connection::SshTunnelConfig) -> crate::models::connection::SshTunnelConfig,
) -> Vec<PlannedTransportLayer> {
let layers = resolve_ssh_layers(layers, resolve);
let layers = layers.as_slice();
let mut planned = Vec::new();
let mut next_connect_endpoint: Option<(String, u16)> = None;
for (index, layer) in layers.iter().enumerate() {
@@ -198,7 +237,10 @@ fn plan_transport_layers(
#[cfg(test)]
mod tests {
use super::{plan_transport_layers, validate_transport_layers, PlannedLayerType, PlannedTransportLayer};
use super::{
plan_transport_layers, plan_transport_layers_with_resolver, validate_transport_layers, PlannedLayerType,
PlannedTransportLayer,
};
use crate::models::connection::{
HttpTunnelConfig, ProxyTunnelConfig, ProxyType, SshTunnelConfig, TransportLayerConfig,
};
@@ -218,6 +260,7 @@ mod tests {
expose_lan: false,
use_ssh_agent: false,
ssh_agent_sock_path: String::new(),
auth_method: "password".to_string(),
})
}
@@ -295,6 +338,71 @@ mod tests {
assert!(err.contains("HTTP tunnel must be the first transport layer"));
}
#[test]
fn single_ssh_hop_with_config_alias_connects_to_resolved_host_and_port() {
// RF-001 regression: a single SSH layer whose `host` is a
// `~/.ssh/config` alias must dial the resolved HostName/Port, not
// the literal alias string.
let layers = vec![ssh_layer("ssh-a", "myserver", 22)];
let planned = plan_transport_layers_with_resolver(&layers, "db.internal", 5432, &[], |ssh| {
assert_eq!(ssh.host, "myserver");
let mut resolved = ssh.clone();
resolved.host = "10.0.0.5".to_string();
resolved.port = 2222;
resolved
});
assert_eq!(
planned,
vec![PlannedTransportLayer {
layer_type: PlannedLayerType::Ssh,
connect_host: "10.0.0.5".to_string(),
connect_port: 2222,
remote_host: "db.internal".to_string(),
remote_port: 5432,
}]
);
}
#[test]
fn earlier_hop_forwards_to_resolved_alias_of_next_ssh_hop() {
// RF-002 regression: when hop N+1 is an SSH layer addressed by a
// config alias, hop N's forward target must be the resolved
// HostName/Port, not the literal alias (which the remote host at
// hop N cannot resolve).
let layers = vec![ssh_layer("ssh-a", "bastion-a", 22), ssh_layer("ssh-b", "myserver", 22)];
let planned = plan_transport_layers_with_resolver(&layers, "db.internal", 5432, &[41001], |ssh| {
let mut resolved = ssh.clone();
if ssh.host == "myserver" {
resolved.host = "10.0.0.5".to_string();
resolved.port = 2222;
}
resolved
});
assert_eq!(
planned,
vec![
PlannedTransportLayer {
layer_type: PlannedLayerType::Ssh,
connect_host: "bastion-a".to_string(),
connect_port: 22,
remote_host: "10.0.0.5".to_string(),
remote_port: 2222,
},
PlannedTransportLayer {
layer_type: PlannedLayerType::Ssh,
connect_host: "127.0.0.1".to_string(),
connect_port: 41001,
remote_host: "db.internal".to_string(),
remote_port: 5432,
},
]
);
}
#[test]
fn http_tunnel_first_layer_targets_next_layer() {
let layers = vec![
+1
View File
@@ -51,6 +51,7 @@ pub mod sql_file_import;
pub mod sql_risk;
pub mod sqlite_backup;
pub(crate) mod sqlserver_temporal;
pub mod ssh_config;
pub mod storage;
pub mod table_export;
pub mod table_import;
+7
View File
@@ -166,6 +166,13 @@ pub struct SshTunnelConfig {
/// the `SSH_AUTH_SOCK` environment variable.
#[serde(default)]
pub ssh_agent_sock_path: String,
/// Login method: `"password"`, `"key"`, `"agent"`, or `"none"`.
/// Empty string means an older saved connection predating this field —
/// the backend falls back to probing key > password > agent based on
/// which fields are non-empty. When set to a specific method the backend
/// only tries that method (after the standard `none` probe).
#[serde(default)]
pub auth_method: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+37
View File
@@ -4073,6 +4073,43 @@ pub async fn list_rules_core(
.await
}
pub async fn list_extensions_core(
state: &AppState,
connection_id: &str,
database: &str,
schema: &str,
) -> Result<Vec<db::ExtensionInfo>, String> {
retry_metadata_connection(state, connection_id, Some(database), || async {
let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?;
let connections = state.connections.read().await;
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Postgres(p) => db::postgres::list_extensions(p, schema).await,
_ => Ok(vec![]),
}
})
.await
}
pub async fn list_available_extensions_core(
state: &AppState,
connection_id: &str,
database: &str,
) -> Result<Vec<db::ExtensionInfo>, String> {
retry_metadata_connection(state, connection_id, Some(database), || async {
let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?;
let connections = state.connections.read().await;
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Postgres(p) => db::postgres::list_available_extensions(p).await,
_ => Ok(vec![]),
}
})
.await
}
pub async fn list_owners_core(
state: &AppState,
connection_id: &str,
+288
View File
@@ -0,0 +1,288 @@
use serde::Serialize;
use crate::models::connection::SshTunnelConfig;
use crate::path_utils::expand_tilde;
/// Sentinel values the frontend fills in when the user leaves a field blank
/// (see `normalizeSshTunnel` / `defaultSshTunnel` in ConnectionDialog.vue).
/// There is no way to distinguish "user explicitly typed 22" from "field was
/// left empty and defaulted to 22", so we treat these as "unset" for the
/// purpose of filling in values from `~/.ssh/config`.
const DEFAULT_USER_SENTINEL: &str = "root";
const DEFAULT_PORT_SENTINEL: u16 = 22;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SshConfigHostEntry {
pub alias: String,
pub host_name: Option<String>,
pub port: Option<u16>,
pub user: Option<String>,
pub identity_file: Option<String>,
}
/// Reads and parses `~/.ssh/config`. Returns an empty list (not an error) if
/// the file does not exist, since that's a normal state for users without
/// an SSH config.
pub fn list_hosts() -> Result<Vec<SshConfigHostEntry>, String> {
let path = expand_tilde("~/.ssh/config");
match std::fs::read_to_string(&path) {
Ok(content) => Ok(parse_ssh_config(&content)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(err) => Err(format!("Failed to read {path}: {err}")),
}
}
pub fn find_host(alias: &str) -> Option<SshConfigHostEntry> {
list_hosts().ok()?.into_iter().find(|entry| entry.alias == alias)
}
/// Fills in `host`, `user`, `port`, and `key_path` from a matching `~/.ssh/config`
/// `Host` block, without overwriting values the user has explicitly set.
///
/// Only `ssh.host` is matched against config aliases; `user`/`port`/`key_path`
/// are filled in from that same matched entry. Values already present on
/// `ssh` win, except for `user`/`port` which use the sentinel defaults above
/// to detect "not actually set by the user".
pub fn resolve_ssh_tunnel_config(ssh: &SshTunnelConfig) -> SshTunnelConfig {
match find_host(&ssh.host) {
Some(entry) => apply_host_entry(ssh, entry),
None => ssh.clone(),
}
}
/// Applies a resolved `~/.ssh/config` entry onto `ssh`, without overwriting
/// values the user has explicitly set. `user`/`port` use the sentinel
/// defaults above to detect "not actually set by the user".
fn apply_host_entry(ssh: &SshTunnelConfig, entry: SshConfigHostEntry) -> SshTunnelConfig {
let mut resolved = ssh.clone();
if let Some(host_name) = entry.host_name {
resolved.host = host_name;
}
if resolved.user == DEFAULT_USER_SENTINEL {
if let Some(user) = entry.user {
resolved.user = user;
}
}
if resolved.port == DEFAULT_PORT_SENTINEL {
if let Some(port) = entry.port {
resolved.port = port;
}
}
if resolved.key_path.is_empty() {
if let Some(identity_file) = entry.identity_file {
resolved.key_path = identity_file;
// If the SSH config supplied the only usable credential, make the
// backend use it even when an older/default UI payload still says
// "password" with an empty password.
if resolved.auth_method.is_empty() || (resolved.auth_method == "password" && resolved.password.is_empty()) {
resolved.auth_method = "key".to_string();
}
}
}
resolved
}
/// Parses a minimal subset of OpenSSH client config syntax: `Host`, `HostName`,
/// `Port`, `User`, `IdentityFile`. Wildcard host patterns (containing `*` or
/// `?`) are skipped since they aren't usable as a literal alias in the host
/// field. `Include` and other directives are not supported.
fn parse_ssh_config(content: &str) -> Vec<SshConfigHostEntry> {
let mut entries: Vec<SshConfigHostEntry> = Vec::new();
let mut current_aliases: Vec<String> = Vec::new();
for raw_line in content.lines() {
let line = strip_comment(raw_line).trim();
if line.is_empty() {
continue;
}
let Some((keyword, value)) = split_directive(line) else {
continue;
};
match keyword.to_ascii_lowercase().as_str() {
"host" => {
current_aliases = value
.split_whitespace()
.filter(|alias| !alias.contains('*') && !alias.contains('?'))
.map(str::to_string)
.collect();
for alias in &current_aliases {
entries.push(SshConfigHostEntry {
alias: alias.clone(),
host_name: None,
port: None,
user: None,
identity_file: None,
});
}
}
"hostname" => set_current_field(&mut entries, &current_aliases, |entry| {
entry.host_name = Some(value.to_string());
}),
"port" => {
if let Ok(port) = value.parse::<u16>() {
set_current_field(&mut entries, &current_aliases, |entry| {
entry.port = Some(port);
});
}
}
"user" => set_current_field(&mut entries, &current_aliases, |entry| {
entry.user = Some(value.to_string());
}),
"identityfile" => set_current_field(&mut entries, &current_aliases, |entry| {
entry.identity_file = Some(value.to_string());
}),
_ => {}
}
}
entries
}
fn set_current_field(
entries: &mut [SshConfigHostEntry],
current_aliases: &[String],
apply: impl Fn(&mut SshConfigHostEntry),
) {
for entry in entries.iter_mut() {
if current_aliases.contains(&entry.alias) {
apply(entry);
}
}
}
fn strip_comment(line: &str) -> &str {
match line.find('#') {
Some(index) => &line[..index],
None => line,
}
}
/// Splits a config line into `(keyword, value)`. OpenSSH allows the keyword
/// and value to be separated by whitespace or a single `=`.
fn split_directive(line: &str) -> Option<(&str, &str)> {
let line = line.trim();
let split_index = line.find(|c: char| c.is_whitespace() || c == '=')?;
let keyword = &line[..split_index];
let value = line[split_index..].trim_start_matches(|c: char| c.is_whitespace() || c == '=').trim();
if keyword.is_empty() || value.is_empty() {
return None;
}
Some((keyword, value))
}
#[cfg(test)]
mod tests {
use super::*;
fn config(host: &str) -> SshTunnelConfig {
SshTunnelConfig {
id: "1".to_string(),
name: String::new(),
enabled: true,
host: host.to_string(),
port: DEFAULT_PORT_SENTINEL,
user: DEFAULT_USER_SENTINEL.to_string(),
password: String::new(),
key_path: String::new(),
key_passphrase: String::new(),
connect_timeout_secs: 5,
expose_lan: false,
use_ssh_agent: false,
ssh_agent_sock_path: String::new(),
auth_method: "password".to_string(),
}
}
#[test]
fn parses_basic_host_block() {
let entries = parse_ssh_config(
"Host myserver\n HostName 10.0.0.5\n Port 2222\n User deploy\n IdentityFile ~/.ssh/id_ed25519\n",
);
assert_eq!(entries.len(), 1);
let entry = &entries[0];
assert_eq!(entry.alias, "myserver");
assert_eq!(entry.host_name, Some("10.0.0.5".to_string()));
assert_eq!(entry.port, Some(2222));
assert_eq!(entry.user, Some("deploy".to_string()));
assert_eq!(entry.identity_file, Some("~/.ssh/id_ed25519".to_string()));
}
#[test]
fn one_line_can_declare_multiple_aliases() {
let entries = parse_ssh_config("Host prod prod-alias\n HostName 10.0.0.9\n");
assert_eq!(entries.len(), 2);
assert!(entries.iter().all(|entry| entry.host_name == Some("10.0.0.9".to_string())));
assert_eq!(entries[0].alias, "prod");
assert_eq!(entries[1].alias, "prod-alias");
}
#[test]
fn skips_wildcard_host_patterns() {
let entries = parse_ssh_config("Host *.example.com\n User git\nHost real\n User deploy\n");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].alias, "real");
}
#[test]
fn ignores_comments_and_blank_lines() {
let entries = parse_ssh_config("# a comment\n\nHost myserver # inline comment\n User deploy\n");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].user, Some("deploy".to_string()));
}
fn entry(alias: &str) -> SshConfigHostEntry {
SshConfigHostEntry {
alias: alias.to_string(),
host_name: Some("10.0.0.5".to_string()),
port: Some(2222),
user: Some("deploy".to_string()),
identity_file: Some("~/.ssh/id_ed25519".to_string()),
}
}
#[test]
fn resolve_fills_unset_fields_from_matching_alias() {
let ssh = config("myserver");
let resolved = apply_host_entry(&ssh, entry("myserver"));
assert_eq!(resolved.host, "10.0.0.5");
assert_eq!(resolved.port, 2222);
assert_eq!(resolved.user, "deploy");
assert_eq!(resolved.key_path, "~/.ssh/id_ed25519");
assert_eq!(resolved.auth_method, "key");
}
#[test]
fn resolve_keeps_password_auth_when_password_is_present() {
let mut ssh = config("myserver");
ssh.password = "secret".to_string();
let resolved = apply_host_entry(&ssh, entry("myserver"));
assert_eq!(resolved.key_path, "~/.ssh/id_ed25519");
assert_eq!(resolved.auth_method, "password");
}
#[test]
fn resolve_does_not_override_explicit_values() {
let mut ssh = config("myserver");
ssh.user = "alice".to_string();
ssh.port = 9999;
ssh.key_path = "/explicit/key".to_string();
let resolved = apply_host_entry(&ssh, entry("myserver"));
assert_eq!(resolved.host, "10.0.0.5");
assert_eq!(resolved.user, "alice");
assert_eq!(resolved.port, 9999);
assert_eq!(resolved.key_path, "/explicit/key");
}
#[test]
fn resolve_is_noop_when_host_does_not_match_any_alias() {
// `resolve_ssh_tunnel_config` looks up the real `~/.ssh/config`; an
// alias this unlikely to exist on a test machine exercises the
// "no match found" branch without needing to mock the filesystem.
let ssh = config("dbx-test-alias-that-should-never-exist-anywhere");
let resolved = resolve_ssh_tunnel_config(&ssh);
assert_eq!(resolved, ssh);
}
}
+8
View File
@@ -42,6 +42,14 @@ pub struct ObjectInfo {
pub parent_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionInfo {
pub name: String,
pub version: String,
pub comment: Option<String>,
pub schema: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectStatistics {
pub name: String,