diff --git a/package.json b/package.json index 076fe35..56ad53d 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "@tanstack/react-virtual": "^3.13.23", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-dialog": "^2.7.0", - "@tauri-apps/plugin-fs": "^2.5.0", "@tauri-apps/plugin-store": "^2.4.2", "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9b3be2..3a47f58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,6 @@ importers: '@tauri-apps/plugin-dialog': specifier: ^2.7.0 version: 2.7.1 - '@tauri-apps/plugin-fs': - specifier: ^2.5.0 - version: 2.5.1 '@tauri-apps/plugin-store': specifier: ^2.4.2 version: 2.4.3 @@ -1293,42 +1290,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} @@ -1433,28 +1424,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.4': resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.4': resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.4': resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.4': resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} @@ -1543,35 +1530,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.11.0': resolution: {integrity: sha512-DtSE8ZBlB9H+L+eHkfZ3myt00EVEyAB3e41juEHoE2qT88fgVlJvyrwa9SZYc/xTwCS9TnmK+R84tpg+ZsAg7Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.11.0': resolution: {integrity: sha512-5QdgS4LD+kntClI1aj2JmwjW38LosNXxwCe8viIHEwqYIWuMPdNEIau6/cLogI38Yzx9DnfCPRfEWLyI+5li8Q==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.11.0': resolution: {integrity: sha512-5UynPXo3Zq9khjVdAbD+YogeLltdVUeOah2ioSIM3tu6H7wY9vMy6rgGJhv9r5R8ZXmk9GttMippdqYJWrnLnA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.11.0': resolution: {integrity: sha512-CNz7fHbApz1Zyhhq73jtGn9JqgNEV/lIWnTnUo6h6ujw+mHsTmkLszvJSM8W6JBaDjNpTTFr/RSNoVL5FMwcTg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.11.0': resolution: {integrity: sha512-K+br+VXZ+Xx0n/9FdWohpW5Ugq+2FQUpJScqcPl1hTxXfh3fgjYgt4qA2NgrjlJo+zZPNrmUMl+NLvm0ufEqBQ==} @@ -1599,9 +1581,6 @@ packages: '@tauri-apps/plugin-dialog@2.7.1': resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} - '@tauri-apps/plugin-fs@2.5.1': - resolution: {integrity: sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==} - '@tauri-apps/plugin-store@2.4.3': resolution: {integrity: sha512-9LWPj9yMphRi9czEtUv87XHbl1b6xgd9EXpPrUnq6nG7+nbtoF84d4Kwz9xhAv/Hf30sr58pq7EOlyI936y8qw==} @@ -2810,28 +2789,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -5347,10 +5322,6 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.0 - '@tauri-apps/plugin-fs@2.5.1': - dependencies: - '@tauri-apps/api': 2.11.0 - '@tauri-apps/plugin-store@2.4.3': dependencies: '@tauri-apps/api': 2.11.0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a9c089f..52cf787 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5969,7 +5969,6 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", - "tauri-plugin-fs", "tauri-plugin-log", "tauri-plugin-store", "tokio", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e1f3e08..5e16056 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,7 +24,6 @@ deadpool-postgres = "0.14" log = "0.4" tauri = { version = "2.10.3", features = [] } tauri-plugin-dialog = "2" -tauri-plugin-fs = "2" tauri-plugin-log = "2" tauri-plugin-store = "2" tokio = { version = "1", features = ["full"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 7adeab1..64cb816 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,7 +8,6 @@ "permissions": [ "core:default", "store:default", - "dialog:default", - "fs:default" + "dialog:default" ] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 24eb224..6bd109f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -16,7 +16,8 @@ use crate::db::{ build_mysql_pool, build_mysql_pool_custom, build_pool, build_pool_custom, build_sqlite_pool, disconnect_connection, drop_pool, get_or_create_mysql_pool, get_or_create_sqlite_pool, list_connections, load_connection, persist_connection_with_password, quote_identifier, - refresh_connection_pools, resolve_connection_engine, with_pool_client_retry, AppState, + refresh_connection_pools, require_safe_identifier, resolve_connection_engine, + with_pool_client_retry, AppState, DEFAULT_MYSQL_PORT, MAX_QUERY_ROWS, }; use crate::credentials; @@ -831,6 +832,14 @@ pub async fn run_query( return Err("Enter a SQL statement before running the query.".to_string()); } + if !input.allow_write.unwrap_or(false) && !is_read_only_sql(&sql) { + return Err( + "This statement modifies data or schema. Confirm execution in the editor, \ + or use the model/DDL workflow for schema changes." + .to_string(), + ); + } + let max_query_rows = input.max_rows.unwrap_or(MAX_QUERY_ROWS); match engine { @@ -995,6 +1004,7 @@ async fn fetch_query_editor_metadata_for_connection( let mut truncated_columns = false; for row in table_rows.into_iter().take(MAX_EDITOR_TABLES as usize) { let name: String = sqlite_get_idx(&row, 0, "name", "get_query_editor_metadata")?; + require_safe_identifier(&name, "table name")?; let pragma_sql = format!("PRAGMA table_info(\"{}\");", quote_identifier(&name)); let column_rows = sqlx::query(&pragma_sql) .fetch_all(&pool) @@ -1207,6 +1217,7 @@ async fn fetch_foreign_keys_for_connection( let mut edges = Vec::new(); for table in tables { let table_name: String = sqlite_get_idx(&table, 0, "name", "get_foreign_keys")?; + require_safe_identifier(&table_name, "table name")?; let fk_sql = format!("PRAGMA foreign_key_list(\"{}\");", quote_identifier(&table_name)); let fk_rows = sqlx::query(&fk_sql) .fetch_all(&pool) @@ -1450,6 +1461,7 @@ pub async fn get_query_editor_metadata( let mut truncated_columns = false; for row in table_rows.into_iter().take(MAX_EDITOR_TABLES as usize) { let name: String = sqlite_get_idx(&row, 0, "name", "get_query_editor_metadata")?; + require_safe_identifier(&name, "table name")?; let pragma_sql = format!("PRAGMA table_info(\"{}\");", quote_identifier(&name)); let column_rows = sqlx::query(&pragma_sql) .fetch_all(&pool) @@ -1757,6 +1769,29 @@ fn classify_sql_intent(sql: &str) -> String { "unknown".to_string() } +/// Whether every statement in `sql` is read-only. Transaction-control keywords +/// (`begin`/`commit`/`rollback`/`start`/`savepoint`) are ignored so a wrapped +/// `BEGIN; SELECT ...; COMMIT;` still counts as read-only, while any write +/// statement makes the whole batch non-read-only. +fn is_read_only_sql(sql: &str) -> bool { + let mut saw_statement = false; + for statement in sql.split(';').map(str::trim).filter(|s| !s.is_empty()) { + let normalized = statement.to_ascii_lowercase(); + let is_transaction_control = ["begin", "commit", "rollback", "start", "savepoint", "release"] + .iter() + .any(|kw| normalized.starts_with(kw)); + if is_transaction_control { + continue; + } + saw_statement = true; + match classify_sql_intent(statement).as_str() { + "select" | "explain" => {} + _ => return false, + } + } + saw_statement +} + fn has_multiple_statements(sql: &str) -> bool { let statements = sql .split(';') @@ -2712,6 +2747,7 @@ pub async fn get_tables( let mut tables = Vec::new(); for row in rows { let name: String = sqlite_get_idx(&row, 0, "name", "get_tables")?; + require_safe_identifier(&name, "table name")?; tables.push(TableInfo { schema: "main".to_string(), preview_query: format!("select * from \"{}\" limit 100;", quote_identifier(&name)), @@ -2796,6 +2832,7 @@ pub async fn get_schema( } DatabaseEngine::Sqlite => { let pool = get_or_create_sqlite_pool(&app, &state, &connection_id).await?; + require_safe_identifier(&schema_request.table_name, "table name")?; let pragma_sql = format!( "PRAGMA table_info(\"{}\");", quote_identifier(&schema_request.table_name) @@ -2956,6 +2993,7 @@ pub async fn get_table_properties( if engine == DatabaseEngine::Sqlite { let pool = get_or_create_sqlite_pool(&app, &state, &connection_id).await?; + require_safe_identifier(&ctx.table_name, "table name")?; let pragma_sql = format!("PRAGMA table_info(\"{}\");", quote_identifier(&ctx.table_name)); let rows = sqlx::query(&pragma_sql) .fetch_all(&pool) @@ -2978,6 +3016,7 @@ pub async fn get_table_properties( continue; } let index_name = sqlite_get_name::(&index, "name", "get_table_properties")?; + require_safe_identifier(&index_name, "index name")?; let info_sql = format!("PRAGMA index_info(\"{}\");", quote_identifier(&index_name)); let info_rows = sqlx::query(&info_sql) .fetch_all(&pool) @@ -3167,6 +3206,9 @@ pub async fn apply_table_properties( let table_name = input.table_name; let columns = input.columns; + require_safe_identifier(&table_schema, "schema name")?; + require_safe_identifier(&table_name, "table name")?; + let current_columns = client .query( " @@ -3273,6 +3315,7 @@ pub async fn apply_table_properties( quote_identifier(&table_name) ); + require_safe_identifier(column_name, "column name")?; let qualified_column = format!("\"{}\"", quote_identifier(column_name)); if *desired_is_nullable { @@ -3328,6 +3371,7 @@ pub async fn apply_table_properties( quote_identifier(&table_schema), quote_identifier(&table_name) ); + require_safe_identifier(column_name, "column name")?; let qualified_column = format!("\"{}\"", quote_identifier(column_name)); if *desired_is_unique { @@ -3363,6 +3407,7 @@ pub async fn apply_table_properties( .unwrap_or_default(); for constraint_name in constraint_names { + require_safe_identifier(&constraint_name, "constraint name")?; let sql = format!( "ALTER TABLE {} DROP CONSTRAINT \"{}\"", qualified_table, @@ -3438,6 +3483,7 @@ pub async fn get_foreign_keys( let mut edges = Vec::new(); for table in tables { let table_name: String = sqlite_get_idx(&table, 0, "name", "get_foreign_keys")?; + require_safe_identifier(&table_name, "table name")?; let fk_sql = format!("PRAGMA foreign_key_list(\"{}\");", quote_identifier(&table_name)); let fk_rows = sqlx::query(&fk_sql) .fetch_all(&pool) @@ -3576,6 +3622,7 @@ pub async fn get_table_indexes( if engine == DatabaseEngine::Sqlite { let pool = get_or_create_sqlite_pool(&app, &state, &connection_id).await?; + require_safe_identifier(&ctx.table_name, "table name")?; let pragma_sql = format!( "PRAGMA index_list(\"{}\");", quote_identifier(&ctx.table_name) @@ -3588,6 +3635,7 @@ pub async fn get_table_indexes( let mut indexes = Vec::new(); for row in rows.into_iter().take(MAX_TABLE_INDEX_ROWS as usize) { let index_name: String = sqlite_get_name(&row, "name", "get_table_indexes")?; + require_safe_identifier(&index_name, "index name")?; let index_info_sql = format!("PRAGMA index_info(\"{}\");", quote_identifier(&index_name)); let index_info_rows = sqlx::query(&index_info_sql) .fetch_all(&pool) @@ -3844,6 +3892,24 @@ pub async fn save_text_file(content: String, output_path: String) -> Result<(), std::fs::write(&output_path, content).map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn store_openrouter_api_key(api_key: String) -> Result<(), String> { + if api_key.trim().is_empty() { + return credentials::delete_openrouter_api_key(); + } + credentials::store_openrouter_api_key(&api_key) +} + +#[tauri::command] +pub async fn get_openrouter_api_key() -> Result, String> { + credentials::get_openrouter_api_key() +} + +#[tauri::command] +pub async fn delete_openrouter_api_key() -> Result<(), String> { + credentials::delete_openrouter_api_key() +} + #[cfg(test)] mod tests { use super::{ @@ -3971,6 +4037,23 @@ mod tests { assert_eq!(classify_sql_intent("UPDATE foo SET bar = 1"), "update"); } + #[test] + fn read_only_check_allows_selects_and_explain() { + assert!(super::is_read_only_sql("SELECT 1")); + assert!(super::is_read_only_sql("EXPLAIN ANALYZE SELECT * FROM t")); + assert!(super::is_read_only_sql("WITH x AS (SELECT 1) SELECT * FROM x")); + assert!(super::is_read_only_sql("BEGIN; SELECT 1; COMMIT;")); + } + + #[test] + fn read_only_check_blocks_writes() { + assert!(!super::is_read_only_sql("DELETE FROM t")); + assert!(!super::is_read_only_sql("DROP TABLE t")); + assert!(!super::is_read_only_sql("BEGIN; UPDATE t SET a = 1; COMMIT;")); + assert!(!super::is_read_only_sql("SELECT 1; DELETE FROM t")); + assert!(!super::is_read_only_sql("")); + } + #[test] fn mysql_timestamp_formats_as_datetime_string() { let dt = chrono::DateTime::parse_from_rfc3339("2024-03-15T10:30:45Z") diff --git a/src-tauri/src/credentials.rs b/src-tauri/src/credentials.rs index f3c3e39..b13616e 100644 --- a/src-tauri/src/credentials.rs +++ b/src-tauri/src/credentials.rs @@ -1,6 +1,7 @@ use keyring::Entry; const SERVICE_NAME: &str = "com.veloxdb.app"; +const OPENROUTER_API_KEY_ACCOUNT: &str = "openrouter-api-key"; fn entry(connection_id: &str) -> Result { Entry::new(SERVICE_NAME, connection_id).map_err(|e| format!("Keychain error: {}", e)) @@ -27,3 +28,25 @@ pub fn delete_password(connection_id: &str) -> Result<(), String> { Err(e) => Err(format!("Failed to delete password from keychain: {}", e)), } } + +pub fn store_openrouter_api_key(api_key: &str) -> Result<(), String> { + entry(OPENROUTER_API_KEY_ACCOUNT)? + .set_password(api_key) + .map_err(|e| format!("Failed to store API key in keychain: {}", e)) +} + +pub fn get_openrouter_api_key() -> Result, String> { + match entry(OPENROUTER_API_KEY_ACCOUNT)?.get_password() { + Ok(api_key) => Ok(Some(api_key)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("Failed to read API key from keychain: {}", e)), + } +} + +pub fn delete_openrouter_api_key() -> Result<(), String> { + match entry(OPENROUTER_API_KEY_ACCOUNT)?.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(format!("Failed to delete API key from keychain: {}", e)), + } +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index a20bee2..1b618ab 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -237,7 +237,15 @@ fn mysql_url(host: &str, port: u16, input: &ConnectionInput) -> String { let password = urlencoding::encode(&input.password); let user = urlencoding::encode(&input.user); let database = urlencoding::encode(&input.database); - format!("mysql://{}:{}@{}:{}/{}", user, password, host, port, database) + let ssl_param = match input.ssl_mode { + ConnectionSslMode::Disable => "ssl-mode=DISABLED", + ConnectionSslMode::Prefer => "ssl-mode=PREFERRED", + ConnectionSslMode::Require => "ssl-mode=REQUIRED", + }; + format!( + "mysql://{}:{}@{}:{}/{}?{}", + user, password, host, port, database, ssl_param + ) } fn sqlite_url(input: &ConnectionInput) -> Result { @@ -711,3 +719,72 @@ pub fn load_connection( pub fn quote_identifier(value: &str) -> String { value.replace('"', "\"\"") } + +/// Whether `name` is safe to interpolate into dynamic SQL (e.g. SQLite `PRAGMA` +/// statements, where bind parameters are not allowed). Restricted to ASCII +/// alphanumerics and underscores so it cannot terminate or escape a statement. +pub fn is_safe_identifier(name: &str) -> bool { + !name.is_empty() + && name.len() <= 128 + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Validates an identifier before it is interpolated into dynamic SQL. Returns +/// the identifier unchanged when safe, or a descriptive error otherwise. +pub fn require_safe_identifier<'a>(name: &'a str, context: &str) -> Result<&'a str, String> { + if is_safe_identifier(name) { + Ok(name) + } else { + Err(format!("Invalid identifier for {}: {:?}", context, name)) + } +} + +#[cfg(test)] +mod tests { + use super::{is_safe_identifier, mysql_url, require_safe_identifier}; + use crate::models::{ConnectionInput, ConnectionSslMode, DatabaseEngine}; + + fn mysql_input(ssl_mode: ConnectionSslMode) -> ConnectionInput { + ConnectionInput { + id: None, + name: "test".to_string(), + engine: DatabaseEngine::Mysql, + host: "localhost".to_string(), + port: 3306, + database: "app".to_string(), + file_path: None, + user: "root".to_string(), + password: "pw".to_string(), + ssl_mode, + ssh_config: None, + extra_params: None, + } + } + + #[test] + fn mysql_url_maps_ssl_mode() { + assert!(mysql_url("localhost", 3306, &mysql_input(ConnectionSslMode::Disable)) + .ends_with("?ssl-mode=DISABLED")); + assert!(mysql_url("localhost", 3306, &mysql_input(ConnectionSslMode::Prefer)) + .ends_with("?ssl-mode=PREFERRED")); + assert!(mysql_url("localhost", 3306, &mysql_input(ConnectionSslMode::Require)) + .ends_with("?ssl-mode=REQUIRED")); + } + + #[test] + fn rejects_sql_injection_in_identifiers() { + assert!(!is_safe_identifier("\"; DROP TABLE users; --")); + assert!(!is_safe_identifier("foo; DELETE FROM bar")); + assert!(!is_safe_identifier("a\0b")); + assert!(!is_safe_identifier("")); + assert!(require_safe_identifier("foo);", "table name").is_err()); + } + + #[test] + fn accepts_plain_identifiers() { + assert!(is_safe_identifier("users")); + assert!(is_safe_identifier("_internal")); + assert!(is_safe_identifier("Table123")); + assert_eq!(require_safe_identifier("users", "table name"), Ok("users")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ee6b279..e5f991e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,12 +9,12 @@ mod ssh_tunnel; use commands::{ apply_table_properties, cancel_veloxy_request, chat_with_db, clear_veloxy_conversation, connect_db, delete_connection, - disconnect_db, execute_ddl_statement, execute_ddl_transaction, export_diagram_png, + delete_openrouter_api_key, disconnect_db, execute_ddl_statement, execute_ddl_transaction, export_diagram_png, export_results_csv_command, export_results_json_command, generate_sql_from_nl, get_foreign_keys, - get_query_editor_metadata, get_schema, get_table_indexes, get_table_properties, get_tables, + get_openrouter_api_key, get_query_editor_metadata, get_schema, get_table_indexes, get_table_properties, get_tables, lint_sql, list_connections_command, list_databases, load_veloxy_conversation, ping_connection, refresh_connection, rename_connection, run_query, save_base64_png, save_text_file, set_active_connection, - switch_database, + store_openrouter_api_key, switch_database, }; use db::AppState; use tauri::Manager; @@ -25,7 +25,6 @@ pub fn run() { .manage(AppState::default()) .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_fs::init()) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( @@ -79,7 +78,10 @@ pub fn run() { chat_with_db, cancel_veloxy_request, load_veloxy_conversation, - clear_veloxy_conversation + clear_veloxy_conversation, + store_openrouter_api_key, + get_openrouter_api_key, + delete_openrouter_api_key ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 38a6234..3f3c876 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -150,6 +150,10 @@ pub struct QueryRequest { pub sql: String, /// Maximum rows to return for this query. When omitted, the default (`MAX_QUERY_ROWS`) is used. pub max_rows: Option, + /// Allows non-read-only statements to run. Defaults to false so the editor + /// must explicitly confirm writes; programmatic DML (grid edits) sets it. + #[serde(default)] + pub allow_write: Option, } #[derive(Debug, Clone, Serialize)] diff --git a/src-tauri/src/ssh_tunnel.rs b/src-tauri/src/ssh_tunnel.rs index 94be6ae..138b73f 100644 --- a/src-tauri/src/ssh_tunnel.rs +++ b/src-tauri/src/ssh_tunnel.rs @@ -40,15 +40,15 @@ async fn spawn_ssh_tunnel_password( .as_deref() .ok_or("SSH password required")?; + // `sshpass -e` reads the password from the SSHPASS env var, which keeps it + // out of the process argument list (visible via `ps aux`). let child = Command::new("sshpass") - .arg("-p") - .arg(password) + .arg("-e") + .env("SSHPASS", password) .arg("ssh") .arg("-o") .arg("StrictHostKeyChecking=accept-new") .arg("-o") - .arg("UserKnownHostsFile=/dev/null") - .arg("-o") .arg("PasswordAuthentication=yes") .arg("-o") .arg("PubkeyAuthentication=no") @@ -85,8 +85,6 @@ async fn spawn_ssh_tunnel_key( cmd.arg("-o") .arg("StrictHostKeyChecking=accept-new") .arg("-o") - .arg("UserKnownHostsFile=/dev/null") - .arg("-o") .arg("ServerAliveInterval=30") .arg("-o") .arg("ServerAliveCountMax=3") diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bc4a678..079c276 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,7 +22,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' https://openrouter.ai https://api.github.com http://localhost:3000 ws://localhost:3000; img-src 'self' data:; font-src 'self'; worker-src 'self' blob:" } }, "bundle": { diff --git a/src/App.tsx b/src/App.tsx index d0f6edc..c06f970 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,6 +48,7 @@ import { } from "@/features/queries/components/AskVeloxyDialog"; import { useSaveResultEditsMutation, useDeleteRowsMutation } from "@/features/queries/queries"; import { notifyError, notifySuccess } from "@/lib/error-notifier"; +import { loadOpenRouterApiKey } from "@/lib/openrouter-credentials"; import { useSettings, resolveTheme } from "@/lib/settings"; import { buildDropTableSql, @@ -181,6 +182,10 @@ function VeloxApp() { document.documentElement.style.fontSize = `${sizes[fontSize]}px` }, [fontSize]) + useEffect(() => { + void loadOpenRouterApiKey() + }, []) + useEffect(() => { window.localStorage.setItem( SIDEBAR_COLLAPSED_KEY, @@ -614,7 +619,7 @@ function VeloxApp() { setTablePropertiesTarget(null); } }, - [connection?.id, deleteConnectionMutation], + [connection?.id, deleteConnectionMutation, t], ); const handleCopyConnectionString = useCallback( diff --git a/src/data/types.ts b/src/data/types.ts index 3581a1d..e8610f1 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -51,6 +51,8 @@ export type QueryRequest = { sql: string /** Maximum rows to return. Matches the user's Settings > Results > Max rows preference. */ maxRows?: number + /** Allows non-read-only statements to run. The editor sets this after confirming a write. */ + allowWrite?: boolean } export type LintSqlRequest = { diff --git a/src/features/commands/components/SettingsDialog.tsx b/src/features/commands/components/SettingsDialog.tsx index 1584211..c43f358 100644 --- a/src/features/commands/components/SettingsDialog.tsx +++ b/src/features/commands/components/SettingsDialog.tsx @@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { Dialog, DialogContent } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' +import { saveOpenRouterApiKey } from '@/lib/openrouter-credentials' import { fetchOpenRouterModels, OPENROUTER_POPULAR_MODELS, type OpenRouterModelOption } from '@/lib/openrouter-models' import { cn } from '@/lib/utils' import { useSettings, type AppTheme, type FontSize, type NullDisplay } from '@/lib/settings' @@ -53,7 +54,8 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh const [tab, setTab] = useState('appearance') const handleExport = useCallback(() => { - const data = JSON.stringify(useSettings.getState(), null, 2) + const { veloxyOpenRouterApiKey: _omitApiKey, ...exportable } = useSettings.getState() + const data = JSON.stringify(exportable, null, 2) const blob = new Blob([data], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a'); a.href = url; a.download = 'veloxdb-settings.json'; a.click() @@ -222,7 +224,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh value={settings.veloxyOpenRouterApiKey} onChange={(e) => { const value = e.target.value - useSettings.setState({ veloxyOpenRouterApiKey: value }) + void saveOpenRouterApiKey(value) if (value.trim()) void refreshOpenRouterModels() }} className="h-8 w-[260px] text-[11px]" diff --git a/src/features/connections/components/ConnectionDialog.tsx b/src/features/connections/components/ConnectionDialog.tsx index 4d6b723..f4269b5 100644 --- a/src/features/connections/components/ConnectionDialog.tsx +++ b/src/features/connections/components/ConnectionDialog.tsx @@ -351,7 +351,10 @@ export function ConnectionDialog({ filePath: values.filePath || null, user: values.engine === 'sqlite' ? '' : values.user, password: values.engine === 'sqlite' ? '' : values.password, - sslMode: values.engine === 'postgres' ? values.sslMode : 'disable', + sslMode: + values.engine === 'postgres' || values.engine === 'mysql' + ? values.sslMode + : 'disable', extraParams: values.engine === 'postgres' && Object.keys(extraParams).length > 0 ? extraParams : null, sshConfig: values.engine !== 'sqlite' && values.sshEnabled @@ -605,7 +608,7 @@ export function ConnectionDialog({ )} - {engine === 'postgres' && ( + {(engine === 'postgres' || engine === 'mysql') && ( - veloxDbRepository.runQuery({ connectionId, sql, maxRows: maxQueryRows }), + mutationFn: ({ connectionId, sql, allowWrite }: RunQueryTabVariables) => + veloxDbRepository.runQuery({ connectionId, sql, allowWrite, maxRows: maxQueryRows }), onSuccess: (result, variables) => { options.onSuccess?.(result, variables); }, @@ -212,6 +214,7 @@ export function useInsertRowMutation( await veloxDbRepository.runQuery({ connectionId: request.connectionId, sql, + allowWrite: true, }); }, onError: (error, variables) => { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4a85e08..0f6e5f2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -309,6 +309,7 @@ "insertFailed": "Insert failed", "copyFailed": "Copy failed", "runQueryBtn": "Run Query", + "confirmWrite": "This statement modifies data or schema. Run it anyway?", "stopQuery": "Stop Query", "formatSql": "Format SQL", "explainAnalyze": "Explain Analyze", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 809aed4..acf1197 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -309,6 +309,7 @@ "insertFailed": "插入失败", "copyFailed": "复制失败", "runQueryBtn": "运行查询", + "confirmWrite": "此语句将修改数据或结构。仍要运行吗?", "stopQuery": "停止查询", "formatSql": "格式化 SQL", "explainAnalyze": "Explain Analyze", diff --git a/src/lib/connection-string.test.ts b/src/lib/connection-string.test.ts index 6f6a443..af9df76 100644 --- a/src/lib/connection-string.test.ts +++ b/src/lib/connection-string.test.ts @@ -19,6 +19,24 @@ describe('connection string parsing and building', () => { expect(parsed?.port).toBe(3306) }) + it('parses mysql ssl-mode and builds it back', () => { + const parsed = parseConnectionString( + 'mysql://root:pw@127.0.0.1:3306/app_db?ssl-mode=REQUIRED', + ) + expect(parsed?.sslMode).toBe('require') + + const value = buildConnectionString({ + engine: 'mysql', + host: '127.0.0.1', + port: 3306, + database: 'app_db', + user: 'root', + password: 'pw', + sslMode: 'require', + }) + expect(value).toContain('ssl-mode=REQUIRED') + }) + it('parses sqlite uri', () => { const parsed = parseConnectionString('sqlite:///tmp/velox.db') expect(parsed).not.toBeNull() diff --git a/src/lib/connection-string.ts b/src/lib/connection-string.ts index c503a3c..7ca7587 100644 --- a/src/lib/connection-string.ts +++ b/src/lib/connection-string.ts @@ -14,9 +14,22 @@ export type ParsedConnectionString = { const DEFAULT_PG_PORT = 5432 const SSL_MODE_KEY = 'sslmode' +const MYSQL_SSL_MODE_KEY = 'ssl-mode' const VALID_SSL_MODES: Set = new Set(['disable', 'prefer', 'require']) +const MYSQL_SSL_MODE_FROM_PARAM: Record = { + disabled: 'disable', + preferred: 'prefer', + required: 'require', +} + +const MYSQL_SSL_MODE_TO_PARAM: Record = { + disable: 'DISABLED', + prefer: 'PREFERRED', + require: 'REQUIRED', +} + function normalizeUrl(raw: string): string { return raw.trim() } @@ -70,7 +83,7 @@ export function parseConnectionString(raw: string): ParsedConnectionString | nul const password = decodeURIComponent(url.password || '') const params = new URLSearchParams(url.search) - let sslMode: ConnectionSslMode = engine === 'postgres' ? 'prefer' : 'disable' + let sslMode: ConnectionSslMode = 'prefer' if (params.has(SSL_MODE_KEY)) { const rawMode = (params.get(SSL_MODE_KEY) ?? '').toLowerCase() @@ -80,6 +93,14 @@ export function parseConnectionString(raw: string): ParsedConnectionString | nul params.delete(SSL_MODE_KEY) } + if (engine === 'mysql' && params.has(MYSQL_SSL_MODE_KEY)) { + const rawMode = (params.get(MYSQL_SSL_MODE_KEY) ?? '').toLowerCase() + if (rawMode in MYSQL_SSL_MODE_FROM_PARAM) { + sslMode = MYSQL_SSL_MODE_FROM_PARAM[rawMode] + } + params.delete(MYSQL_SSL_MODE_KEY) + } + const extraParams: Record = {} params.forEach((value, key) => { extraParams[key] = value @@ -116,6 +137,9 @@ export function buildConnectionString(fields: { if (fields.engine === 'postgres' && fields.sslMode !== 'prefer') { params.set('sslmode', fields.sslMode) } + if (fields.engine === 'mysql' && fields.sslMode !== 'prefer') { + params.set(MYSQL_SSL_MODE_KEY, MYSQL_SSL_MODE_TO_PARAM[fields.sslMode]) + } if (fields.extraParams) { for (const [key, value] of Object.entries(fields.extraParams)) { params.set(key, value) diff --git a/src/lib/openrouter-credentials.ts b/src/lib/openrouter-credentials.ts new file mode 100644 index 0000000..bc36682 --- /dev/null +++ b/src/lib/openrouter-credentials.ts @@ -0,0 +1,43 @@ +import { invoke } from '@tauri-apps/api/core' + +import { useSettings } from '@/lib/settings' + +/** + * The OpenRouter API key lives in the OS keychain, not in localStorage. These + * helpers keep the in-memory zustand state in sync with the keychain so the + * rest of the app can keep reading `settings.veloxyOpenRouterApiKey`. + */ + +let loaded = false + +export async function loadOpenRouterApiKey(): Promise { + if (loaded) return + loaded = true + + // One-time migration: an older build may have persisted the key in + // localStorage. Move it into the keychain so the next write (below) scrubs + // the plaintext copy via the store's `partialize`. + const persisted = useSettings.getState().veloxyOpenRouterApiKey + if (persisted.trim()) { + try { + await invoke('store_openrouter_api_key', { apiKey: persisted }) + } catch { + // Keep the in-memory key so Veloxy still works this session. + return + } + } + + try { + const stored = await invoke('get_openrouter_api_key') + // Re-setting state triggers a partialized persist, removing any lingering + // plaintext key from localStorage. + useSettings.setState({ veloxyOpenRouterApiKey: stored ?? persisted }) + } catch { + // Leave whatever is already in memory. + } +} + +export async function saveOpenRouterApiKey(apiKey: string): Promise { + useSettings.setState({ veloxyOpenRouterApiKey: apiKey }) + await invoke('store_openrouter_api_key', { apiKey }) +} diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 87a3281..24e1cca 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -45,7 +45,11 @@ const defaults: AppSettings = { } export const useSettings = create()( - persist(() => defaults, { name: 'veloxdb.settings' }), + persist(() => defaults, { + name: 'veloxdb.settings', + // The OpenRouter API key is kept in the OS keychain, never in localStorage. + partialize: ({ veloxyOpenRouterApiKey: _omitApiKey, ...rest }) => rest, + }), ) export function resolveTheme(theme: AppTheme): 'light' | 'dark' { diff --git a/src/lib/sql-intent.test.ts b/src/lib/sql-intent.test.ts new file mode 100644 index 0000000..c5c78f3 --- /dev/null +++ b/src/lib/sql-intent.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { classifySqlIntent, isReadOnlySql } from "@/lib/sql-intent"; + +describe("classifySqlIntent", () => { + it("recognizes statement kinds", () => { + expect(classifySqlIntent("SELECT 1")).toBe("select"); + expect(classifySqlIntent("with x as (select 1) select * from x")).toBe("select"); + expect(classifySqlIntent("INSERT INTO t VALUES (1)")).toBe("insert"); + expect(classifySqlIntent("UPDATE t SET a = 1")).toBe("update"); + expect(classifySqlIntent("DELETE FROM t")).toBe("delete"); + expect(classifySqlIntent("EXPLAIN SELECT 1")).toBe("explain"); + expect(classifySqlIntent("DROP TABLE t")).toBe("unknown"); + }); +}); + +describe("isReadOnlySql", () => { + it("treats selects and explain as read-only", () => { + expect(isReadOnlySql("SELECT 1")).toBe(true); + expect(isReadOnlySql("EXPLAIN ANALYZE SELECT * FROM t")).toBe(true); + expect(isReadOnlySql("BEGIN; SELECT 1; COMMIT;")).toBe(true); + }); + + it("flags writes as not read-only", () => { + expect(isReadOnlySql("DELETE FROM t")).toBe(false); + expect(isReadOnlySql("DROP TABLE t")).toBe(false); + expect(isReadOnlySql("BEGIN; UPDATE t SET a = 1; COMMIT;")).toBe(false); + expect(isReadOnlySql("SELECT 1; DELETE FROM t")).toBe(false); + expect(isReadOnlySql("")).toBe(false); + }); +}); diff --git a/src/lib/sql-intent.ts b/src/lib/sql-intent.ts new file mode 100644 index 0000000..120b967 --- /dev/null +++ b/src/lib/sql-intent.ts @@ -0,0 +1,40 @@ +/** + * Mirrors the Rust `classify_sql_intent` / `is_read_only_sql` in + * `src-tauri/src/commands.rs`. Keep the two in sync: the backend enforces the + * guard, this module powers the confirmation prompt before we send the query. + */ + +export type SqlIntent = + | 'select' + | 'insert' + | 'update' + | 'delete' + | 'explain' + | 'unknown' + +export function classifySqlIntent(sql: string): SqlIntent { + const normalized = sql.trimStart().toLowerCase() + if (normalized.startsWith('select') || normalized.startsWith('with')) return 'select' + if (normalized.startsWith('insert')) return 'insert' + if (normalized.startsWith('update')) return 'update' + if (normalized.startsWith('delete')) return 'delete' + if (normalized.startsWith('explain')) return 'explain' + return 'unknown' +} + +const TRANSACTION_CONTROL = ['begin', 'commit', 'rollback', 'start', 'savepoint', 'release'] + +/** True when every statement in `sql` is read-only (select/explain). */ +export function isReadOnlySql(sql: string): boolean { + let sawStatement = false + for (const raw of sql.split(';')) { + const statement = raw.trim() + if (!statement) continue + const normalized = statement.toLowerCase() + if (TRANSACTION_CONTROL.some((kw) => normalized.startsWith(kw))) continue + sawStatement = true + const intent = classifySqlIntent(statement) + if (intent !== 'select' && intent !== 'explain') return false + } + return sawStatement +}