Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
127 changes: 111 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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| {

@velocitysystems velocitysystems Jun 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dynamic-path setup reads as more boilerplate than the old add_migrations("main.db", …) one-liner, and some of that looks avoidable without giving up the closure's flexibility (arbitrary, app-derived absolute paths).

Two things stand out in the example:

  1. We register the same database twice — allow_path(&db) and add_migrations(...) — and have to keep the keys in sync by hand. That's the same coupling behind the byte-identical-key concern: if the two spellings ever drift, migrations silently attach to a key load() never matches.

  2. The two calls take different types for the same value: allow_path takes &db (a path) while add_migrations takes db.to_string_lossy().into_owned() (a String).

Two ways to collapse this to a singlecall, both keeping &Path and canonicalizing to the same key internally:

Option Aadd_migrations auto-allowlists. Have it take impl AsRef<Path> and register the path on the allowlist itself. You can't migrate a database you're not allowed to open, so allow-listing a migration target is implied:

 .on_setup(|app, reg| {
     let db = app.path().app_data_dir()?.join("main.db");
     reg.add_migrations(&db, sqlx::migrate!("./migrations")); // allows + migrates
     Ok(())
 })

allow_path(&db) stays for databases without migrations. Downside: add_migrations now has an allowlist side effect, which is slightly less obvious from its name.

Option B — chained handle. Keep allow_path as the single allowlist entry point and have it return a per-path handle you attach migrations to:

 .on_setup(|app, reg| {
     let db = app.path().app_data_dir()?.join("main.db");
     reg.allow_path(&db).with_migrations(sqlx::migrate!("./migrations"));
     Ok(())
 })

This keeps add_migrations/allow_path responsibilities cleanly separated (allow-listing stays the one explicit gate) while still guaranteeing the migration key is the allowed path. allow_path(&db) alone still works for the no-migrations case.

Either removes the stringified key and the hand-synced double registration. What are your thoughts @onehumandev @jjhafer?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Option A makes more sense to me; when we register the path, we register the migrations with it as well.
I'll make that change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we go with Option A, we'll still need to retain the allow_path(..) API for databases that don't require migrations. I'm still somewhat hesitant about this approach, though, since it introduces implicit behavior into the add_migrations(..) method.

That said, the builder pattern in Option B is appealing because it keeps the API surface unchanged while making it more composable. Perhaps @jjhafer may have some thoughts before we commit to either path?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I realized I didn't actually read option B :) That is a better option than A; I'll await @jjhafer's thoughts.

And one other question for my own edification, as it relates to this; is there a particular reason we chose to pass down and cache the raw strings as the path key instead of using PathBuf?

It's not an issue either way, but given that we are keying against a string that we are requiring to be a path, I was curious why we didn't use PathBuf, even if just for semantic reasons.

@velocitysystems velocitysystems Jun 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — it was deliberate for a couple of specific reasons:

  1. The key is string-shaped at both ends. It arrives from the frontend as a JSON string and ends up as a SQLite connection string for sqlx. Where path semantics matter — .. checks, canonicalization, the allowlist match — the code does use PathBuf; the String only reappears after validation, as an opaque cache key.
  2. Not all keys are paths. :memory: and file:…?mode=memory URIs share the same maps, and PathBuf is the wrong type for those.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After looking at this more, I think there's a simpler way to do this, which is a single register_database method that takes the db path and an optional Migrator as a parameter. That solves the possible duplication issue, and allows creating a DB without a migrator.

I've updated this MR with this change; let me know if this is acceptable, or if another solution would be preferred. Thanks!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @onehumandev. I like it; nice! Two small follow-ups:

  1. Could both methods take impl AsRef<Path> (dropping the to_string_lossy().into_owned() boilerplate)?
  2. Could Builder (&str) and SetupRegistrar (impl Into<String>) share the same signature?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it with the above follow ups that @velocitysystems mentioned.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After discussion with @velocitysystems , there is a larger simplification that can be made here. To avoid having a polluted MR, I'll be closing down this one and opening a new one with the changed approach, as the changes will be significantly different from what this MR currently has.
Thanks!

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`
Expand All @@ -139,23 +195,39 @@ 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!())
.expect("error while running tauri application");
}
```

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
Expand All @@ -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();
Expand All @@ -195,22 +268,35 @@ await listen<MigrationEvent>('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:
Expand Down Expand Up @@ -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
Expand All @@ -409,7 +497,7 @@ const results = await db.fetchAll(
[]
).attach([
{
databasePath: 'orders.db',
databasePath: '/var/lib/myapp/orders.db',
schemaName: 'orders',
mode: 'readOnly'
}
Expand All @@ -422,7 +510,7 @@ await db.execute(
['archived']
).attach([
{
databasePath: 'archive.db',
databasePath: '/var/lib/myapp/archive.db',
schemaName: 'archive',
mode: 'readOnly'
}
Expand All @@ -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'
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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';
}
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion crates/sqlx-sqlite-conn-mgr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/sqlx-sqlite-conn-mgr/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = std::result::Result<T, Error>;
28 changes: 26 additions & 2 deletions crates/sqlx-sqlite-conn-mgr/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,23 @@ fn registry() -> &'static RwLock<HashMap<PathBuf, Weak<SqliteDatabase>>> {
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
Expand Down Expand Up @@ -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"
)));
}
}
Loading