diff --git a/Cargo.toml b/Cargo.toml index 7271450..a5864fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,5 +47,6 @@ futures = "0.3.31" tauri-plugin = { version = "2.5.1", features = ["build"] } [dev-dependencies] +tauri = { version = "2.9.3", features = ["test"] } tempfile = "3.23.0" tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros"] } diff --git a/README.md b/README.md index dc3aa6b..3cfed8c 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,62 @@ fn main() { } ``` +### Registering Database Paths + +Databases can only be opened by **absolute path**, and every absolute path must be +**registered** with the plugin before the frontend can load it. The only exception is +in-memory databases (`:memory:` and friends), which bypass the allowlist. Relative paths, +unregistered absolute paths, `..` segments, and null bytes are rejected with +`INVALID_PATH`, `PATH_NOT_REGISTERED`, or `PATH_TRAVERSAL` respectively. + +**Why:** `Database.load()` is callable from the frontend over IPC. Without an allowlist, +untrusted or buggy frontend code could ask the plugin to open (and create) arbitrary +files anywhere on disk. Restricting access to an explicit list of absolute paths closes +that hole. + +**How it works:** Registered paths must be absolute, and are canonicalized once during +plugin setup (so the check is symlink-safe). At load time, the requested path is +canonicalized and must exactly match a registered entry. + +Because the legitimate paths almost always depend on runtime values (such as the +OS-specific app data directory), registration normally happens in the `on_setup` hook, +which runs during plugin setup once the `app` instance exists: + +```rust +use tauri_plugin_sqlite::Builder; +use tauri::Manager; + +fn main() { + tauri::Builder::default() + .plugin( + Builder::new() + .on_setup(|app, reg| { + let db = app.path().app_data_dir()?.join("main.db"); + reg.register_database(db.to_string_lossy().into_owned(), None); + Ok(()) + }) + .build() + ) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +A static, fully-known absolute path can instead be registered up front with +`register_database`: + +```rust +use tauri_plugin_sqlite::Builder; + +# fn main() { +let _ = Builder::new() + .register_database("/var/lib/myapp/main.db", None); +# } +``` + +The frontend then calls `Database.load()` with a path that **canonicalizes to the same +location** (see [Connecting](#connecting)). + ### Migrations This plugin uses [SQLx's migration system][sqlx-migrate]. Create numbered `.sql` @@ -139,16 +195,28 @@ src-tauri/migrations/ └── 0003_create_posts.sql ``` -Register migrations using SQLx's `migrate!()` macro, which embeds them at compile time: +Register migrations using SQLx's `migrate!()` macro, which embeds them at compile time. +The migration key is a database path, so it follows the same rules as +[registration](#registering-database-paths). Pass the migrator to +`register_database` — the path is allowlisted automatically. The `on_setup` hook is the +usual place to register app-derived paths: ```rust use tauri_plugin_sqlite::Builder; +use tauri::Manager; fn main() { tauri::Builder::default() .plugin( Builder::new() - .add_migrations("main.db", sqlx::migrate!("./migrations")) + .on_setup(|app, reg| { + let db = app.path().app_data_dir()?.join("main.db"); + reg.register_database( + db.to_string_lossy().into_owned(), + Some(sqlx::migrate!("./migrations")), + ); + Ok(()) + }) .build() ) .run(tauri::generate_context!()) @@ -156,6 +224,10 @@ fn main() { } ``` +The path passed to `register_database` is the database key: the frontend must call +`Database.load()` with a path that canonicalizes to the same path for migrations to be +awaited correctly. + **Timing:** Migrations start automatically at plugin setup (non-blocking). When TypeScript calls `Database.load()`, it waits for migrations to complete before returning. If migrations fail, `load()` returns an error. Applied migrations are @@ -168,7 +240,8 @@ Use `getMigrationEvents()` to retrieve cached events: ```typescript import Database from '@silvermine/tauri-plugin-sqlite'; -const db = await Database.load('mydb.db'); +// `dbPath` is the same absolute, registered path used to register migrations +const db = await Database.load(dbPath); // Get all migration events (including ones emitted before listener could be registered) const events = await db.getMigrationEvents(); @@ -195,22 +268,35 @@ await listen('sqlite:migration', (event) => { ### Connecting +Pass the **same absolute path** that was registered on the Rust side (see +[Registering Database Paths](#registering-database-paths)). + ```typescript import Database from '@silvermine/tauri-plugin-sqlite'; +import { appDataDir, join } from '@tauri-apps/api/path'; -// Path is relative to app config directory (no sqlite: prefix needed) -let db = await Database.load('mydb.db'); +const dbPath = await join(await appDataDir(), 'main.db'); + +// Connect (no sqlite: prefix needed) +let db = await Database.load(dbPath); // With custom configuration -db = await Database.load('mydb.db', { +db = await Database.load(dbPath, { maxReadConnections: 10, // default: 6 idleTimeoutSecs: 60 // default: 30 }); // Lazy initialization (connects on first query) -db = Database.get('mydb.db'); +db = Database.get(dbPath); + +// In-memory databases bypass the allowlist and need no registration +const mem = await Database.load(':memory:'); ``` +Loading a relative or malformed path throws `INVALID_PATH`. An unregistered absolute path +throws `PATH_NOT_REGISTERED`. Paths with `..` segments or null bytes throw +`PATH_TRAVERSAL`. + ### Parameter Binding All query methods use `$1`, `$2`, etc. syntax with `SqlValue` types: @@ -400,7 +486,9 @@ tables. **Builder Pattern:** All query methods (`execute`, `executeTransaction`, `fetchAll`, `fetchOne`, `fetchPage`) return builders that support `.attach()` -for cross-database operations. +for cross-database operations. Each `databasePath` must be the same absolute, registered +path used to load that database (see +[Registering Database Paths](#registering-database-paths)). ```typescript // Join data from multiple databases @@ -409,7 +497,7 @@ const results = await db.fetchAll( [] ).attach([ { - databasePath: 'orders.db', + databasePath: '/var/lib/myapp/orders.db', schemaName: 'orders', mode: 'readOnly' } @@ -422,7 +510,7 @@ await db.execute( ['archived'] ).attach([ { - databasePath: 'archive.db', + databasePath: '/var/lib/myapp/archive.db', schemaName: 'archive', mode: 'readOnly' } @@ -438,7 +526,7 @@ await db.executeTransaction([ ['UPDATE stats.order_count SET count = count + 1', []] ]).attach([ { - databasePath: 'stats.db', + databasePath: '/var/lib/myapp/stats.db', schemaName: 'stats', mode: 'readWrite' } @@ -536,7 +624,9 @@ Common error codes: * `SQLITE_CONSTRAINT` - Constraint violation (unique, foreign key, etc.) * `SQLITE_NOTFOUND` - Table or column not found * `DATABASE_NOT_LOADED` - Database hasn't been loaded yet - * `INVALID_PATH` - Invalid database path + * `INVALID_PATH` - Relative or malformed database path + * `PATH_NOT_REGISTERED` - Absolute path not on the registered allowlist + * `PATH_TRAVERSAL` - Path contains `..` segments or null bytes * `IO_ERROR` - File system error * `MIGRATION_ERROR` - Migration failed * `MULTIPLE_ROWS_RETURNED` - `fetchOne()` returned multiple rows @@ -619,7 +709,7 @@ interface CustomConfig { } interface AttachedDatabaseSpec { - databasePath: string; // Path relative to app config directory + databasePath: string; // Absolute, registered path of a database already loaded via load() schemaName: string; // Schema name for accessing tables (e.g., 'orders') mode: 'readOnly' | 'readWrite'; } @@ -985,9 +1075,14 @@ pagination to keep memory usage bounded on both the Rust and TypeScript sides. ### Path Validation -Database paths are validated to prevent directory traversal. Absolute paths, -`..` segments, and null bytes are rejected. All paths are resolved relative to -the app config directory. +`Database.load()` is reachable from the frontend over IPC, so the plugin only opens +databases on an explicit allowlist. A path is accepted only if it is an in-memory database +or an absolute path that was registered on the Rust side via `Builder::register_database` +or `SetupRegistrar::register_database` (see +[Registering Database Paths](#registering-database-paths)). +Relative paths return `INVALID_PATH`; unregistered absolute paths return +`PATH_NOT_REGISTERED`; `..` segments and null bytes return `PATH_TRAVERSAL`. +Registered paths are canonicalized at setup so the match is symlink-safe. ## Development diff --git a/crates/sqlx-sqlite-conn-mgr/README.md b/crates/sqlx-sqlite-conn-mgr/README.md index 578dddf..7d93261 100644 --- a/crates/sqlx-sqlite-conn-mgr/README.md +++ b/crates/sqlx-sqlite-conn-mgr/README.md @@ -89,7 +89,7 @@ Migrations are tracked in `_sqlx_migrations` — calling `run_migrations()` mult times is safe (already-applied migrations are skipped). > **Note:** When using the Tauri plugin, migrations are handled automatically via -> `Builder::add_migrations()`. The plugin starts migrations at setup and waits for +> `Builder::register_database(..., Some(migrator))`. The plugin starts migrations at setup and waits for > completion when `load()` is called. ### Attached Databases diff --git a/crates/sqlx-sqlite-conn-mgr/src/lib.rs b/crates/sqlx-sqlite-conn-mgr/src/lib.rs index c4a15b7..7729440 100644 --- a/crates/sqlx-sqlite-conn-mgr/src/lib.rs +++ b/crates/sqlx-sqlite-conn-mgr/src/lib.rs @@ -80,5 +80,7 @@ pub use write_guard::WriteGuard; // Re-export sqlx migrate types for convenience pub use sqlx::migrate::Migrator; +pub use registry::is_memory_database; + /// A type alias for Results with our custom Error type pub type Result = std::result::Result; diff --git a/crates/sqlx-sqlite-conn-mgr/src/registry.rs b/crates/sqlx-sqlite-conn-mgr/src/registry.rs index fb98009..e00f159 100644 --- a/crates/sqlx-sqlite-conn-mgr/src/registry.rs +++ b/crates/sqlx-sqlite-conn-mgr/src/registry.rs @@ -16,14 +16,23 @@ fn registry() -> &'static RwLock>> { DATABASE_REGISTRY.get_or_init(|| RwLock::new(HashMap::new())) } +/// Returns true when a `file:` URI query string contains an exact `mode=memory` parameter. +fn file_uri_has_mode_memory(path_str: &str) -> bool { + let Some(query) = path_str.split_once('?').map(|(_, query)| query) else { + return false; + }; + query.split('&').any(|param| param == "mode=memory") +} + /// Check if a path represents an in-memory SQLite database /// -/// Returns true for `:memory:` and `file::memory:*` URIs +/// Returns true for `:memory:` and `file::memory:*` URIs, and for `file:` URIs whose +/// query string includes a `mode=memory` parameter (not merely a substring match). pub fn is_memory_database(path: &Path) -> bool { let path_str = path.to_str().unwrap_or(""); path_str == ":memory:" || path_str.starts_with("file::memory:") - || path_str.contains("mode=memory") + || (path_str.starts_with("file:") && file_uri_has_mode_memory(path_str)) } /// Get or open a SQLite database connection @@ -167,4 +176,19 @@ mod tests { let result = canonicalize_path(&nonexistent); assert!(result.is_err()); } + + #[test] + fn test_mode_memory_query_param() { + assert!(is_memory_database(Path::new("file:test?mode=memory"))); + assert!(is_memory_database(Path::new( + "file:/data/db?cache=shared&mode=memory" + ))); + } + + #[test] + fn test_mode_memory_substring_in_value_is_not_memory() { + assert!(!is_memory_database(Path::new( + "file:/home/user/real.db?x=mode=memory" + ))); + } } diff --git a/guest-js/index.ts b/guest-js/index.ts index 3537fae..cd39ac7 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -248,7 +248,7 @@ export interface CustomConfig { */ export interface MigrationEvent { - /** Database path (relative, as registered with the plugin) */ + /** Database key as registered with the plugin (an absolute path, or `:memory:`) */ dbPath: string; /** Status: "running", "completed", "failed" */ @@ -777,18 +777,29 @@ export default class Database { * A static initializer which connects to the underlying SQLite database and * returns a `Database` instance once a connection is established. * - * The path is relative to `tauri::path::BaseDirectory::AppConfig`. + * The path must be an absolute path that the Rust side has registered with the plugin + * (via `Builder::register_database` / `SetupRegistrar::register_database`), + * or an in-memory database such as `:memory:`. + * Relative paths throw `INVALID_PATH`; unregistered absolute paths throw + * `PATH_NOT_REGISTERED`; `..` segments and null bytes throw `PATH_TRAVERSAL`. + * Pass a path that canonicalizes to a registered location. + * It is the responsibility of the caller to ensure a consistent passing down + * of the needed path. * - * @param path - Database file path (relative to AppConfig directory) + * @param path - Absolute, registered database file path (or `:memory:`) * @param customConfig - Optional custom configuration for connection pools * * @example * ```ts + * import { appDataDir, join } from '@tauri-apps/api/path'; + * + * const dbPath = await join(await appDataDir(), 'main.db'); + * * // Use default configuration - * const db = await Database.load("test.db"); + * const db = await Database.load(dbPath); * * // Use custom configuration - * const db = await Database.load("test.db", { + * const db2 = await Database.load(dbPath, { * maxReadConnections: 10, * idleTimeoutSecs: 60 * }); @@ -813,11 +824,12 @@ export default class Database { * the Database class while deferring the actual database connection * until the first invocation or selection on the database. * - * The path is relative to `tauri::path::BaseDirectory::AppConfig`. + * The path must be an absolute path registered with the plugin on the Rust side, or an + * in-memory database. See {@link Database.load} for details. * * @example * ```ts - * const db = Database.get("test.db"); + * const db = Database.get("/absolute/registered/path/main.db"); * ``` */ public static get(path: string): Database { @@ -866,7 +878,7 @@ export default class Database { * "(SELECT todo_id FROM archive.completed)", * [ "archived" ] * ).attach([{ - * databasePath: "archive.db", + * databasePath: "/var/lib/myapp/archive.db", * schemaName: "archive", * mode: "readOnly" * }]); @@ -918,7 +930,7 @@ export default class Database { * ['INSERT INTO main.orders (user_id, total) VALUES ($1, $2)', [userId, total]], * ['UPDATE archive.stats SET order_count = order_count + 1', []] * ]).attach([{ - * databasePath: "archive.db", + * databasePath: "/var/lib/myapp/archive.db", * schemaName: "archive", * mode: "readWrite" * }]); @@ -957,7 +969,7 @@ export default class Database { * "SELECT u.name, o.total FROM users u JOIN orders.orders o ON u.id = o.user_id", * [] * ).attach([{ - * databasePath: "orders.db", + * databasePath: "/var/lib/myapp/orders.db", * schemaName: "orders", * mode: "readOnly" * }]); @@ -992,7 +1004,7 @@ export default class Database { * "SELECT COUNT(*) as total FROM users u JOIN orders.orders o ON u.id = o.user_id", * [] * ).attach([{ - * databasePath: "orders.db", + * databasePath: "/var/lib/myapp/orders.db", * schemaName: "orders", * mode: "readOnly" * }]); @@ -1061,7 +1073,7 @@ export default class Database { * keyset, * 25, * ).attach([{ - * databasePath: 'archive.db', + * databasePath: '/var/lib/myapp/archive.db', * schemaName: 'archive', * mode: 'readOnly', * }]); @@ -1292,7 +1304,7 @@ export default class Database { * let tx = await db.beginInterruptibleTransaction([ * ['DELETE FROM users WHERE archived = 1'] * ]).attach([{ - * databasePath: 'archive.db', + * databasePath: '/var/lib/myapp/archive.db', * schemaName: 'archive', * mode: 'readWrite' * }]); diff --git a/src/commands.rs b/src/commands.rs index 2222bda..4a5a474 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -94,6 +94,11 @@ fn resolve_attached_specs( /// If the database is already loaded, returns the existing connection. /// Otherwise, creates a new connection with optional custom configuration. /// +/// `db` must be either an in-memory database or an absolute path that was registered with +/// the plugin (via `Builder::register_database` / `SetupRegistrar::register_database`). +/// Unregistered or +/// relative paths are rejected with a path error. +/// /// # Migration Timing /// /// If migrations are registered for this database, this function waits for them @@ -109,6 +114,12 @@ pub async fn load( db: String, custom_config: Option, ) -> Result { + // Resolve and canonicalize before cache/migration lookup so equivalent spellings share + // one connection and migration state. + let db = crate::resolve::resolve_database_path(&db, &app)? + .to_string_lossy() + .into_owned(); + // Wait for migrations to complete if registered for this database await_migrations(&migration_states, &db).await?; diff --git a/src/error.rs b/src/error.rs index 4fc5e1e..6b075ec 100644 --- a/src/error.rs +++ b/src/error.rs @@ -31,6 +31,10 @@ pub enum Error { #[error("path traversal not allowed: {0}")] PathTraversal(String), + /// Absolute path is not on the registered allowlist. + #[error("path not registered in allowlist: {0}")] + PathNotRegistered(String), + /// Attempted to access a database that hasn't been loaded. #[error("database {0} not loaded")] DatabaseNotLoaded(String), @@ -84,6 +88,7 @@ impl Error { Error::Migration(_) => "MIGRATION_ERROR".to_string(), Error::InvalidPath(_) => "INVALID_PATH".to_string(), Error::PathTraversal(_) => "PATH_TRAVERSAL".to_string(), + Error::PathNotRegistered(_) => "PATH_NOT_REGISTERED".to_string(), Error::DatabaseNotLoaded(_) => "DATABASE_NOT_LOADED".to_string(), Error::ObservationNotEnabled(_) => "OBSERVATION_NOT_ENABLED".to_string(), Error::TooManyDatabases(_) => "TOO_MANY_DATABASES".to_string(), @@ -123,6 +128,26 @@ mod tests { assert_eq!(err.error_code(), "INVALID_PATH"); } + #[test] + fn test_error_code_path_not_registered() { + let err = Error::PathNotRegistered("/unregistered/db.sqlite".into()); + assert_eq!(err.error_code(), "PATH_NOT_REGISTERED"); + } + + #[test] + fn test_error_serialization_path_not_registered() { + let err = Error::PathNotRegistered("/unregistered/db.sqlite".into()); + let json = serde_json::to_value(&err).unwrap(); + + assert_eq!(json["code"], "PATH_NOT_REGISTERED"); + assert!( + json["message"] + .as_str() + .unwrap() + .contains("not registered in allowlist") + ); + } + #[test] fn test_error_code_unsupported_datatype() { let err = Error::Toolkit(sqlx_sqlite_toolkit::Error::UnsupportedDatatype( diff --git a/src/lib.rs b/src/lib.rs index f1ff209..a72b096 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,11 @@ use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicU8, Ordering}; use serde::Serialize; use sqlx_sqlite_conn_mgr::Migrator; -use tauri::{Emitter, Manager, RunEvent, Runtime, plugin::Builder as PluginBuilder}; +use tauri::{AppHandle, Emitter, Manager, RunEvent, Runtime, plugin::Builder as PluginBuilder}; use tokio::sync::{Notify, RwLock}; use tracing::{debug, error, info, trace, warn}; @@ -79,6 +80,16 @@ impl DbInstances { } } +/// Allowlist of the absolute database paths that may be opened. +/// +/// A path is only accepted by the resolver if its canonical form exactly matches an entry +/// here (in-memory databases bypass the allowlist). Entries are canonicalized once at plugin +/// setup so the equality check is symlink-safe and cheap. +#[derive(Clone, Default)] +pub struct AllowedPaths { + pub(crate) absolute_database_paths: Arc>, +} + /// Migration status for a database. #[derive(Debug, Clone)] pub enum MigrationStatus { @@ -126,7 +137,7 @@ pub struct MigrationStates(pub RwLock>); #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct MigrationEvent { - /// Database path (relative, as registered) + /// Database key as registered (an absolute path, or `:memory:` and friends) pub db_path: String, /// Status: "running", "completed", "failed" pub status: String, @@ -142,6 +153,18 @@ pub struct MigrationEvent { /// /// Use this to configure the plugin and build the plugin instance. /// +/// # Database path registration +/// +/// Databases can only be opened by absolute path, and every absolute path must be +/// **registered** with the plugin before it can be loaded (in-memory databases such as +/// `:memory:` are the only exception). Unregistered paths are rejected. This keeps the +/// frontend, which calls `load()` over IPC, from opening arbitrary files on disk. +/// +/// Because the legitimate paths usually depend on runtime values (for example +/// `app.path().app_data_dir()`), registration normally happens in the [`Builder::on_setup`] +/// hook, which runs during plugin setup once the `app` instance exists. Static absolute +/// paths can be registered up front with [`Builder::register_database`]. +/// /// # Example /// /// ```ignore @@ -151,7 +174,7 @@ pub struct MigrationEvent { /// use tauri_plugin_sqlite::Builder; /// /// # fn main() { -/// // Basic setup (no migrations): +/// // Basic setup (no databases registered yet — register them in `on_setup`): /// tauri::Builder::default() /// .plugin(Builder::new().build()) /// .run(tauri::generate_context!()) @@ -166,49 +189,140 @@ pub struct MigrationEvent { /// // tauri::generate_context!() requires tauri.conf.json at compile time, /// // which cannot be provided in doc test environments. /// use tauri_plugin_sqlite::Builder; +/// use tauri::Manager; /// /// # fn main() { -/// // Setup with migrations: +/// // Resolve the database path from the app instance and register it with migrations. +/// // The frontend then calls `Database.load()` with a path that canonicalizes to the +/// // same location. /// tauri::Builder::default() /// .plugin( /// Builder::new() -/// .add_migrations("main.db", sqlx::migrate!("./migrations/main")) -/// .add_migrations("cache.db", sqlx::migrate!("./migrations/cache")) +/// .on_setup(|app, reg| { +/// let db = app.path().app_data_dir()?.join("main.db"); +/// reg.register_database( +/// db.to_string_lossy().into_owned(), +/// Some(sqlx::migrate!("./migrations/main")), +/// ); +/// Ok(()) +/// }) /// .build() /// ) /// .run(tauri::generate_context!()) /// .expect("error while running tauri application"); /// # } /// ``` -#[derive(Debug, Default)] -pub struct Builder { +/// +/// Collects allowlist entries and migrations registered from the [`Builder::on_setup`] hook. +/// +/// Passed to the `on_setup` closure during plugin setup, where the `app` instance is +/// available. Use it to register values that can only be computed at runtime (for example, +/// paths derived from `app.path().app_data_dir()`). +#[derive(Default)] +pub struct SetupRegistrar { + databases: Vec, +} + +#[derive(Debug)] +pub struct RegisteredDatabase { + path: String, + migrations: Option>, +} + +impl SetupRegistrar { + /// Register a database path, optionally with migrations. See [`Builder::register_database`]. + /// + /// This invocation is to be used when the database path is known at runtime (such as + /// a path dependent on `app.path().app_data_dir()`). + /// + /// For a path that is known at compile time, use [`Builder::register_database`] + /// instead. + /// + /// If the same path is registered more than once (statically or via this hook), the + /// last registration wins after paths are canonicalized at plugin setup. + pub fn register_database(&mut self, key: impl Into, migrator: Option) { + self.databases.push(RegisteredDatabase { + path: key.into(), + migrations: migrator.map(Arc::new), + }); + } +} + +/// Canonicalize registered databases once and split into allowlist + migration map. +/// +/// A later registration for the same canonical path overrides an earlier one. +fn finalize_registrations( + databases: Vec, +) -> Result<(Vec, HashMap>)> { + let mut by_canonical: HashMap>> = HashMap::new(); + + for db in databases { + let canonical_key = resolve::canonicalize_database_key(&db.path)?; + by_canonical.insert(canonical_key, db.migrations); + } + + let allowlist: Vec = by_canonical.keys().map(PathBuf::from).collect(); + let migrations = by_canonical + .into_iter() + .filter_map(|(path, migrator)| migrator.map(|m| (path, m))) + .collect(); + + Ok((allowlist, migrations)) +} + +/// Closure type for the deferred [`Builder::on_setup`] hook. +type OnSetupHook = Box, &mut SetupRegistrar) -> Result<()> + Send>; + +pub struct Builder { /// Migrations registered per database path - migrations: HashMap>, + registered_databases: Vec, /// Timeout for interruptible transactions. Defaults to 5 minutes. transaction_timeout: Option, /// Maximum number of concurrently loaded databases. Defaults to 50. max_databases: Option, + /// Deferred hook run during plugin setup with the app handle. Lets callers register + /// paths/migrations computed from `app`. Returning `Err` aborts app startup. + on_setup: Option>, +} + +impl Default for Builder { + fn default() -> Self { + Self::new() + } } -impl Builder { +impl std::fmt::Debug for Builder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Builder") + .field("registered_databases", &self.registered_databases) + .field("transaction_timeout", &self.transaction_timeout) + .field("max_databases", &self.max_databases) + .field("on_setup", &self.on_setup.is_some()) + .finish() + } +} + +impl Builder { /// Create a new builder instance. pub fn new() -> Self { Self { - migrations: HashMap::new(), + registered_databases: Vec::new(), transaction_timeout: None, max_databases: None, + on_setup: None, } } - /// Register migrations for a database path. + /// Register a database path on the allowlist, optionally with migrations. /// - /// Migrations will be run automatically at plugin initialization. - /// Multiple databases can have their own migrations. + /// Every registration allowlists the path. Pass `None` for `migrator` when the database + /// has no migrations. Migrations run automatically at plugin initialization when provided. /// - /// # Arguments + /// Use this when the path is known at compile time. For paths derived from the `app` + /// instance (for example `app.path().app_data_dir()`), use [`on_setup`](Self::on_setup) + /// and [`SetupRegistrar::register_database`] instead. /// - /// * `path` - Database path (relative to app config directory) - /// * `migrator` - Migrator instance, typically from `sqlx::migrate!()` + /// The frontend must call `load()` with a path that canonicalizes to the same location. /// /// # Example /// @@ -216,13 +330,19 @@ impl Builder { /// use tauri_plugin_sqlite::Builder; /// /// # fn example() { - /// Builder::new() - /// .add_migrations("main.db", sqlx::migrate!("./doc-test-fixtures/migrations")) - /// .build::(); + /// Builder::::new() + /// .register_database( + /// "/var/lib/myapp/main.db", + /// Some(sqlx::migrate!("./doc-test-fixtures/migrations")), + /// ) + /// .build(); /// # } /// ``` - pub fn add_migrations(mut self, path: &str, migrator: Migrator) -> Self { - self.migrations.insert(path.to_string(), Arc::new(migrator)); + pub fn register_database(mut self, path: &str, migrator: Option) -> Self { + self.registered_databases.push(RegisteredDatabase { + path: path.to_string(), + migrations: migrator.map(Arc::new), + }); self } @@ -258,11 +378,52 @@ impl Builder { Ok(self) } + /// Register a hook that runs during plugin setup, once the `app` instance exists. + /// + /// This is the primary way to register database paths, because the legitimate absolute + /// paths usually depend on runtime values — for example paths derived from + /// `app.path().app_data_dir()`. The closure receives the app handle and a + /// [`SetupRegistrar`] on which you call [`register_database`](SetupRegistrar::register_database). + /// + /// Entries registered here are merged with those registered statically via + /// [`register_database`](Self::register_database); a later registration for the same + /// canonical path overrides an earlier one. + /// + /// Returning `Err` from the hook aborts app startup (fail-fast). + /// + /// # Example + /// + /// ```no_run + /// use tauri_plugin_sqlite::Builder; + /// use tauri::Manager; + /// + /// # fn example() { + /// Builder::::new() + /// .on_setup(|app, reg| { + /// let db = app.path().app_data_dir().unwrap().join("main.db"); + /// reg.register_database( + /// db.to_string_lossy().into_owned(), + /// Some(sqlx::migrate!("./doc-test-fixtures/migrations")) + /// ); + /// Ok(()) + /// }) + /// .build(); + /// # } + /// ``` + pub fn on_setup( + mut self, + f: impl FnOnce(&AppHandle, &mut SetupRegistrar) -> Result<()> + Send + 'static, + ) -> Self { + self.on_setup = Some(Box::new(f)); + self + } + /// Build the plugin with command registration and state management. - pub fn build(self) -> tauri::plugin::TauriPlugin { - let migrations = Arc::new(self.migrations); + pub fn build(self) -> tauri::plugin::TauriPlugin { + let registered_databases = self.registered_databases; let transaction_timeout = self.transaction_timeout; let max_databases = self.max_databases; + let on_setup = self.on_setup; PluginBuilder::::new("sqlite") .invoke_handler(tauri::generate_handler![ @@ -297,7 +458,21 @@ impl Builder { app.manage(ActiveRegularTransactions::default()); app.manage(subscriptions::ActiveSubscriptions::default()); - // Initialize migration states as Pending for all registered databases + // Run the deferred setup hook (if any), merge with static registrations, then + // canonicalize once. Hook errors abort startup (fail-fast). + let mut databases = registered_databases; + if let Some(hook) = on_setup { + let mut registrar = SetupRegistrar::default(); + hook(app, &mut registrar)?; + databases.extend(registrar.databases); + } + + let (canonical_files, migrations) = finalize_registrations(databases)?; + + app.manage(AllowedPaths { + absolute_database_paths: Arc::new(canonical_files), + }); + let migration_states = app.state::(); { let mut states = migration_states.0.blocking_write(); @@ -306,15 +481,11 @@ impl Builder { } } - // Spawn parallel migration tasks for each registered database if !migrations.is_empty() { info!("Starting migrations for {} database(s)", migrations.len()); - for (path, migrator) in migrations.iter() { + for (path, migrator) in migrations { let app_handle = app.clone(); - let path = path.clone(); - let migrator = Arc::clone(migrator); - tauri::async_runtime::spawn(async move { run_migrations_for_database(app_handle, path, migrator).await; }); @@ -452,7 +623,7 @@ impl Builder { /// Initializes the plugin with default configuration. pub fn init() -> tauri::plugin::TauriPlugin { - Builder::new().build() + Builder::::new().build() } /// Run migrations for a single database and emit events. @@ -599,22 +770,129 @@ fn resolve_migration_path( #[cfg(test)] mod tests { use super::*; + use std::fs; + use std::sync::atomic::{AtomicU64, Ordering}; + use tauri::test::MockRuntime; + + fn make_temp_dir() -> PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = + std::env::temp_dir().join(format!("tauri_sqlite_finalize_test_{}_{}", std::process::id(), n)); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn dummy_migrator() -> Arc { + Arc::new(sqlx::migrate!("./doc-test-fixtures/migrations")) + } + + #[test] + fn test_finalize_registrations_empty() { + let (allowlist, migrations) = finalize_registrations(vec![]).unwrap(); + assert!(allowlist.is_empty()); + assert!(migrations.is_empty()); + } + + #[test] + fn test_finalize_registrations_allow_only() { + let dir = make_temp_dir(); + let db = dir.join("allow-only.db"); + let db_str = db.to_str().unwrap().to_string(); + let canonical = resolve::canonicalize_database_key(&db_str).unwrap(); + + let (allowlist, migrations) = finalize_registrations(vec![RegisteredDatabase { + path: db_str, + migrations: None, + }]) + .unwrap(); + + assert_eq!(allowlist, vec![PathBuf::from(&canonical)]); + assert!(migrations.is_empty()); + } + + #[test] + fn test_finalize_registrations_with_migrations() { + let dir = make_temp_dir(); + let db = dir.join("with-migrations.db"); + let db_str = db.to_str().unwrap().to_string(); + let canonical = resolve::canonicalize_database_key(&db_str).unwrap(); + let migrator = dummy_migrator(); + + let (allowlist, migrations) = finalize_registrations(vec![RegisteredDatabase { + path: db_str, + migrations: Some(Arc::clone(&migrator)), + }]) + .unwrap(); + + assert_eq!(allowlist, vec![PathBuf::from(&canonical)]); + assert_eq!(migrations.len(), 1); + assert!(Arc::ptr_eq(migrations.get(&canonical).unwrap(), &migrator)); + } + + #[test] + fn test_finalize_registrations_last_wins_drops_migrator() { + let dir = make_temp_dir(); + let db = dir.join("last-wins.db"); + let db_str = db.to_str().unwrap().to_string(); + let canonical = resolve::canonicalize_database_key(&db_str).unwrap(); + + let (allowlist, migrations) = finalize_registrations(vec![ + RegisteredDatabase { + path: db_str.clone(), + migrations: Some(dummy_migrator()), + }, + RegisteredDatabase { + path: db_str, + migrations: None, + }, + ]) + .unwrap(); + + assert_eq!(allowlist, vec![PathBuf::from(&canonical)]); + assert!(migrations.is_empty()); + } + + #[test] + fn test_finalize_registrations_last_wins_adds_migrator() { + let dir = make_temp_dir(); + let db = dir.join("last-wins-migrate.db"); + let db_str = db.to_str().unwrap().to_string(); + let canonical = resolve::canonicalize_database_key(&db_str).unwrap(); + let migrator = dummy_migrator(); + + let (allowlist, migrations) = finalize_registrations(vec![ + RegisteredDatabase { + path: db_str.clone(), + migrations: None, + }, + RegisteredDatabase { + path: db_str, + migrations: Some(Arc::clone(&migrator)), + }, + ]) + .unwrap(); + + assert_eq!(allowlist, vec![PathBuf::from(&canonical)]); + assert_eq!(migrations.len(), 1); + assert!(Arc::ptr_eq(migrations.get(&canonical).unwrap(), &migrator)); + } #[test] fn test_max_databases_rejects_zero() { - let err = Builder::new().max_databases(0).unwrap_err(); + let err = Builder::::new().max_databases(0).unwrap_err(); assert!(matches!(err, Error::InvalidConfig(_))); } #[test] fn test_max_databases_accepts_positive() { - let builder = Builder::new().max_databases(1).unwrap(); + let builder = Builder::::new().max_databases(1).unwrap(); assert_eq!(builder.max_databases, Some(1)); } #[test] fn test_transaction_timeout_rejects_zero() { - let err = Builder::new() + let err = Builder::::new() .transaction_timeout(std::time::Duration::ZERO) .unwrap_err(); assert!(matches!(err, Error::InvalidConfig(_))); @@ -622,7 +900,7 @@ mod tests { #[test] fn test_transaction_timeout_accepts_positive() { - let builder = Builder::new() + let builder = Builder::::new() .transaction_timeout(std::time::Duration::from_secs(1)) .unwrap(); assert_eq!( diff --git a/src/resolve.rs b/src/resolve.rs index 08d3b25..d756284 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -1,17 +1,18 @@ use std::fs::create_dir_all; use std::path::{Component, Path, PathBuf}; -use sqlx_sqlite_conn_mgr::SqliteDatabaseConfig; +use sqlx_sqlite_conn_mgr::{SqliteDatabaseConfig, is_memory_database}; use sqlx_sqlite_toolkit::DatabaseWrapper; use tauri::{AppHandle, Manager, Runtime}; -use crate::Error; +use crate::{AllowedPaths, Error}; -/// Connect to a SQLite database via the connection manager, resolving -/// the path relative to the app config directory. +/// Connect to a SQLite database via the connection manager. /// -/// This is the Tauri-specific connection method that resolves relative paths -/// before delegating to the toolkit's `DatabaseWrapper::connect()`. +/// This is the Tauri-specific connection method that validates the path against the +/// registered allowlist (see [`crate::Builder::register_database`]) before delegating to the +/// toolkit's `DatabaseWrapper::connect()`. Only registered absolute paths and in-memory +/// databases are accepted. pub async fn connect( path: &str, app: &AppHandle, @@ -21,29 +22,71 @@ pub async fn connect( Ok(DatabaseWrapper::connect(&abs_path, custom_config).await?) } -/// Resolve database file path relative to app config directory. +/// Resolve a database file path. /// -/// Paths are joined to `app_config_dir()` (e.g., `Library/Application Support/${bundleIdentifier}` -/// on iOS). Special paths like `:memory:` are passed through unchanged. +/// A path is accepted only if it is either an in-memory database (`:memory:` and friends, +/// passed through unchanged) or an absolute path that exactly matches an entry registered +/// via [`crate::Builder::register_database`] / [`crate::SetupRegistrar::register_database`]. /// -/// Returns `Err(Error::PathTraversal)` if the path attempts to escape the app config directory -/// via absolute paths, `..` segments, or null bytes. +/// Returns `Err(Error::PathTraversal)` if the path contains `..` segments or null bytes. +/// Returns `Err(Error::InvalidPath)` for relative or malformed paths. +/// Returns `Err(Error::PathNotRegistered)` if the path is not on the registered allowlist. pub fn resolve_database_path(path: &str, app: &AppHandle) -> Result { - let app_path = app - .path() - .app_config_dir() - .map_err(|_| Error::InvalidPath("No app config path found".to_string()))?; + let allowed = app.state::(); + validate_and_resolve(path, &allowed.absolute_database_paths) +} + +/// Canonicalize a database key string for registration (allowlist / migrations). +/// +/// In-memory paths pass through unchanged. On-disk paths are canonicalized with parent +/// directories created as needed. +pub(crate) fn canonicalize_database_key(key: &str) -> Result { + if is_memory_path(key) { + return Ok(key.to_string()); + } - create_dir_all(&app_path)?; + canonicalize_database_path(Path::new(key), true).map(|p| p.to_string_lossy().into_owned()) +} - validate_and_resolve(path, &app_path) +/// Canonicalize a database file path that may not exist yet. +/// +/// When `create_parent` is `true`, missing parent directories are created before +/// canonicalizing (plugin setup and confirmed allowlist matches at load time). When `false`, +/// only existing path components are canonicalized — no filesystem side effects. +pub(crate) fn canonicalize_database_path( + path: &Path, + create_parent: bool, +) -> Result { + if path.exists() { + return path + .canonicalize() + .map_err(|e| Error::InvalidPath(format!("cannot canonicalize path: {e}"))); + } + + let parent = path + .parent() + .ok_or_else(|| Error::InvalidPath("path has no parent".to_string()))?; + + let file_name = path + .file_name() + .ok_or_else(|| Error::InvalidPath("path has no file name".to_string()))?; + + if create_parent { + create_dir_all(parent)?; + } + + parent + .canonicalize() + .map(|p| p.join(file_name)) + .map_err(|e| Error::InvalidPath(format!("cannot canonicalize path: {e}"))) } -/// Validate a user-supplied path and resolve it against a base directory. +/// Validate a user-supplied path and resolve it. /// -/// In-memory database paths are passed through unchanged. All other paths are validated -/// to ensure they cannot escape the base directory. -fn validate_and_resolve(path: &str, base: &Path) -> Result { +/// In-memory database paths are passed through unchanged. Every other path must be an +/// absolute path whose canonical form exactly matches a registered allowlist entry +/// (`allowed_files`); `..` segments and null bytes are always rejected. +fn validate_and_resolve(path: &str, allowed_files: &[PathBuf]) -> Result { // Pass through in-memory database paths unchanged — they don't touch the filesystem. // Matches the same patterns as `is_memory_database` in sqlx-sqlite-conn-mgr. if is_memory_path(path) { @@ -55,17 +98,17 @@ fn validate_and_resolve(path: &str, base: &Path) -> Result { return Err(Error::PathTraversal("path contains null byte".to_string())); } - let rel = Path::new(path); + let candidate = Path::new(path); - // Reject absolute paths — PathBuf::join replaces the base when given an absolute path - if rel.is_absolute() { - return Err(Error::PathTraversal( - "absolute paths are not allowed".to_string(), + if !candidate.is_absolute() { + return Err(Error::InvalidPath( + "database path must be absolute".to_string(), )); } - // Reject parent directory components — prevents escaping the base via `../` - for component in rel.components() { + // Reject parent directory components outright so a registered path cannot be used as a + // springboard to reach a sibling/parent location via `..`. + for component in candidate.components() { if matches!(component, Component::ParentDir) { return Err(Error::PathTraversal( "parent directory references are not allowed".to_string(), @@ -73,149 +116,173 @@ fn validate_and_resolve(path: &str, base: &Path) -> Result { } } - // Join and canonicalize to verify containment. The parent directory is canonicalized - // because the file may not exist yet. - let joined = base.join(rel); - let canonical_base = base - .canonicalize() - .map_err(|e| Error::InvalidPath(format!("cannot canonicalize base path: {e}")))?; - - let canonical_resolved = if joined.exists() { - joined.canonicalize() - } else { - // Ensure intermediate directories exist so that canonicalize can resolve the - // parent. This matches the caller's expectation that nested relative paths like - // "subdir/mydb.db" work without pre-creating "subdir/". - let parent = joined - .parent() - .ok_or_else(|| Error::InvalidPath("path has no parent".to_string()))?; - - create_dir_all(parent)?; - - parent - .canonicalize() - .map(|p| p.join(joined.file_name().unwrap_or_default())) - } - .map_err(|e| Error::InvalidPath(format!("cannot canonicalize path: {e}")))?; + // Canonicalize without creating directories so unregistered paths cannot mkdir over IPC. + let canonical = match canonicalize_database_path(candidate, false) { + Ok(canonical) => canonical, + Err(_) => { + return Err(Error::PathNotRegistered(format!( + "absolute path is not covered by the allowlist: {candidate:?}" + ))); + } + }; - if !canonical_resolved.starts_with(&canonical_base) { - return Err(Error::PathTraversal( - "resolved path escapes the base directory".to_string(), - )); + if !allowed_files.contains(&canonical) { + return Err(Error::PathNotRegistered(format!( + "absolute path is not covered by the allowlist: {canonical:?}" + ))); } - // Return the original (non-canonicalized) joined path for consistency with how the - // rest of the codebase references database paths. - Ok(joined) + // Path is allowed — create parent directories if needed, then return the canonical path + // so the opened file matches the value that passed validation. + canonicalize_database_path(candidate, true) } /// Check if a path string represents an in-memory SQLite database. /// /// Matches the same patterns as `is_memory_database` in `sqlx-sqlite-conn-mgr`: /// `:memory:`, `file::memory:*` URIs, and `mode=memory` query parameters. -fn is_memory_path(path: &str) -> bool { - path == ":memory:" - || path.starts_with("file::memory:") - || (path.starts_with("file:") && path.contains("mode=memory")) +pub(crate) fn is_memory_path(path: &str) -> bool { + is_memory_database(Path::new(path)) } #[cfg(test)] mod tests { use super::*; use std::fs; + use std::sync::atomic::{AtomicU64, Ordering}; - /// Helper that creates a temporary base directory for testing. - fn make_temp_base() -> PathBuf { - let dir = std::env::temp_dir().join(format!("tauri_sqlite_test_{}", std::process::id())); + /// Empty allowlist — nothing registered. + const NO_FILES: &[PathBuf] = &[]; + + /// Helper that creates a unique temporary directory for testing. + fn make_temp_dir() -> PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = + std::env::temp_dir().join(format!("tauri_sqlite_test_{}_{}", std::process::id(), n)); fs::create_dir_all(&dir).unwrap(); dir } - #[test] - fn test_simple_filename() { - let base = make_temp_base(); - let result = validate_and_resolve("mydb.db", &base).unwrap(); - assert_eq!(result, base.join("mydb.db")); - } - - #[test] - fn test_subdirectory_path() { - let base = make_temp_base(); - // Intermediate directories are auto-created — no manual setup needed - let result = validate_and_resolve("subdir/mydb.db", &base).unwrap(); - assert_eq!(result, base.join("subdir/mydb.db")); - assert!(base.join("subdir").is_dir()); - } - - #[test] - fn test_nested_subdirectory_path() { - let base = make_temp_base(); - let result = validate_and_resolve("a/b/c/mydb.db", &base).unwrap(); - assert_eq!(result, base.join("a/b/c/mydb.db")); - assert!(base.join("a/b/c").is_dir()); - } - #[test] fn test_memory_passthrough() { - let base = make_temp_base(); assert_eq!( - validate_and_resolve(":memory:", &base).unwrap(), + validate_and_resolve(":memory:", NO_FILES).unwrap(), PathBuf::from(":memory:"), ); } #[test] fn test_file_memory_uri_passthrough() { - let base = make_temp_base(); assert_eq!( - validate_and_resolve("file::memory:?cache=shared", &base).unwrap(), + validate_and_resolve("file::memory:?cache=shared", NO_FILES).unwrap(), PathBuf::from("file::memory:?cache=shared"), ); } #[test] fn test_mode_memory_passthrough() { - let base = make_temp_base(); assert_eq!( - validate_and_resolve("file:test?mode=memory", &base).unwrap(), + validate_and_resolve("file:test?mode=memory", NO_FILES).unwrap(), PathBuf::from("file:test?mode=memory"), ); } #[test] - fn test_rejects_parent_traversal() { - let base = make_temp_base(); - let err = validate_and_resolve("../../../etc/passwd", &base).unwrap_err(); - assert!(matches!(err, Error::PathTraversal(_))); + fn test_mode_memory_substring_in_value_is_not_treated_as_memory() { + let result = validate_and_resolve("file:/home/user/real.db?x=mode=memory", NO_FILES); + assert!( + result.is_err(), + "substring mode=memory must not bypass the allowlist" + ); + } + + #[test] + fn test_accepts_registered_absolute_path() { + let dir = make_temp_dir(); + let canonical_dir = dir.canonicalize().unwrap(); + let abs = dir.join("exact.db"); + let abs_str = abs.to_str().unwrap(); + + let files = [canonical_dir.join("exact.db")]; + let result = validate_and_resolve(abs_str, &files).unwrap(); + assert_eq!(result, canonical_dir.join("exact.db")); + } + + #[test] + fn test_rejects_unregistered_absolute_path() { + let err = validate_and_resolve("/etc/passwd", NO_FILES).unwrap_err(); + assert!(matches!(err, Error::PathNotRegistered(_))); + } + + #[test] + fn test_rejects_unregistered_path_without_creating_parent() { + let base = make_temp_dir(); + let unregistered = base.join("nested").join("not-allowed.db"); + let unregistered_str = unregistered.to_str().unwrap().to_string(); + + let err = validate_and_resolve(&unregistered_str, NO_FILES).unwrap_err(); + assert!(matches!(err, Error::PathNotRegistered(_))); + assert!( + !base.join("nested").exists(), + "rejected load must not create parent directories" + ); + } + + #[test] + fn test_rejects_unregistered_path_with_existing_parent_without_creating_child() { + let dir = make_temp_dir(); + let unregistered = dir.join("not-allowed.db"); + let unregistered_str = unregistered.to_str().unwrap().to_string(); + + let err = validate_and_resolve(&unregistered_str, NO_FILES).unwrap_err(); + assert!(matches!(err, Error::PathNotRegistered(_))); + assert!(!unregistered.exists()); } #[test] - fn test_rejects_absolute_path() { - let base = make_temp_base(); - let err = validate_and_resolve("/etc/passwd", &base).unwrap_err(); + fn test_rejects_relative_path() { + let err = validate_and_resolve("relative.db", NO_FILES).unwrap_err(); + assert!(matches!(err, Error::InvalidPath(_))); + } + + #[test] + fn test_rejects_absolute_path_with_parent_traversal() { + let dir = make_temp_dir(); + let abs_str = format!("{}/../escape.db", dir.to_str().unwrap()); + + let err = validate_and_resolve(&abs_str, NO_FILES).unwrap_err(); assert!(matches!(err, Error::PathTraversal(_))); } #[test] - fn test_rejects_embedded_traversal() { - let base = make_temp_base(); - let err = validate_and_resolve("foo/../../bar", &base).unwrap_err(); + fn test_rejects_absolute_path_with_embedded_traversal() { + let dir = make_temp_dir(); + let abs_str = format!("{}/sub/../../escape.db", dir.to_str().unwrap()); + + let err = validate_and_resolve(&abs_str, NO_FILES).unwrap_err(); assert!(matches!(err, Error::PathTraversal(_))); } #[test] fn test_rejects_null_byte() { - let base = make_temp_base(); - let err = validate_and_resolve("path\0evil", &base).unwrap_err(); + let err = validate_and_resolve("path\0evil", NO_FILES).unwrap_err(); assert!(matches!(err, Error::PathTraversal(_))); } #[test] - fn test_rejects_non_uri_mode_memory() { - let base = make_temp_base(); - // A bare filename containing "mode=memory" is not a valid SQLite URI — - // it should go through normal path validation, not be passed through. - let result = validate_and_resolve("evil.db?mode=memory", &base).unwrap(); - assert_eq!(result, base.join("evil.db?mode=memory")); + fn test_setup_and_resolver_canonicalize_agree() { + let dir = make_temp_dir(); + let db = dir.join("agree.db"); + + let from_setup = canonicalize_database_path(&db, true).unwrap(); + let from_resolver = + validate_and_resolve(db.to_str().unwrap(), std::slice::from_ref(&from_setup)).unwrap(); + assert_eq!(from_setup, from_resolver); + } + + #[test] + fn test_canonicalize_database_key_memory_passthrough() { + assert_eq!(canonicalize_database_key(":memory:").unwrap(), ":memory:"); } }