Skip to main content

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}