fix(tools): resolve external MySQL/PG client tools correctly (#44)

Addresses #44 (closed after the v0.8.3 release builds are green).

Three independent defects in one shared tool-resolution path: bundled tools were
looked up at the wrong path (so bundled fallback never worked, for PostgreSQL
too), macOS GUI apps inherit launchd's minimal PATH, and only mariadb-dump/mariadb
were probed — never mysqldump/mysql, which is what brew install mysql-client
installs. Both engines now resolve through one headless, cached, timed resolver,
plus a non-blocking client/server version warning.

Also fixes passing MySQL-only CLI options to the bundled MariaDB client, and
carries the tool-page UI polish and the requested #40 icon revert.
This commit is contained in:
2026-09-30 20:04:16 +08:00
committed by GitHub
parent 092b3b2831
commit daa38d163e
45 changed files with 2603 additions and 1268 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1001 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 74 KiB

+281 -78
View File
@@ -3,6 +3,7 @@ use std::io::Write;
use std::process::{Command, Stdio};
use tauri::{AppHandle, Emitter, Manager, State};
use crate::db::tool_resolver::{self, ToolRequest, ToolResolution};
use crate::models::backup::*;
// ---------------------------------------------------------------------------
@@ -40,74 +41,106 @@ impl PgConnParams {
// Helpers
// ---------------------------------------------------------------------------
fn get_version(tool: &str) -> Option<String> {
Command::new(tool)
.arg("--version")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
}
fn sanitize_error(s: &str) -> String {
crate::commands::test_connection::sanitize_error(s)
}
fn bundled_bin_name(tool: &str) -> String {
if cfg!(windows) { format!("{tool}.exe") } else { tool.to_string() }
}
/// Tauri-facing one-shot resolution with the process-lifetime cache.
/// `force` bypasses the cache for an explicit user re-check.
fn resolve_request(app: &AppHandle, request: &ToolRequest, force: bool) -> ToolResolution {
let key = request.cache_key();
if !force {
if let Some(hit) = tool_resolver::cache_get(&key) {
return hit;
}
}
/// Pure resolution decision (unit-testable): system > bundled > bare name.
fn pick_tool(system_ok: bool, bundled: Option<&str>, tool: &str) -> (String, Option<String>) {
if system_ok { return (tool.to_string(), Some("system".to_string())); }
if let Some(b) = bundled { return (b.to_string(), Some("bundled".to_string())); }
(tool.to_string(), None)
let resource_dir = app.path().resource_dir().ok();
let bundled = resource_dir
.as_ref()
.map(|rd| tool_resolver::bundled_tool_path(rd, request.subdir, request.bundled_name()));
let trusted = tool_resolver::trusted_dirs();
let start = std::time::Instant::now();
let probe = |p: &std::path::Path| {
tool_resolver::probe_within(start, tool_resolver::AGGREGATE_DEADLINE, p)
};
let resolved = tool_resolver::resolve_with(
request,
&trusted,
&tool_resolver::path_lookup,
bundled.as_deref(),
probe,
);
let version = resolved
.as_ref()
.and_then(|r| tool_resolver::version_of(&r.program));
let bundled_available = bundled
.as_deref()
.map(tool_resolver::bundled_present)
.unwrap_or(false);
let out = ToolResolution {
resolved,
bundled_path: bundled,
bundled_available,
version,
};
tool_resolver::cache_put(&key, out.clone());
out
}
/// System-first, bundled-fallback resolver. Returns (command_to_invoke, source).
pub fn resolve_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) {
let system_ok = Command::new(tool).arg("--version").output().is_ok();
let bundled = app.path().resource_dir().ok()
.map(|rd| rd.join("pg_tools").join(bundled_bin_name(tool)))
.filter(|p| p.exists())
.map(|p| p.to_string_lossy().to_string());
pick_tool(system_ok, bundled.as_deref(), tool)
match tool_resolver::pg_request(tool) {
Some(request) => {
tool_resolver::adapt_resolution(&resolve_request(app, &request, false), tool)
}
None => (tool.to_string(), None),
}
}
pub fn resolve_tool_paths(app: &AppHandle) -> PgToolPaths {
let (d, _) = resolve_tool(app, "pg_dump");
let (r, _) = resolve_tool(app, "pg_restore");
let (p, _) = resolve_tool(app, "psql");
PgToolPaths { pg_dump: d, pg_restore: r, psql: p }
PgToolPaths {
pg_dump: resolve_tool(app, "pg_dump").0,
pg_restore: resolve_tool(app, "pg_restore").0,
psql: resolve_tool(app, "psql").0,
}
}
// ---------------------------------------------------------------------------
// detect_pg_tools
// ---------------------------------------------------------------------------
/// Shapes the tool status from resolved tool paths + sources. Headless so the
/// Tauri command stays thin and the status logic stays unit-testable.
fn build_pg_tool_status(
dump: &str,
restore: &str,
dump_src: Option<String>,
restore_src: Option<String>,
) -> PgToolStatus {
/// Shapes both PG statuses from resolved tools. Headless so the Tauri command
/// stays thin and the shaping logic stays unit-testable.
fn build_pg_tool_status(dump: &ToolResolution, restore: &ToolResolution) -> PgToolStatus {
PgToolStatus {
pg_dump_found: Command::new(dump).arg("--version").output().is_ok(),
pg_restore_found: Command::new(restore).arg("--version").output().is_ok(),
pg_dump_version: get_version(dump),
pg_restore_version: get_version(restore),
pg_dump_source: dump_src,
pg_restore_source: restore_src,
pg_dump_found: dump.resolved.is_some(),
pg_restore_found: restore.resolved.is_some(),
pg_dump_version: dump.version.clone(),
pg_restore_version: restore.version.clone(),
pg_dump_source: dump.resolved.as_ref().map(|r| r.source.as_str().to_string()),
pg_restore_source: restore.resolved.as_ref().map(|r| r.source.as_str().to_string()),
pg_dump_resolved_name: dump.resolved.as_ref().map(|r| r.name.clone()),
pg_restore_resolved_name: restore.resolved.as_ref().map(|r| r.name.clone()),
pg_dump_bundled_available: dump.bundled_available,
pg_restore_bundled_available: restore.bundled_available,
}
}
/// Detection runs off the UI thread and can be forced past the cache.
#[tauri::command]
pub fn detect_pg_tools(app_handle: AppHandle) -> PgToolStatus {
let (dump, dump_src) = resolve_tool(&app_handle, "pg_dump");
let (restore, restore_src) = resolve_tool(&app_handle, "pg_restore");
build_pg_tool_status(&dump, &restore, dump_src, restore_src)
pub async fn detect_pg_tools(force: Option<bool>, app_handle: AppHandle) -> PgToolStatus {
let force = force.unwrap_or(false);
tauri::async_runtime::spawn_blocking(move || {
let dump = resolve_request(&app_handle, &tool_resolver::pg_dump_request(), force);
let restore = resolve_request(&app_handle, &tool_resolver::pg_restore_request(), force);
build_pg_tool_status(&dump, &restore)
})
.await
.unwrap_or_else(|_| {
build_pg_tool_status(&ToolResolution::none(), &ToolResolution::none())
})
}
// ---------------------------------------------------------------------------
@@ -501,50 +534,109 @@ fn base_mysql_args(conn: &MySqlConnParams) -> Vec<String> {
]
}
/// Whether a resolved client is MariaDB's. MariaDB's clients print a
/// `...-MariaDB...` banner from `--version`; MySQL's print the Oracle banner.
/// This matters because MariaDB's clients — including the `mysqldump`/`mysql`
/// compatibility names some MariaDB packages ship — reject several MySQL-only
/// options with "unknown option", aborting the dump before it connects.
/// Probed once per program and cached for the process.
fn client_is_mariadb(program: &str) -> bool {
static CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, bool>>,
> = std::sync::OnceLock::new();
let cache = CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
if let Ok(map) = cache.lock() {
if let Some(hit) = map.get(program) {
return *hit;
}
}
let is_mariadb = Command::new(program)
.arg("--version")
.output()
.map(|o| {
let mut text = String::from_utf8_lossy(&o.stdout).to_string();
text.push_str(&String::from_utf8_lossy(&o.stderr));
text.contains("MariaDB")
})
.unwrap_or(false);
if let Ok(mut map) = cache.lock() {
map.insert(program.to_string(), is_mariadb);
}
is_mariadb
}
/// TLS flags for a dump client. MySQL's clients take `--ssl-mode=<mode>`;
/// MariaDB's have no `--ssl-mode` and use the boolean `--ssl`, which is the
/// equivalent of the only mode this code path uses (`REQUIRED`).
pub fn tls_args_for_client(client_is_mariadb: bool, tls_mode: Option<&str>) -> Vec<String> {
match (client_is_mariadb, tls_mode) {
(true, Some("REQUIRED")) => vec!["--ssl".into()],
(true, _) => Vec::new(),
(false, Some(m)) => vec![format!("--ssl-mode={m}")],
(false, None) => Vec::new(),
}
}
/// `mariadb-dump`/`mysqldump` args. Passwords go via MYSQL_PWD env (set by the
/// command), NEVER --password (process-list visibility).
pub fn build_mysql_dump_args(conn: &MySqlConnParams, options: &MySqlBackupOptions) -> Vec<String> {
///
/// `client_is_mariadb` gates the MySQL-only options: MariaDB's dump rejects
/// `--skip-column-statistics`. `--databases` is always emitted as a boolean
/// flag followed by the database name — `--databases=<db>` is rejected by
/// clients as an invalid value.
pub fn build_mysql_dump_args(
conn: &MySqlConnParams,
options: &MySqlBackupOptions,
client_is_mariadb: bool,
) -> Vec<String> {
let mut a = base_mysql_args(conn);
if options.single_transaction { a.push("--single-transaction".into()); }
if options.no_data { a.push("--no-data".into()); }
if options.routines { a.push("--routines".into()); }
if options.triggers { a.push("--triggers".into()); }
if options.events { a.push("--events".into()); }
a.push(format!("--databases={}", options.database));
a.push("--databases".into());
a.push(options.database.clone());
a.push(format!("--result-file={}", options.file_path));
a.push("--skip-column-statistics".into());
if !client_is_mariadb {
a.push("--skip-column-statistics".into());
}
a
}
/// System-first mariadb-dump/mariadb (bundled fallback in resources/mysql_tools).
/// System-first mariadb-dump/mysqldump resolution (bundled fallback).
pub fn resolve_mysql_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) {
let system_ok = Command::new(tool).arg("--version").output().is_ok();
let bundled = app.path().resource_dir().ok()
.map(|rd| rd.join("mysql_tools").join(bundled_bin_name(tool)))
.filter(|p| p.exists())
.map(|p| p.to_string_lossy().to_string());
pick_tool(system_ok, bundled.as_deref(), tool)
match tool_resolver::mysql_request(tool) {
Some(request) => {
tool_resolver::adapt_resolution(&resolve_request(app, &request, false), tool)
}
None => (tool.to_string(), None),
}
}
pub fn resolve_mysql_tool_paths(app: &AppHandle) -> MySqlToolPaths {
let (d, _) = resolve_mysql_tool(app, "mariadb-dump");
let (m, _) = resolve_mysql_tool(app, "mariadb");
MySqlToolPaths { mysqldump: d, mysql: m }
MySqlToolPaths {
mysqldump: resolve_mysql_tool(app, "mariadb-dump").0,
mysql: resolve_mysql_tool(app, "mariadb").0,
}
}
/// Headless core: spawn dump with MYSQL_PWD env. `tls_mode` (e.g. `REQUIRED`)
/// is appended as `--ssl-mode=` when routing through an SSH tunnel. (Live
/// behavior = #[ignore] integration test.)
/// is translated per client — `--ssl-mode=` on MySQL clients, `--ssl` on
/// MariaDB's (see `tls_args_for_client`) when routing through an SSH tunnel.
/// (Live behavior = #[ignore] integration test.)
pub fn run_mysql_dump(
conn: &MySqlConnParams,
options: &MySqlBackupOptions,
tools: &MySqlToolPaths,
tls_mode: Option<&str>,
) -> Result<(), String> {
let mut args = build_mysql_dump_args(conn, options);
if let Some(m) = tls_mode {
args.push(format!("--ssl-mode={m}"));
}
let is_mariadb = client_is_mariadb(&tools.mysqldump);
let mut args = build_mysql_dump_args(conn, options, is_mariadb);
args.extend(tls_args_for_client(is_mariadb, tls_mode));
let out = Command::new(&tools.mysqldump).env("MYSQL_PWD", &conn.password).args(&args).output()
.map_err(|e| e.to_string())?;
if out.status.success() { Ok(()) } else { Err(sanitize_error(&String::from_utf8_lossy(&out.stderr))) }
@@ -556,13 +648,13 @@ pub fn run_mysql_sync(
tools: &MySqlToolPaths,
tls_mode: Option<&str>,
) -> Result<(), String> {
let is_mariadb = client_is_mariadb(&tools.mysqldump);
let mut dump_args = base_mysql_args(source);
dump_args.push("--single-transaction".into());
dump_args.push("--add-drop-table".into());
dump_args.push(format!("--databases={}", source.database));
if let Some(m) = tls_mode {
dump_args.push(format!("--ssl-mode={m}"));
}
dump_args.push("--databases".into());
dump_args.push(source.database.clone());
dump_args.extend(tls_args_for_client(is_mariadb, tls_mode));
let mut dump = Command::new(&tools.mysqldump).env("MYSQL_PWD", &source.password).args(&dump_args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("dump: {e}"))?;
let stdout = dump.stdout.take().unwrap();
let mut restore_args = base_mysql_args(target);
@@ -729,6 +821,7 @@ fn emit_result(app_handle: &AppHandle, job_id: &str, result: Result<(), String>)
progress: Some(1.0),
output_line: None,
error: None,
warning: None,
},
Err(e) => BackupProgressEvent {
job_id: job_id.to_string(),
@@ -736,6 +829,7 @@ fn emit_result(app_handle: &AppHandle, job_id: &str, result: Result<(), String>)
progress: None,
output_line: None,
error: Some(e),
warning: None,
},
};
let _ = app_handle.emit("backup-progress", event);
@@ -924,20 +1018,40 @@ pub async fn db_sync(
// MySQL dump / restore / sync commands (v0.7.8)
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn detect_mysql_tools(app_handle: AppHandle) -> MySqlToolStatus {
let (d, ds) = resolve_mysql_tool(&app_handle, "mariadb-dump");
let (m, ms) = resolve_mysql_tool(&app_handle, "mariadb");
fn build_mysql_tool_status(dump: &ToolResolution, client: &ToolResolution) -> MySqlToolStatus {
MySqlToolStatus {
mysqldump_found: Command::new(&d).arg("--version").output().is_ok(),
mysql_found: Command::new(&m).arg("--version").output().is_ok(),
mysqldump_version: get_version(&d),
mysql_version: get_version(&m),
mysqldump_source: ds,
mysql_source: ms,
mysqldump_found: dump.resolved.is_some(),
mysql_found: client.resolved.is_some(),
mysqldump_version: dump.version.clone(),
mysql_version: client.version.clone(),
mysqldump_source: dump.resolved.as_ref().map(|r| r.source.as_str().to_string()),
mysql_source: client.resolved.as_ref().map(|r| r.source.as_str().to_string()),
mysqldump_resolved_name: dump.resolved.as_ref().map(|r| r.name.clone()),
mysql_resolved_name: client.resolved.as_ref().map(|r| r.name.clone()),
mysqldump_is_mariadb: dump
.resolved
.as_ref()
.map(|r| r.name.starts_with("mariadb"))
.unwrap_or(false),
mysqldump_bundled_available: dump.bundled_available,
mysql_bundled_available: client.bundled_available,
}
}
#[tauri::command]
pub async fn detect_mysql_tools(force: Option<bool>, app_handle: AppHandle) -> MySqlToolStatus {
let force = force.unwrap_or(false);
tauri::async_runtime::spawn_blocking(move || {
let dump = resolve_request(&app_handle, &tool_resolver::mysql_dump_request(), force);
let client = resolve_request(&app_handle, &tool_resolver::mysql_client_request(), force);
build_mysql_tool_status(&dump, &client)
})
.await
.unwrap_or_else(|_| {
build_mysql_tool_status(&ToolResolution::none(), &ToolResolution::none())
})
}
/// Resolve (host, port, via_tunnel) for a MySQL connection, routing through the
/// SSH tunnel endpoint when present.
fn mysql_endpoint(
@@ -952,6 +1066,68 @@ fn mysql_endpoint(
(conn.host.clone(), conn.port.unwrap_or(3306), false)
}
/// Advisory server version for the compatibility check. Fail-open by design:
/// any error (TLS, SSH tunnel, auth) returns `None`, and the dump proceeds.
async fn mysql_server_version(
host: &str,
port: i64,
via_tunnel: bool,
conn: &crate::models::Connection,
password: &str,
) -> Option<String> {
let database = conn.database.clone().unwrap_or_default();
let original_host = conn.host.clone();
let ssl_mode = conn.ssl_mode.clone();
let ssl_ca = conn.ssl_ca_path.clone();
let username = conn.username.clone().unwrap_or_else(|| "root".into());
let mut c = crate::commands::db_viewer::open_mysql_connection(
host,
port as u16,
&username,
password,
&database,
&original_host,
ssl_mode.as_deref(),
ssl_ca.as_deref(),
via_tunnel,
)
.await
.ok()?;
sqlx::query_scalar::<_, String>("SELECT VERSION()")
.fetch_one(&mut c)
.await
.ok()
}
/// Emit the advisory warning when the resolved dump client is clearly older
/// than the server. Never returns an error — the job always proceeds.
async fn maybe_emit_version_warning(
app_handle: &AppHandle,
job_id: &str,
resolution: &ToolResolution,
server_version: Option<String>,
) {
let (Some(client_version), Some(tool)) = (&resolution.version, &resolution.resolved) else {
return;
};
let Some(server_version) = server_version else { return };
let Some(warning) = tool_resolver::warning_for(client_version, &server_version, &tool.name)
else {
return;
};
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id.to_string(),
status: "running".into(),
progress: Some(0.0),
output_line: None,
error: None,
warning: Some(warning),
},
);
}
#[tauri::command]
pub async fn mysql_dump(
connection_id: String,
@@ -976,6 +1152,15 @@ pub async fn mysql_dump(
.unwrap_or_default();
let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn);
// Advisory client/server compatibility check — never blocks the job, and
// only pays for a connection when the resolved client reported a version.
let dump_resolution = resolve_request(&app_handle, &tool_resolver::mysql_dump_request(), false);
let server_version = if dump_resolution.version.is_some() {
mysql_server_version(&host, port, via_tunnel, &conn, &password).await
} else {
None
};
maybe_emit_version_warning(&app_handle, &job_id, &dump_resolution, server_version).await;
let params = MySqlConnParams::new(
host,
port,
@@ -1085,6 +1270,7 @@ pub async fn mysql_restore(
progress: Some(done as f64 / total.max(1) as f64),
output_line: Some(format!("Statement {done} of {total}")),
error: None,
warning: None,
},
);
};
@@ -1198,6 +1384,23 @@ pub async fn mysql_sync(
let (src_host, src_port, src_via_tunnel) = mysql_endpoint(&state, &source_conn.id, &source_conn);
let (tgt_host, tgt_port, tgt_via_tunnel) = mysql_endpoint(&state, &target_conn.id, &target_conn);
// Advisory client/server compatibility check — never blocks the job, and
// only pays for a connection when the resolved client reported a version.
let dump_resolution = resolve_request(&app_handle, &tool_resolver::mysql_dump_request(), false);
let server_version = if dump_resolution.version.is_some() {
mysql_server_version(
&src_host,
src_port,
src_via_tunnel,
&source_conn,
&src_password,
)
.await
} else {
None
};
maybe_emit_version_warning(&app_handle, &job_id, &dump_resolution, server_version).await;
let source = MySqlConnParams::new(
src_host,
src_port,
+255 -25
View File
@@ -132,15 +132,44 @@ fn build_pg_restore_args_with_schema() {
// ------------------------------------------------------------------
#[test]
fn detect_pg_tools_does_not_panic() {
// detect_pg_tools needs a Tauri AppHandle; the headless core keeps the
// status-shaping logic testable without one.
let status = build_pg_tool_status("pg_dump", "pg_restore", None, None);
// May or may not find tools, but the call itself must not panic
let _ = status.pg_dump_found;
let _ = status.pg_restore_found;
let _ = status.pg_dump_version;
let _ = status.pg_restore_version;
fn build_pg_tool_status_reflects_resolutions() {
let dump = ToolResolution {
resolved: Some(crate::db::tool_resolver::Resolution {
program: "/opt/homebrew/bin/mariadb-dump".into(),
source: crate::db::tool_resolver::ToolSource::TrustedDir,
name: "pg_dump".into(),
}),
bundled_path: Some(std::path::PathBuf::from("/app/resources/pg_tools/pg_dump")),
bundled_available: true,
version: Some("pg_dump (PostgreSQL) 16.4".into()),
};
let status = build_pg_tool_status(&dump, &ToolResolution::none());
assert!(status.pg_dump_found);
assert!(!status.pg_restore_found);
assert_eq!(status.pg_dump_source.as_deref(), Some("system"));
assert_eq!(status.pg_dump_resolved_name.as_deref(), Some("pg_dump"));
assert!(status.pg_dump_bundled_available);
assert!(!status.pg_restore_bundled_available);
}
#[test]
fn build_mysql_tool_status_reports_the_resolved_dump_name() {
let dump = ToolResolution {
resolved: Some(crate::db::tool_resolver::Resolution {
program: "/opt/homebrew/bin/mariadb-dump".into(),
source: crate::db::tool_resolver::ToolSource::TrustedDir,
name: "mariadb-dump".into(),
}),
bundled_path: Some(std::path::PathBuf::from("/app/resources/mysql_tools/mariadb-dump")),
bundled_available: true,
version: Some("mariadb-dump 11.4.5-MariaDB".into()),
};
let status = build_mysql_tool_status(&dump, &ToolResolution::none());
assert!(status.mysqldump_found);
assert!(!status.mysql_found);
assert_eq!(status.mysqldump_resolved_name.as_deref(), Some("mariadb-dump"));
assert!(status.mysqldump_is_mariadb);
assert!(!status.mysql_bundled_available);
}
#[test]
@@ -152,6 +181,10 @@ fn pg_tool_status_serialization() {
pg_restore_version: None,
pg_dump_source: None,
pg_restore_source: None,
pg_dump_resolved_name: Some("pg_dump".into()),
pg_restore_resolved_name: None,
pg_dump_bundled_available: true,
pg_restore_bundled_available: false,
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("pg_dump_found"));
@@ -171,6 +204,7 @@ fn backup_progress_event_completed() {
progress: Some(1.0),
output_line: None,
error: None,
warning: None,
};
let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"completed\""));
@@ -185,12 +219,41 @@ fn backup_progress_event_failed() {
progress: None,
output_line: None,
error: Some("connection refused".into()),
warning: None,
};
let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"failed\""));
assert!(json.contains("\"connection refused\""));
}
#[test]
fn backup_progress_event_serializes_a_non_fatal_warning() {
let evt = BackupProgressEvent {
job_id: "job-warn".into(),
status: "running".into(),
progress: Some(0.0),
output_line: None,
error: None,
warning: Some("Client/server version mismatch: …".into()),
};
let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"warning\":\"Client/server version mismatch"), "{json}");
}
#[test]
fn backup_progress_event_serializes_a_null_warning_when_absent() {
let evt = BackupProgressEvent {
job_id: "job-quiet".into(),
status: "completed".into(),
progress: Some(1.0),
output_line: None,
error: None,
warning: None,
};
let json = serde_json::to_string(&evt).unwrap();
assert!(json.contains("\"warning\":null"), "{json}");
}
// ------------------------------------------------------------------
// Integration tests (headless, against live DBs)
//
@@ -406,19 +469,6 @@ fn integration_plain_dump_restore() {
// Tool resolution (Task 2.1: system-first, bundled-fallback)
// ------------------------------------------------------------------
#[test]
fn pick_tool_prefers_system_then_bundled_then_bare() {
assert_eq!(pick_tool(true, None, "pg_dump"), ("pg_dump".to_string(), Some("system".into())));
assert_eq!(pick_tool(false, Some("/r/pg_dump"), "pg_dump"), ("/r/pg_dump".to_string(), Some("bundled".into())));
assert_eq!(pick_tool(false, None, "pg_dump"), ("pg_dump".to_string(), None));
}
#[test]
fn bundled_bin_name_appends_exe_on_windows() {
let name = bundled_bin_name("pg_dump");
if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); }
}
// ------------------------------------------------------------------
// SQLite .dump / restore / sync core (Task 2.2)
// ------------------------------------------------------------------
@@ -507,11 +557,20 @@ fn mysql_dump_args_single_transaction_no_data_routines() {
database: "shop".into(), file_path: "/tmp/d.sql".into(),
single_transaction: true, no_data: true, routines: true, triggers: false, events: false,
};
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts);
let args = build_mysql_dump_args(
&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()),
&opts,
false,
);
assert!(args.iter().any(|a| a == "--single-transaction"));
assert!(args.iter().any(|a| a == "--no-data"));
assert!(args.iter().any(|a| a == "--routines"));
assert!(args.iter().any(|a| a == "--databases=shop"));
// `--databases` is a BOOLEAN flag: the database name must be a separate
// positional argument. `--databases=shop` is rejected by clients as
// "invalid value 'shop'".
let i = args.iter().position(|a| a == "--databases").expect("--databases flag");
assert_eq!(args[i + 1], "shop");
assert!(!args.iter().any(|a| a.starts_with("--databases=")), "{args:?}");
assert!(args.iter().any(|a| a == "--result-file=/tmp/d.sql"));
// no --password on the command line (uses MYSQL_PWD env)
assert!(args.iter().all(|a| !a.starts_with("--password")));
@@ -520,10 +579,93 @@ fn mysql_dump_args_single_transaction_no_data_routines() {
#[test]
fn mysql_env_uses_mysql_pwd_not_password_arg() {
let opts = MySqlBackupOptions { database: "db".into(), file_path: "/tmp/x.sql".into(), single_transaction: false, no_data: false, routines: false, triggers: false, events: false };
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "db".into(), "p".into()), &opts);
let args = build_mysql_dump_args(
&MySqlConnParams::new("h".into(), 3306, "u".into(), "db".into(), "p".into()),
&opts,
false,
);
assert!(args.iter().all(|a| !a.starts_with("--password")));
}
#[test]
fn mysql_dump_args_omit_column_statistics_for_mariadb_clients() {
// The bundled client is MariaDB 11.4.5 and rejects the MySQL-8-only option
// with `unknown option '--skip-column-statistics'`, aborting the dump.
let opts = MySqlBackupOptions {
database: "shop".into(), file_path: "/tmp/d.sql".into(),
single_transaction: true, no_data: false, routines: true, triggers: true, events: false,
};
let conn = MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into());
let mariadb = build_mysql_dump_args(&conn, &opts, true);
assert!(!mariadb.iter().any(|a| a == "--skip-column-statistics"), "{mariadb:?}");
let mysql = build_mysql_dump_args(&conn, &opts, false);
assert!(mysql.iter().any(|a| a == "--skip-column-statistics"), "{mysql:?}");
}
#[test]
fn tls_args_map_ssl_mode_only_for_mysql_clients() {
// MySQL clients take `--ssl-mode=<mode>`; MariaDB clients have no such
// option and use the boolean `--ssl`.
assert_eq!(tls_args_for_client(false, Some("REQUIRED")), vec!["--ssl-mode=REQUIRED".to_string()]);
assert_eq!(tls_args_for_client(true, Some("REQUIRED")), vec!["--ssl".to_string()]);
assert_eq!(tls_args_for_client(false, None), Vec::<String>::new());
assert_eq!(tls_args_for_client(true, None), Vec::<String>::new());
assert_eq!(tls_args_for_client(true, Some("DISABLED")), Vec::<String>::new());
}
#[test]
fn client_is_mariadb_reads_the_version_banner() {
// A program that cannot be executed must not panic — it reports false
// (keep the MySQL-only flags), and the dump fails at spawn anyway.
assert!(!client_is_mariadb("/nonexistent/definitely-not-a-tool"));
}
/// Regression guard for the bundled fallback path: the args we generate must be
/// *accepted* by the real bundled MariaDB client. It spawns the staged binary
/// against a closed port and requires the failure to be a CONNECTION error —
/// never an option-parsing error. No live server needed.
///
/// The binaries are gitignored (the release workflow downloads them), so when
/// they are not staged this test logs loudly and returns instead of failing.
#[test]
fn bundled_mariadb_dump_accepts_our_generated_args() {
let bundled = std::path::PathBuf::from(format!(
"{}/resources/mysql_tools",
env!("CARGO_MANIFEST_DIR")
))
.join(crate::db::tool_resolver::bundled_bin_name("mariadb-dump"));
if !bundled.is_file() {
eprintln!(
"SKIPPED bundled_mariadb_dump_accepts_our_generated_args: {bundled:?} is not staged \
(run the release workflow's tool-staging step to enable this guard)"
);
return;
}
let conn = MySqlConnParams::new("127.0.0.1".into(), 1, "root".into(), "mysql".into(), "unused".into());
let opts = MySqlBackupOptions {
database: "mysql".into(),
file_path: std::env::temp_dir().join("gridline_args_guard.sql").to_string_lossy().to_string(),
single_transaction: true, no_data: false, routines: true, triggers: true, events: false,
};
assert!(client_is_mariadb(bundled.to_str().unwrap()), "the bundled dump tool must be MariaDB's");
let mut args = build_mysql_dump_args(&conn, &opts, true);
args.extend(tls_args_for_client(true, Some("REQUIRED")));
let out = std::process::Command::new(&bundled).args(&args).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
// "unknown option" covers `--skip-column-statistics`; "unknown variable"
// covers `--ssl-mode` (MariaDB's own wording for the same class of mistake).
assert!(!err.contains("unknown option"), "bundled client rejected an option: {err}");
assert!(!err.contains("unknown variable"), "bundled client rejected an option: {err}");
assert!(!err.contains("invalid value"), "bundled client rejected an option value: {err}");
assert!(
err.contains("2002") || err.contains("can't connect") || err.contains("connection refused"),
"expected a connection failure (port 1 is closed), got: {err}"
);
}
// ── native MySQL restore: error reporting contract ──────────────────
#[test]
@@ -635,3 +777,91 @@ fn restore_size_cap_rejects_a_file_over_the_limit_and_states_both_sizes() {
assert!(err.contains("100 MB"), "error must state the limit in MB; got {err:?}");
assert!(err.contains("MB"), "error must state the actual size in MB; got {err:?}");
}
// ── live MySQL dump: system tool vs. bundled tool ───────────────────
//
// Issue #44: the app reported "mysqldump not found" even with MySQL
// client tools installed. These two #[ignore]d tests exercise the real
// `run_mysql_dump` against a live server — once via a tool the shared
// resolver found as a *system* client, once via the *bundled* MariaDB
// client — so both provenance paths are covered end to end.
//
// Run with:
//
// GRIDLINE_TEST_MYSQL_HOST=... GRIDLINE_TEST_MYSQL_PORT=... \
// GRIDLINE_TEST_MYSQL_USER=... GRIDLINE_TEST_MYSQL_DB=... \
// GRIDLINE_TEST_MYSQL_PASSWORD=... \
// cargo test --lib live_mysql_dump -- --ignored --nocapture
fn mysql_conn_from_env(prefix: &str) -> MySqlConnParams {
MySqlConnParams::new(
env(&format!("{prefix}_HOST")),
env(&format!("{prefix}_PORT")).parse().unwrap(),
env(&format!("{prefix}_USER")),
env(&format!("{prefix}_DB")),
env(&format!("{prefix}_PASSWORD")),
)
}
fn live_mysql_dump_opts(conn: &MySqlConnParams, file_name: &str) -> MySqlBackupOptions {
MySqlBackupOptions {
database: conn.database.clone(),
file_path: std::env::temp_dir().join(file_name).to_string_lossy().to_string(),
single_transaction: true,
no_data: false,
routines: true,
triggers: true,
events: false,
}
}
/// Requires GRIDLINE_TEST_MYSQL_{HOST,PORT,USER,DB,PASSWORD} and a system
/// mariadb-dump/mysqldump on this machine.
#[test]
#[ignore]
fn live_mysql_dump_with_a_system_tool() {
let conn = mysql_conn_from_env("GRIDLINE_TEST_MYSQL");
let opts = live_mysql_dump_opts(&conn, "gridline_live_mysql_system.sql");
let req = crate::db::tool_resolver::mysql_dump_request();
let trusted = crate::db::tool_resolver::trusted_dirs();
let resolved = crate::db::tool_resolver::resolve_with(
&req,
&trusted,
&crate::db::tool_resolver::path_lookup,
None,
crate::db::tool_resolver::probe,
)
.expect("no system mariadb-dump/mysqldump resolved");
eprintln!("resolved: {} ({})", resolved.program, resolved.source.as_str());
let tools = MySqlToolPaths {
mysqldump: resolved.program.clone(),
mysql: resolved.program.clone(),
};
run_mysql_dump(&conn, &opts, &tools, None).expect("dump failed");
let len = std::fs::metadata(&opts.file_path).unwrap().len();
assert!(len > 0, "dump file is empty");
let _ = std::fs::remove_file(&opts.file_path);
}
/// Requires GRIDLINE_TEST_MYSQL_* and a staged `resources/mysql_tools` tree.
#[test]
#[ignore]
fn live_mysql_dump_with_the_bundled_tool() {
let conn = mysql_conn_from_env("GRIDLINE_TEST_MYSQL");
let opts = live_mysql_dump_opts(&conn, "gridline_live_mysql_bundled.sql");
let bundled = std::path::PathBuf::from(format!(
"{}/resources/mysql_tools",
env!("CARGO_MANIFEST_DIR")
))
.join(crate::db::tool_resolver::bundled_bin_name("mariadb-dump"));
assert!(bundled.is_file(), "bundled mariadb-dump not staged at {bundled:?}");
assert!(crate::db::tool_resolver::probe(&bundled), "bundled mariadb-dump does not run");
let tools = MySqlToolPaths {
mysqldump: bundled.to_string_lossy().to_string(),
mysql: bundled.to_string_lossy().to_string(),
};
run_mysql_dump(&conn, &opts, &tools, None).expect("bundled dump failed");
let len = std::fs::metadata(&opts.file_path).unwrap().len();
assert!(len > 0, "bundled dump file is empty");
let _ = std::fs::remove_file(&opts.file_path);
}
+1
View File
@@ -6,6 +6,7 @@ pub mod object_crud;
pub mod object_ddl;
pub mod pool;
pub mod tls;
pub mod tool_resolver;
pub mod schema_diff;
#[allow(unused_imports)]
+415
View File
@@ -0,0 +1,415 @@
//! Unified, headless external-client-tool resolver shared by the PostgreSQL
//! and MySQL backup/restore/sync paths.
//!
//! Fixes issue #44: bundled tools live under `resource_dir/resources/<subdir>/`
//! (Tauri preserves the `resources/` component from `tauri.conf.json`), not
//! directly under `resource_dir/`. System lookup is ordered — trusted absolute
//! directories first, then the inherited PATH, then bundled — and probes both
//! `mariadb-dump`/`mysqldump` style names.
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
/// The nested directory component Tauri injects when copying
/// `bundle.resources`. Pinned by `bundled_prefix_matches_tauri_config_resources`.
pub const BUNDLED_RESOURCE_PREFIX: &str = "resources";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolSource {
/// Found in a fixed, trusted absolute directory.
TrustedDir,
/// Found on the inherited `PATH`.
Path,
/// Found in the app's bundled resource directory.
Bundled,
}
impl ToolSource {
/// The value surfaced to the UI (matches the pre-existing
/// `"system" | "bundled"` strings).
pub fn as_str(self) -> &'static str {
match self {
ToolSource::TrustedDir | ToolSource::Path => "system",
ToolSource::Bundled => "bundled",
}
}
}
/// A successfully resolved tool: the runnable program plus its provenance.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolution {
pub program: String,
pub source: ToolSource,
/// The binary name that resolved (`mariadb-dump`, `mysqldump`, …).
pub name: String,
}
/// One engine + tool role, e.g. the MySQL dump tool.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolRequest {
/// Resource subdirectory name, e.g. `mysql_tools` / `pg_tools`.
pub subdir: &'static str,
/// Candidate binary names, in preference order.
pub names: Vec<&'static str>,
}
impl ToolRequest {
pub fn new(subdir: &'static str, names: &[&'static str]) -> Self {
Self { subdir, names: names.to_vec() }
}
/// The bundled binary that ships for this role.
pub fn bundled_name(&self) -> &'static str {
self.names[0]
}
/// Stable cache key for this role.
pub fn cache_key(&self) -> String {
format!("{}::{}", self.subdir, self.names.join(","))
}
}
/// Platform executable suffix.
pub fn exe_suffix() -> &'static str {
if cfg!(windows) { ".exe" } else { "" }
}
/// `pg_dump` -> `pg_dump` (POSIX) / `pg_dump.exe` (Windows).
pub fn bundled_bin_name(bin: &str) -> String {
format!("{bin}{}", exe_suffix())
}
/// Fixed, trusted absolute directories probed before the inherited `PATH`.
/// Windows deliberately returns an empty list (spec §9 Q5): PATH + bundled only.
pub fn trusted_dirs() -> Vec<PathBuf> {
let raw: &[&str] = if cfg!(target_os = "macos") {
&[
"/opt/homebrew/bin",
"/opt/homebrew/opt/mysql-client/bin",
"/opt/homebrew/opt/mariadb/bin",
"/opt/homebrew/opt/libpq/bin",
"/usr/local/bin",
"/usr/local/opt/mysql-client/bin",
"/usr/local/opt/mariadb/bin",
"/usr/local/opt/libpq/bin",
"/opt/local/bin",
"/Applications/Postgres.app/Contents/Versions/latest/bin",
]
} else if cfg!(target_os = "linux") {
&["/usr/bin", "/usr/local/bin", "/usr/local/mysql/bin", "/opt/mysql/bin"]
} else {
&[]
};
raw.iter().map(PathBuf::from).collect()
}
pub fn pg_dump_request() -> ToolRequest { ToolRequest::new("pg_tools", &["pg_dump"]) }
pub fn pg_restore_request() -> ToolRequest { ToolRequest::new("pg_tools", &["pg_restore"]) }
pub fn psql_request() -> ToolRequest { ToolRequest::new("pg_tools", &["psql"]) }
/// Maps a PostgreSQL tool name to its request; `None` for unknown names.
pub fn pg_request(tool: &str) -> Option<ToolRequest> {
match tool {
"pg_dump" => Some(pg_dump_request()),
"pg_restore" => Some(pg_restore_request()),
"psql" => Some(psql_request()),
_ => None,
}
}
/// MySQL dump role: system `mariadb-dump` -> system `mysqldump` -> bundled.
pub fn mysql_dump_request() -> ToolRequest {
ToolRequest::new("mysql_tools", &["mariadb-dump", "mysqldump"])
}
/// MySQL client role (used by sync as the restore target).
pub fn mysql_client_request() -> ToolRequest {
ToolRequest::new("mysql_tools", &["mariadb", "mysql"])
}
/// Maps a MySQL tool name to its request; `None` for unknown names.
pub fn mysql_request(tool: &str) -> Option<ToolRequest> {
match tool {
"mariadb-dump" | "mysqldump" => Some(mysql_dump_request()),
"mariadb" | "mysql" => Some(mysql_client_request()),
_ => None,
}
}
/// The bundled binary path for a role. Note the nested
/// `BUNDLED_RESOURCE_PREFIX` component — this is the issue #44 fix.
pub fn bundled_tool_path(resource_dir: &Path, subdir: &str, bin: &str) -> PathBuf {
resource_dir
.join(BUNDLED_RESOURCE_PREFIX)
.join(subdir)
.join(bundled_bin_name(bin))
}
/// Locate `name` on a `PATH`-shaped value without invoking a shell.
pub fn path_lookup_in(name: &str, path_var: &OsStr) -> Option<PathBuf> {
let file = bundled_bin_name(name);
for dir in std::env::split_paths(path_var) {
if dir.as_os_str().is_empty() {
continue;
}
let candidate = dir.join(&file);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
/// Locate `name` on the inherited `PATH`.
pub fn path_lookup(name: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
path_lookup_in(name, &path_var)
}
/// Per-probe timeout. `--version` returns in milliseconds; this only guards a
/// pathological binary.
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(1);
/// Aggregate deadline for one detection pass across all candidates.
pub const AGGREGATE_DEADLINE: Duration = Duration::from_secs(3);
/// Run `<program> --version` with a bounded wait. The child's stdio is
/// discarded — output is read separately by `version_of`.
pub fn run_version(program: &Path, timeout: Duration) -> bool {
let mut cmd = Command::new(program);
cmd.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let Ok(mut child) = cmd.spawn() else {
return false;
};
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => return status.success(),
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return false;
}
std::thread::sleep(Duration::from_millis(5));
}
Err(_) => {
let _ = child.kill();
return false;
}
}
}
}
/// A tool candidate counts only when it is a regular file AND runs.
pub fn probe(program: &Path) -> bool {
program.is_file() && run_version(program, PROBE_TIMEOUT)
}
/// A probe that stops running anything once the aggregate deadline is crossed.
pub fn probe_within(start: Instant, deadline: Duration, program: &Path) -> bool {
if start.elapsed() >= deadline {
return false;
}
probe(program)
}
/// Ordered, injectable resolution.
///
/// 1. every trusted absolute directory, for each candidate name in order;
/// 2. the inherited `PATH`, for each candidate name in order;
/// 3. the bundled binary.
///
/// `probe_fn` decides whether a candidate is runnable, so tests never touch
/// the real filesystem. The aggregate deadline is enforced by the caller's
/// `probe_fn` (see `probe_within`).
pub fn resolve_with<F>(
request: &ToolRequest,
trusted: &[PathBuf],
path_lookup: &dyn Fn(&str) -> Option<PathBuf>,
bundled: Option<&Path>,
mut probe_fn: F,
) -> Option<Resolution>
where
F: FnMut(&Path) -> bool,
{
for dir in trusted {
for name in &request.names {
let candidate = dir.join(bundled_bin_name(name));
if probe_fn(&candidate) {
return Some(Resolution {
program: candidate.to_string_lossy().to_string(),
source: ToolSource::TrustedDir,
name: (*name).to_string(),
});
}
}
}
for name in &request.names {
if let Some(found) = path_lookup(name) {
if probe_fn(&found) {
return Some(Resolution {
program: found.to_string_lossy().to_string(),
source: ToolSource::Path,
name: (*name).to_string(),
});
}
}
}
if let Some(bundled) = bundled {
if probe_fn(bundled) {
let name = bundled
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| request.bundled_name().to_string());
return Some(Resolution {
program: bundled.to_string_lossy().to_string(),
source: ToolSource::Bundled,
name,
});
}
}
None
}
/// Read the first non-empty line of `<program> --version` (stdout, else stderr).
pub fn version_of(program: &str) -> Option<String> {
let out = Command::new(program).arg("--version").output().ok()?;
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
return Some(stdout);
}
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if stderr.is_empty() { None } else { Some(stderr) }
}
/// Lenient major-version extraction. Scans for numeric groups, unwraps the
/// legacy MariaDB `5.5.5-<real>-MariaDB` compatibility prefix, and returns
/// `None` when there is no number at all.
pub fn parse_major(version: &str) -> Option<u32> {
let mut nums: Vec<u32> = Vec::new();
let mut cur = String::new();
for ch in version.chars() {
if ch.is_ascii_digit() {
cur.push(ch);
continue;
}
if !cur.is_empty() {
nums.push(cur.parse::<u32>().ok()?);
cur.clear();
// `6` is the only magic number here: it caps how many groups an
// adversarial/advertising version string can make us collect
// (we only ever read the first four).
if nums.len() == 6 {
break;
}
}
}
if !cur.is_empty() {
if let Ok(n) = cur.parse::<u32>() {
nums.push(n);
}
}
if nums.is_empty() {
return None;
}
if nums.len() >= 4 && nums[0] == 5 && nums[1] == 5 && nums[2] == 5 {
return Some(nums[3]);
}
Some(nums[0])
}
/// Client/server version relationship. Advisory only — never an error.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Compat {
Ok,
/// The client's major version is below the server's.
ClientOlder,
/// Either version could not be parsed.
Unknown,
}
pub fn compat(client: Option<u32>, server: Option<u32>) -> Compat {
match (client, server) {
(Some(c), Some(s)) if s > c => Compat::ClientOlder,
(Some(_), Some(_)) => Compat::Ok,
_ => Compat::Unknown,
}
}
/// Non-blocking warning text, or `None` when there is nothing to say.
pub fn warning_for(client_version: &str, server_version: &str, client_name: &str) -> Option<String> {
match compat(parse_major(client_version), parse_major(server_version)) {
Compat::ClientOlder => Some(format!(
"Client/server version mismatch: the resolved dump tool `{client_name}` reports {client_version}, \
but the server is {server_version}. The backup may fail or omit data — installing a matching MySQL client is recommended."
)),
Compat::Ok | Compat::Unknown => None,
}
}
/// Everything one detection pass knows about a tool role.
#[derive(Debug, Clone, PartialEq)]
pub struct ToolResolution {
pub resolved: Option<Resolution>,
pub bundled_path: Option<PathBuf>,
/// Whether the bundled file exists — regardless of whether it runs. Lets
/// the UI say "ships with the app but could not run" instead of "absent".
pub bundled_available: bool,
pub version: Option<String>,
}
impl ToolResolution {
pub fn none() -> Self {
Self { resolved: None, bundled_path: None, bundled_available: false, version: None }
}
}
/// Whether a bundled tool file exists at `path`.
pub fn bundled_present(path: &Path) -> bool {
path.is_file()
}
/// Shape the existing resolver call sites expect: `(command, source)`.
pub fn adapt_resolution(res: &ToolResolution, fallback_name: &str) -> (String, Option<String>) {
match &res.resolved {
Some(r) => (r.program.clone(), Some(r.source.as_str().to_string())),
None => (fallback_name.to_string(), None),
}
}
// Cache is keyed by `ToolRequest::cache_key()` and holds no secrets.
static CACHE: OnceLock<Mutex<HashMap<String, ToolResolution>>> = OnceLock::new();
fn cache() -> &'static Mutex<HashMap<String, ToolResolution>> {
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn cache_get(key: &str) -> Option<ToolResolution> {
cache().lock().ok()?.get(key).cloned()
}
pub fn cache_put(key: &str, value: ToolResolution) {
if let Ok(mut map) = cache().lock() {
map.insert(key.to_string(), value);
}
}
pub fn cache_clear() {
if let Ok(mut map) = cache().lock() {
map.clear();
}
}
#[cfg(test)]
#[path = "tool_resolver.test.rs"]
mod tests;
@@ -0,0 +1,403 @@
use super::*;
#[test]
fn bundled_bin_name_appends_exe_only_on_windows() {
let expected = if cfg!(windows) { "pg_dump.exe" } else { "pg_dump" };
assert_eq!(bundled_bin_name("pg_dump"), expected);
}
#[test]
fn pg_request_maps_known_tool_names() {
assert_eq!(pg_request("pg_dump").unwrap(), ToolRequest::new("pg_tools", &["pg_dump"]));
assert_eq!(pg_request("pg_restore").unwrap(), ToolRequest::new("pg_tools", &["pg_restore"]));
assert_eq!(pg_request("psql").unwrap(), ToolRequest::new("pg_tools", &["psql"]));
assert!(pg_request("not_a_tool").is_none());
}
#[test]
fn mysql_dump_request_prefers_mariadb_dump_then_mysqldump() {
let req = mysql_dump_request();
assert_eq!(req.subdir, "mysql_tools");
assert_eq!(req.names, vec!["mariadb-dump", "mysqldump"]);
assert_eq!(req.bundled_name(), "mariadb-dump");
}
#[test]
fn mysql_client_request_prefers_mariadb_then_mysql() {
let req = mysql_client_request();
assert_eq!(req.names, vec!["mariadb", "mysql"]);
assert_eq!(req.bundled_name(), "mariadb");
}
#[test]
fn cache_key_is_stable_and_name_ordered() {
assert_eq!(mysql_dump_request().cache_key(), "mysql_tools::mariadb-dump,mysqldump");
assert_eq!(pg_dump_request().cache_key(), "pg_tools::pg_dump");
}
#[test]
fn tool_source_maps_to_ui_string() {
assert_eq!(ToolSource::TrustedDir.as_str(), "system");
assert_eq!(ToolSource::Path.as_str(), "system");
assert_eq!(ToolSource::Bundled.as_str(), "bundled");
}
#[cfg(target_os = "macos")]
#[test]
fn trusted_dirs_include_homebrew_and_postgres_app_on_macos() {
let dirs = trusted_dirs();
assert!(dirs.contains(&PathBuf::from("/opt/homebrew/bin")), "{dirs:?}");
assert!(dirs.contains(&PathBuf::from("/opt/homebrew/opt/mysql-client/bin")), "{dirs:?}");
assert!(dirs.contains(&PathBuf::from("/usr/local/opt/libpq/bin")), "{dirs:?}");
assert!(dirs.iter().any(|d| d.ends_with("Postgres.app/Contents/Versions/latest/bin")), "{dirs:?}");
}
#[cfg(target_os = "linux")]
#[test]
fn trusted_dirs_include_usr_bin_on_linux() {
assert!(trusted_dirs().contains(&PathBuf::from("/usr/bin")));
}
#[cfg(windows)]
#[test]
fn trusted_dirs_are_empty_on_windows() {
assert!(trusted_dirs().is_empty());
}
#[test]
fn bundled_tool_path_includes_the_nested_resources_prefix() {
// THE BUG FROM ISSUE #44: the shipped app stores the tool at
// <resource_dir>/resources/mysql_tools/mariadb-dump, never at
// <resource_dir>/mysql_tools/mariadb-dump.
let p = bundled_tool_path(Path::new("/app/Contents/Resources"), "mysql_tools", "mariadb-dump");
assert_eq!(
p,
PathBuf::from("/app/Contents/Resources/resources/mysql_tools").join(bundled_bin_name("mariadb-dump"))
);
assert!(p.to_string_lossy().ends_with("resources/mysql_tools/mariadb-dump"));
}
#[test]
fn bundled_tool_path_works_for_postgres_tools_too() {
let p = bundled_tool_path(Path::new("/app/Contents/Resources"), "pg_tools", "pg_dump");
assert!(p.to_string_lossy().ends_with("resources/pg_tools/pg_dump"));
}
#[test]
fn bundled_prefix_matches_tauri_config_resources() {
let conf = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tauri.conf.json"))
.expect("tauri.conf.json must be readable");
let json: serde_json::Value = serde_json::from_str(&conf).unwrap();
let resources = json["bundle"]["resources"]
.as_array()
.expect("bundle.resources must be an array");
assert!(!resources.is_empty(), "bundle.resources must not be empty");
for entry in resources {
let glob = entry.as_str().unwrap();
let top = Path::new(glob).components().next().unwrap();
assert_eq!(
top.as_os_str().to_str().unwrap(),
BUNDLED_RESOURCE_PREFIX,
"resource glob `{glob}` must start with `{BUNDLED_RESOURCE_PREFIX}/`"
);
}
}
#[test]
fn path_lookup_finds_an_executable_by_name() {
let dir = unique_dir();
let exe = dir.join(bundled_bin_name("mysqldump"));
std::fs::write(&exe, b"").unwrap();
let path_var = std::env::join_paths([&dir]).unwrap();
assert_eq!(path_lookup_in("mysqldump", &path_var), Some(exe));
}
#[test]
fn path_lookup_skips_empty_entries_and_missing_names() {
let dir = unique_dir();
let path_var = std::env::join_paths([PathBuf::from(""), dir.clone()]).unwrap();
assert_eq!(path_lookup_in("definitely-absent-tool", &path_var), None);
}
#[test]
fn probe_rejects_a_missing_file_and_a_directory() {
let dir = unique_dir();
assert!(!probe(&dir.join("absent-tool")));
assert!(!probe(&dir), "a directory must never be reported as a runnable tool");
}
#[cfg(unix)]
#[test]
fn run_version_is_true_for_zero_exit_and_false_for_nonzero() {
let ok = unique_dir().join("ok_tool");
write_exe(&ok, "#!/bin/sh\nexit 0\n");
assert!(run_version(&ok, Duration::from_secs(1)));
let bad = unique_dir().join("bad_tool");
write_exe(&bad, "#!/bin/sh\nexit 3\n");
assert!(!run_version(&bad, Duration::from_secs(1)));
}
#[cfg(unix)]
#[test]
fn run_version_kills_a_hung_process_at_the_timeout() {
let slow = unique_dir().join("slow_tool");
write_exe(&slow, "#!/bin/sh\nsleep 5\n");
assert!(!run_version(&slow, Duration::from_millis(50)));
}
#[cfg(unix)]
#[test]
fn probe_accepts_a_runnable_script() {
let tool = unique_dir().join(bundled_bin_name("mariadb-dump"));
write_exe(&tool, "#!/bin/sh\necho 'mariadb-dump 11.4.5-MariaDB'\nexit 0\n");
assert!(probe(&tool));
}
#[cfg(unix)]
#[test]
fn version_of_reads_the_version_from_stdout() {
let tool = unique_dir().join("versioned_tool");
write_exe(&tool, "#!/bin/sh\necho 'mariadb-dump 11.4.5-MariaDB'\n");
let v = version_of(tool.to_str().unwrap()).unwrap();
assert!(v.contains("11.4.5-MariaDB"), "got {v}");
}
#[cfg(unix)]
#[test]
fn probe_within_returns_false_once_the_aggregate_deadline_passed() {
let tool = unique_dir().join("ok_tool");
write_exe(&tool, "#!/bin/sh\nexit 0\n");
let past = Instant::now() - Duration::from_secs(10);
assert!(!probe_within(past, Duration::from_secs(3), &tool));
assert!(probe_within(Instant::now(), Duration::from_secs(3), &tool));
}
#[test]
fn resolve_prefers_a_trusted_dir_and_the_first_candidate_name() {
let req = mysql_dump_request();
let trusted = vec![PathBuf::from("/trusted")];
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
let r = resolve_with(&req, &trusted, &path_lookup, None, |_p: &Path| true).unwrap();
assert_eq!(r.source, ToolSource::TrustedDir);
assert_eq!(r.name, "mariadb-dump");
assert_eq!(r.program, format!("/trusted/{}", bundled_bin_name("mariadb-dump")));
}
#[test]
fn resolve_falls_through_to_the_inherited_path() {
let req = mysql_dump_request();
let trusted = vec![PathBuf::from("/trusted")];
let path_lookup = |name: &str| -> Option<PathBuf> {
if name == "mysqldump" { Some(PathBuf::from("/usr/bin/mysqldump")) } else { None }
};
let probe = |p: &Path| p == Path::new("/usr/bin/mysqldump");
let r = resolve_with(&req, &trusted, &path_lookup, None, probe).unwrap();
assert_eq!(r.source, ToolSource::Path);
assert_eq!(r.name, "mysqldump");
assert_eq!(r.program, "/usr/bin/mysqldump");
}
#[test]
fn resolve_prefers_any_system_tool_over_the_bundled_one() {
let req = mysql_dump_request();
let trusted = vec![PathBuf::from("/trusted")];
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
let bundled = Path::new("/rd/resources/mysql_tools/mariadb-dump");
let probe = |p: &Path| p != bundled; // only the trusted candidate runs
let r = resolve_with(&req, &trusted, &path_lookup, Some(bundled), probe).unwrap();
assert_eq!(r.source, ToolSource::TrustedDir);
}
#[test]
fn resolve_uses_the_bundled_binary_when_no_system_tool_runs() {
let req = mysql_dump_request();
let bundled = Path::new("/rd/resources/mysql_tools/mariadb-dump");
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
let probe = |p: &Path| p == bundled;
let r = resolve_with(&req, &[], &path_lookup, Some(bundled), probe).unwrap();
assert_eq!(r.source, ToolSource::Bundled);
assert_eq!(r.name, "mariadb-dump");
assert_eq!(r.program, "/rd/resources/mysql_tools/mariadb-dump");
}
#[test]
fn resolve_skips_a_candidate_that_fails_the_probe() {
let req = mysql_dump_request();
let trusted = vec![PathBuf::from("/trusted")];
let target = PathBuf::from("/trusted").join(bundled_bin_name("mysqldump"));
let probe = move |p: &Path| p == target.as_path();
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
let r = resolve_with(&req, &trusted, &path_lookup, None, probe).unwrap();
assert_eq!(r.name, "mysqldump");
assert_eq!(r.source, ToolSource::TrustedDir);
}
#[test]
fn resolve_returns_none_when_nothing_runs() {
let req = mysql_dump_request();
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
let r = resolve_with(&req, &[PathBuf::from("/trusted")], &path_lookup, Some(Path::new("/rd/x")), |_p: &Path| false);
assert!(r.is_none());
}
#[cfg(unix)]
fn write_exe(path: &Path, body: &str) {
use std::os::unix::fs::PermissionsExt;
std::fs::write(path, body).unwrap();
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
}
fn unique_dir() -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir()
.join(format!("gridline_tool_resolver_{}_{}", std::process::id(), nanos));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn parse_major_handles_common_version_strings() {
assert_eq!(parse_major("8.0.36"), Some(8));
assert_eq!(parse_major("8.4.0"), Some(8));
assert_eq!(parse_major("11.4.5-MariaDB"), Some(11));
assert_eq!(parse_major("5.7.44-log"), Some(5));
assert_eq!(parse_major("26.7.0"), Some(26));
assert_eq!(parse_major("mariadb-dump from 11.4.5-MariaDB, client 10.19"), Some(11));
}
#[test]
fn parse_major_unwraps_the_legacy_mariadb_compat_prefix() {
// MariaDB historically reported 5.5.5-<real version>-MariaDB for compat
// with MySQL-era clients; the real major is the 4th group.
assert_eq!(parse_major("5.5.5-10.4.11-MariaDB"), Some(10));
assert_eq!(parse_major("5.5.5-10.11.6-MariaDB-log"), Some(10));
// A genuine 5.5.5 has only three groups and must stay 5.
assert_eq!(parse_major("5.5.5"), Some(5));
}
#[test]
fn parse_major_returns_none_for_garbage() {
assert_eq!(parse_major("no digits here"), None);
assert_eq!(parse_major(""), None);
}
#[test]
fn compat_flags_only_a_clearly_older_client() {
assert_eq!(compat(Some(8), Some(26)), Compat::ClientOlder);
assert_eq!(compat(Some(11), Some(8)), Compat::Ok);
assert_eq!(compat(Some(8), Some(8)), Compat::Ok);
assert_eq!(compat(None, Some(8)), Compat::Unknown);
assert_eq!(compat(Some(8), None), Compat::Unknown);
}
#[test]
fn warning_for_is_silent_unless_the_client_is_older() {
assert!(warning_for("mariadb-dump 11.4.5-MariaDB", "8.0.36", "mariadb-dump").is_none());
assert!(warning_for("unknown", "8.0.36", "mariadb-dump").is_none());
let w = warning_for("mariadb-dump 11.4.5-MariaDB", "26.7.0", "mariadb-dump").unwrap();
assert!(w.contains("11.4.5-MariaDB"), "{w}");
assert!(w.contains("26.7.0"), "{w}");
assert!(w.contains("mariadb-dump"), "{w}");
// The `\` continuation in the format literal must not leave stray leading
// indentation, so assert the exact single-line render (no newline, no run
// of consecutive spaces).
assert!(!w.contains('\n'), "warning must be one line: {w:?}");
assert!(!w.contains(" "), "warning must not contain double spaces: {w:?}");
assert_eq!(
w,
concat!(
"Client/server version mismatch: the resolved dump tool `mariadb-dump` reports ",
"mariadb-dump 11.4.5-MariaDB, but the server is 26.7.0. ",
"The backup may fail or omit data — installing a matching MySQL client is recommended."
)
);
}
#[test]
fn parse_major_never_panics_on_hostile_input() {
// A digit group that overflows u32 yields None, never a panic.
assert_eq!(parse_major("99999999999999999999.1.1"), None);
assert_eq!(parse_major(&"9".repeat(4096)), None);
// Non-ASCII digits, unicode and control characters are simply skipped.
assert_eq!(parse_major("\u{fc}nicode ٨.٠.٣٦"), None);
assert_eq!(parse_major("\u{1F600}"), None);
assert_eq!(parse_major("\u{0}\u{1}\u{2}"), None);
assert_eq!(parse_major("....."), None);
// Long adversarial input: the group collector is capped at six, and only
// the first group decides the major.
assert_eq!(parse_major(&"1.2.3.4.".repeat(1000)), Some(1));
// An overflowing *trailing* group is skipped by the final flush, so a
// genuine-looking `5.5.5` prefix still yields 5 rather than panicking.
// Harmless: no real tool prints such a version, and the result is advisory.
assert_eq!(parse_major("5.5.5-99999999999999999999"), Some(5));
}
#[test]
fn adapt_resolution_returns_program_and_source() {
let res = ToolResolution {
resolved: Some(Resolution {
program: "/x/mariadb-dump".into(),
source: ToolSource::Bundled,
name: "mariadb-dump".into(),
}),
bundled_path: Some(PathBuf::from("/x/mariadb-dump")),
bundled_available: true,
version: Some("11.4.5".into()),
};
assert_eq!(
adapt_resolution(&res, "mariadb-dump"),
("/x/mariadb-dump".to_string(), Some("bundled".to_string()))
);
}
#[test]
fn adapt_resolution_falls_back_to_the_requested_name() {
assert_eq!(
adapt_resolution(&ToolResolution::none(), "pg_dump"),
("pg_dump".to_string(), None)
);
}
#[test]
fn cache_round_trips_and_clears() {
cache_clear();
let res = ToolResolution {
resolved: Some(Resolution {
program: "/c/tool".into(),
source: ToolSource::Path,
name: "tool".into(),
}),
bundled_path: None,
bundled_available: false,
version: None,
};
assert!(cache_get("k_task6_unique").is_none());
cache_put("k_task6_unique", res.clone());
assert_eq!(cache_get("k_task6_unique"), Some(res));
cache_clear();
assert!(cache_get("k_task6_unique").is_none());
}
#[test]
fn bundled_present_is_true_only_for_a_regular_file() {
let dir = unique_dir();
assert!(!bundled_present(&dir), "a directory is not a present bundled tool");
let file = dir.join("mariadb-dump");
std::fs::write(&file, b"x").unwrap();
assert!(bundled_present(&file));
}
#[test]
fn mysql_request_maps_known_tool_names() {
assert_eq!(mysql_request("mariadb-dump").unwrap(), mysql_dump_request());
assert_eq!(mysql_request("mysqldump").unwrap(), mysql_dump_request());
assert_eq!(mysql_request("mariadb").unwrap(), mysql_client_request());
assert_eq!(mysql_request("mysql").unwrap(), mysql_client_request());
assert!(mysql_request("mysqladmin").is_none());
}
+25 -1
View File
@@ -42,6 +42,12 @@ pub struct PgToolStatus {
pub pg_restore_version: Option<String>,
pub pg_dump_source: Option<String>, // "system" | "bundled" | None
pub pg_restore_source: Option<String>,
/// The binary name that actually resolved (`mariadb-dump`, `mysqldump`, …).
pub pg_dump_resolved_name: Option<String>,
pub pg_restore_resolved_name: Option<String>,
/// Whether a bundled file exists at the expected path (runnable or not).
pub pg_dump_bundled_available: bool,
pub pg_restore_bundled_available: bool,
}
/// Resolved on-disk paths for the three client tools (system-first, bundled-fallback).
@@ -111,6 +117,12 @@ pub struct MySqlToolStatus {
pub mysql_version: Option<String>,
pub mysqldump_source: Option<String>,
pub mysql_source: Option<String>,
pub mysqldump_resolved_name: Option<String>,
pub mysql_resolved_name: Option<String>,
/// True when the dump tool that resolved is MariaDB's `mariadb-dump`.
pub mysqldump_is_mariadb: bool,
pub mysqldump_bundled_available: bool,
pub mysql_bundled_available: bool,
}
#[derive(Debug, Clone)]
@@ -141,6 +153,9 @@ pub struct BackupProgressEvent {
pub progress: Option<f64>,
pub output_line: Option<String>,
pub error: Option<String>,
/// Non-fatal advisory message (e.g. a client/server version mismatch).
/// `None` for ordinary progress events.
pub warning: Option<String>,
}
#[cfg(test)]
@@ -185,7 +200,11 @@ mod tests {
fn pg_tool_status_reports_source() {
let s = PgToolStatus { pg_dump_found: true, pg_restore_found: true,
pg_dump_version: Some("pg_dump 16".into()), pg_restore_version: Some("pg_restore 16".into()),
pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()) };
pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()),
pg_dump_resolved_name: Some("pg_dump".into()),
pg_restore_resolved_name: None,
pg_dump_bundled_available: true,
pg_restore_bundled_available: false };
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"pg_dump_source\":\"system\""));
assert!(json.contains("\"pg_restore_source\":\"bundled\""));
@@ -225,6 +244,11 @@ mod tests {
mysql_version: Some("mariadb 10.6".into()),
mysqldump_source: Some("bundled".into()),
mysql_source: Some("system".into()),
mysqldump_resolved_name: Some("mariadb-dump".into()),
mysql_resolved_name: Some("mariadb".into()),
mysqldump_is_mariadb: true,
mysqldump_bundled_available: true,
mysql_bundled_available: true,
};
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"mysqldumpSource\":\"bundled\""));