sqlite_objs/lib.rs
1//! # sqlite-objs - SQLite VFS backed by Azure Blob Storage
2//!
3//! This crate provides safe Rust bindings to sqlite-objs, a SQLite VFS (Virtual File System)
4//! that stores database files in Azure Blob Storage.
5//!
6//! ## Features
7//!
8//! - Store SQLite databases in Azure Blob Storage (page blobs for DB, block blobs for journal)
9//! - Blob lease-based locking for safe concurrent access
10//! - Full-blob caching for performance
11//! - SAS token and Shared Key authentication
12//! - URI-based per-database configuration
13//!
14//! ## Usage
15//!
16//! ### Basic Registration (Environment Variables)
17//!
18//! ```no_run
19//! use sqlite_objs::SqliteObjsVfs;
20//! use rusqlite::Connection;
21//!
22//! // Register VFS from environment variables
23//! SqliteObjsVfs::register(false)?;
24//!
25//! // Open a database using the sqlite-objs VFS
26//! let conn = Connection::open_with_flags_and_vfs(
27//! "mydb.db",
28//! rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_CREATE,
29//! "sqlite-objs"
30//! )?;
31//! # Ok::<(), Box<dyn std::error::Error>>(())
32//! ```
33//!
34//! ### URI Mode (Per-Database Credentials)
35//!
36//! ```no_run
37//! use sqlite_objs::{SqliteObjsVfs, UriBuilder};
38//! use rusqlite::Connection;
39//!
40//! // Register VFS in URI mode (no global config)
41//! SqliteObjsVfs::register_uri(false)?;
42//!
43//! // Build URI with proper URL encoding
44//! let uri = UriBuilder::new("mydb.db", "myaccount", "databases")
45//! .sas_token("sv=2024-08-04&ss=b&srt=sco&sp=rwdlacyx&se=2026-01-01T00:00:00Z&sig=abc123")
46//! .cache_dir("/var/cache/myapp")
47//! .cache_reuse(true)
48//! .build();
49//!
50//! // Open database with Azure credentials in URI
51//! let conn = Connection::open_with_flags_and_vfs(
52//! &uri,
53//! rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_CREATE | rusqlite::OpenFlags::SQLITE_OPEN_URI,
54//! "sqlite-objs"
55//! )?;
56//! # Ok::<(), Box<dyn std::error::Error>>(())
57//! ```
58//!
59//! ### Explicit Configuration
60//!
61//! ```no_run
62//! use sqlite_objs::{SqliteObjsVfs, SqliteObjsConfig};
63//! use rusqlite::Connection;
64//!
65//! let config = SqliteObjsConfig {
66//! account: "myaccount".to_string(),
67//! container: "databases".to_string(),
68//! sas_token: Some("sv=2024-08-04&...".to_string()),
69//! account_key: None,
70//! endpoint: None,
71//! };
72//!
73//! SqliteObjsVfs::register_with_config(&config, false)?;
74//!
75//! let conn = Connection::open_with_flags_and_vfs(
76//! "mydb.db",
77//! rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_CREATE,
78//! "sqlite-objs"
79//! )?;
80//! # Ok::<(), Box<dyn std::error::Error>>(())
81//! ```
82
83use std::ffi::CString;
84use std::fmt::Write;
85use std::ptr;
86use thiserror::Error;
87
88/// Error type for sqlite-objs operations.
89#[derive(Error, Debug)]
90pub enum SqliteObjsError {
91 /// SQLite returned an error code
92 #[error("SQLite error: {0}")]
93 Sqlite(i32),
94
95 /// Invalid configuration (e.g., null bytes in strings)
96 #[error("Invalid configuration: {0}")]
97 InvalidConfig(String),
98
99 /// VFS registration failed
100 #[error("VFS registration failed: {0}")]
101 RegistrationFailed(String),
102}
103
104/// Result type for sqlite-objs operations.
105pub type Result<T> = std::result::Result<T, SqliteObjsError>;
106
107/// Configuration for the sqlite-objs VFS.
108///
109/// Maps to the C `sqlite_objs_config_t` struct. All fields are owned strings
110/// for safety and convenience.
111#[derive(Debug, Clone)]
112pub struct SqliteObjsConfig {
113 /// Azure Storage account name
114 pub account: String,
115 /// Blob container name
116 pub container: String,
117 /// SAS token (preferred)
118 pub sas_token: Option<String>,
119 /// Shared Key (fallback)
120 pub account_key: Option<String>,
121 /// Custom endpoint (e.g., for Azurite)
122 pub endpoint: Option<String>,
123}
124
125/// Handle to the sqlite-objs VFS.
126///
127/// The VFS is registered globally and persists for the lifetime of the process.
128/// This is a zero-sized type that provides static methods for VFS registration.
129pub struct SqliteObjsVfs;
130
131impl SqliteObjsVfs {
132 /// Register the sqlite-objs VFS using environment variables.
133 ///
134 /// Reads configuration from:
135 /// - `AZURE_STORAGE_ACCOUNT`
136 /// - `AZURE_STORAGE_CONTAINER`
137 /// - `AZURE_STORAGE_SAS` (checked first)
138 /// - `AZURE_STORAGE_KEY` (fallback)
139 ///
140 /// # Arguments
141 ///
142 /// * `make_default` - If true, sqlite-objs becomes the default VFS for all connections
143 ///
144 /// # Errors
145 ///
146 /// Returns an error if registration fails (e.g., missing environment variables).
147 pub fn register(make_default: bool) -> Result<()> {
148 let rc = unsafe { sqlite_objs_sys::sqlite_objs_vfs_register(make_default as i32) };
149 if rc == sqlite_objs_sys::SQLITE_OK {
150 Ok(())
151 } else {
152 Err(SqliteObjsError::RegistrationFailed(format!(
153 "sqlite_objs_vfs_register returned {}",
154 rc
155 )))
156 }
157 }
158
159 /// Register the sqlite-objs VFS with explicit configuration.
160 ///
161 /// # Arguments
162 ///
163 /// * `config` - Azure Storage configuration
164 /// * `make_default` - If true, sqlite-objs becomes the default VFS for all connections
165 ///
166 /// # Errors
167 ///
168 /// Returns an error if the configuration contains invalid data (null bytes)
169 /// or if registration fails.
170 pub fn register_with_config(config: &SqliteObjsConfig, make_default: bool) -> Result<()> {
171 // Convert Rust strings to C strings
172 let account = CString::new(config.account.as_str())
173 .map_err(|_| SqliteObjsError::InvalidConfig("account contains null byte".into()))?;
174 let container = CString::new(config.container.as_str())
175 .map_err(|_| SqliteObjsError::InvalidConfig("container contains null byte".into()))?;
176
177 let sas_token = config
178 .sas_token
179 .as_ref()
180 .map(|s| CString::new(s.as_str()))
181 .transpose()
182 .map_err(|_| SqliteObjsError::InvalidConfig("sas_token contains null byte".into()))?;
183
184 let account_key = config
185 .account_key
186 .as_ref()
187 .map(|s| CString::new(s.as_str()))
188 .transpose()
189 .map_err(|_| SqliteObjsError::InvalidConfig("account_key contains null byte".into()))?;
190
191 let endpoint = config
192 .endpoint
193 .as_ref()
194 .map(|s| CString::new(s.as_str()))
195 .transpose()
196 .map_err(|_| SqliteObjsError::InvalidConfig("endpoint contains null byte".into()))?;
197
198 let c_config = sqlite_objs_sys::sqlite_objs_config_t {
199 account: account.as_ptr(),
200 container: container.as_ptr(),
201 sas_token: sas_token
202 .as_ref()
203 .map(|s| s.as_ptr())
204 .unwrap_or(ptr::null()),
205 account_key: account_key
206 .as_ref()
207 .map(|s| s.as_ptr())
208 .unwrap_or(ptr::null()),
209 endpoint: endpoint
210 .as_ref()
211 .map(|s| s.as_ptr())
212 .unwrap_or(ptr::null()),
213 ops: ptr::null(),
214 ops_ctx: ptr::null_mut(),
215 };
216
217 let rc = unsafe {
218 sqlite_objs_sys::sqlite_objs_vfs_register_with_config(&c_config, make_default as i32)
219 };
220
221 if rc == sqlite_objs_sys::SQLITE_OK {
222 Ok(())
223 } else {
224 Err(SqliteObjsError::RegistrationFailed(format!(
225 "sqlite_objs_vfs_register_with_config returned {}",
226 rc
227 )))
228 }
229 }
230
231 /// Register the sqlite-objs VFS in URI mode.
232 ///
233 /// In this mode, Azure credentials must be provided via URI parameters for each database:
234 ///
235 /// ```text
236 /// file:mydb.db?azure_account=acct&azure_container=cont&azure_sas=token
237 /// ```
238 ///
239 /// Supported URI parameters:
240 /// - `azure_account` (required)
241 /// - `azure_container`
242 /// - `azure_sas`
243 /// - `azure_key`
244 /// - `azure_endpoint`
245 ///
246 /// # Arguments
247 ///
248 /// * `make_default` - If true, sqlite-objs becomes the default VFS for all connections
249 ///
250 /// # Errors
251 ///
252 /// Returns an error if registration fails.
253 pub fn register_uri(make_default: bool) -> Result<()> {
254 let rc = unsafe { sqlite_objs_sys::sqlite_objs_vfs_register_uri(make_default as i32) };
255 if rc == sqlite_objs_sys::SQLITE_OK {
256 Ok(())
257 } else {
258 Err(SqliteObjsError::RegistrationFailed(format!(
259 "sqlite_objs_vfs_register_uri returned {}",
260 rc
261 )))
262 }
263 }
264}
265
266/// Builder for constructing sqlite-objs URIs with proper URL encoding.
267///
268/// SQLite URIs use query parameters to pass Azure credentials. SAS tokens contain
269/// special characters (`&`, `=`, `%`) that must be percent-encoded to avoid breaking
270/// the URI query string.
271///
272/// # Example
273///
274/// ```
275/// use sqlite_objs::UriBuilder;
276///
277/// let uri = UriBuilder::new("mydb.db", "myaccount", "databases")
278/// .sas_token("sv=2024-08-04&ss=b&srt=sco&sp=rwdlacyx&se=2026-01-01T00:00:00Z&sig=abc123")
279/// .build();
280///
281/// // URI is properly encoded:
282/// // file:mydb.db?azure_account=myaccount&azure_container=databases&azure_sas=sv%3D2024-08-04%26ss%3Db...
283/// ```
284///
285/// # Authentication
286///
287/// Use either `sas_token()` or `account_key()`, not both. If both are set, `sas_token`
288/// takes precedence.
289pub struct UriBuilder {
290 database: String,
291 account: String,
292 container: String,
293 sas_token: Option<String>,
294 account_key: Option<String>,
295 endpoint: Option<String>,
296 cache_dir: Option<String>,
297 cache_reuse: bool,
298}
299
300impl UriBuilder {
301 /// Create a new URI builder with required parameters.
302 ///
303 /// # Arguments
304 ///
305 /// * `database` - Database filename (e.g., "mydb.db")
306 /// * `account` - Azure Storage account name
307 /// * `container` - Blob container name
308 pub fn new(database: &str, account: &str, container: &str) -> Self {
309 Self {
310 database: database.to_string(),
311 account: account.to_string(),
312 container: container.to_string(),
313 sas_token: None,
314 account_key: None,
315 endpoint: None,
316 cache_dir: None,
317 cache_reuse: false,
318 }
319 }
320
321 /// Set the SAS token for authentication (preferred).
322 ///
323 /// The token will be URL-encoded automatically. Do not encode it yourself.
324 pub fn sas_token(mut self, token: &str) -> Self {
325 self.sas_token = Some(token.to_string());
326 self
327 }
328
329 /// Set the account key for Shared Key authentication (fallback).
330 ///
331 /// The key will be URL-encoded automatically.
332 pub fn account_key(mut self, key: &str) -> Self {
333 self.account_key = Some(key.to_string());
334 self
335 }
336
337 /// Set a custom endpoint (e.g., for Azurite: "http://127.0.0.1:10000").
338 pub fn endpoint(mut self, endpoint: &str) -> Self {
339 self.endpoint = Some(endpoint.to_string());
340 self
341 }
342
343 /// Set the local cache directory for downloaded database files.
344 ///
345 /// If not set, defaults to `/tmp`. The directory will be created if it doesn't exist.
346 pub fn cache_dir(mut self, dir: &str) -> Self {
347 self.cache_dir = Some(dir.to_string());
348 self
349 }
350
351 /// Enable persistent cache reuse across database connections.
352 ///
353 /// When enabled, the local cache file is kept after closing the database.
354 /// On reopen, the VFS checks the blob's ETag — if unchanged, the cached
355 /// file is reused instead of re-downloading (saving ~20s for large databases).
356 ///
357 /// Requires `cache_dir` to be set for predictable cache file locations.
358 /// Default: `false` (cache files are deleted on close).
359 pub fn cache_reuse(mut self, enabled: bool) -> Self {
360 self.cache_reuse = enabled;
361 self
362 }
363
364 /// Build the URI string with proper URL encoding.
365 ///
366 /// Returns a SQLite URI in the format:
367 /// `file:{database}?azure_account={account}&azure_container={container}&...`
368 pub fn build(self) -> String {
369 let mut uri = format!("file:{}?azure_account={}&azure_container={}",
370 self.database,
371 percent_encode(&self.account),
372 percent_encode(&self.container)
373 );
374
375 // Prefer SAS token over account key
376 if let Some(sas) = &self.sas_token {
377 uri.push_str("&azure_sas=");
378 uri.push_str(&percent_encode(sas));
379 } else if let Some(key) = &self.account_key {
380 uri.push_str("&azure_key=");
381 uri.push_str(&percent_encode(key));
382 }
383
384 if let Some(endpoint) = &self.endpoint {
385 uri.push_str("&azure_endpoint=");
386 uri.push_str(&percent_encode(endpoint));
387 }
388
389 if let Some(cache_dir) = &self.cache_dir {
390 uri.push_str("&cache_dir=");
391 uri.push_str(&percent_encode(cache_dir));
392 }
393
394 if self.cache_reuse {
395 uri.push_str("&cache_reuse=1");
396 }
397
398 uri
399 }
400}
401
402/// Percent-encode a string for use in URI query parameters.
403///
404/// Encodes characters that have special meaning in URIs:
405/// - Reserved: `&`, `=`, `%`, `#`, `?`, `+`, `/`, `:`, `@`
406/// - Space
407///
408/// This is a minimal implementation sufficient for SQLite URI parameters.
409/// Uses uppercase hex digits per RFC 3986.
410fn percent_encode(s: &str) -> String {
411 let mut result = String::with_capacity(s.len() * 2);
412
413 for byte in s.bytes() {
414 match byte {
415 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
416 // Unreserved characters (RFC 3986 section 2.3)
417 result.push(byte as char);
418 }
419 _ => {
420 // Encode everything else
421 write!(result, "%{:02X}", byte).unwrap();
422 }
423 }
424 }
425
426 result
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 #[test]
434 fn test_register_uri() {
435 // URI mode should succeed without config
436 SqliteObjsVfs::register_uri(false).expect("URI registration should succeed");
437 }
438
439 #[test]
440 fn test_config_with_sas() {
441 let config = SqliteObjsConfig {
442 account: "testaccount".to_string(),
443 container: "testcontainer".to_string(),
444 sas_token: Some("sv=2024-08-04&sig=test".to_string()),
445 account_key: None,
446 endpoint: None,
447 };
448
449 // This will fail since we don't have real Azure creds,
450 // but it tests the FFI layer
451 let _ = SqliteObjsVfs::register_with_config(&config, false);
452 }
453
454 #[test]
455 fn test_invalid_config() {
456 let config = SqliteObjsConfig {
457 account: "test\0account".to_string(),
458 container: "container".to_string(),
459 sas_token: None,
460 account_key: None,
461 endpoint: None,
462 };
463
464 let result = SqliteObjsVfs::register_with_config(&config, false);
465 assert!(matches!(result, Err(SqliteObjsError::InvalidConfig(_))));
466 }
467
468 #[test]
469 fn test_uri_builder_basic() {
470 let uri = UriBuilder::new("mydb.db", "myaccount", "mycontainer").build();
471 assert_eq!(uri, "file:mydb.db?azure_account=myaccount&azure_container=mycontainer");
472 }
473
474 #[test]
475 fn test_uri_builder_with_sas() {
476 let uri = UriBuilder::new("mydb.db", "myaccount", "mycontainer")
477 .sas_token("sv=2024-08-04&ss=b&srt=sco&sp=rwdlacyx&se=2026-01-01T00:00:00Z&sig=abc123")
478 .build();
479
480 // Verify SAS token is encoded (&, =, :)
481 assert!(uri.contains("azure_sas=sv%3D2024-08-04%26ss%3Db%26srt%3Dsco%26sp%3Drwdlacyx%26se%3D2026-01-01T00%3A00%3A00Z%26sig%3Dabc123"));
482 assert!(uri.starts_with("file:mydb.db?azure_account=myaccount&azure_container=mycontainer&azure_sas="));
483 }
484
485 #[test]
486 fn test_uri_builder_with_account_key() {
487 let uri = UriBuilder::new("test.db", "account", "container")
488 .account_key("my/secret+key==")
489 .build();
490
491 // Verify account key is encoded (/, +, =)
492 assert!(uri.contains("azure_key=my%2Fsecret%2Bkey%3D%3D"));
493 }
494
495 #[test]
496 fn test_uri_builder_with_endpoint() {
497 let uri = UriBuilder::new("test.db", "devstoreaccount1", "testcontainer")
498 .endpoint("http://127.0.0.1:10000/devstoreaccount1")
499 .build();
500
501 // Verify endpoint is encoded (://)
502 assert!(uri.contains("azure_endpoint=http%3A%2F%2F127.0.0.1%3A10000%2Fdevstoreaccount1"));
503 }
504
505 #[test]
506 fn test_uri_builder_sas_precedence() {
507 let uri = UriBuilder::new("test.db", "account", "container")
508 .sas_token("sas_token_value")
509 .account_key("key_value")
510 .build();
511
512 // SAS token should be present, account key should not
513 assert!(uri.contains("azure_sas="));
514 assert!(!uri.contains("azure_key="));
515 }
516
517 #[test]
518 fn test_uri_builder_with_cache_dir() {
519 let uri = UriBuilder::new("mydb.db", "myaccount", "mycontainer")
520 .sas_token("token")
521 .cache_dir("/var/cache/myapp")
522 .build();
523
524 assert!(uri.contains("cache_dir=%2Fvar%2Fcache%2Fmyapp"));
525
526 // Verify it appears after the other parameters
527 let cache_pos = uri.find("cache_dir=").unwrap();
528 let sas_pos = uri.find("azure_sas=").unwrap();
529 assert!(cache_pos > sas_pos);
530 }
531
532 #[test]
533 fn test_uri_builder_cache_dir_without_auth() {
534 let uri = UriBuilder::new("test.db", "account", "container")
535 .cache_dir("/tmp/test")
536 .build();
537
538 assert_eq!(
539 uri,
540 "file:test.db?azure_account=account&azure_container=container&cache_dir=%2Ftmp%2Ftest"
541 );
542 }
543
544 #[test]
545 fn test_uri_builder_cache_reuse_enabled() {
546 let uri = UriBuilder::new("mydb.db", "myaccount", "mycontainer")
547 .sas_token("token")
548 .cache_reuse(true)
549 .build();
550
551 assert!(uri.contains("&cache_reuse=1"));
552 }
553
554 #[test]
555 fn test_uri_builder_cache_reuse_default_omitted() {
556 let uri = UriBuilder::new("mydb.db", "myaccount", "mycontainer")
557 .sas_token("token")
558 .build();
559
560 assert!(!uri.contains("cache_reuse"));
561 }
562
563 #[test]
564 fn test_uri_builder_cache_reuse_with_cache_dir() {
565 let uri = UriBuilder::new("mydb.db", "myaccount", "mycontainer")
566 .sas_token("token")
567 .cache_dir("/var/cache/myapp")
568 .cache_reuse(true)
569 .build();
570
571 assert!(uri.contains("cache_dir=%2Fvar%2Fcache%2Fmyapp"));
572 assert!(uri.contains("&cache_reuse=1"));
573
574 // cache_reuse should appear after cache_dir
575 let dir_pos = uri.find("cache_dir=").unwrap();
576 let reuse_pos = uri.find("cache_reuse=").unwrap();
577 assert!(reuse_pos > dir_pos);
578 }
579
580 #[test]
581 fn test_percent_encode_special_chars() {
582 // Test all special characters that need encoding
583 assert_eq!(percent_encode("hello&world"), "hello%26world");
584 assert_eq!(percent_encode("key=value"), "key%3Dvalue");
585 assert_eq!(percent_encode("100%"), "100%25");
586 assert_eq!(percent_encode("a#b"), "a%23b");
587 assert_eq!(percent_encode("a?b"), "a%3Fb");
588 assert_eq!(percent_encode("a+b"), "a%2Bb");
589 assert_eq!(percent_encode("a/b"), "a%2Fb");
590 assert_eq!(percent_encode("a:b"), "a%3Ab");
591 assert_eq!(percent_encode("a@b"), "a%40b");
592 assert_eq!(percent_encode("hello world"), "hello%20world");
593 }
594
595 #[test]
596 fn test_percent_encode_unreserved() {
597 // Unreserved characters should not be encoded
598 assert_eq!(percent_encode("azAZ09-_.~"), "azAZ09-_.~");
599 }
600
601 #[test]
602 fn test_percent_encode_empty() {
603 assert_eq!(percent_encode(""), "");
604 }
605}