From 6c41f37fb518696730a83e27f018639971e58c0f Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Sun, 13 Sep 2026 16:06:13 +0300 Subject: [PATCH] Harden and extend sqlite-wasm-rs OPFS --- Cargo.toml | 3 +- flake.nix | 3 + .../integration/vendor-regressions.test.ts | 33 + svelte-test/vitest.config.js | 3 + vendor/sqlite-wasm-rs/Cargo.toml | 4 - vendor/sqlite-wasm-rs/README.md | 2 + vendor/sqlite-wasm-rs/src/shim/impl.rs | 62 +- vendor/sqlite-wasm-rs/src/shim/mod.rs | 3 +- vendor/sqlite-wasm-rs/src/shim/vfs/memory.rs | 45 +- vendor/sqlite-wasm-rs/src/shim/vfs/sahpool.rs | 1704 +++++++++++++++-- 10 files changed, 1711 insertions(+), 151 deletions(-) create mode 100644 svelte-test/tests/integration/vendor-regressions.test.ts diff --git a/Cargo.toml b/Cargo.toml index 10d7b5b..aea03e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,8 @@ members = [ "packages/sqlite-web" ] exclude = [ - "lib/rain.math.float" + "lib/rain.math.float", + "vendor/sqlite-wasm-rs" ] resolver = "2" diff --git a/flake.nix b/flake.nix index e6be566..891bbc9 100644 --- a/flake.nix +++ b/flake.nix @@ -15,6 +15,9 @@ name = "test-wasm"; body = '' set -euxo pipefail + cd vendor/sqlite-wasm-rs + TZ=America/New_York wasm-pack test --headless --chrome . --lib --no-default-features --features precompiled + cd ../.. cd packages/sqlite-web-core wasm-pack test --headless --chrome cd ../.. diff --git a/svelte-test/tests/integration/vendor-regressions.test.ts b/svelte-test/tests/integration/vendor-regressions.test.ts new file mode 100644 index 0000000..a1cc8f5 --- /dev/null +++ b/svelte-test/tests/integration/vendor-regressions.test.ts @@ -0,0 +1,33 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { SQLiteWasmDatabase } from '@rainlanguage/sqlite-web'; +import { cleanupDatabase, createTestDatabase } from '../fixtures/test-helpers.js'; + +describe('vendored sqlite runtime regressions', () => { + let db: SQLiteWasmDatabase; + + beforeEach(async () => { + db = await createTestDatabase(); + }); + + afterEach(async () => { + await cleanupDatabase(db); + }); + + it('uses the configured local offset and daylight-saving rules', async () => { + const result = await db.query( + `SELECT + datetime(0, 'unixepoch', 'localtime') AS winter_time, + datetime(1593561600, 'unixepoch', 'localtime') AS summer_time` + ); + const rows = JSON.parse(result.value || '[]') as Array<{ + winter_time: string; + summer_time: string; + }>; + + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual({ + winter_time: '1969-12-31 19:00:00', + summer_time: '2020-06-30 20:00:00' + }); + }); +}); diff --git a/svelte-test/vitest.config.js b/svelte-test/vitest.config.js index 2c41202..34f9c91 100644 --- a/svelte-test/vitest.config.js +++ b/svelte-test/vitest.config.js @@ -39,6 +39,9 @@ export default defineConfig({ }, // Add cross-origin isolation headers for SharedArrayBuffer and OPFS providerOptions: { + context: { + timezoneId: 'America/New_York' + }, launch: { args: [ '--enable-features=SharedArrayBuffer', diff --git a/vendor/sqlite-wasm-rs/Cargo.toml b/vendor/sqlite-wasm-rs/Cargo.toml index d407a98..5f1abc3 100644 --- a/vendor/sqlite-wasm-rs/Cargo.toml +++ b/vendor/sqlite-wasm-rs/Cargo.toml @@ -51,10 +51,6 @@ precompiled = [] name = "sqlite_wasm_rs" path = "src/lib.rs" -[[test]] -name = "main" -path = "tests/main.rs" - [dependencies.fragile] version = "2.0.0" diff --git a/vendor/sqlite-wasm-rs/README.md b/vendor/sqlite-wasm-rs/README.md index a104d96..b32f5d0 100644 --- a/vendor/sqlite-wasm-rs/README.md +++ b/vendor/sqlite-wasm-rs/README.md @@ -50,6 +50,8 @@ async fn open_db() -> anyhow::Result<()> { ) }; assert_eq!(ffi::SQLITE_OK, ret); + + Ok(()) } ``` diff --git a/vendor/sqlite-wasm-rs/src/shim/impl.rs b/vendor/sqlite-wasm-rs/src/shim/impl.rs index c6e5b8d..d5306fd 100644 --- a/vendor/sqlite-wasm-rs/src/shim/impl.rs +++ b/vendor/sqlite-wasm-rs/src/shim/impl.rs @@ -21,8 +21,11 @@ pub struct tm { pub tm_zone: *mut std::os::raw::c_char, } -const INT53_MAX: time_t = 9007199254740992; -const INT53_MIN: time_t = -9007199254740992; +// ECMAScript Date's TimeClip range is +/- 8.64e15 milliseconds. Staying +// within JavaScript's wider safe-integer range is not sufficient because Date +// returns NaN outside this narrower range. +const JS_DATE_MAX_SECONDS: time_t = 8_640_000_000_000; +const JS_DATE_MIN_SECONDS: time_t = -JS_DATE_MAX_SECONDS; fn yday_from_date(date: &Date) -> u32 { const MONTH_DAYS_LEAP_CUMULATIVE: [u32; 12] = @@ -45,9 +48,13 @@ fn yday_from_date(date: &Date) -> u32 { /// https://github.com/sqlite/sqlite-wasm/blob/7c1b309c3bd07d8e6d92f82344108cebbd14f161/sqlite-wasm/jswasm/sqlite3-bundler-friendly.mjs#L3404 #[no_mangle] pub unsafe extern "C" fn rust_sqlite_wasm_shim_localtime_js(t: time_t, tm: *mut tm) { - assert!(!(INT53_MIN..=INT53_MAX).contains(&t), "wrong time range"); + assert!( + (JS_DATE_MIN_SECONDS..=JS_DATE_MAX_SECONDS).contains(&t), + "wrong time range" + ); - let date = Date::new(&(t * 1000).into()); + // Converting i64 directly creates a JavaScript BigInt, which Date rejects. + let date = Date::new(&((t as f64) * 1000.0).into()); (*tm).tm_sec = date.get_seconds() as _; (*tm).tm_min = date.get_minutes() as _; (*tm).tm_hour = date.get_hours() as _; @@ -66,7 +73,7 @@ pub unsafe extern "C" fn rust_sqlite_wasm_shim_localtime_js(t: time_t, tm: *mut && date.get_timezone_offset() == winter_offset.min(summer_offset), ); - (*tm).tm_gmtoff = (date.get_timezone_offset() * 60.0) as _; + (*tm).tm_gmtoff = (-date.get_timezone_offset() * 60.0) as _; } /// https://github.com/sqlite/sqlite-wasm/blob/7c1b309c3bd07d8e6d92f82344108cebbd14f161/sqlite-wasm/jswasm/sqlite3-bundler-friendly.mjs#L3460 @@ -177,3 +184,48 @@ pub unsafe extern "C" fn rust_sqlite_wasm_shim_realloc(ptr: *mut u8, new_size: u ptr.add(ALIGN) } + +#[cfg(test)] +mod tests { + use super::*; + use wasm_bindgen_test::wasm_bindgen_test; + + #[wasm_bindgen_test] + fn localtime_populates_gmt_offset() { + let timestamp = 1_593_561_600; + let mut value: tm = unsafe { std::mem::zeroed() }; + + unsafe { rust_sqlite_wasm_shim_localtime_js(timestamp, &mut value) }; + + assert_eq!( + Date::new(&((timestamp as f64) * 1000.0).into()).get_timezone_offset(), + 240.0 + ); + assert_eq!(value.tm_gmtoff, -4 * 60 * 60); + assert_eq!(value.tm_isdst, 1); + } + + #[wasm_bindgen_test] + fn localtime_accepts_javascript_date_boundaries() { + for timestamp in [JS_DATE_MIN_SECONDS, JS_DATE_MAX_SECONDS] { + let mut value: tm = unsafe { std::mem::zeroed() }; + unsafe { rust_sqlite_wasm_shim_localtime_js(timestamp, &mut value) }; + assert!((0..=11).contains(&value.tm_mon)); + assert!((1..=31).contains(&value.tm_mday)); + } + } + + #[wasm_bindgen_test] + #[should_panic(expected = "wrong time range")] + fn localtime_rejects_above_javascript_date_range() { + let mut value: tm = unsafe { std::mem::zeroed() }; + unsafe { rust_sqlite_wasm_shim_localtime_js(JS_DATE_MAX_SECONDS + 1, &mut value) }; + } + + #[wasm_bindgen_test] + #[should_panic(expected = "wrong time range")] + fn localtime_rejects_below_javascript_date_range() { + let mut value: tm = unsafe { std::mem::zeroed() }; + unsafe { rust_sqlite_wasm_shim_localtime_js(JS_DATE_MIN_SECONDS - 1, &mut value) }; + } +} diff --git a/vendor/sqlite-wasm-rs/src/shim/mod.rs b/vendor/sqlite-wasm-rs/src/shim/mod.rs index 8ae70dd..696dc12 100644 --- a/vendor/sqlite-wasm-rs/src/shim/mod.rs +++ b/vendor/sqlite-wasm-rs/src/shim/mod.rs @@ -19,7 +19,8 @@ pub mod export { // Some sqlite types copied from libsqlite3-sys pub use super::libsqlite3::*; pub use super::vfs::sahpool::{ - install_opfs_sahpool, OpfsSAHError, OpfsSAHPoolCfg, OpfsSAHPoolCfgBuilder, OpfsSAHPoolUtil, + install_opfs_sahpool, OpfsSAHError, OpfsSAHPoolCfg, OpfsSAHPoolCfgBuilder, + OpfsSAHPoolImport, OpfsSAHPoolUtil, }; #[cfg(feature = "custom-libc")] diff --git a/vendor/sqlite-wasm-rs/src/shim/vfs/memory.rs b/vendor/sqlite-wasm-rs/src/shim/vfs/memory.rs index d55f21c..dcde8d1 100644 --- a/vendor/sqlite-wasm-rs/src/shim/vfs/memory.rs +++ b/vendor/sqlite-wasm-rs/src/shim/vfs/memory.rs @@ -35,7 +35,7 @@ unsafe extern "C" fn xRandomness( zOut: *mut ::std::os::raw::c_char, ) -> ::std::os::raw::c_int { for i in 0..nByte { - *zOut.offset(i as isize) = (Math::random() * 255000.0) as _; + *zOut.offset(i as isize) = (Math::random() * 256.0) as u8 as _; } nByte } @@ -94,23 +94,44 @@ unsafe extern "C" fn xOpen( flags: ::std::os::raw::c_int, pOutFlags: *mut ::std::os::raw::c_int, ) -> ::std::os::raw::c_int { - let Ok(s) = CStr::from_ptr(zName).to_str() else { - return SQLITE_ERROR; + let requested_name = if zName.is_null() { + None + } else { + let Ok(name) = CStr::from_ptr(zName).to_str() else { + return SQLITE_ERROR; + }; + Some(name.to_owned()) }; let mut name2file = name2file(); - let mem_file = if let Some(mem_file) = name2file.get(s) { + let (name, flags) = match requested_name { + Some(name) => (name, flags), + None => { + let name = loop { + let candidate = format!( + ":sqlite-temp:{:08x}", + (Math::random() * (u32::MAX as f64 + 1.0)) as u32 + ); + if !name2file.contains_key(&candidate) { + break candidate; + } + }; + (name, flags | SQLITE_OPEN_CREATE | SQLITE_OPEN_DELETEONCLOSE) + } + }; + + let mem_file = if let Some(mem_file) = name2file.get(&name) { Arc::clone(mem_file) } else { if flags & SQLITE_OPEN_CREATE == 0 { return SQLITE_CANTOPEN; } let file = Arc::new(RwLock::new(MemFile { - name: s.into(), + name: name.clone(), flags, data: Vec::new(), })); - name2file.insert(s.into(), Arc::clone(&file)); + name2file.insert(name, Arc::clone(&file)); file }; @@ -156,7 +177,17 @@ unsafe extern "C" fn xFullPathname( nOut: ::std::os::raw::c_int, zOut: *mut ::std::os::raw::c_char, ) -> ::std::os::raw::c_int { - zName.copy_to(zOut, nOut as usize); + if zName.is_null() || zOut.is_null() || nOut <= 0 { + return SQLITE_CANTOPEN; + } + let bytes = CStr::from_ptr(zName).to_bytes_with_nul(); + if bytes.len() > nOut as usize { + return SQLITE_CANTOPEN; + } + bytes + .as_ptr() + .cast::<::std::os::raw::c_char>() + .copy_to_nonoverlapping(zOut, bytes.len()); SQLITE_OK } diff --git a/vendor/sqlite-wasm-rs/src/shim/vfs/sahpool.rs b/vendor/sqlite-wasm-rs/src/shim/vfs/sahpool.rs index c20636a..ce48e4b 100644 --- a/vendor/sqlite-wasm-rs/src/shim/vfs/sahpool.rs +++ b/vendor/sqlite-wasm-rs/src/shim/vfs/sahpool.rs @@ -29,10 +29,119 @@ const HEADER_CORPUS_SIZE: usize = HEADER_MAX_PATH_SIZE + HEADER_FLAGS_SIZE; const HEADER_OFFSET_FLAGS: usize = HEADER_MAX_PATH_SIZE; const HEADER_OFFSET_DIGEST: usize = HEADER_CORPUS_SIZE; const HEADER_OFFSET_DATA: usize = SECTOR_SIZE; +const SQLITE_HEADER_SIZE: usize = 18; +const JS_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; const PERSISTENT_FILE_TYPES: i32 = SQLITE_OPEN_MAIN_DB | SQLITE_OPEN_MAIN_JOURNAL | SQLITE_OPEN_SUPER_JOURNAL | SQLITE_OPEN_WAL; +fn combined_error(primary: OpfsSAHError, cleanup: OpfsSAHError) -> OpfsSAHError { + OpfsSAHError::Custom(format!( + "primary operation failed: {primary:?}; cleanup also failed, resource quarantined: {cleanup:?}" + )) +} + +fn validate_path_input(name: &str) -> Result<(), OpfsSAHError> { + if name.is_empty() { + return Err(OpfsSAHError::Custom("path must not be empty".into())); + } + if name.as_bytes().contains(&0) { + return Err(OpfsSAHError::Custom( + "path must not contain NUL bytes".into(), + )); + } + Ok(()) +} + +fn canonicalize_path(name: &str) -> Result { + validate_path_input(name)?; + if name.contains("://") { + return Err(OpfsSAHError::Custom( + "URL schemes are not valid database paths".into(), + )); + } + + let mut segments = Vec::new(); + for segment in name.split('/') { + match segment { + "" | "." => {} + ".." => { + segments.pop(); + } + segment => segments.push(segment), + } + } + let path = format!("/{}", segments.join("/")); + Ok(path) +} + +fn parent_url_path(name: &str) -> Result { + validate_path_input(name)?; + Url::new_with_base(name, "file://localhost/") + .map(|url| url.pathname()) + .map_err(OpfsSAHError::GetPath) +} + +fn new_path(name: &str) -> Result { + let path = canonicalize_path(name)?; + if path.len() >= HEADER_MAX_PATH_SIZE { + return Err(OpfsSAHError::Custom(format!("Path too long: {path}"))); + } + Ok(path) +} + +fn new_utility_path(name: &str) -> Result { + validate_path_input(name)?; + let bytes = name.as_bytes(); + if matches!(bytes.first(), Some(byte) if *byte <= b' ') + || matches!(bytes.last(), Some(byte) if *byte <= b' ') + || bytes + .iter() + .any(|byte| matches!(byte, b'\t' | b'\r' | b'\n')) + { + return Err(OpfsSAHError::Custom(format!( + "WHATWG-trimmed whitespace and control bytes are not valid utility destinations: {name:?}" + ))); + } + let path = new_path(name)?; + if parent_url_path(name)? != parent_url_path(&path)? { + return Err(OpfsSAHError::Custom(format!( + "Utility path identity would change after publication; use an explicit ordinary filesystem path: {name}" + ))); + } + Ok(path) +} + +fn path_identities(name: &str) -> Result, OpfsSAHError> { + validate_path_input(name)?; + let mut paths = vec![name.to_owned()]; + if let Ok(path) = canonicalize_path(name) { + if !paths.contains(&path) { + paths.push(path); + } + } + let legacy = parent_url_path(name)?; + if !paths.contains(&legacy) { + paths.push(legacy.clone()); + } + if let Ok(canonical_legacy) = canonicalize_path(&legacy) { + if !paths.contains(&canonical_legacy) { + paths.push(canonical_legacy); + } + } + Ok(paths) +} + +fn paths_overlap(left: &str, right: &str) -> Result { + let left = path_identities(left)?; + let right = path_identities(right)?; + Ok(left.iter().any(|path| right.contains(path))) +} + +// sqlite-wasm-rs 0.3.0 wrote a zero digest. Reuse a VFS-irrelevant flag to +// distinguish new metadata while retaining compatibility with existing pools. +const FLAG_COMPUTE_DIGEST_V2: i32 = SQLITE_OPEN_MEMORY; + static VFS2SAH: Lazy>>> = Lazy::new(|| RwLock::new(HashMap::new())); @@ -50,18 +159,28 @@ unsafe fn file2vfs(file: *mut sqlite3_file) -> *mut sqlite3_vfs { (*(file.cast::())).vfs } -// this function only return [0, 0] for now -// -// https://github.com/sqlite/sqlite-wasm/issues/97 -fn compute_digest(_byte_array: &Uint8Array) -> Uint32Array { - let u32_array = Uint32Array::new_with_length(2); - u32_array.set_index(0, 0); - u32_array.set_index(1, 0); - u32_array +fn compute_digest(byte_array: &Uint8Array, file_flags: u32) -> Uint32Array { + let digest = Uint32Array::new_with_length(2); + if file_flags & FLAG_COMPUTE_DIGEST_V2 as u32 == 0 { + return digest; + } + + let mut h1 = 0xdead_beefu32; + let mut h2 = 0x41c6_ce57u32; + for index in 0..byte_array.length() { + let value = byte_array.get_index(index) as u32; + h1 = (h1 ^ value).wrapping_mul(2_654_435_761); + h2 = (h2 ^ value).wrapping_mul(104_729); + } + digest.set_index(0, h1); + digest.set_index(1, h2); + digest } fn get_random_name() -> String { - let random = Number::from(Math::random()).to_string(36).unwrap(); + let random = Number::from(Math::random()) + .to_string_with_radix(36) + .unwrap(); random.slice(2, random.length()).as_string().unwrap() } @@ -111,6 +230,8 @@ struct OpfsSAHPool { dv_body: DataView, /// Maps client-side file names to SAHs map_filename_to_sah: Map, + /// Logical paths claimed by imports which are not published yet. + reserved_paths: Set, /// Set of currently-unused SAHs available_sah: Set, /// Maps SAHs to their opaque file names @@ -121,6 +242,8 @@ struct OpfsSAHPool { /// /// Never poison, unwrap `lock()` is fine last_error: Mutex>, + #[cfg(test)] + association_failures_after_body: Mutex, } impl OpfsSAHPool { @@ -174,10 +297,13 @@ impl OpfsSAHPool { ap_body, dv_body, map_filename_to_sah: Map::new(), + reserved_paths: Set::default(), available_sah: Set::default(), map_sah_to_name: Map::new(), map_s3_file_to_o_file: Map::new(), last_error: Mutex::new(None), + #[cfg(test)] + association_failures_after_body: Mutex::new(0), }; pool.acquire_access_handles(clear_files).await?; if pool.get_capacity() == 0 { @@ -267,9 +393,39 @@ impl OpfsSAHPool { &self, sah: &FileSystemSyncAccessHandle, ) -> Result, OpfsSAHError> { - sah.read_with_buffer_source_and_options(&self.ap_body, &read_write_options(0.0)) + let body_read = sah + .read_with_buffer_source_and_options(&self.ap_body, &read_write_options(0.0)) .map_err(OpfsSAHError::Read)?; + if body_read != HEADER_CORPUS_SIZE as f64 { + return Err(OpfsSAHError::Custom(format!( + "Expected to read {HEADER_CORPUS_SIZE} metadata bytes but read {body_read}." + ))); + } let flags = self.dv_body.get_uint32(HEADER_OFFSET_FLAGS); + + // size is 2 + let file_digest = Uint32Array::new_with_length(HEADER_DIGEST_SIZE as u32 / 4); + let digest_read = sah + .read_with_buffer_source_and_options( + &file_digest, + &read_write_options(HEADER_OFFSET_DIGEST as f64), + ) + .map_err(OpfsSAHError::Read)?; + if digest_read != HEADER_DIGEST_SIZE as f64 { + return Err(OpfsSAHError::Custom(format!( + "Expected to read {HEADER_DIGEST_SIZE} digest bytes but read {digest_read}." + ))); + } + + let comp_digest = compute_digest(&self.ap_body, flags); + if !Array::from(&file_digest) + .every(&mut |v, i, _| v.as_f64().unwrap() as u32 == comp_digest.get_index(i)) + { + return Err(OpfsSAHError::Custom( + "SAH metadata digest mismatch; refusing to modify the stored database".into(), + )); + } + if self.ap_body.get_index(0) != 0 && ((flags & SQLITE_OPEN_DELETEONCLOSE as u32 != 0) || (flags & PERSISTENT_FILE_TYPES as u32) == 0) @@ -278,41 +434,29 @@ impl OpfsSAHPool { return Ok(None); } - // size is 2 - let file_digest = Uint32Array::new_with_length(HEADER_DIGEST_SIZE as u32 / 4); - sah.read_with_buffer_source_and_options( - &file_digest, - &read_write_options(HEADER_OFFSET_DIGEST as f64), - ) - .map_err(OpfsSAHError::Read)?; - - let comp_digest = compute_digest(&self.ap_body); - if Array::from(&file_digest) - .every(&mut |v, i, _| v.as_f64().unwrap() as u32 == comp_digest.get_index(i)) - { - let path_size = Array::from(&self.ap_body) - .find_index(&mut |x, _, _| x.as_f64().unwrap() as u8 == 0) - as u32; - if path_size == 0 { - sah.truncate_with_u32(HEADER_OFFSET_DATA as u32) - .map_err(OpfsSAHError::Truncate)?; - return Ok(None); - } - let path_bytes = self.ap_body.subarray(0, path_size); - let mut path = vec![0; path_size as usize]; - for idx in 0..path_size { - // why not `copy_to`? - // - // see - path[idx as usize] = path_bytes.get_index(idx); - } - // set_associated_path ensures that it is utf8 - let path = String::from_utf8(path).unwrap(); - Ok(Some(path)) + let nul_index = + Array::from(&self.ap_body).find_index(&mut |x, _, _| x.as_f64().unwrap() as u8 == 0); + let path_size = if nul_index < 0 { + HEADER_MAX_PATH_SIZE as u32 } else { - self.set_associated_path(sah, "", 0)?; - Ok(None) + nul_index as u32 + }; + if path_size == 0 { + sah.truncate_with_u32(HEADER_OFFSET_DATA as u32) + .map_err(OpfsSAHError::Truncate)?; + return Ok(None); + } + let path_bytes = self.ap_body.subarray(0, path_size); + let mut path = vec![0; path_size as usize]; + for idx in 0..path_size { + // why not `copy_to`? + // + // see + path[idx as usize] = path_bytes.get_index(idx); } + // set_associated_path ensures that it is utf8 + let path = String::from_utf8(path).unwrap(); + Ok(Some(path)) } /// Stores the given client-defined path and SQLITE_OPEN_xyz flags @@ -325,7 +469,28 @@ impl OpfsSAHPool { path: &str, flags: i32, ) -> Result<(), OpfsSAHError> { - if HEADER_MAX_PATH_SIZE < path.len() { + self.set_associated_path_with_limit(sah, path, flags, false) + } + + fn restore_legacy_associated_path( + &self, + sah: &FileSystemSyncAccessHandle, + path: &str, + flags: i32, + ) -> Result<(), OpfsSAHError> { + self.set_associated_path_with_limit(sah, path, flags, true) + } + + fn set_associated_path_with_limit( + &self, + sah: &FileSystemSyncAccessHandle, + path: &str, + flags: i32, + allow_legacy_max: bool, + ) -> Result<(), OpfsSAHError> { + if (allow_legacy_max && HEADER_MAX_PATH_SIZE < path.len()) + || (!allow_legacy_max && HEADER_MAX_PATH_SIZE <= path.len()) + { return Err(OpfsSAHError::Custom(format!("Path too long: {path}"))); } for (idx, byte) in path.bytes().enumerate() { @@ -335,19 +500,51 @@ impl OpfsSAHPool { self.ap_body.set_index(idx as u32, byte); } + let flags = if !path.is_empty() && flags != 0 { + flags | FLAG_COMPUTE_DIGEST_V2 + } else { + flags + }; + self.ap_body .fill(0, path.len() as u32, HEADER_MAX_PATH_SIZE as u32); self.dv_body.set_uint32(HEADER_OFFSET_FLAGS, flags as u32); - let digest = compute_digest(&self.ap_body); + let digest = compute_digest(&self.ap_body, flags as u32); - sah.write_with_js_u8_array_and_options(&self.ap_body, &read_write_options(0.0)) + let body_written = sah + .write_with_js_u8_array_and_options(&self.ap_body, &read_write_options(0.0)) .map_err(OpfsSAHError::Write)?; - sah.write_with_buffer_source_and_options( - &digest, - &read_write_options(HEADER_OFFSET_DIGEST as f64), - ) - .map_err(OpfsSAHError::Write)?; + if body_written != self.ap_body.byte_length() as f64 { + return Err(OpfsSAHError::Custom(format!( + "Expected to write {} metadata bytes but wrote {}.", + self.ap_body.byte_length(), + body_written + ))); + } + #[cfg(test)] + { + let mut remaining = self.association_failures_after_body.lock(); + if *remaining > 0 { + *remaining -= 1; + return Err(OpfsSAHError::Custom( + "injected metadata failure after body write".into(), + )); + } + } + let digest_written = sah + .write_with_buffer_source_and_options( + &digest, + &read_write_options(HEADER_OFFSET_DIGEST as f64), + ) + .map_err(OpfsSAHError::Write)?; + if digest_written != digest.byte_length() as f64 { + return Err(OpfsSAHError::Custom(format!( + "Expected to write {} digest bytes but wrote {}.", + digest.byte_length(), + digest_written + ))); + } sah.flush().map_err(OpfsSAHError::Flush)?; if path.is_empty() { @@ -474,22 +671,64 @@ impl OpfsSAHPool { } } + fn is_path_open(&self, path: &str) -> Result { + for file in self.map_s3_file_to_o_file.values().into_iter().flatten() { + if paths_overlap(path, &FileObject::new(file.into())?.path)? { + return Ok(true); + } + } + Ok(false) + } + + fn unique_temp_path(&self) -> Result { + for _ in 0..100 { + let path = new_path(&format!("/.sqlite-temp-{}", get_random_name()))?; + if !self.is_path_claimed(&path)? { + return Ok(path); + } + } + Err(OpfsSAHError::Custom( + "Could not allocate a unique temporary database path.".into(), + )) + } + /// Removes the association of the given client-specified file /// name (JS string) from the pool. Returns true if a mapping /// is found, else false. fn delete_path(&self, path: &str) -> Result { - let sah = self.map_filename_to_sah.get(&JsValue::from(path)); - let found = !sah.is_undefined(); - if found { - let sah: FileSystemSyncAccessHandle = sah.into(); - self.map_filename_to_sah.delete(&JsValue::from(path)); - self.set_associated_path(&sah, "", 0)?; + self.delete_path_with_mode(path, false) + } + + fn delete_sqlite_path(&self, path: &str) -> Result { + self.delete_path_with_mode(path, true) + } + + fn delete_path_with_mode(&self, path: &str, sqlite: bool) -> Result { + if self.is_path_reservation_claimed(path)? { + return Err(OpfsSAHError::Custom(format!( + "Path is reserved and cannot be unlinked: {path}" + ))); + } + let resolved = if sqlite { + self.resolve_sqlite_path(path)? + } else { + self.resolve_utility_path(path)? + }; + let Some((stored_path, sah)) = resolved else { + return Ok(false); + }; + if self.is_path_open(path)? { + return Err(OpfsSAHError::Custom(format!( + "Path is open and cannot be unlinked: {path}" + ))); } - Ok(found) + self.set_associated_path(&sah, "", 0)?; + self.map_filename_to_sah.delete(&JsValue::from(stored_path)); + Ok(true) } - /// All "../" parts and duplicate slashes are resolve/removed from - /// the returned result. + /// Reads the original SQLite filename. Identity resolution deliberately + /// happens later so parent WHATWG-URL behavior can use the raw spelling. fn get_path(&self, name: *const ::std::os::raw::c_char) -> Result { if name.is_null() { return Err(OpfsSAHError::Custom("name is null ptr".into())); @@ -499,22 +738,112 @@ impl OpfsSAHPool { .to_str() .map_err(|e| OpfsSAHError::Custom(format!("{e:?}")))? }; - Url::new_with_base(name, "file://localhost/") - .map(|x| x.pathname()) - .map_err(OpfsSAHError::GetPath) + validate_path_input(name)?; + Ok(name.to_owned()) + } + + fn resolve_candidates( + &self, + candidates: impl IntoIterator, + ) -> Result, OpfsSAHError> { + for candidate in candidates { + let sah = self.map_filename_to_sah.get(&JsValue::from(&candidate)); + if !sah.is_undefined() { + return Ok(Some((candidate, FileSystemSyncAccessHandle::from(sah)))); + } + } + Ok(None) + } + + fn resolve_utility_path( + &self, + raw: &str, + ) -> Result, OpfsSAHError> { + validate_path_input(raw)?; + let exact = self.map_filename_to_sah.get(&JsValue::from(raw)); + if !exact.is_undefined() { + return Ok(Some(( + raw.to_owned(), + FileSystemSyncAccessHandle::from(exact), + ))); + } + let mut candidates = Vec::new(); + if let Ok(literal) = canonicalize_path(raw) { + candidates.push(literal); + } + let legacy = parent_url_path(raw)?; + if !candidates.contains(&legacy) { + candidates.push(legacy.clone()); + } + if let Ok(canonical_legacy) = canonicalize_path(&legacy) { + if !candidates.contains(&canonical_legacy) { + candidates.push(canonical_legacy); + } + } + self.resolve_candidates(candidates) + } + + fn resolve_sqlite_path( + &self, + raw: &str, + ) -> Result, OpfsSAHError> { + validate_path_input(raw)?; + let legacy = parent_url_path(raw)?; + let mut candidates = vec![legacy.clone()]; + if let Ok(canonical_legacy) = canonicalize_path(&legacy) { + if !candidates.contains(&canonical_legacy) { + candidates.push(canonical_legacy); + } + } + if let Ok(literal) = canonicalize_path(raw) { + if !candidates.contains(&literal) { + candidates.push(literal); + } + } + if !candidates.iter().any(|path| path == raw) { + candidates.push(raw.to_owned()); + } + self.resolve_candidates(candidates) + } + + fn any_matching_key(&self, keys: &Set, path: &str) -> Result { + for key in keys.keys().into_iter().flatten() { + if paths_overlap(path, &key.as_string().unwrap())? { + return Ok(true); + } + } + Ok(false) + } + + fn is_path_claimed(&self, path: &str) -> Result { + for key in self.map_filename_to_sah.keys().into_iter().flatten() { + if paths_overlap(path, &key.as_string().unwrap())? { + return Ok(true); + } + } + self.any_matching_key(&self.reserved_paths, path) + } + + fn is_path_reservation_claimed(&self, path: &str) -> Result { + self.any_matching_key(&self.reserved_paths, path) + } + + fn reserve_path(&self, path: &str) -> Result<(), OpfsSAHError> { + if self.is_path_claimed(path)? { + return Err(OpfsSAHError::Custom(format!( + "Destination path already exists or is reserved: {path}" + ))); + } + self.reserved_paths.add(&JsValue::from(path)); + Ok(()) } - /// Returns true if the given client-defined file name is in this - /// object's name-to-SAH map. - fn has_filename(&self, name: &str) -> bool { - self.map_filename_to_sah.has(&JsValue::from(name)) + fn release_path(&self, path: &str) { + self.reserved_paths.delete(&JsValue::from(path)); } - /// Returns the SAH associated with the given - /// client-defined file name. - fn get_sah_for_path(&self, path: &str) -> Option { - self.has_filename(path) - .then(|| self.map_filename_to_sah.get(&JsValue::from(path)).into()) + fn quarantine_path(&self, path: &str) { + self.reserved_paths.add(&JsValue::from(path)); } /// Returns the next available SAH without removing @@ -529,11 +858,9 @@ impl OpfsSAHPool { } fn export_file(&self, name: &str) -> Result, OpfsSAHError> { - let sah = self.map_filename_to_sah.get(&JsValue::from(name)); - if sah.is_undefined() { + let Some((_, sah)) = self.resolve_utility_path(name)? else { return Err(OpfsSAHError::Custom("File not found:".into())); - } - let sah = FileSystemSyncAccessHandle::from(sah); + }; let n = sah.get_size().map_err(OpfsSAHError::GetSize)? - HEADER_OFFSET_DATA as f64; let n = n.max(0.0) as usize; let mut data = vec![0; n]; @@ -554,48 +881,112 @@ impl OpfsSAHPool { Ok(data) } - fn import_db(&self, path: &str, bytes: &[u8]) -> Result<(), OpfsSAHError> { - const HEADER: &str = "SQLite format 3"; + fn import_db(&self, path: &str, reservation: &str, bytes: &[u8]) -> Result<(), OpfsSAHError> { + let page_size = sqlite_page_size(bytes)?; + let length = u64::try_from(bytes.len()) + .map_err(|_| OpfsSAHError::Custom("SQLite database is too large to import.".into()))?; + validate_database_length(length, page_size)?; + checked_data_offset(length)?; - let sah = self.map_filename_to_sah.get(&JsValue::from(path)); - let sah = if sah.is_undefined() { - self.next_available_sah() - .ok_or_else(|| OpfsSAHError::Custom("No available handles to import to.".into()))? - } else { - FileSystemSyncAccessHandle::from(sah) + self.reserve_path(reservation)?; + let sah = match self.next_available_sah() { + Some(sah) => sah, + None => { + self.release_path(reservation); + return Err(OpfsSAHError::Custom( + "No available handles to import to.".into(), + )); + } }; - let length = bytes.len(); - if length < 512 && length % 512 != 0 { - return Err(OpfsSAHError::Custom( - "Byte array size is invalid for an SQLite db.".into(), - )); - } - if HEADER.as_bytes().iter().zip(bytes).any(|(x, y)| x != y) { - return Err(OpfsSAHError::Custom( - "Input does not contain an SQLite database header.".into(), - )); - } - let write = sah - .write_with_u8_array_and_options(bytes, &read_write_options(HEADER_OFFSET_DATA as f64)) - .map_err(OpfsSAHError::Write)?; - if write != length as f64 { - self.set_associated_path(&sah, "", 0)?; - return Err(OpfsSAHError::Custom(format!( - "Expected to write {} bytes but wrote {}.", - length, write - ))); + self.available_sah.delete(&sah); + let result = (|| { + sah.truncate_with_u32(HEADER_OFFSET_DATA as u32) + .map_err(OpfsSAHError::Truncate)?; + let write = sah + .write_with_u8_array_and_options( + bytes, + &read_write_options(HEADER_OFFSET_DATA as f64), + ) + .map_err(OpfsSAHError::Write)?; + if write != length as f64 { + return Err(OpfsSAHError::Custom(format!( + "Expected to write {} bytes but wrote {}.", + length, write + ))); + } + + let journal_mode = [1, 1]; + let written = sah + .write_with_u8_array_and_options( + &journal_mode, + &read_write_options((HEADER_OFFSET_DATA + 18) as f64), + ) + .map_err(OpfsSAHError::Write)?; + if written != journal_mode.len() as f64 { + return Err(OpfsSAHError::Custom(format!( + "Expected to write {} journal-mode bytes but wrote {}.", + journal_mode.len(), + written + ))); + } + self.set_associated_path(&sah, path, SQLITE_OPEN_MAIN_DB) + })(); + + if let Err(primary) = result { + return match self.set_associated_path(&sah, "", 0) { + Ok(()) => { + self.release_path(reservation); + Err(primary) + } + Err(cleanup) => Err(combined_error(primary, cleanup)), + }; } + self.release_path(reservation); + Ok(()) + } +} - let bytes = [1, 1]; - sah.write_with_u8_array_and_options( - &bytes, - &read_write_options((HEADER_OFFSET_DATA + 18) as f64), - ) - .map_err(OpfsSAHError::Write)?; - self.set_associated_path(&sah, path, SQLITE_OPEN_MAIN_DB)?; +fn sqlite_page_size(header: &[u8]) -> Result { + if header.len() < SQLITE_HEADER_SIZE || &header[..16] != b"SQLite format 3\0" { + return Err(OpfsSAHError::Custom( + "Input does not contain a valid SQLite database header.".into(), + )); + } - Ok(()) + let encoded = u16::from_be_bytes([header[16], header[17]]); + let page_size = if encoded == 1 { + 65_536 + } else { + u64::from(encoded) + }; + if page_size != 65_536 && (!(512..=32_768).contains(&page_size) || !page_size.is_power_of_two()) + { + return Err(OpfsSAHError::Custom(format!( + "Invalid SQLite database page size: {page_size}." + ))); + } + Ok(page_size) +} + +fn validate_database_length(length: u64, page_size: u64) -> Result<(), OpfsSAHError> { + if length < page_size || length % page_size != 0 { + return Err(OpfsSAHError::Custom(format!( + "SQLite database size {length} is not a positive multiple of its {page_size}-byte page size." + ))); } + Ok(()) +} + +fn checked_data_offset(length: u64) -> Result { + let offset = (HEADER_OFFSET_DATA as u64) + .checked_add(length) + .ok_or_else(|| OpfsSAHError::Custom("SQLite database offset overflow.".into()))?; + if offset > JS_MAX_SAFE_INTEGER { + return Err(OpfsSAHError::Custom( + "SQLite database offset exceeds JavaScript's safe integer range.".into(), + )); + } + Ok(offset as f64) } unsafe extern "C" fn xCheckReservedLock( @@ -650,10 +1041,20 @@ unsafe extern "C" fn xFileSize( ) -> ::std::os::raw::c_int { let vfs = file2vfs(pFile); let pool = pool(vfs); + pool.pop_err(); - if let Ok(file) = pool.get_o_file_for_s3_file(pFile) { - let size = file.sah.get_size().unwrap() as i64 - HEADER_OFFSET_DATA as i64; - *pSize = size; + let result = pool.get_o_file_for_s3_file(pFile).and_then(|file| { + let size = file.sah.get_size().map_err(OpfsSAHError::GetSize)?; + if size < HEADER_OFFSET_DATA as f64 { + return Err(OpfsSAHError::Custom( + "OPFS file is smaller than its metadata header".into(), + )); + } + *pSize = size as i64 - HEADER_OFFSET_DATA as i64; + Ok(()) + }); + if let Err(error) = result { + return pool.store_err(&error, Some(SQLITE_IOERR_FSTAT)); } SQLITE_OK } @@ -795,9 +1196,12 @@ unsafe extern "C" fn xAccess( let pool = pool(pVfs); pool.pop_err(); - *pResOut = match pool.get_path(zName) { - Ok(s) => i32::from(pool.has_filename(&s)), - Err(_) => 0, + *pResOut = match pool + .get_path(zName) + .and_then(|path| pool.resolve_sqlite_path(&path)) + { + Ok(path) => i32::from(path.is_some()), + Err(error) => return pool.store_err(&error, Some(SQLITE_CANTOPEN)), }; SQLITE_OK @@ -811,7 +1215,10 @@ unsafe extern "C" fn xDelete( let pool = pool(pVfs); pool.pop_err(); - if let Err(e) = pool.get_path(zName).map(|name| pool.delete_path(&name)) { + if let Err(e) = pool + .get_path(zName) + .and_then(|name| pool.delete_sqlite_path(&name)) + { return pool.store_err(&e, Some(SQLITE_IOERR_DELETE)); } @@ -824,7 +1231,18 @@ unsafe extern "C" fn xFullPathname( nOut: ::std::os::raw::c_int, zOut: *mut ::std::os::raw::c_char, ) -> ::std::os::raw::c_int { - zName.copy_to(zOut, nOut as usize); + if zName.is_null() || zOut.is_null() || nOut <= 0 { + return SQLITE_CANTOPEN; + } + let path = CStr::from_ptr(zName); + let bytes = path.to_bytes_with_nul(); + if bytes.len() > nOut as usize { + return SQLITE_CANTOPEN; + } + bytes + .as_ptr() + .cast::<::std::os::raw::c_char>() + .copy_to_nonoverlapping(zOut, bytes.len()); SQLITE_OK } @@ -861,16 +1279,36 @@ unsafe extern "C" fn xOpen( let pool = pool(pVfs); let f = || { - let name = pool.get_path(zName)?; - let sah = match pool.get_sah_for_path(&name) { - Some(sah) => sah, + let name = if zName.is_null() { + pool.unique_temp_path()? + } else { + pool.get_path(zName)? + }; + if pool.is_path_reservation_claimed(&name)? { + return Err(OpfsSAHError::Custom(format!( + "file is reserved by an active import: {name}" + ))); + } + let (stored_name, sah) = match pool.resolve_sqlite_path(&name)? { + Some((stored_name, sah)) => (stored_name, sah), None => { if flags & SQLITE_OPEN_CREATE == 0 { return Err(OpfsSAHError::Custom(format!("file not found: {name}"))); } + if pool.is_path_claimed(&name)? { + return Err(OpfsSAHError::Custom(format!( + "A compatible database path already exists: {name}" + ))); + } + let stored_name = parent_url_path(&name)?; + if stored_name.len() >= HEADER_MAX_PATH_SIZE { + return Err(OpfsSAHError::Custom(format!( + "Path too long: {stored_name}" + ))); + } if let Some(sah) = pool.next_available_sah() { - pool.set_associated_path(&sah, &name, flags)?; - sah + pool.set_associated_path(&sah, &stored_name, flags)?; + (stored_name, sah) } else { return Err(OpfsSAHError::Custom( "SAH pool is full. Cannot create file".into(), @@ -879,7 +1317,7 @@ unsafe extern "C" fn xOpen( } }; let file = Object::new(); - Reflect::set(&file, &JsValue::from("path"), &JsValue::from(name)).unwrap(); + Reflect::set(&file, &JsValue::from("path"), &JsValue::from(stored_name)).unwrap(); Reflect::set(&file, &JsValue::from("flags"), &JsValue::from(flags)).unwrap(); Reflect::set(&file, &JsValue::from("sah"), &JsValue::from(sah)).unwrap(); pool.map_s3_file_to_o_file(pFile, Some(file)); @@ -1081,6 +1519,165 @@ pub struct OpfsSAHPoolUtil { pool: Arc>, } +/// Incremental SQLite database importer for the OPFS SAH pool. +/// +/// This keeps only the caller's current chunk in memory and writes it directly +/// into the pool slot. The database becomes visible under `path` only after +/// [`finish`](Self::finish) validates and commits the import. +pub struct OpfsSAHPoolImport { + pool: Arc>, + sah: FileSystemSyncAccessHandle, + path: String, + reservation: String, + length: u64, + header: Vec, + active: bool, +} + +impl OpfsSAHPoolImport { + fn cleanup_once(&mut self) -> Result<(), OpfsSAHError> { + if !self.active { + return Ok(()); + } + self.active = false; + self.pool.set_associated_path(&self.sah, "", 0)?; + self.pool.release_path(&self.reservation); + Ok(()) + } + + fn fail(&mut self, error: OpfsSAHError) -> Result { + match self.cleanup_once() { + Ok(()) => Err(error), + Err(cleanup) => Err(combined_error(error, cleanup)), + } + } + + /// Append one decompressed database-file chunk. + pub fn write_chunk(&mut self, bytes: &[u8]) -> Result<(), OpfsSAHError> { + if !self.active { + return Err(OpfsSAHError::Custom( + "Cannot write to a finished database import.".into(), + )); + } + if bytes.is_empty() { + return Ok(()); + } + + if self.header.len() < SQLITE_HEADER_SIZE { + let needed = SQLITE_HEADER_SIZE - self.header.len(); + self.header + .extend_from_slice(&bytes[..bytes.len().min(needed)]); + if self.header.len() == SQLITE_HEADER_SIZE { + if let Err(error) = sqlite_page_size(&self.header) { + return self.fail(error); + } + } + } + + let chunk_length = match u64::try_from(bytes.len()) { + Ok(length) => length, + Err(_) => { + return self.fail(OpfsSAHError::Custom( + "SQLite database chunk is too large.".into(), + )) + } + }; + let next_length = match self.length.checked_add(chunk_length) { + Some(length) => length, + None => { + return self.fail(OpfsSAHError::Custom( + "SQLite database size overflow.".into(), + )) + } + }; + if let Err(error) = checked_data_offset(next_length) { + return self.fail(error); + } + let offset = match checked_data_offset(self.length) { + Ok(offset) => offset, + Err(error) => return self.fail(error), + }; + let write = match self + .sah + .write_with_u8_array_and_options(bytes, &read_write_options(offset)) + { + Ok(write) => write, + Err(error) => return self.fail(OpfsSAHError::Write(error)), + }; + if write != bytes.len() as f64 { + return self.fail(OpfsSAHError::Custom(format!( + "Expected to write {} bytes but wrote {}.", + bytes.len(), + write + ))); + } + self.length = next_length; + Ok(()) + } + + /// Validate and publish the imported database under its requested path. + pub fn finish(mut self) -> Result { + if !self.active { + return Err(OpfsSAHError::Custom( + "Cannot finish an inactive database import.".into(), + )); + } + let page_size = match sqlite_page_size(&self.header) { + Ok(page_size) => page_size, + Err(error) => return self.fail(error), + }; + if let Err(error) = validate_database_length(self.length, page_size) { + return self.fail(error); + } + + let truncate_length = match checked_data_offset(self.length) { + Ok(length) => length, + Err(error) => return self.fail(error), + }; + if let Err(error) = self.sah.truncate_with_f64(truncate_length) { + return self.fail(OpfsSAHError::Truncate(error)); + } + let journal_mode = [1, 1]; + let written = match self.sah.write_with_u8_array_and_options( + &journal_mode, + &read_write_options((HEADER_OFFSET_DATA + 18) as f64), + ) { + Ok(written) => written, + Err(error) => return self.fail(OpfsSAHError::Write(error)), + }; + if written != journal_mode.len() as f64 { + return self.fail(OpfsSAHError::Custom(format!( + "Expected to write {} journal-mode bytes but wrote {}.", + journal_mode.len(), + written + ))); + } + if let Err(error) = self.sah.flush() { + return self.fail(OpfsSAHError::Flush(error)); + } + if let Err(error) = + self.pool + .set_associated_path(&self.sah, &self.path, SQLITE_OPEN_MAIN_DB) + { + return self.fail(error); + } + self.active = false; + self.pool.release_path(&self.reservation); + Ok(self.length) + } + + /// Discard a partial import and return its pool slot for reuse. + pub fn abort(mut self) -> Result<(), OpfsSAHError> { + self.cleanup_once() + } +} + +impl Drop for OpfsSAHPoolImport { + fn drop(&mut self) { + let _ = self.cleanup_once(); + } +} + impl OpfsSAHPoolUtil { /// Adds n entries to the current pool. pub async fn add_capacity(&self, n: u32) -> Result { @@ -1108,6 +1705,64 @@ impl OpfsSAHPoolUtil { self.pool.get_file_names() } + /// Returns whether a logical path is currently associated with a pool + /// entry. + pub fn has_path(&self, path: &str) -> bool { + self.pool + .resolve_utility_path(path) + .is_ok_and(|path| path.is_some()) + } + + /// Reassociate a closed logical path with a new logical path without + /// copying its database bytes. The source must not be open through SQLite. + pub fn rename_path(&self, from: &str, to: &str) -> Result<(), OpfsSAHError> { + if from.is_empty() || to.is_empty() { + return Err(OpfsSAHError::Custom( + "Source and destination paths must not be empty.".into(), + )); + } + let to_path = new_utility_path(to)?; + + let Some((stored_from, sah)) = self.pool.resolve_utility_path(from)? else { + return Err(OpfsSAHError::Custom(format!( + "Source path does not exist: {from}" + ))); + }; + if self.pool.is_path_open(from)? { + return Err(OpfsSAHError::Custom(format!( + "Source path is open and cannot be renamed: {from}" + ))); + } + self.pool.reserve_path(to)?; + if let Err(error) = self + .pool + .set_associated_path(&sah, &to_path, SQLITE_OPEN_MAIN_DB) + { + return match self.pool.restore_legacy_associated_path( + &sah, + &stored_from, + SQLITE_OPEN_MAIN_DB, + ) { + Ok(()) => { + self.pool.release_path(to); + Err(error) + } + Err(rollback) => { + self.pool.quarantine_path(&stored_from); + self.pool + .map_filename_to_sah + .delete(&JsValue::from(&stored_from)); + Err(combined_error(error, rollback)) + } + }; + } + self.pool + .map_filename_to_sah + .delete(&JsValue::from(stored_from)); + self.pool.release_path(to); + Ok(()) + } + /// Removes up to n entries from the pool, with the caveat that it can only /// remove currently-unused entries. pub async fn reserve_minimum_capacity(&self, min: u32) -> Result<(), OpfsSAHError> { @@ -1129,20 +1784,61 @@ impl OpfsSAHPoolUtil { self.pool.export_file(name) } - /// Imports the contents of an SQLite database, provided as a byte array or ArrayBuffer, - /// under the given name, overwriting any existing content. - /// - /// path must start with '/' + /// Imports an SQLite database into a new logical path. + /// Existing or actively-imported destinations are rejected. pub fn import_db(&self, path: &str, bytes: &[u8]) -> Result<(), OpfsSAHError> { - if !path.starts_with('/') { - return Err(OpfsSAHError::Custom("path must start with '/'".into())); + let stored_path = new_utility_path(path)?; + self.pool.import_db(&stored_path, path, bytes) + } + + /// Begin a bounded-memory import into an unused pool slot. + /// Existing destinations must be activated separately after this import + /// has finished so a failed stream cannot destroy the current database. + /// The target database must not be open while the import is active. + pub fn begin_import_db(&self, path: &str) -> Result { + let stored_path = new_utility_path(path)?; + self.pool.reserve_path(path)?; + + let sah = match self.pool.next_available_sah() { + Some(sah) => sah, + None => { + self.pool.release_path(path); + return Err(OpfsSAHError::Custom( + "No available handles to import to.".into(), + )); + } + }; + self.pool.available_sah.delete(&sah); + if let Err(error) = sah.truncate_with_u32(HEADER_OFFSET_DATA as u32) { + self.pool.available_sah.add(&sah); + self.pool.release_path(path); + return Err(OpfsSAHError::Truncate(error)); } - self.pool.import_db(path, bytes) + + Ok(OpfsSAHPoolImport { + pool: Arc::clone(&self.pool), + sah, + path: stored_path, + reservation: path.to_owned(), + length: 0, + header: Vec::with_capacity(SQLITE_HEADER_SIZE), + active: true, + }) } /// Clears all client-defined state of all SAHs and makes all of them available /// for re-use by the pool. pub async fn wipe_files(&self) -> Result<(), OpfsSAHError> { + if self.pool.reserved_paths.size() != 0 { + return Err(OpfsSAHError::Custom( + "Cannot wipe files while a database import is active.".into(), + )); + } + if self.pool.map_s3_file_to_o_file.size() != 0 { + return Err(OpfsSAHError::Custom( + "Cannot wipe files while SQLite files are open.".into(), + )); + } self.pool.release_access_handles(); self.pool.acquire_access_handles(true).await?; Ok(()) @@ -1201,3 +1897,745 @@ pub async fn install_opfs_sahpool( Ok(util) } + +#[cfg(test)] +mod tests { + use super::*; + use wasm_bindgen_test::wasm_bindgen_test; + + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_worker); + + const UNSTABLE_UTILITY_PATHS: &[&str] = &[ + "snapshot:v1.db", + "snapshot:/v1.db", + "https:orders.db", + "https://example.com/orders.db", + "file:orders.db", + "file:/orders.db", + "//host/v1.db", + " snapshot:/v1.db", + "snapshot:/v1.db ", + "\u{0001}snapshot:/v1.db", + "snap\tshot:/v1.db", + "snap\rshot:/v1.db", + "snap\nshot:/v1.db", + "\t//host/v1.db", + r"\\host\v1.db", + r"file:\host\v1.db", + ]; + + fn sqlite_header(encoded_page_size: u16) -> [u8; SQLITE_HEADER_SIZE] { + let mut header = [0; SQLITE_HEADER_SIZE]; + header[..16].copy_from_slice(b"SQLite format 3\0"); + header[16..18].copy_from_slice(&encoded_page_size.to_be_bytes()); + header + } + + #[wasm_bindgen_test] + fn parses_supported_sqlite_page_sizes() { + assert_eq!(sqlite_page_size(&sqlite_header(512)).unwrap(), 512); + assert_eq!(sqlite_page_size(&sqlite_header(4096)).unwrap(), 4096); + assert_eq!(sqlite_page_size(&sqlite_header(1)).unwrap(), 65_536); + } + + #[wasm_bindgen_test] + fn rejects_invalid_sqlite_page_sizes_and_lengths() { + assert!(sqlite_page_size(&sqlite_header(0)).is_err()); + assert!(sqlite_page_size(&sqlite_header(1000)).is_err()); + assert!(validate_database_length(4097, 4096).is_err()); + assert!(validate_database_length(4096, 4096).is_ok()); + } + + #[wasm_bindgen_test] + fn rejects_offsets_outside_the_javascript_safe_integer_range() { + assert!(checked_data_offset(JS_MAX_SAFE_INTEGER - HEADER_OFFSET_DATA as u64).is_ok()); + assert!(checked_data_offset(JS_MAX_SAFE_INTEGER).is_err()); + assert!(checked_data_offset(u64::MAX).is_err()); + } + + #[wasm_bindgen_test] + fn canonical_paths_preserve_parent_format_names() { + assert_eq!( + canonicalize_path("café db.sqlite").unwrap(), + "/café db.sqlite" + ); + assert_eq!( + canonicalize_path("//folder/./child/../café db.sqlite").unwrap(), + "/folder/café db.sqlite" + ); + assert_eq!( + canonicalize_path(&canonicalize_path("../../café db.sqlite").unwrap()).unwrap(), + "/café db.sqlite" + ); + assert!(canonicalize_path("https://example.com/db").is_err()); + assert!(canonicalize_path("").is_err()); + assert!(canonicalize_path("bad\0path").is_err()); + assert_eq!( + path_identities("café db.sqlite").unwrap(), + vec![ + "café db.sqlite", + "/café db.sqlite", + "/caf%C3%A9%20db.sqlite" + ] + ); + assert_eq!( + parent_url_path("opfs-sahpool:orders.db").unwrap(), + "orders.db" + ); + assert!(paths_overlap("/café.db", "/caf%C3%A9.db").unwrap()); + for &unsafe_name in UNSTABLE_UTILITY_PATHS { + assert!(new_utility_path(unsafe_name).is_err(), "{unsafe_name}"); + } + assert_eq!( + new_utility_path("/snapshot:v1.db").unwrap(), + "/snapshot:v1.db" + ); + for stable_name in ["orders.db", "/snapshot:v1.db", "folder/../orders.db"] { + let published = new_utility_path(stable_name).unwrap(); + assert_eq!( + parent_url_path(stable_name).unwrap(), + parent_url_path(&published).unwrap() + ); + } + } + + fn sqlite_database_bytes() -> Vec { + let mut bytes = vec![0; 512]; + bytes[..16].copy_from_slice(b"SQLite format 3\0"); + bytes[16..18].copy_from_slice(&512u16.to_be_bytes()); + bytes[18] = 1; + bytes[19] = 1; + bytes + } + + fn open_test_database(config: &OpfsSAHPoolCfg, name: &str, flags: i32) -> *mut sqlite3 { + let name = CString::new(name).unwrap(); + let vfs_name = CString::new(config.vfs_name.as_str()).unwrap(); + let mut db = std::ptr::null_mut(); + let result = unsafe { sqlite3_open_v2(name.as_ptr(), &mut db, flags, vfs_name.as_ptr()) }; + assert_eq!(result, SQLITE_OK, "database {name:?} must open"); + db + } + + fn exec_test_sql(db: *mut sqlite3, sql: &str) { + let sql = CString::new(sql).unwrap(); + assert_eq!( + unsafe { + sqlite3_exec( + db, + sql.as_ptr(), + None, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + SQLITE_OK + ); + } + + fn query_test_integer(db: *mut sqlite3, sql: &str) -> i32 { + let sql = CString::new(sql).unwrap(); + let mut statement = std::ptr::null_mut(); + assert_eq!( + unsafe { + sqlite3_prepare_v2(db, sql.as_ptr(), -1, &mut statement, std::ptr::null_mut()) + }, + SQLITE_OK + ); + assert_eq!(unsafe { sqlite3_step(statement) }, SQLITE_ROW); + let value = unsafe { sqlite3_column_int(statement, 0) }; + assert_eq!(unsafe { sqlite3_finalize(statement) }, SQLITE_OK); + value + } + + fn create_database_bytes( + util: &OpfsSAHPoolUtil, + config: &OpfsSAHPoolCfg, + path: &str, + value: i32, + ) -> Vec { + let db = open_test_database(config, path, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE); + exec_test_sql( + db, + &format!("CREATE TABLE snapshot_value(value INTEGER); INSERT INTO snapshot_value VALUES ({value})"), + ); + assert_eq!(unsafe { sqlite3_close(db) }, SQLITE_OK); + let bytes = util.export_file(path).unwrap(); + assert!(util.unlink(path).unwrap()); + bytes + } + + fn test_pool_config(capacity: u32) -> OpfsSAHPoolCfg { + let suffix = get_random_name(); + OpfsSAHPoolCfgBuilder::new() + .vfs_name(&format!("opfs-test-{suffix}")) + .directory(&format!(".opfs-test-{suffix}")) + .initial_capacity(capacity) + .build() + } + + fn rewrite_as_legacy_metadata(util: &OpfsSAHPoolUtil, current_path: &str, legacy_path: &str) { + let sah = util + .pool + .map_filename_to_sah + .get(&JsValue::from(current_path)); + assert!(!sah.is_undefined(), "seed database must exist"); + let sah = FileSystemSyncAccessHandle::from(sah); + + util.pool + .restore_legacy_associated_path(&sah, legacy_path, SQLITE_OPEN_MAIN_DB) + .expect("legacy path seed must be written"); + util.pool + .map_filename_to_sah + .delete(&JsValue::from(current_path)); + + // sqlite-wasm-rs 0.3.0 metadata used the same body with no V2 marker + // and a zero digest. Rewrite both pieces to exercise that exact format. + util.pool + .dv_body + .set_uint32(HEADER_OFFSET_FLAGS, SQLITE_OPEN_MAIN_DB as u32); + let digest = compute_digest(&util.pool.ap_body, SQLITE_OPEN_MAIN_DB as u32); + let body_written = sah + .write_with_js_u8_array_and_options(&util.pool.ap_body, &read_write_options(0.0)) + .expect("legacy metadata body write must succeed"); + assert_eq!(body_written, util.pool.ap_body.byte_length() as f64); + let digest_written = sah + .write_with_buffer_source_and_options( + &digest, + &read_write_options(HEADER_OFFSET_DIGEST as f64), + ) + .expect("legacy metadata digest write must succeed"); + assert_eq!(digest_written, digest.byte_length() as f64); + sah.flush().expect("legacy metadata must be durable"); + } + + async fn persisted_opaque_size(pool: &OpfsSAHPool, name: &str) -> f64 { + let options = FileSystemGetFileOptions::new(); + let handle: FileSystemFileHandle = + JsFuture::from(pool.dh_opaque.get_file_handle_with_options(name, &options)) + .await + .unwrap() + .into(); + let sah: FileSystemSyncAccessHandle = JsFuture::from(handle.create_sync_access_handle()) + .await + .unwrap() + .into(); + let size = sah.get_size().unwrap(); + sah.close(); + size + } + + async fn assert_short_metadata_reload_is_non_destructive(truncated_size: u32) { + let config = test_pool_config(2); + let util = install_opfs_sahpool(Some(&config), false).await.unwrap(); + util.import_db("legacy-seed.db", &sqlite_database_bytes()) + .unwrap(); + rewrite_as_legacy_metadata(&util, "/legacy-seed.db", "/legacy.db"); + let legacy_sah = FileSystemSyncAccessHandle::from( + util.pool + .map_filename_to_sah + .get(&JsValue::from("/legacy.db")), + ); + let legacy_name = util + .pool + .map_sah_to_name + .get(&legacy_sah) + .as_string() + .unwrap(); + let legacy_size = legacy_sah.get_size().unwrap(); + + let malformed = util.pool.next_available_sah().unwrap(); + let malformed_name = util + .pool + .map_sah_to_name + .get(&malformed) + .as_string() + .unwrap(); + malformed.truncate_with_u32(truncated_size).unwrap(); + malformed.flush().unwrap(); + + util.pool.release_access_handles(); + assert!(util.pool.acquire_access_handles(false).await.is_err()); + assert_eq!( + persisted_opaque_size(&util.pool, &malformed_name).await, + truncated_size as f64 + ); + assert_eq!( + persisted_opaque_size(&util.pool, &legacy_name).await, + legacy_size + ); + } + + #[wasm_bindgen_test] + async fn short_metadata_body_fails_reload_without_mutation() { + assert_short_metadata_reload_is_non_destructive((HEADER_CORPUS_SIZE - 1) as u32).await; + } + + #[wasm_bindgen_test] + async fn short_metadata_digest_fails_reload_without_mutation() { + assert_short_metadata_reload_is_non_destructive( + (HEADER_CORPUS_SIZE + HEADER_DIGEST_SIZE - 1) as u32, + ) + .await; + } + + #[wasm_bindgen_test] + async fn streaming_import_reserves_path_and_capacity_then_reuses_aborted_slot() { + let config = test_pool_config(1); + let util = install_opfs_sahpool(Some(&config), false) + .await + .expect("OPFS pool installation must succeed"); + + let first = util + .begin_import_db("snapshot.db") + .expect("first import must reserve a slot"); + assert!(util.begin_import_db("/snapshot.db").is_err()); + assert!(util.begin_import_db("other.db").is_err()); + + first.abort().expect("abort must durably return the slot"); + util.begin_import_db("other.db") + .expect("aborted slot must be reusable") + .abort() + .expect("second abort must succeed"); + } + + #[wasm_bindgen_test] + async fn parent_scheme_name_reopens_existing_database_without_allocating() { + let config = test_pool_config(2); + let util = install_opfs_sahpool(Some(&config), false) + .await + .expect("OPFS pool installation must succeed"); + let bytes = sqlite_database_bytes(); + util.import_db("seed.db", &bytes).unwrap(); + rewrite_as_legacy_metadata(&util, "/seed.db", "orders.db"); + util.pool.release_access_handles(); + util.pool.acquire_access_handles(false).await.unwrap(); + + let name = CString::new("opfs-sahpool:orders.db").unwrap(); + let vfs_name = CString::new(config.vfs_name.as_str()).unwrap(); + let mut db = std::ptr::null_mut(); + assert_eq!( + unsafe { + sqlite3_open_v2( + name.as_ptr(), + &mut db, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, + vfs_name.as_ptr(), + ) + }, + SQLITE_OK + ); + assert_eq!(unsafe { sqlite3_close(db) }, SQLITE_OK); + assert_eq!(util.get_file_count(), 1); + assert_eq!(util.export_file("opfs-sahpool:orders.db").unwrap(), bytes); + } + + #[wasm_bindgen_test] + async fn scheme_open_reads_canonical_utility_snapshot_after_reload() { + let config = test_pool_config(3); + let util = install_opfs_sahpool(Some(&config), false).await.unwrap(); + let bytes = create_database_bytes(&util, &config, "/source.db", 42); + util.import_db("/snapshot.db", &bytes).unwrap(); + util.pool.release_access_handles(); + util.pool.acquire_access_handles(false).await.unwrap(); + + let db = open_test_database( + &config, + "opfs-sahpool:snapshot.db", + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, + ); + assert_eq!( + query_test_integer(db, "SELECT value FROM snapshot_value"), + 42 + ); + assert_eq!(unsafe { sqlite3_close(db) }, SQLITE_OK); + assert_eq!(util.get_file_count(), 1); + } + + #[wasm_bindgen_test] + async fn scheme_open_prefers_legacy_exact_when_both_identities_exist() { + let config = test_pool_config(4); + let util = install_opfs_sahpool(Some(&config), false).await.unwrap(); + let canonical_bytes = create_database_bytes(&util, &config, "/canonical-seed.db", 22); + let legacy_bytes = create_database_bytes(&util, &config, "/legacy-source.db", 11); + util.import_db("/shared.db", &canonical_bytes).unwrap(); + util.import_db("/legacy-seed.db", &legacy_bytes).unwrap(); + rewrite_as_legacy_metadata(&util, "/legacy-seed.db", "shared.db"); + util.pool.release_access_handles(); + util.pool.acquire_access_handles(false).await.unwrap(); + + let legacy = open_test_database( + &config, + "opfs-sahpool:shared.db", + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, + ); + assert_eq!( + query_test_integer(legacy, "SELECT value FROM snapshot_value"), + 11 + ); + assert_eq!(unsafe { sqlite3_close(legacy) }, SQLITE_OK); + + let canonical = open_test_database(&config, "/shared.db", SQLITE_OPEN_READWRITE); + assert_eq!( + query_test_integer(canonical, "SELECT value FROM snapshot_value"), + 22 + ); + assert_eq!(unsafe { sqlite3_close(canonical) }, SQLITE_OK); + assert_eq!(util.get_file_count(), 2); + } + + #[wasm_bindgen_test] + async fn compatible_reservations_and_published_paths_block_bypass_spellings() { + let config = test_pool_config(2); + let util = install_opfs_sahpool(Some(&config), false) + .await + .expect("OPFS pool installation must succeed"); + let pending = util.begin_import_db("/caf%C3%A9.db").unwrap(); + let name = CString::new("/café.db").unwrap(); + let vfs_name = CString::new(config.vfs_name.as_str()).unwrap(); + let mut db = std::ptr::null_mut(); + assert_ne!( + unsafe { + sqlite3_open_v2( + name.as_ptr(), + &mut db, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, + vfs_name.as_ptr(), + ) + }, + SQLITE_OK + ); + if !db.is_null() { + unsafe { sqlite3_close(db) }; + } + assert_eq!(util.get_file_count(), 0); + pending.abort().unwrap(); + + let bytes = sqlite_database_bytes(); + util.import_db("café.db", &bytes).unwrap(); + assert!(util.import_db("caf%C3%A9.db", &bytes).is_err()); + assert!(util.rename_path("café.db", "caf%C3%A9.db").is_err()); + assert_eq!(util.get_file_count(), 1); + assert_eq!(util.export_file("café.db").unwrap(), bytes); + } + + #[wasm_bindgen_test] + async fn parent_maximum_length_path_can_be_exported_and_migrated() { + let config = test_pool_config(1); + let util = install_opfs_sahpool(Some(&config), false).await.unwrap(); + let bytes = sqlite_database_bytes(); + util.import_db("seed.db", &bytes).unwrap(); + let legacy_path = format!("/{}", "a".repeat(HEADER_MAX_PATH_SIZE - 1)); + assert_eq!(legacy_path.len(), HEADER_MAX_PATH_SIZE); + rewrite_as_legacy_metadata(&util, "/seed.db", &legacy_path); + util.pool.release_access_handles(); + util.pool.acquire_access_handles(false).await.unwrap(); + + assert_eq!(util.export_file(&legacy_path).unwrap(), bytes); + assert!(util.import_db(&legacy_path, &bytes).is_err()); + util.rename_path(&legacy_path, "migrated.db").unwrap(); + assert_eq!(util.export_file("migrated.db").unwrap(), bytes); + } + + #[wasm_bindgen_test] + async fn unicode_and_space_paths_round_trip_across_metadata_reload() { + let config = test_pool_config(2); + let util = install_opfs_sahpool(Some(&config), false) + .await + .expect("OPFS pool installation must succeed"); + let bytes = sqlite_database_bytes(); + + util.import_db("encoded-seed.db", &bytes) + .expect("relative unicode import must succeed"); + util.import_db("literal-seed.db", &bytes) + .expect("literal seed import must succeed"); + rewrite_as_legacy_metadata(&util, "/encoded-seed.db", "/caf%C3%A9%20legacy.db"); + rewrite_as_legacy_metadata(&util, "/literal-seed.db", "/literal legacy db.sqlite"); + + util.pool.release_access_handles(); + util.pool + .acquire_access_handles(false) + .await + .expect("metadata reload must succeed"); + + assert!(util.has_path("/café legacy.db")); + assert_eq!( + util.export_file("./café legacy.db").unwrap(), + bytes, + "legacy URL-normalized metadata must remain accessible by its original path" + ); + assert_eq!( + util.export_file("literal legacy db.sqlite").unwrap(), + bytes, + "legacy literal importer metadata must remain accessible" + ); + + let legacy_name = CString::new("/café legacy.db").unwrap(); + let vfs_name = CString::new(config.vfs_name.as_str()).unwrap(); + let mut db = std::ptr::null_mut(); + assert_eq!( + unsafe { + sqlite3_open_v2( + legacy_name.as_ptr(), + &mut db, + SQLITE_OPEN_READWRITE, + vfs_name.as_ptr(), + ) + }, + SQLITE_OK, + "SQLite must resolve the original literal name to URL-normalized legacy metadata" + ); + assert_eq!(unsafe { sqlite3_close(db) }, SQLITE_OK); + + assert!(util + .unlink("nested/../café legacy.db") + .expect("unlink must succeed")); + assert!(!util.has_path("/café legacy.db")); + assert!(util + .unlink("literal legacy db.sqlite") + .expect("literal unlink must succeed")); + } + + #[wasm_bindgen_test] + async fn distinct_legacy_alias_entries_remain_addressable_without_new_collisions() { + let config = test_pool_config(2); + let util = install_opfs_sahpool(Some(&config), false) + .await + .expect("OPFS pool installation must succeed"); + let mut encoded_bytes = sqlite_database_bytes(); + encoded_bytes[100] = 17; + let mut literal_bytes = sqlite_database_bytes(); + literal_bytes[100] = 29; + util.import_db("encoded-seed.db", &encoded_bytes).unwrap(); + util.import_db("literal-seed.db", &literal_bytes).unwrap(); + rewrite_as_legacy_metadata(&util, "/encoded-seed.db", "/caf%C3%A9%20db.sqlite"); + rewrite_as_legacy_metadata(&util, "/literal-seed.db", "/café db.sqlite"); + + util.pool.release_access_handles(); + util.pool + .acquire_access_handles(false) + .await + .expect("both legacy metadata entries must reload"); + + assert_eq!(util.export_file("café db.sqlite").unwrap(), literal_bytes); + assert_eq!( + util.export_file("caf%C3%A9%20db.sqlite").unwrap(), + encoded_bytes + ); + assert_eq!(util.get_file_count(), 2); + assert!(util.begin_import_db("café db.sqlite").is_err()); + assert!(util.begin_import_db("caf%C3%A9%20db.sqlite").is_err()); + } + + #[wasm_bindgen_test] + async fn exact_parent_dot_segment_names_select_the_requested_database() { + let config = test_pool_config(2); + let util = install_opfs_sahpool(Some(&config), false).await.unwrap(); + let mut exact_bytes = sqlite_database_bytes(); + exact_bytes[100] = 41; + let mut normalized_bytes = sqlite_database_bytes(); + normalized_bytes[100] = 73; + util.import_db("exact-seed.db", &exact_bytes).unwrap(); + util.import_db("normalized-seed.db", &normalized_bytes) + .unwrap(); + rewrite_as_legacy_metadata(&util, "/exact-seed.db", "/dir/../db.sqlite"); + rewrite_as_legacy_metadata(&util, "/normalized-seed.db", "/db.sqlite"); + util.pool.release_access_handles(); + util.pool.acquire_access_handles(false).await.unwrap(); + + assert_eq!(util.export_file("/dir/../db.sqlite").unwrap(), exact_bytes); + assert_eq!(util.export_file("/db.sqlite").unwrap(), normalized_bytes); + util.rename_path("/dir/../db.sqlite", "/migrated.db") + .unwrap(); + assert_eq!(util.export_file("/migrated.db").unwrap(), exact_bytes); + assert_eq!(util.export_file("/db.sqlite").unwrap(), normalized_bytes); + assert!(util.unlink("/db.sqlite").unwrap()); + assert_eq!(util.export_file("/migrated.db").unwrap(), exact_bytes); + } + + #[wasm_bindgen_test] + async fn utility_scheme_destinations_require_explicit_filesystem_paths() { + let config = test_pool_config(3); + let util = install_opfs_sahpool(Some(&config), false).await.unwrap(); + let bytes = sqlite_database_bytes(); + + util.import_db("source.db", &bytes).unwrap(); + for &unsafe_name in UNSTABLE_UTILITY_PATHS { + assert!( + util.import_db(unsafe_name, &bytes).is_err(), + "{unsafe_name}" + ); + assert!(util.begin_import_db(unsafe_name).is_err(), "{unsafe_name}"); + assert!( + util.rename_path("source.db", unsafe_name).is_err(), + "{unsafe_name}" + ); + } + assert_eq!(util.export_file("source.db").unwrap(), bytes); + assert!(util.unlink("source.db").unwrap()); + + util.import_db("/snapshot:v1.db", &bytes).unwrap(); + assert!(util.import_db("other:v1.db", &bytes).is_err()); + assert!(util.begin_import_db("other:v1.db").is_err()); + + let other = CString::new("other:v1.db").unwrap(); + let vfs_name = CString::new(config.vfs_name.as_str()).unwrap(); + let mut db = std::ptr::null_mut(); + assert_eq!( + unsafe { + sqlite3_open_v2( + other.as_ptr(), + &mut db, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, + vfs_name.as_ptr(), + ) + }, + SQLITE_OK + ); + assert_eq!(unsafe { sqlite3_close(db) }, SQLITE_OK); + assert_eq!(util.export_file("/snapshot:v1.db").unwrap(), bytes); + + util.pool.release_access_handles(); + util.pool.acquire_access_handles(false).await.unwrap(); + assert_eq!(util.export_file("/snapshot:v1.db").unwrap(), bytes); + assert!(util.import_db("other:v1.db", &bytes).is_err()); + assert_eq!(util.get_file_count(), 2); + } + + #[wasm_bindgen_test] + async fn corrupt_destructive_flags_do_not_modify_database_bytes() { + let config = test_pool_config(1); + let util = install_opfs_sahpool(Some(&config), false) + .await + .expect("OPFS pool installation must succeed"); + let bytes = sqlite_database_bytes(); + util.import_db("protected.db", &bytes).unwrap(); + let sah = FileSystemSyncAccessHandle::from( + util.pool + .map_filename_to_sah + .get(&JsValue::from("/protected.db")), + ); + let size_before = sah.get_size().unwrap(); + + sah.read_with_buffer_source_and_options(&util.pool.ap_body, &read_write_options(0.0)) + .unwrap(); + util.pool + .dv_body + .set_uint32(HEADER_OFFSET_FLAGS, SQLITE_OPEN_DELETEONCLOSE as u32); + sah.write_with_js_u8_array_and_options(&util.pool.ap_body, &read_write_options(0.0)) + .unwrap(); + + assert!(util.pool.get_associated_path(&sah).is_err()); + assert_eq!(sah.get_size().unwrap(), size_before); + assert_eq!(util.export_file("protected.db").unwrap(), bytes); + + util.pool + .set_associated_path(&sah, "/protected.db", SQLITE_OPEN_MAIN_DB) + .expect("test metadata restoration must succeed"); + } + + #[wasm_bindgen_test] + async fn rename_rejects_reserved_target_and_open_source() { + let config = test_pool_config(2); + let util = install_opfs_sahpool(Some(&config), false) + .await + .expect("OPFS pool installation must succeed"); + util.import_db("source.db", &sqlite_database_bytes()) + .expect("source import must succeed"); + + let target = util + .begin_import_db("target.db") + .expect("target reservation must succeed"); + assert!(util.rename_path("source.db", "target.db").is_err()); + target.abort().expect("target abort must succeed"); + + let source = CString::new("/source.db").unwrap(); + let vfs_name = CString::new(config.vfs_name.as_str()).unwrap(); + let mut db = std::ptr::null_mut(); + let result = unsafe { + sqlite3_open_v2( + source.as_ptr(), + &mut db, + SQLITE_OPEN_READWRITE, + vfs_name.as_ptr(), + ) + }; + assert_eq!(result, SQLITE_OK, "source database must open"); + assert!(util.rename_path("source.db", "renamed.db").is_err()); + assert!(util.unlink("source.db").is_err()); + assert!(util.wipe_files().await.is_err()); + assert_eq!(unsafe { sqlite3_close(db) }, SQLITE_OK); + + util.rename_path("source.db", "renamed.db") + .expect("closed source must rename"); + assert!(util.has_path("renamed.db")); + assert!(util + .unlink("renamed.db") + .expect("closed renamed path must unlink")); + util.wipe_files() + .await + .expect("pool with no open SQLite files must wipe"); + } + + #[wasm_bindgen_test] + async fn failed_rename_rollback_quarantines_both_names_and_preserves_bytes() { + let config = test_pool_config(2); + let util = install_opfs_sahpool(Some(&config), false).await.unwrap(); + let bytes = sqlite_database_bytes(); + util.import_db("source.db", &bytes).unwrap(); + let sah = FileSystemSyncAccessHandle::from( + util.pool + .map_filename_to_sah + .get(&JsValue::from("/source.db")), + ); + *util.pool.association_failures_after_body.lock() = 2; + + assert!(util.rename_path("source.db", "target.db").is_err()); + assert!(!util.has_path("source.db")); + assert!(!util.has_path("target.db")); + assert!(util.begin_import_db("source.db").is_err()); + assert!(util.begin_import_db("target.db").is_err()); + assert!(util.unlink("source.db").is_err()); + assert!(util.unlink("target.db").is_err()); + + let mut stored = vec![0; bytes.len()]; + assert_eq!( + sah.read_with_u8_array_and_options( + &mut stored, + &read_write_options(HEADER_OFFSET_DATA as f64), + ) + .unwrap(), + bytes.len() as f64 + ); + assert_eq!(stored, bytes); + } + + #[wasm_bindgen_test] + async fn null_filename_temporary_database_is_deleted_on_close() { + let config = test_pool_config(1); + let util = install_opfs_sahpool(Some(&config), false) + .await + .expect("OPFS pool installation must succeed"); + let vfs_name = CString::new(config.vfs_name.as_str()).unwrap(); + let vfs = unsafe { sqlite3_vfs_find(vfs_name.as_ptr()) }; + assert!(!vfs.is_null()); + let mut file: OpfsFile = unsafe { std::mem::zeroed() }; + + let result = unsafe { + xOpen( + vfs, + std::ptr::null(), + (&mut file as *mut OpfsFile).cast(), + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_DELETEONCLOSE, + std::ptr::null_mut(), + ) + }; + assert_eq!(result, SQLITE_OK, "temporary database must open"); + assert_eq!(util.get_file_count(), 1); + assert_eq!( + unsafe { xClose((&mut file as *mut OpfsFile).cast()) }, + SQLITE_OK + ); + assert_eq!(util.get_file_count(), 0); + } +}