From 0f92369800979c67414a5588152f5d29aed1a852 Mon Sep 17 00:00:00 2001 From: Andrew de Waal Date: Tue, 9 Jun 2026 06:24:10 -0700 Subject: [PATCH] feat: require explicit registration of absolute database paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Previously, only databases that lived in `app_config_dir` could be opened with this plugin. The user passed in the path relative to `app_config_dir` to load the datbase. This meant that there was no support for databases that may live anywhere else within the app, which is a reasonable requirement. For example, some databases may need to live the `Library` folder on an iOS app so the database will be kept as part of the app backup. Solution: Databases can now only be opened by an exact, pre-registered absolute path (in-memory databases are the sole exception). Paths are registered via Builder::allow_path or, more commonly, in the new Builder::on_setup hook, which runs during plugin setup once `app` exists — so paths derived from app.path().app_data_dir() and friends can be computed and registered at startup. Registered paths are canonicalized once at setup so the equality check is symlink-safe. The resolver canonicalizes each requested path and requires an exact allowlist match. `..` segments and null bytes are always rejected. Migration registration flows through the same resolver, so a migration's key must be a registered absolute path. Why no relative paths: A relative-path default (app_config_dir) is an implicit grant — every file under that dir becomes reachable without the developer opting in. Removing it makes the set of reachable databases exactly the set the developer registered, with no ambient authority, and gives exactly one path to accessing a db. Why no registered root folders: Root allow-listing grants a whole subtree, including files created later that the developer never considered. Prefix matching is also easy to get subtly wrong against symlinks and traversal. Exact-path allow-listing is the least-privilege equivalent: explicit, auditable, and unambiguous. BREAKING CHANGE: relative paths are removed. load() now requires an absolute path registered via allow_path / on_setup. Previously-relative callers must register the absolute path and pass it from the frontend. It is the responsibility of the caller to ensure a consistent passing down of the needed path. --- Cargo.toml | 1 + README.md | 127 ++++++- crates/sqlx-sqlite-conn-mgr/README.md | 2 +- crates/sqlx-sqlite-conn-mgr/src/lib.rs | 2 + crates/sqlx-sqlite-conn-mgr/src/registry.rs | 28 +- guest-js/index.ts | 38 ++- src/commands.rs | 11 + src/error.rs | 25 ++ src/lib.rs | 348 ++++++++++++++++++-- src/resolve.rs | 295 ++++++++++------- 10 files changed, 696 insertions(+), 181 deletions(-) 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:"); } }