@evolu/sqlite-wasm 2.2.4 → 3.53.4-build1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -218
  3. package/dist/src/CApi.d.ts +2108 -0
  4. package/dist/src/CApi.d.ts.map +1 -0
  5. package/dist/src/CApi.js +1919 -0
  6. package/dist/src/Constants.d.ts +868 -0
  7. package/dist/src/Constants.d.ts.map +1 -0
  8. package/dist/src/Constants.js +602 -0
  9. package/dist/src/Database.d.ts +641 -0
  10. package/dist/src/Database.d.ts.map +1 -0
  11. package/dist/src/Database.js +1177 -0
  12. package/dist/src/Memory.d.ts +119 -0
  13. package/dist/src/Memory.d.ts.map +1 -0
  14. package/dist/src/Memory.js +207 -0
  15. package/dist/src/Pointer.d.ts +100 -0
  16. package/dist/src/Pointer.d.ts.map +1 -0
  17. package/dist/src/Pointer.js +15 -0
  18. package/dist/src/SahPool.d.ts +744 -0
  19. package/dist/src/SahPool.d.ts.map +1 -0
  20. package/dist/src/SahPool.js +1985 -0
  21. package/dist/src/Wasm.d.ts +315 -0
  22. package/dist/src/Wasm.d.ts.map +1 -0
  23. package/dist/src/Wasm.js +756 -0
  24. package/dist/src/WasmUrl.d.ts +21 -0
  25. package/dist/src/WasmUrl.d.ts.map +1 -0
  26. package/dist/src/WasmUrl.js +20 -0
  27. package/dist/src/c-api/index.d.ts +9 -0
  28. package/dist/src/c-api/index.d.ts.map +1 -0
  29. package/dist/src/c-api/index.js +8 -0
  30. package/dist/src/index.d.ts +15 -0
  31. package/dist/src/index.d.ts.map +1 -0
  32. package/dist/src/index.js +13 -0
  33. package/dist/wasm/sqlite3.wasm +0 -0
  34. package/package.json +60 -60
  35. package/src/CApi.test.ts +217 -0
  36. package/src/CApi.ts +3294 -0
  37. package/src/Constants.ts +803 -0
  38. package/src/Database.ts +1714 -0
  39. package/src/Memory.test.ts +319 -0
  40. package/src/Memory.ts +237 -0
  41. package/src/Pointer.ts +121 -0
  42. package/src/SahPool.test.ts +180 -0
  43. package/src/SahPool.ts +2627 -0
  44. package/src/Wasm.test.ts +444 -0
  45. package/src/Wasm.ts +1026 -0
  46. package/src/WasmUrl.ts +26 -0
  47. package/src/c-api/index.ts +9 -0
  48. package/src/index.ts +15 -0
  49. package/bin/index.js +0 -110
  50. package/index.d.ts +0 -8118
  51. package/index.mjs +0 -7
  52. package/node.mjs +0 -3
  53. package/sqlite-wasm/jswasm/sqlite3-bundler-friendly.mjs +0 -13659
  54. package/sqlite-wasm/jswasm/sqlite3-node.mjs +0 -11671
  55. package/sqlite-wasm/jswasm/sqlite3-opfs-async-proxy.js +0 -691
  56. package/sqlite-wasm/jswasm/sqlite3-worker1-bundler-friendly.mjs +0 -35
  57. package/sqlite-wasm/jswasm/sqlite3-worker1-promiser.js +0 -193
  58. package/sqlite-wasm/jswasm/sqlite3-worker1-promiser.mjs +0 -187
  59. package/sqlite-wasm/jswasm/sqlite3-worker1.js +0 -46
  60. package/sqlite-wasm/jswasm/sqlite3.js +0 -13697
  61. package/sqlite-wasm/jswasm/sqlite3.mjs +0 -13661
  62. package/sqlite-wasm/jswasm/sqlite3.wasm +0 -0
package/src/SahPool.ts ADDED
@@ -0,0 +1,2627 @@
1
+ /**
2
+ * A VFS over a pool of OPFS sync access handles, byte-compatible with SQLite's
3
+ * opfs-sahpool.
4
+ *
5
+ * Every file is a `FileSystemSyncAccessHandle` the pool holds exclusively, so
6
+ * reads and writes are synchronous in the worker, with no helper worker and no
7
+ * cross-origin isolation. As in opfs-sahpool, one pool may use a directory at a
8
+ * time, across workers and wasm instances, so the caller must serialize opens
9
+ * of a directory, as Evolu does with its leader Web Lock.
10
+ *
11
+ * Status: implemented, and tested in Node.js over a fake OPFS, including the
12
+ * lock table and recovery after a worker died, and in workers of Chromium,
13
+ * Firefox and WebKit on real OPFS, including storage quotas, real in Chromium
14
+ * and Firefox, and pools shared with SQLite's opfs-sahpool as
15
+ * `@evolu/sqlite-wasm` 2.2.4 ships it. Encryption is tested in Node.js over a
16
+ * fake OPFS, against 2.2.4 and `node:crypto`, and in the three engines on real
17
+ * OPFS against 2.2.4, which opens what the pool writes and the reverse, also
18
+ * after either's worker was terminated mid-transaction.
19
+ *
20
+ * ## On-disk format
21
+ *
22
+ * The slots must stay byte for byte what opfs-sahpool writes. Existing users'
23
+ * pools, and older tabs and cached PWAs running SQLite's JavaScript, share the
24
+ * directory, and opfs-sahpool wipes any slot whose header it does not accept.
25
+ *
26
+ * - The pool creates only {@link sahPoolOpaqueDirectoryName} in the pool
27
+ * directory, such as `.evolu`, with one randomly named file per slot.
28
+ * - Each slot starts with a {@link sahPoolHeaderSize}-byte header. Bytes 0-511
29
+ * hold the path in UTF-8, NUL-padded, so `xOpen` rejects a path of 511 bytes
30
+ * or more with SQLITE_CANTOPEN, and a database's path longer than
31
+ * {@link sahPoolMaxDatabasePathSize}, 498 bytes, whose super-journal's path
32
+ * would be that long, before it takes a slot. Bytes 512-515 hold the
33
+ * `SQLITE_OPEN_*` flags as a big-endian u32. Bytes 516-523 hold the digest as
34
+ * two platform-endian u32 values, which is little-endian everywhere Evolu
35
+ * runs.
36
+ * - File data starts at offset {@link sahPoolHeaderSize}.
37
+ * - A free slot has an all-zero path, flags 0 and digest `[0, 0]`, and is
38
+ * normally exactly {@link sahPoolHeaderSize} bytes long. A failed truncate can
39
+ * leave it longer, and the `xOpen` that takes it truncates it first.
40
+ * - Paths are normalized with `new URL(name, "file://localhost/").pathname`, so
41
+ * `file:evolu1.db` and `evolu1.db` both become `/evolu1.db`, and the journal
42
+ * `/evolu1.db-journal`. URL paths percent-encode characters such as spaces
43
+ * and non-ASCII ones. A host is ignored, as in opfs-sahpool, because engines
44
+ * parse it differently. A name is rejected unless its path with `-journal`
45
+ * appended is the path of the name with `-journal` appended. Otherwise the
46
+ * suffix lands in a query, a fragment or a host, as for `/a?b.db`, `/a#b.db`
47
+ * or `//evolu.db`, mapping the database and its journal to one path.
48
+ *
49
+ * ## Setup
50
+ *
51
+ * - Setup does not serialize opens of a directory; the caller must, as
52
+ * opfs-sahpool documents. A held slot fails an open, but two openers of a new
53
+ * or empty directory list no slot to hold, and each can create its own slots.
54
+ * A file both then create exists twice, and on the next open only one version
55
+ * shows, while the other is kept hidden and never reused, as below. In WebKit
56
+ * on macOS, names that differ only in case name one directory, so spell each
57
+ * directory in one case, as {@link OpfsName} describes.
58
+ * - Every slot's handle is acquired, in the default exclusive mode and never
59
+ * `readwrite-unsafe`, before anything is written. Setup waits for every
60
+ * acquisition to settle before it gives up, then closes every handle it
61
+ * obtained, including those that resolve after the first rejection. No header
62
+ * is read, repaired or truncated until every slot is held.
63
+ * - Opening a directory this instance has open, or is opening, also while it
64
+ * waits for held slots, fails with {@link SahPoolAlreadyOpenError} before it
65
+ * touches OPFS. The claim is released when setup fails or the pool is
66
+ * disposed.
67
+ * - When another context or instance still holds a slot, setup writes nothing,
68
+ * closes every handle it acquired, and fails with {@link SahPoolHeldError},
69
+ * even when another slot fails otherwise. With
70
+ * {@link SahPoolOptions.heldTimeout}, it first tries again from a new listing
71
+ * until the timeout passes, holding no slot in between. Handles can outlive a
72
+ * Web Lock an app serializes workers with, such as Evolu's: WebKit releases
73
+ * the two in no set order when a worker ends without closing its database,
74
+ * and Chromium keeps them for a page in the back/forward cache until it is
75
+ * evicted, and after site data is cleared on Windows until the browser
76
+ * restarts. Chromium also lets a worker that `Worker.terminate()` ended
77
+ * finish the task it runs, holding its handles, for up to two seconds.
78
+ * - Each header is read into a zeroed buffer, so a short slot is not parsed with
79
+ * the previous slot's bytes, then classified as opfs-sahpool does: a
80
+ * transient or DELETEONCLOSE file, or a bad digest, is freed; a valid free
81
+ * slot is truncated to the header. Of slots whose headers map the same path,
82
+ * which two openers of a new or empty directory can leave, the one listed
83
+ * last maps the path, and the others stay unused, as in opfs-sahpool: no
84
+ * other file truncates, writes or reuses them. Unlike opfs-sahpool, deleting
85
+ * the path frees them too, so the deleted file does not come back on the next
86
+ * open.
87
+ * - Slots are topped up to {@link sahPoolDefaultCapacity}.
88
+ * - Any other failure, such as OPFS refusing access, a header that cannot be read
89
+ * or written, or a VFS that cannot be allocated or registered, fails with
90
+ * {@link SahPoolSetupError}. Every handle setup obtained is closed, also when
91
+ * setup throws. In a SharedWorker, Chromium and Firefox have no
92
+ * `createSyncAccessHandle`, as the spec says, so setup fails there with a
93
+ * `TypeError` as the cause; WebKit has sync access handles in SharedWorkers
94
+ * too.
95
+ * - The directory is never removed. 2.2.4 removed it, database included, when
96
+ * setup failed.
97
+ *
98
+ * ## Locking
99
+ *
100
+ * - Locks live in a table per path, like `unixInodeInfo` in SQLite's `os_unix.c`,
101
+ * because several connections of one worker can open the same path. A lock
102
+ * that conflicts with another connection's lock returns SQLITE_BUSY, and a
103
+ * failed step from RESERVED to EXCLUSIVE leaves PENDING, which admits no new
104
+ * SHARED locks.
105
+ * - The table accepts every sequence SQLite makes, as `unixLock` and
106
+ * `posixUnlock` in `os_unix.c` do: an `xLock` at or below the connection's
107
+ * level does nothing; RESERVED to EXCLUSIVE goes through PENDING, but SHARED
108
+ * straight to EXCLUSIVE, as a hot journal's rollback asks, takes no PENDING
109
+ * when it fails; `xUnlock` goes from any level to SHARED or NONE, and
110
+ * `xClose` to NONE. Each connection's own level guards the shared per-path
111
+ * state, so an unlock from a connection that never locked changes nothing.
112
+ * `xRead` and `xFileControl` work with no lock held.
113
+ * - `xCheckReservedLock` reports whether any connection holds more than SHARED on
114
+ * the path. The exclusive handles make this pool the only writer of its
115
+ * slots, so the answer is authoritative, and SQLite rolls back a hot journal
116
+ * left by an interrupted commit. opfs-sahpool always answered yes until
117
+ * SQLite fixed it on 2026-09-30, so such a transaction stayed half-applied.
118
+ * - `xSleep` does nothing. Contention can only come from connections in the same
119
+ * thread, which sleeping cannot resolve.
120
+ *
121
+ * ## I/O errors
122
+ *
123
+ * - Every method catches everything and returns a result code, and records the
124
+ * first failure since {@link SahPool.clearFailure} as a
125
+ * {@link SqliteVfsFailure}. `xGetLastError` never clears it.
126
+ * - Every write's returned byte count is checked, header writes included. When
127
+ * storage is full, Chromium off the record returns 0xFFFFFFF8 and Firefox a
128
+ * short count instead of throwing.
129
+ * - `xWrite` returns SQLITE_FULL when the handle wrote a different number of
130
+ * bytes than requested, recording a {@link SqliteShortWriteError}, or threw an
131
+ * error named `QuotaExceededError`, matched by name so subclasses and
132
+ * non-DOMException errors count. Any other error, including WebKit's
133
+ * `InvalidStateError` on a full disk, returns SQLITE_IOERR_WRITE.
134
+ * - Over its quota, Chromium throws `QuotaExceededError`, and Firefox writes what
135
+ * fits, if anything, and returns the short count. Chromium reserves quota for
136
+ * a handle ahead of its writes and releases what the file does not use only
137
+ * when the handle closes, so a file the pool deletes frees no quota for the
138
+ * pool's other files until the pool is opened again.
139
+ * - For an unencrypted file or a journal, `xRead` zero-fills the rest of a short
140
+ * read and returns SQLITE_IOERR_SHORT_READ, as SQLite requires. A page of an
141
+ * encrypted database that ends early fails as one whose HMAC differs, as
142
+ * Reads and writes under Encryption describes.
143
+ * - Other methods return their specific code, such as SQLITE_IOERR_TRUNCATE,
144
+ * SQLITE_IOERR_FSYNC or SQLITE_IOERR_FSTAT, so the extended code says which
145
+ * operation failed.
146
+ * - Exporting a database fails when the pool recorded a failure during the
147
+ * export, because SQLite zero-fills a page it cannot read and reports
148
+ * success.
149
+ *
150
+ * ## Files
151
+ *
152
+ * - `xOpen` with CREATE takes a free slot. A slot that is longer than the header,
153
+ * because an earlier shrink failed, is truncated first; if reading its size
154
+ * or truncating it fails, the open fails rather than letting a new journal
155
+ * inherit stale bytes, with SQLITE_FULL when `xWrite` would return it, and
156
+ * with SQLITE_CANTOPEN otherwise. With no free slot, the open fails with
157
+ * SQLITE_CANTOPEN and records a {@link SahPoolFullError}. A failed open leaves
158
+ * `pMethods` NULL, so SQLite does not call `xClose`.
159
+ * - A file without a name fails `xOpen` with SQLITE_CANTOPEN. SQLite opens one
160
+ * only for a temporary file, which this build keeps in memory, and every one
161
+ * would share a path. It is also the only file SQLite opens with
162
+ * DELETEONCLOSE, so `xClose` deletes no file. A name that is no valid URL
163
+ * path, such as `//[`, or whose suffix would not land in its path, fails
164
+ * `xOpen` with SQLITE_CANTOPEN and `xDelete` with SQLITE_IOERR_DELETE, and
165
+ * `xAccess` finds no file.
166
+ * - `xOpen` with CREATE and EXCLUSIVE, as SQLite opens a super-journal, fails
167
+ * with SQLITE_CANTOPEN when the path exists, as unix's O_EXCL does. It
168
+ * records no failure, because SQLite checks with `xAccess` that the path is
169
+ * free first.
170
+ * - Before writing a new file's header, `xOpen` flushes the free slot, and every
171
+ * slot whose free header's flush failed, as below. That makes the truncate a
172
+ * delete, setup or an opfs-sahpool tab left unflushed durable before the path
173
+ * is. Otherwise a power loss can keep the new header but not the truncate,
174
+ * and SQLite reads the previous file's bytes. A deleted journal comes back
175
+ * hot and rolls a committed transaction back, wholly or partly, or corrupts
176
+ * the database, and a new database is NOTADB. A failed flush fails the open
177
+ * with SQLITE_IOERR_FSYNC, and the slot stays free. The header itself is
178
+ * written without a flush, because SQLite syncs a file before it relies on
179
+ * it. A failed header write fails the open with SQLITE_FULL when `xWrite`
180
+ * would return it, and with SQLITE_CANTOPEN otherwise, and the slot stays
181
+ * free.
182
+ * - Deleting a file writes and flushes the free header first, which is the
183
+ * durable delete, and only then forgets the path and truncates the slot.
184
+ * Slots that its slot shadows are freed the same way before it. If writing a
185
+ * header fails before it changes a byte, the path stays mapped, its slot is
186
+ * not freed, and `xDelete` returns SQLITE_IOERR_DELETE, so SQLite rolls back
187
+ * from an intact journal. Once a byte changed, the header names no path on
188
+ * disk, so a short write, a failed flush or a failed truncate still succeeds,
189
+ * and a committed transaction is not reported as an error. Deleting a path
190
+ * the pool does not have succeeds, as in opfs-sahpool.
191
+ * - A free header whose flush failed is flushed again before the pool writes any
192
+ * later header, as the first sync of a new journal in unix syncs its
193
+ * directory, and so is every slot free when the pool opened, because the pool
194
+ * whose flush of it failed may have ended first, as when its worker died. It
195
+ * is also flushed before the pool truncates a file, because SQLite truncates
196
+ * a database that shrank right after deleting its journal, and a journal that
197
+ * came back over the shorter file would lack the cut pages, as unix orders an
198
+ * unlink before a later truncate. When that flush fails again, an open fails
199
+ * before it takes a slot, a delete before it frees the path's slot, keeping
200
+ * the path mapped, and a truncate before it changes the file, with
201
+ * SQLITE_IOERR_TRUNCATE, so a COMMIT can fail although it committed, leaving
202
+ * the file longer than its database, which SQLite ignores and a later
203
+ * shrinking commit truncates. So a power loss can bring back only the file
204
+ * deleted last: a journal then rolls back its database's last commit as a
205
+ * whole, or repeats the rollback that deleted it, as SQLite accepts in
206
+ * synchronous FULL, which does not sync the directory after deleting a
207
+ * journal. When SQLite asks for a durable delete, as for a journal in
208
+ * synchronous EXTRA and for every super-journal, `xDelete` flushes again, and
209
+ * when that fails, returns SQLITE_IOERR_DIR_FSYNC although the file is
210
+ * deleted, as unix does when syncing the directory fails.
211
+ * - {@link SahPool.unlink} deletes a database's `-journal` and `-wal` paths too,
212
+ * although SQLite deletes a leftover journal of an empty database anyway. It
213
+ * deletes the database before its journal, and the journal only once the
214
+ * database's free header is flushed, so neither a failure nor a power loss
215
+ * leaves the database without the journal that would roll it back. It throws
216
+ * when a connection has one of the files open, because its slot would be
217
+ * reused under it.
218
+ * - Each slot is mapped to exactly one path, free, or unused because a slot
219
+ * listed after it maps its path, until that path is deleted.
220
+ *
221
+ * ## Encryption
222
+ *
223
+ * The pool encrypts a database as SQLite3 Multiple Ciphers encrypts it with its
224
+ * `sqlcipher` scheme and that scheme's default parameters, those of SQLCipher
225
+ * 4, so the databases `@evolu/web` 3 encrypted with `@evolu/sqlite-wasm` 2.2.4
226
+ * open unchanged, and 2.2.4 opens what the pool writes. SQLite3 Multiple
227
+ * Ciphers writes SQLCipher 4's pages except that page 1 keeps bytes 16 to 23
228
+ * unencrypted, and so does the pool.
229
+ *
230
+ * `@evolu/web` 1.0.1-preview.6 to 2.4.0 also ran `PRAGMA legacy = 4`, which
231
+ * writes SQLCipher 4's own format: 4096-byte pages, and page 1 encrypted and
232
+ * authenticated from byte 16. Those databases have not opened since
233
+ * `@evolu/web` 3.0.0, which dropped the pragma, and the pool does not open
234
+ * them: page 1 fails to authenticate. Reading them, to migrate them later,
235
+ * needs the page size, 4096, before page 1 is decrypted, because SQLite reads
236
+ * it from the encrypted bytes 16 and 17, and page 1 authenticated and decrypted
237
+ * from byte 16 to its reserved bytes, 4000 bytes in whole AES blocks, so
238
+ * without ciphertext stealing. Like the databases of `@evolu/web` 3, they were
239
+ * keyed with the `x'<hex>'` text, so their key is the one
240
+ * {@link deriveLegacySqliteKey} derives.
241
+ *
242
+ * ### Keys
243
+ *
244
+ * - {@link SahPool.registerKey} registers a raw 32-byte key for a path until the
245
+ * registration is disposed. The path is a {@link SqliteVfsPath}, the path
246
+ * `xOpen` maps the file's name to, so it is registered as it is, and
247
+ * registering a path again before its registration is disposed throws. Only
248
+ * an `xOpen` of the path as a main database takes the key, copying it: that
249
+ * file and its rollback journal are then encrypted until the file closes,
250
+ * which zeroes the copy and the HMAC key derived from it. Disposing the
251
+ * registration zeroes its copy, and the caller's key is never modified.
252
+ * - Each open file has its own copy, so connections that open the same path with
253
+ * different keys do not share one. A journal belongs to the database whose
254
+ * name its name leads back to, as `sqlite3_filename_database` finds it.
255
+ * - The key never reaches SQLite: no SQL, URI, file name or memory of SQLite
256
+ * holds it, and no error or recorded failure contains it.
257
+ *
258
+ * ### Pages
259
+ *
260
+ * - Every page ends with 80 reserved bytes: a random 16-byte IV, then a 64-byte
261
+ * HMAC. The bytes before them, from byte 24 of page 1 and from byte 0 of any
262
+ * other page, are encrypted with AES-256-CBC, the key and the IV.
263
+ * - Page 1's encrypted bytes are not a whole number of 16-byte blocks. Their last
264
+ * partial block is encrypted with ciphertext stealing, CBC-CS3: the last
265
+ * whole block of ciphertext C is replaced by the encryption of C XOR the
266
+ * partial block padded with zeros, followed by the first bytes of C, as many
267
+ * as the partial block has.
268
+ * - Bytes 0 to 15 of page 1 hold the salt instead of SQLite's header string,
269
+ * which decryption restores. Bytes 16 to 23, the page size, the file format
270
+ * versions, the reserved bytes and the payload fractions, stay unencrypted
271
+ * and unauthenticated, so SQLite learns the page size and the reserved bytes
272
+ * before anything is decrypted. A page 1 whose reserved bytes are not 80
273
+ * fails as an altered page, because SQLite would read every page at another
274
+ * usable size, and a blob that overflows its page would read as other bytes.
275
+ * - The HMAC is HMAC-SHA512 of the encrypted bytes, the IV and the page number as
276
+ * a little-endian u32. Its key is PBKDF2-HMAC-SHA512 of the key, salted with
277
+ * the salt XOR 0x3a, with 2 iterations and 32 bytes. A page is authenticated,
278
+ * comparing in constant time, before it is decrypted.
279
+ * - The salt is what page 1 stores. The pool learns it from a read of page 1,
280
+ * whole or in part, that authenticates, which SQLite makes before it reads or
281
+ * writes any other page of a database that has pages. A page 1 that fails to
282
+ * authenticate, as a stale page in a journal or a page a power loss tore can,
283
+ * gives no salt, or every page written later would get its salt. A new
284
+ * database gets 16 random bytes when the first of its pages is written. The
285
+ * HMAC key is derived again, and the previous one zeroed, whenever the salt
286
+ * changes, such as to one another connection wrote.
287
+ * - SQLite rolls a hot journal back before it reads page 1, so a connection can
288
+ * have no salt, as when it opened the database while it was empty, or a stale
289
+ * one, as when the database was emptied and created again since a page 1
290
+ * proved its key. So `xOpen` of a journal that SQLite would roll back, whose
291
+ * first byte is not 0 and whose first header stores an original size other
292
+ * than 0 pages, takes the salt from the first of these that authenticates:
293
+ * the journal's first page-1 record, as below, and the database's page 1,
294
+ * read at the page size it stores. When neither does, it takes the database's
295
+ * first 16 bytes while no page 1 has proven the key, and keeps the salt once
296
+ * one has, because a database keeps its salt while it has pages and a tear
297
+ * must not replace it. The kept salt is stale only when the database was
298
+ * created again since, which needs `synchronous = OFF` and a power loss,
299
+ * where SQLite promises nothing either: otherwise, SQLite syncs the journal's
300
+ * page-1 record before it writes page 1, so one of the two authenticates. It
301
+ * takes no salt when the database is empty, or when the original size is 0
302
+ * pages: such a journal journals no page, and the database's first bytes are
303
+ * then a hole a spill left before page 1 was written. When a read throws, the
304
+ * open fails with SQLITE_CANTOPEN, recording what it threw.
305
+ * - Any power of two from 1024 to 65536 bytes is a page size. SQLite raises a
306
+ * requested 512 to 1024, because a page reserves more than 32 bytes, as with
307
+ * SQLite3 Multiple Ciphers. A new database gets SQLite's default, 8192 bytes
308
+ * in this build, as in 2.2.4, unless `PRAGMA page_size` sets another before
309
+ * its first write.
310
+ * - An existing database's page size cannot change. A VACUUM after `PRAGMA
311
+ * page_size` sets another size writes page 1 with that size in its header but
312
+ * in pages of the old size, so every later read of page 1 would fail to
313
+ * authenticate. Writing a page 1 whose header stores another page size than
314
+ * its length fails with SQLITE_IOERR_WRITE and records a
315
+ * {@link SahPoolEncryptionUnsupportedError}, so SQLite rolls the VACUUM back
316
+ * and the database stays as it was. SQLite3 Multiple Ciphers patches VACUUM
317
+ * to keep the old page size instead, so in 2.2.4 such a VACUUM succeeds with
318
+ * the old size. A VACUUM that keeps the page size, and `auto_vacuum`, work.
319
+ * - SQLite must leave the 80 bytes unused. An existing database's header says so,
320
+ * and for a new one, the database layer asks for them with
321
+ * SQLITE_FCNTL_RESERVE_BYTES before its first write. Writing a page 1 whose
322
+ * header reserves another number of bytes fails with SQLITE_IOERR_WRITE and
323
+ * records a {@link SahPoolEncryptionUnsupportedError}: with fewer, the IV and
324
+ * the HMAC would overwrite data, and with more, page 1 would fail every
325
+ * read.
326
+ * - SQLite must write every page it frees. With `secure_delete` off or `FAST`, it
327
+ * does not write a page that a transaction added and then freed, so the file
328
+ * keeps zeros there, which fail to authenticate, and an export, which reads
329
+ * every page, fails with SQLITE_CORRUPT. So for an encrypted database,
330
+ * `xFileControl` refuses a `PRAGMA secure_delete` whose value is not `on`,
331
+ * `yes`, `true` or `1`, ignoring case: the pragma fails with SQLITE_ERROR and
332
+ * a message that says why, recording a
333
+ * {@link SahPoolEncryptionUnsupportedError}, or with SQLITE_NOMEM when the
334
+ * message cannot be allocated. A query of the pragma works, and an
335
+ * EncryptedFile database turns it on. SQLite gives an attached database
336
+ * `main`'s setting, and applies a pragma without a schema to every attached
337
+ * database but asks only `main`'s file, so an encrypted database attached to
338
+ * a connection whose `main` is not encrypted can have it off. SQLite3
339
+ * Multiple Ciphers lets `secure_delete` be turned off, and 2.2.4's export
340
+ * returns zeros for such pages without an error.
341
+ *
342
+ * ### Reads and writes
343
+ *
344
+ * - SQLite reads and writes a database one whole page at a time, at an offset
345
+ * that is a multiple of the page's size, which is the page's number less one
346
+ * times the size.
347
+ * - A whole page is read, authenticated and decrypted in place. A page whose HMAC
348
+ * differs, because the key is wrong or the page was altered, is zero-filled
349
+ * and fails the read with SQLITE_NOTADB for page 1 and SQLITE_CORRUPT for any
350
+ * other page, as SQLite3 Multiple Ciphers fails it, and records a
351
+ * {@link SqlitePageAuthenticationError}. A short read zero-fills the whole
352
+ * page, so no ciphertext reaches SQLite as data. A read that gets nothing is
353
+ * past the end, as page 1 of an empty file is, and returns
354
+ * SQLITE_IOERR_SHORT_READ. SQLite reads a page only within the file, so a
355
+ * page that ends early was cut off or torn and fails as one whose HMAC
356
+ * differs, or zeros would pass as its data, which no integrity check notices
357
+ * in an overflow page.
358
+ * - SQLite reads part of page 1 twice: its first 100 bytes when it opens a
359
+ * database, for the page size and the reserved bytes, and 16 bytes at offset
360
+ * 24, the change counter, when a transaction starts with pages cached. Both
361
+ * come from page 1 decrypted when it authenticates, and as stored when it
362
+ * does not, as SQLite3 Multiple Ciphers returns them before a key is set. So
363
+ * an open learns the unencrypted page size, a page 1 that a crash left torn
364
+ * does not fail before the key rolls a hot journal back, as below, and a
365
+ * wrong key fails when page 1 is read whole. A read that is not a whole page
366
+ * and ends past page 1, by the page size page 1 stores, fails with
367
+ * SQLITE_IOERR_READ and records a {@link SahPoolEncryptionUnsupportedError}.
368
+ * - A connection that opened the file while it was empty keeps SQLite's default
369
+ * page size, so when another connection then creates the database with
370
+ * another page size, it reads page 1 whole at the wrong size. Page 1 is then
371
+ * authenticated and decrypted at the size it stores, and the read gets its
372
+ * first bytes, with zeros past it, so SQLite learns the size and reads page 1
373
+ * again; it fails as a whole page fails. In 2.2.4, such a connection failed
374
+ * with SQLITE_NOTADB until it was opened again.
375
+ * - A whole page is encrypted with a fresh IV into a buffer of the pool, never in
376
+ * SQLite's page cache, and written. Any other write fails with
377
+ * SQLITE_IOERR_WRITE and records a {@link SahPoolEncryptionUnsupportedError}.
378
+ *
379
+ * ### Journals and other files
380
+ *
381
+ * - SQLite writes each record of a rollback journal as the page number in 4
382
+ * bytes, big-endian, the page, and a 4-byte checksum of the page as SQLite
383
+ * has it. The page is encrypted as that page of the database, with a fresh
384
+ * IV, and authenticated and decrypted when it is read, failing as a database
385
+ * page fails until the key is proven, as below. A read or write of a page's
386
+ * size at offset P + 4, right after one of 4 bytes at P, is a record's page,
387
+ * unless those 4 bytes were the checksum right after the previous record's
388
+ * page. Everything else, the headers, page numbers, checksums and
389
+ * super-journal name, is stored as it is.
390
+ * - A crash can leave zeros in the first bytes of the database's page 1, whose
391
+ * salt the records need, and a plain UPDATE journals page 1 last, when its
392
+ * commit changes the change counter, so the journal's page-1 record, wherever
393
+ * it is, gives the salt first, as above. The pool finds the first such record
394
+ * as SQLite reads a hot journal: the sector and page sizes from the first
395
+ * header, and segments that each start with a header at a multiple of the
396
+ * sector size, with SQLite's magic and the number of records that follow it,
397
+ * where 0xFFFFFFFF counts them up to the end of the journal. A count of 0
398
+ * does too, although SQLite's rollback of a hot journal takes it as none,
399
+ * because any page 1 that authenticates stores the database's salt.
400
+ * - So a wrong key, with which page 1 never authenticates, never rolls back a hot
401
+ * journal: the rollback fails at the first page, and the journal stays for
402
+ * the right key.
403
+ * - Nor does a connection without a key. SQLite would read the journal's pages as
404
+ * stored, take the first record, whose checksum then differs, as the end of
405
+ * the journal, and delete it, leaving the transaction half-applied. So
406
+ * `xOpen` of a journal that exists, whose database is open without a key,
407
+ * reads the database's first 16 bytes, and fails with SQLITE_CANTOPEN when
408
+ * the database is not empty, they are not SQLite's header string, as an
409
+ * encrypted database's salt is not, and SQLite would roll the journal back,
410
+ * as above, recording a {@link SahPoolEncryptionUnsupportedError}, or when a
411
+ * read throws, recording what it threw. A journal of a database that had no
412
+ * pages journals no page, and rolling it back only empties the database, as
413
+ * for a new database whose first transaction spilled the pages after page 1,
414
+ * which leaves zeros in place of the header string. A torn write can leave
415
+ * zeros in place of the salt, so zeros pass only for an empty database: an
416
+ * encrypted database whose salt is zeros keeps its journal, which its key
417
+ * rolls back from the journal's page-1 record, as above, and a plaintext one
418
+ * whose header string is zeros fails such a connection too, rather than risk
419
+ * the other's journal. SQLite counts a journal it cannot open as hot, and its
420
+ * rollback then cannot open it either, so the statement fails with
421
+ * SQLITE_CANTOPEN and the journal stays for the key. A leftover journal that
422
+ * is not hot opens: `journal_mode = PERSIST` zeroes its header and TRUNCATE
423
+ * leaves it empty, and a crash before SQLite first syncs a journal leaves
424
+ * zeros in place of its magic, except with `synchronous = OFF`, which writes
425
+ * the magic with the header. Such a connection then fails with SQLite's
426
+ * SQLITE_NOTADB when it reads page 1, as it does without a journal. A journal
427
+ * that a transaction creates holds nothing to roll back, so opening it reads
428
+ * nothing of the database.
429
+ * - Once a page 1, of the database or of a record, has authenticated, which
430
+ * proves the key, a record's page that fails to authenticate is zero-filled,
431
+ * records nothing, and fails the read with SQLITE_IOERR_SHORT_READ, which
432
+ * SQLite's rollback takes as the end of the journal, as it takes a record
433
+ * whose checksum differs. With `synchronous = OFF`, SQLite plays a journal
434
+ * back to its end and relies on the checksums to stop. A journal that
435
+ * `journal_mode = PERSIST` or `locking_mode = EXCLUSIVE` keeps still holds
436
+ * the previous transaction's records, so a crash right after a record's page
437
+ * number leaves it before a stale page encrypted as another page. SQLite3
438
+ * Multiple Ciphers fails such a rollback with SQLITE_CORRUPT. The
439
+ * opfs-sahpool of 2.2.4 never ran one, because it never rolled a journal
440
+ * back.
441
+ * - A journal header is stored as it is, also one as long as a page that follows
442
+ * a checksum, which SQLite3 Multiple Ciphers encrypts as a page. Headers are
443
+ * 4096 bytes, the sector size, at most, so that happens only with pages of
444
+ * 4096 bytes or fewer, never Evolu's 8192, and SQLite then reads such a
445
+ * header as the end of the journal, with either.
446
+ * - SQLite opens no `-wal` file and no temporary file through the pool: the build
447
+ * omits WAL and keeps temporary files, such as statement journals, sorts and
448
+ * temporary tables, in memory, also with `PRAGMA temp_store = FILE`.
449
+ * - A super-journal holds only file names. An attached database and the output of
450
+ * `VACUUM INTO` are main databases of their own, encrypted only when a key is
451
+ * registered for their path while they open. SQLite reserves no bytes in a
452
+ * file that `ATTACH` creates, so with a key registered its first write fails,
453
+ * as above; `VACUUM INTO` reserves the bytes the main database does.
454
+ *
455
+ * ### Primitives
456
+ *
457
+ * - AES-CBC and SHA-512 are the stubs of `@awasm/noble/stub.js`, and HMAC and
458
+ * PBKDF2 come from its `hmac.js` and `kdf.js` over the SHA-512 stub. Opening
459
+ * a pool installs `@awasm/noble`'s wasm backend into each stub that has none,
460
+ * so an app can install another backend before or after, such as
461
+ * `@awasm/noble/noble.js`, which wraps the audited `@noble/ciphers` and
462
+ * `@noble/hashes`.
463
+ * - `@awasm/noble` zeroes its wasm memory after each operation, and the pool
464
+ * zeroes the buffers that held a decrypted page outside SQLite's memory.
465
+ *
466
+ * ## The VFS
467
+ *
468
+ * - It is named `opfs-sahpool:` followed by the names of the directory joined
469
+ * with `/`, so `[".evolu"]` gets `opfs-sahpool:.evolu` and `["a", "b"]` gets
470
+ * `opfs-sahpool:a/b`, which no other names get, because no {@link OpfsName}
471
+ * contains `/`, NUL, where SQLite ends the name, or a lone surrogate, which
472
+ * UTF-8 cannot encode. It is registered once per VFS name and wasm instance.
473
+ * It is allocated and registered through `SqliteWasm.call`, so on a broken
474
+ * instance opening a new directory throws before anything is allocated, and
475
+ * an exception that escapes the registration breaks the instance.
476
+ * - The VFS struct is version 2 with `xCurrentTimeInt64`, and the I/O methods are
477
+ * version 1 without the shared memory only WAL uses.
478
+ * - Every slot of both structs up to their declared version is a real function,
479
+ * except the `xDl*` slots: the build omits loadable extensions, so SQLite
480
+ * never calls them. SQLite tolerates a NULL `xDelete`, `xSectorSize` or
481
+ * `xGetLastError`, and never calls `xCurrentTime` when `xCurrentTimeInt64` is
482
+ * set, but VFS shims such as SQLite's cksumvfs forward every slot without
483
+ * NULL checks. `xGetLastError` reports no system error, writing nothing, so
484
+ * it accepts nBuf 0 and a NULL buffer.
485
+ * - `xFileControl` returns SQLITE_NOTFOUND for every opcode, as in opfs-sahpool,
486
+ * so SQLite handles every pragma itself, except a `PRAGMA secure_delete` that
487
+ * an encrypted database refuses, as above.
488
+ * - `xDeviceCharacteristics` returns 0, and `xSectorSize` returns 4096.
489
+ * opfs-sahpool returns SQLITE_IOCAP_UNDELETABLE_WHEN_OPEN, which makes SQLite
490
+ * keep a PERSIST or TRUNCATE journal open after it unlocks. A connection in
491
+ * DELETE mode could then delete the journal, freeing its slot, and a crash
492
+ * would leave a transaction half-applied. With 0, SQLite closes the journal
493
+ * when it unlocks, as with unix, and only a connection holding RESERVED
494
+ * deletes a journal.
495
+ * - The VFS struct, the I/O methods struct and the name are allocated once and
496
+ * never freed.
497
+ *
498
+ * ## Pausing
499
+ *
500
+ * Disposing a {@link SahPool} pauses it: every handle closes, so another worker
501
+ * can take the files over. The VFS stays registered for the lifetime of the
502
+ * wasm instance. While no pool holds its files, `xOpen` fails with
503
+ * SQLITE_CANTOPEN, `xAccess` finds no file, and `xDelete` returns
504
+ * SQLITE_IOERR_DELETE. Opening the same directory again unpauses it.
505
+ *
506
+ * @module
507
+ */
508
+
509
+ import {
510
+ assert,
511
+ brand,
512
+ disposable,
513
+ durationToMillis,
514
+ eqUint8Array,
515
+ err,
516
+ isNonEmptyArray,
517
+ mapArray,
518
+ ok,
519
+ performanceDurationBetween,
520
+ safelyStringifyUnknownValue,
521
+ sleep,
522
+ String,
523
+ tryAsync,
524
+ trySync,
525
+ zipArray,
526
+ type NonEmptyReadonlyArray,
527
+ type NonNegativeInt,
528
+ type PositiveDuration,
529
+ type PositiveMillis,
530
+ type Random,
531
+ type RandomBytes,
532
+ type RandomBytesDep,
533
+ type ReportDefectDep,
534
+ type Result,
535
+ type Task,
536
+ type TimeDep,
537
+ type Typed,
538
+ type TypeError,
539
+ } from "@evolu/common";
540
+ import { cbc as wasmCbc, sha512 as wasmSha512 } from "@awasm/noble";
541
+ import { hmac } from "@awasm/noble/hmac.js";
542
+ import { pbkdf2 } from "@awasm/noble/kdf.js";
543
+ import { cbc, sha512 } from "@awasm/noble/stub.js";
544
+ import {
545
+ SQLITE_BUSY,
546
+ SQLITE_CANTOPEN,
547
+ SQLITE_CORRUPT,
548
+ SQLITE_ERROR,
549
+ SQLITE_FCNTL_PRAGMA,
550
+ SQLITE_FULL,
551
+ SQLITE_IOERR_DELETE,
552
+ SQLITE_IOERR_DIR_FSYNC,
553
+ SQLITE_IOERR_FSTAT,
554
+ SQLITE_IOERR_FSYNC,
555
+ SQLITE_IOERR_READ,
556
+ SQLITE_IOERR_SHORT_READ,
557
+ SQLITE_IOERR_TRUNCATE,
558
+ SQLITE_IOERR_WRITE,
559
+ SQLITE_LOCK_EXCLUSIVE,
560
+ SQLITE_LOCK_NONE,
561
+ SQLITE_LOCK_PENDING,
562
+ SQLITE_LOCK_RESERVED,
563
+ SQLITE_LOCK_SHARED,
564
+ SQLITE_NOMEM,
565
+ SQLITE_NOTADB,
566
+ SQLITE_NOTFOUND,
567
+ SQLITE_OK,
568
+ SQLITE_OPEN_CREATE,
569
+ SQLITE_OPEN_DELETEONCLOSE,
570
+ SQLITE_OPEN_EXCLUSIVE,
571
+ SQLITE_OPEN_MAIN_DB,
572
+ SQLITE_OPEN_MAIN_JOURNAL,
573
+ SQLITE_OPEN_MEMORY,
574
+ SQLITE_OPEN_SUPER_JOURNAL,
575
+ SQLITE_OPEN_WAL,
576
+ sqlite3_file_layout,
577
+ sqlite3_io_methods_layout,
578
+ sqlite3_vfs_layout,
579
+ type SqliteLockLevel,
580
+ type SqliteResultCode,
581
+ } from "./Constants.ts";
582
+ import type {
583
+ SqliteEncryptingVfs,
584
+ SqlitePageAuthenticationError,
585
+ SqliteShortWriteError,
586
+ SqliteVfsFailure,
587
+ SqliteVfsIoError,
588
+ SqliteVfsMethod,
589
+ SqliteVfsPath,
590
+ deriveLegacySqliteKey,
591
+ } from "./Database.ts";
592
+ import {
593
+ allocCString,
594
+ allocWasm,
595
+ readCString,
596
+ type SqliteNoMemError,
597
+ } from "./Memory.ts";
598
+ import type {
599
+ CStringPtr,
600
+ SqliteFilePtr,
601
+ SqliteVfsPtr,
602
+ WasmPtr,
603
+ } from "./Pointer.ts";
604
+ import {
605
+ installWasmFunctions,
606
+ type SqliteWasmDep,
607
+ type SqliteWasmFunction,
608
+ type SqliteWasmInitializeError,
609
+ } from "./Wasm.ts";
610
+
611
+ /**
612
+ * A pool of sync access handles, and the VFS SQLite opens its files with, on
613
+ * which File and EncryptedFile databases open.
614
+ */
615
+ export interface SahPool extends SqliteEncryptingVfs, Disposable {
616
+ /**
617
+ * Reads bytes of a file without SQLite, such as the first 16 bytes of an
618
+ * encrypted database, which are its cipher salt. The path is one
619
+ * {@link SahPool.getPaths} returns, and the offset counts from the start of
620
+ * the file's data, after the header. Fewer bytes come back at the end of the
621
+ * file.
622
+ */
623
+ readonly read: (
624
+ path: string,
625
+ offset: NonNegativeInt,
626
+ byteLength: NonNegativeInt,
627
+ ) => Result<
628
+ Uint8Array<ArrayBuffer>,
629
+ SahPoolFileNotFoundError | SqliteVfsIoError
630
+ >;
631
+ }
632
+
633
+ /** Options for {@link openSahPool}. */
634
+ export interface SahPoolOptions {
635
+ /**
636
+ * The pool directory, as the {@link OpfsName}s of the directories from the
637
+ * OPFS root to it, such as `[".evolu"]`. Missing directories are created. The
638
+ * names joined with `/` name the VFS, such as `opfs-sahpool:.evolu`, which no
639
+ * other names get.
640
+ *
641
+ * As in opfs-sahpool, one pool may use a directory at a time, across workers
642
+ * and wasm instances, so serialize opens of a directory, as Evolu does with
643
+ * its leader Web Lock. Otherwise, two openers of a new or empty directory can
644
+ * each create their own slots, and a file both create then exists twice: on
645
+ * the next open only one version shows, and the other is kept hidden and
646
+ * never reused.
647
+ */
648
+ readonly directory: NonEmptyReadonlyArray<OpfsName>;
649
+
650
+ /**
651
+ * How long to keep trying to open the pool while another context holds a slot
652
+ * of it. The pool is opened again 50 ms after a held attempt, then after
653
+ * twice the previous delay, up to a second, and the open fails with
654
+ * {@link SahPoolHeldError} once an attempt ends at least this long after the
655
+ * first one started. Without it, a held slot fails the open at once.
656
+ */
657
+ readonly heldTimeout?: PositiveDuration;
658
+ }
659
+
660
+ /**
661
+ * Opens the pool in a directory, registering its VFS on first use.
662
+ *
663
+ * Dispose every database on the pool before the pool; disposing a pool with an
664
+ * open file throws, because closing a handle under SQLite would corrupt its
665
+ * state.
666
+ */
667
+ export const openSahPool =
668
+ ({
669
+ directory,
670
+ heldTimeout,
671
+ }: SahPoolOptions): Task<
672
+ SahPool,
673
+ SahPoolError,
674
+ OpfsRootDep & SqliteWasmDep
675
+ > =>
676
+ async (run) => {
677
+ const { opfsRoot, random, randomBytes, sqliteWasm, time } = run.deps;
678
+ // An app may have installed another backend, before or after.
679
+ cbc.install(wasmCbc, { onlyMissing: true });
680
+ sha512.install(wasmSha512, { onlyMissing: true });
681
+ // No OpfsName contains "/", NUL or a lone surrogate, so no other names get
682
+ // this name, also as the UTF-8 C string SQLite reads up to its NUL.
683
+ const vfsName = `opfs-sahpool:${directory.join("/")}`;
684
+ const claimed =
685
+ claimedVfsNamesByTable.get(sqliteWasm.functionTable) ?? new Set<string>();
686
+ claimedVfsNamesByTable.set(sqliteWasm.functionTable, claimed);
687
+
688
+ // Claimed before the first await, so a second open in this instance fails
689
+ // as already open rather than on the slots this instance holds or by
690
+ // creating slots of its own in a new directory, and never replaces the
691
+ // first's methods on the shared registration.
692
+ if (claimed.has(vfsName))
693
+ return err({ type: "SahPoolAlreadyOpenError", directory });
694
+ claimed.add(vfsName);
695
+ using disposer = new DisposableStack();
696
+ disposer.defer(() => {
697
+ claimed.delete(vfsName);
698
+ });
699
+
700
+ const toSetupError = (cause: unknown): SahPoolSetupError => ({
701
+ type: "SahPoolSetupError",
702
+ cause,
703
+ });
704
+
705
+ const waitStart = time.performance.now();
706
+ const slots: Array<Slot> = [];
707
+ disposer.defer(() => {
708
+ for (const slot of slots) slot.handle.close();
709
+ });
710
+ let opaque: OpfsDirectoryHandle;
711
+ for (let retryDelay = 50; ; retryDelay = Math.min(retryDelay * 2, 1000)) {
712
+ const listed = await tryAsync(async () => {
713
+ let handle = await opfsRoot.getDirectory();
714
+ for (const name of directory)
715
+ handle = await handle.getDirectoryHandle(name, { create: true });
716
+ handle = await handle.getDirectoryHandle(sahPoolOpaqueDirectoryName, {
717
+ create: true,
718
+ });
719
+ const files: Array<OpfsFileHandle> = [];
720
+ for await (const entry of handle.values())
721
+ if (entry.kind === "file") files.push(entry);
722
+ return { opaque: handle, files };
723
+ }, toSetupError);
724
+ if (!listed.ok) return listed;
725
+ const { files } = listed.value;
726
+
727
+ // Every acquisition settles before setup gives up, so none that resolves
728
+ // after a rejection is left open. An async function turns a synchronous
729
+ // throw into a rejection: a SharedWorker of Chromium or Firefox has no
730
+ // createSyncAccessHandle, so calling it throws a TypeError.
731
+ const acquired = await Promise.allSettled(
732
+ files.map(async (file) => file.createSyncAccessHandle()),
733
+ );
734
+ const rejected: Array<{
735
+ readonly fileName: string;
736
+ readonly cause: unknown;
737
+ }> = [];
738
+ for (const [file, result] of zipArray([files, acquired]))
739
+ if (result.status === "fulfilled")
740
+ slots.push({ fileName: file.name, handle: result.value });
741
+ else rejected.push({ fileName: file.name, cause: result.reason });
742
+ if (!isNonEmptyArray(rejected)) {
743
+ opaque = listed.value.opaque;
744
+ break;
745
+ }
746
+
747
+ // A held slot decides, so the error does not depend on the order OPFS
748
+ // lists the slots in.
749
+ const held = rejected.find(({ cause }) => isHeldError(cause));
750
+ if (held == null) return err(toSetupError(rejected[0].cause));
751
+
752
+ // A worker that ended without closing its database, because its tab
753
+ // closed, crashed or navigated away, can still hold the pool's files
754
+ // after a Web Lock that serializes workers has passed on: WebKit
755
+ // releases a terminated worker's locks and its files separately, in no
756
+ // set order (https://bugs.webkit.org/show_bug.cgi?id=301520), and
757
+ // Chromium lets a terminated worker finish its task, holding them, for up
758
+ // to two seconds. Such a worker can only release them, so the pool is
759
+ // opened again until they are. A browser can also keep them until it
760
+ // restarts, as Chromium does after site data is cleared on Windows, so
761
+ // the wait has a bound.
762
+ if (
763
+ heldTimeout == null ||
764
+ performanceDurationBetween(waitStart, time.performance.now()) >=
765
+ durationToMillis(heldTimeout)
766
+ )
767
+ return err({ type: "SahPoolHeldError", ...held });
768
+ for (const slot of slots.splice(0)) slot.handle.close();
769
+ await run.ok(sleep(retryDelay as PositiveMillis));
770
+ }
771
+
772
+ const slotByPath = new Map<string, Slot>();
773
+ // Slots to flush before the pool writes another header: those free when it
774
+ // opened, those a delete freed whose flush failed, and those xOpen could
775
+ // not flush.
776
+ const unflushedSlots = new Set<Slot>();
777
+ // The paths of slots that a slot listed after them maps. As in
778
+ // opfs-sahpool, they stay unused, so no other file truncates or reuses
779
+ // them, but deleting their path frees them too.
780
+ const shadowedPathBySlot = new Map<Slot, string>();
781
+ // Classifies the slots and tops them up. A failed write is returned, and
782
+ // anything thrown is caught.
783
+ const prepared = await tryAsync(
784
+ async (): Promise<Result<void, unknown>> => {
785
+ // As opfs-sahpool's getAssociatedPath, but each header is read into a
786
+ // zeroed buffer, so a short slot is not parsed with the previous one's.
787
+ for (const slot of slots) {
788
+ const header = new Uint8Array(sahPoolHeaderDigestOffset + 8);
789
+ slot.handle.read(header, { at: 0 });
790
+ const flags = new DataView(header.buffer).getUint32(
791
+ sahPoolHeaderFlagsOffset,
792
+ );
793
+ const digest = computeSahPoolDigest(
794
+ header.subarray(0, sahPoolHeaderCorpusSize),
795
+ flags,
796
+ );
797
+ const stored = new Uint32Array(
798
+ header.buffer,
799
+ sahPoolHeaderDigestOffset,
800
+ 2,
801
+ );
802
+ const pathLength = header.indexOf(0);
803
+ if (
804
+ (pathLength !== 0 &&
805
+ ((flags & SQLITE_OPEN_DELETEONCLOSE) !== 0 ||
806
+ (flags & sahPoolPersistentFileTypes) === 0)) ||
807
+ stored[0] !== digest[0] ||
808
+ stored[1] !== digest[1]
809
+ ) {
810
+ const freed = writeHeader(slot.handle, "", 0);
811
+ if (!freed.ok) return freed;
812
+ slot.handle.flush();
813
+ slot.handle.truncate(sahPoolHeaderSize);
814
+ } else if (pathLength === 0) {
815
+ slot.handle.truncate(sahPoolHeaderSize);
816
+ unflushedSlots.add(slot);
817
+ } else {
818
+ const path = utf8Decoder.decode(header.subarray(0, pathLength));
819
+ const shadowed = slotByPath.get(path);
820
+ if (shadowed != null) shadowedPathBySlot.set(shadowed, path);
821
+ slotByPath.set(path, slot);
822
+ }
823
+ }
824
+
825
+ while (slots.length < sahPoolDefaultCapacity) {
826
+ const fileName = createRandomName(random);
827
+ const file = await opaque.getFileHandle(fileName, { create: true });
828
+ const handle = await file.createSyncAccessHandle();
829
+ slots.push({ fileName, handle });
830
+ handle.truncate(sahPoolHeaderSize);
831
+ }
832
+ return ok();
833
+ },
834
+ err,
835
+ );
836
+ const failed = prepared.ok ? prepared.value : prepared.error;
837
+ if (!failed.ok) return err(toSetupError(failed.error));
838
+
839
+ const openFiles = new Map<SqliteFilePtr, OpenFile>();
840
+ const lockByPath = new Map<string, PathLock>();
841
+ // The registered keys, copies the registrations zero when disposed.
842
+ const keyByPath = new Map<string, Uint8Array<ArrayBuffer>>();
843
+ // The first failure since the last clear.
844
+ let failure: SqliteVfsFailure | null = null;
845
+ const getOpenFile = (pFile: SqliteFilePtr): OpenFile => {
846
+ const file = openFiles.get(pFile);
847
+ assert(file != null, "The file is not open.");
848
+ return file;
849
+ };
850
+
851
+ const recordFailure = (
852
+ method: SqliteVfsMethod,
853
+ path: string | null,
854
+ error: unknown,
855
+ ): void => {
856
+ failure ??= { method, path, error };
857
+ };
858
+
859
+ // Runs a file's handle operation, which returns a result code, and records
860
+ // what it throws, returning the method's own code.
861
+ const runIo = (
862
+ method: SqliteVfsMethod,
863
+ file: OpenFile,
864
+ code: SqliteResultCode,
865
+ operation: (handle: OpfsSyncAccessHandle) => SqliteResultCode,
866
+ ): SqliteResultCode => {
867
+ try {
868
+ return operation(file.slot.handle);
869
+ } catch (error) {
870
+ recordFailure(method, file.path, error);
871
+ return code;
872
+ }
873
+ };
874
+
875
+ const registrations =
876
+ registrationsByTable.get(sqliteWasm.functionTable) ??
877
+ new Map<string, SahPoolRegistration>();
878
+ registrationsByTable.set(sqliteWasm.functionTable, registrations);
879
+ let registration = registrations.get(vfsName);
880
+ if (registration == null) {
881
+ const registered = registerSahPoolVfs(run.deps, vfsName);
882
+ if (!registered.ok) return err(toSetupError(registered.error));
883
+ registration = registered.value;
884
+ registrations.set(vfsName, registration);
885
+ }
886
+ const { ioMethods } = registration;
887
+
888
+ // Flushes the unflushed slots, returning the first failure, so no header
889
+ // written after a delete reaches the disk before the delete does, as the
890
+ // first sync of a new journal in unix syncs its directory.
891
+ const flushUnflushed = (): Result<void, unknown> => {
892
+ for (const unflushed of unflushedSlots) {
893
+ const flushed = trySync(
894
+ () => unflushed.handle.flush(),
895
+ (error) => error,
896
+ );
897
+ if (!flushed.ok) return flushed;
898
+ unflushedSlots.delete(unflushed);
899
+ }
900
+ return ok();
901
+ };
902
+
903
+ // Writes and flushes the free header, which is the durable delete, and only
904
+ // then forgets the path and truncates the slot. Returns false for a path
905
+ // the pool does not have. It fails, keeping the path mapped, only before a
906
+ // header write changed a byte of the path's slot: when the write changed
907
+ // none, or when an earlier free header still cannot be flushed. Once a byte
908
+ // changed, the header names no path on disk, and failing would make SQLite
909
+ // roll back in this session from a journal that a worker dying during the
910
+ // rollback loses, leaving the transaction half undone with integrity_check
911
+ // ok. Restoring the header instead would need another write and flush on
912
+ // the handle that just failed, so a failed flush leaves the slot to flush
913
+ // before any later header.
914
+ const deletePath = (path: string): Result<boolean, unknown> => {
915
+ const slot = slotByPath.get(path);
916
+ if (slot == null) return ok(false);
917
+ // The slots it shadows too, or the next open would map the path to one
918
+ // of them again, and first, so a failure leaves the path mapped.
919
+ const slotsToFree = [...shadowedPathBySlot]
920
+ .filter(([, shadowedPath]) => shadowedPath === path)
921
+ .map(([shadowed]) => shadowed);
922
+ slotsToFree.push(slot);
923
+ for (const freeing of slotsToFree) {
924
+ const ordered = flushUnflushed();
925
+ if (!ordered.ok) return ordered;
926
+ const written = writeHeader(freeing.handle, "", 0);
927
+ // A count above the requested one is no count: Chromium off the record
928
+ // returns FILE_ERROR_NO_SPACE, -8, for a write it refused before
929
+ // copying a byte.
930
+ const changed =
931
+ written.ok ||
932
+ (isShortWrite(written.error) &&
933
+ written.error.written > 0 &&
934
+ written.error.written < written.error.requested);
935
+ if (!changed) return written;
936
+ const flushed = trySync(
937
+ () => freeing.handle.flush(),
938
+ (error) => error,
939
+ );
940
+ if (!flushed.ok) unflushedSlots.add(freeing);
941
+ shadowedPathBySlot.delete(freeing);
942
+ // A failed truncate leaves the slot longer than the header, and the
943
+ // xOpen that takes it next truncates it first, so a committed
944
+ // transaction is not reported as an error.
945
+ trySync(
946
+ () => freeing.handle.truncate(sahPoolHeaderSize),
947
+ (error) => error,
948
+ );
949
+ }
950
+ slotByPath.delete(path);
951
+ return ok(true);
952
+ };
953
+
954
+ // Lowers a file's lock to SHARED or NONE, as posixUnlock in os_unix.c does.
955
+ const unlockFile = (pFile: SqliteFilePtr, level: SqliteLockLevel): void => {
956
+ const file = getOpenFile(pFile);
957
+ // The file's own level guards the path's lock.
958
+ if (file.lock <= level) return;
959
+ const pathLock = lockByPath.get(file.path);
960
+ assert(pathLock != null, "The path is not locked.");
961
+ const shared =
962
+ level === SQLITE_LOCK_NONE ? pathLock.shared - 1 : pathLock.shared;
963
+ if (shared === 0) lockByPath.delete(file.path);
964
+ else
965
+ lockByPath.set(file.path, {
966
+ level:
967
+ file.lock > SQLITE_LOCK_SHARED
968
+ ? SQLITE_LOCK_SHARED
969
+ : pathLock.level,
970
+ shared,
971
+ });
972
+ openFiles.set(pFile, { ...file, lock: level });
973
+ };
974
+
975
+ const toPath = (zName: CStringPtr): Result<string, unknown> =>
976
+ nameToPath(readCString({ sqliteWasm })(zName));
977
+
978
+ // The open database a journal belongs to, found as
979
+ // sqlite3_filename_database finds it: SQLite puts a journal's name after
980
+ // its database's name and URI parameters, and four NUL bytes before the
981
+ // database's name.
982
+ const findDatabaseFile = (zJournal: CStringPtr): OpenFile | undefined => {
983
+ const heap = sqliteWasm.getHeapU8();
984
+ let zName: number = zJournal;
985
+ while (
986
+ zName > 4 &&
987
+ (heap[zName - 1] !== 0 ||
988
+ heap[zName - 2] !== 0 ||
989
+ heap[zName - 3] !== 0 ||
990
+ heap[zName - 4] !== 0)
991
+ )
992
+ zName--;
993
+ return [...openFiles.values()].find((file) => file.zName === zName);
994
+ };
995
+
996
+ const methods: SahPoolMethods = {
997
+ xOpen: (
998
+ _vfs: SqliteVfsPtr,
999
+ zName: CStringPtr | 0,
1000
+ pFile: SqliteFilePtr,
1001
+ flags: number,
1002
+ pOutFlags: WasmPtr | 0,
1003
+ ) => {
1004
+ // SQLite opens a file without a name only for a temporary file, which
1005
+ // this build keeps in memory. Without a name, every such file would
1006
+ // share one path.
1007
+ if (zName === 0) return SQLITE_CANTOPEN;
1008
+ const name = readCString({ sqliteWasm })(zName);
1009
+ const named = nameToPath(name);
1010
+ if (!named.ok) {
1011
+ recordFailure("xOpen", null, named.error);
1012
+ return SQLITE_CANTOPEN;
1013
+ }
1014
+ const path = named.value;
1015
+ const rejectTooLong = (): number => {
1016
+ recordFailure("xOpen", path, {
1017
+ type: "SahPoolInvalidPath",
1018
+ name,
1019
+ } satisfies SahPoolInvalidPathError);
1020
+ return SQLITE_CANTOPEN;
1021
+ };
1022
+ // Its journal's and super-journal's paths must fit a header too, or
1023
+ // every write, or every commit that writes an attached database, would
1024
+ // fail.
1025
+ if (
1026
+ (flags & SQLITE_OPEN_MAIN_DB) !== 0 &&
1027
+ utf8Encoder.encode(path).length > sahPoolMaxDatabasePathSize
1028
+ )
1029
+ return rejectTooLong();
1030
+ const database =
1031
+ (flags & SQLITE_OPEN_MAIN_JOURNAL) === 0
1032
+ ? undefined
1033
+ : findDatabaseFile(zName);
1034
+ // SQLite rolls a hot journal back before it reads the database's page
1035
+ // 1, whose salt authenticates the records, and a connection can have
1036
+ // no salt, or a stale one once the database was emptied and created
1037
+ // again. Without the key, SQLite would read the records as stored,
1038
+ // take the first, whose checksum then differs, as the end of the
1039
+ // journal, and delete it, leaving the transaction half-applied. A
1040
+ // journal SQLite cannot open counts as hot, and a rollback that cannot
1041
+ // open it fails, so the journal stays for the key. A journal the open
1042
+ // creates holds nothing to roll back, so a commit reads no more.
1043
+ const existingSlot = slotByPath.get(path);
1044
+ if (database != null && existingSlot != null) {
1045
+ const start = new Uint8Array(saltSize);
1046
+ const read = trySync(
1047
+ () => database.slot.handle.read(start, { at: sahPoolHeaderSize }),
1048
+ (error) => error,
1049
+ );
1050
+ if (!read.ok) {
1051
+ recordFailure("xOpen", path, read.error);
1052
+ return SQLITE_CANTOPEN;
1053
+ }
1054
+ const codec = database.cipher?.codec;
1055
+ // Without the key, the first bytes matter only when they are not
1056
+ // SQLite's header string, as an encrypted database's salt, which a
1057
+ // torn write can leave as zeros, is not.
1058
+ if (
1059
+ read.value !== 0 &&
1060
+ (codec != null || !eqUint8Array(start, sqliteHeaderString))
1061
+ ) {
1062
+ // Whether SQLite would roll the journal back and no page 1 that
1063
+ // authenticates gave the salt. SQLite rolls back only a journal
1064
+ // whose first byte is not 0, as the magic is not, which it writes
1065
+ // when it first syncs the journal, or with the header when
1066
+ // synchronous = OFF. The journal's first header stores the
1067
+ // database's original size in pages at byte 16, 0 when it had none:
1068
+ // such a journal journals no page, and rolling it back only empties
1069
+ // the database, whose first bytes are then a hole a spill left
1070
+ // before page 1 was written. Any page 1 that authenticates stores
1071
+ // the salt, even when another connection created the database again
1072
+ // since a page 1 proved this codec's key.
1073
+ const unsalted = trySync(
1074
+ () => {
1075
+ const journalStart = new Uint8Array(20);
1076
+ existingSlot.handle.read(journalStart, {
1077
+ at: sahPoolHeaderSize,
1078
+ });
1079
+ if (
1080
+ journalStart[0] === 0 ||
1081
+ journalStart.subarray(16).every((byte) => byte === 0)
1082
+ )
1083
+ return false;
1084
+ if (codec == null) return true;
1085
+ if (authenticateJournalPage1(existingSlot.handle, codec))
1086
+ return false;
1087
+ // The database's page 1 at the page size it stores. One that
1088
+ // ends early ends with zeros, which fail to authenticate.
1089
+ const { handle } = database.slot;
1090
+ const header = new Uint8Array(18);
1091
+ handle.read(header, { at: sahPoolHeaderSize });
1092
+ const pageSize = storedPageSize(header);
1093
+ if (!isPageSize(pageSize)) return true;
1094
+ const page = new Uint8Array(pageSize);
1095
+ handle.read(page, { at: sahPoolHeaderSize });
1096
+ const authenticated = codec.decryptPage(1, page);
1097
+ page.fill(0);
1098
+ return !authenticated;
1099
+ },
1100
+ (error) => error,
1101
+ );
1102
+ if (!unsalted.ok) {
1103
+ recordFailure("xOpen", path, unsalted.error);
1104
+ return SQLITE_CANTOPEN;
1105
+ }
1106
+ if (unsalted.value) {
1107
+ if (codec == null) {
1108
+ recordFailure("xOpen", path, encryptionUnsupported);
1109
+ return SQLITE_CANTOPEN;
1110
+ }
1111
+ // A database keeps its salt while it has pages, so a tear must
1112
+ // not replace a proven one.
1113
+ if (!codec.isKeyProven()) codec.useSalt(start);
1114
+ }
1115
+ }
1116
+ }
1117
+ let slot = existingSlot;
1118
+ // SQLite creates a super-journal with SQLITE_OPEN_EXCLUSIVE, which
1119
+ // must not open a file that exists, as unix opens it with O_EXCL.
1120
+ if (
1121
+ slot != null &&
1122
+ (flags & (SQLITE_OPEN_CREATE | SQLITE_OPEN_EXCLUSIVE)) ===
1123
+ (SQLITE_OPEN_CREATE | SQLITE_OPEN_EXCLUSIVE)
1124
+ )
1125
+ return SQLITE_CANTOPEN;
1126
+ if (slot == null && (flags & SQLITE_OPEN_CREATE) !== 0) {
1127
+ if (!fitsHeader(path)) return rejectTooLong();
1128
+ const used = new Set([
1129
+ ...slotByPath.values(),
1130
+ ...shadowedPathBySlot.keys(),
1131
+ ]);
1132
+ slot = slots.find((free) => !used.has(free));
1133
+ if (slot == null) {
1134
+ recordFailure("xOpen", path, {
1135
+ type: "SahPoolFull",
1136
+ capacity: slots.length,
1137
+ } satisfies SahPoolFullError);
1138
+ return SQLITE_CANTOPEN;
1139
+ }
1140
+ const { handle } = slot;
1141
+ // An earlier delete could not shrink it.
1142
+ const truncated = trySync(
1143
+ () => {
1144
+ if (handle.getSize() > sahPoolHeaderSize)
1145
+ handle.truncate(sahPoolHeaderSize);
1146
+ },
1147
+ (error) => error,
1148
+ );
1149
+ if (!truncated.ok) {
1150
+ recordFailure("xOpen", path, truncated.error);
1151
+ return isStorageFull(truncated.error)
1152
+ ? SQLITE_FULL
1153
+ : SQLITE_CANTOPEN;
1154
+ }
1155
+ // A delete, setup or another pool may have left this slot's truncate
1156
+ // unflushed; a power loss could otherwise keep the new header with
1157
+ // the previous file's bytes behind it. Every unflushed delete too, so
1158
+ // this file cannot reach the disk before it.
1159
+ unflushedSlots.add(slot);
1160
+ const flushed = flushUnflushed();
1161
+ if (!flushed.ok) {
1162
+ recordFailure("xOpen", path, flushed.error);
1163
+ return SQLITE_IOERR_FSYNC;
1164
+ }
1165
+ const written = writeHeader(
1166
+ slot.handle,
1167
+ path,
1168
+ flags | sahPoolDigestV2Flag,
1169
+ );
1170
+ if (!written.ok) {
1171
+ recordFailure("xOpen", path, written.error);
1172
+ return isStorageFull(written.error) ? SQLITE_FULL : SQLITE_CANTOPEN;
1173
+ }
1174
+ slotByPath.set(path, slot);
1175
+ }
1176
+ if (slot == null) return SQLITE_CANTOPEN;
1177
+ const key =
1178
+ (flags & SQLITE_OPEN_MAIN_DB) === 0 ? undefined : keyByPath.get(path);
1179
+ const cipher: FileCipher | null =
1180
+ key != null
1181
+ ? {
1182
+ type: "Database",
1183
+ codec: createSahPoolCodec({ key: key.slice(), randomBytes }),
1184
+ }
1185
+ : database?.cipher?.type === "Database"
1186
+ ? {
1187
+ type: "Journal",
1188
+ codec: database.cipher.codec,
1189
+ records: createJournalRecords(),
1190
+ }
1191
+ : null;
1192
+ openFiles.set(pFile, {
1193
+ path,
1194
+ slot,
1195
+ lock: SQLITE_LOCK_NONE,
1196
+ zName,
1197
+ cipher,
1198
+ });
1199
+ const view = sqliteWasm.getHeapDataView();
1200
+ view.setInt32(
1201
+ pFile + sqlite3_file_layout.members.pMethods.offset,
1202
+ ioMethods,
1203
+ true,
1204
+ );
1205
+ if (pOutFlags !== 0) view.setInt32(pOutFlags, flags, true);
1206
+ return SQLITE_OK;
1207
+ },
1208
+ xDelete: (_vfs: SqliteVfsPtr, zName: CStringPtr, syncDir: number) => {
1209
+ const path = toPath(zName);
1210
+ if (!path.ok) {
1211
+ recordFailure("xDelete", null, path.error);
1212
+ return SQLITE_IOERR_DELETE;
1213
+ }
1214
+ const deleted = deletePath(path.value);
1215
+ if (!deleted.ok) {
1216
+ recordFailure("xDelete", path.value, deleted.error);
1217
+ return SQLITE_IOERR_DELETE;
1218
+ }
1219
+ if (syncDir === 0) return SQLITE_OK;
1220
+ // A durable delete, as unixDelete syncs the directory after the
1221
+ // unlink and fails when that does.
1222
+ const synced = flushUnflushed();
1223
+ if (synced.ok) return SQLITE_OK;
1224
+ recordFailure("xDelete", path.value, synced.error);
1225
+ return SQLITE_IOERR_DIR_FSYNC;
1226
+ },
1227
+ xAccess: (
1228
+ _vfs: SqliteVfsPtr,
1229
+ zName: CStringPtr,
1230
+ _flags: number,
1231
+ pResOut: WasmPtr,
1232
+ ) => {
1233
+ const path = toPath(zName);
1234
+ sqliteWasm
1235
+ .getHeapDataView()
1236
+ .setInt32(
1237
+ pResOut,
1238
+ path.ok && slotByPath.has(path.value) ? 1 : 0,
1239
+ true,
1240
+ );
1241
+ return SQLITE_OK;
1242
+ },
1243
+ xClose: (pFile: SqliteFilePtr) => {
1244
+ const file = getOpenFile(pFile);
1245
+ unlockFile(pFile, SQLITE_LOCK_NONE);
1246
+ openFiles.delete(pFile);
1247
+ if (file.cipher?.type === "Database")
1248
+ file.cipher.codec[Symbol.dispose]();
1249
+ return SQLITE_OK;
1250
+ },
1251
+ xRead: (
1252
+ pFile: SqliteFilePtr,
1253
+ pBuf: WasmPtr,
1254
+ amount: number,
1255
+ offset: bigint,
1256
+ ) => {
1257
+ const file = getOpenFile(pFile);
1258
+ const { cipher, path } = file;
1259
+ return runIo("xRead", file, SQLITE_IOERR_READ, (handle) => {
1260
+ const buffer = sqliteWasm.getHeapU8().subarray(pBuf, pBuf + amount);
1261
+ const at = Number(offset);
1262
+ const read = handle.read(buffer, { at: sahPoolHeaderSize + at });
1263
+ // SQLite requires the rest to be zeros.
1264
+ buffer.fill(0, read);
1265
+ const shortRead =
1266
+ read === amount ? SQLITE_OK : SQLITE_IOERR_SHORT_READ;
1267
+ if (cipher == null) return shortRead;
1268
+ const pageNumber =
1269
+ cipher.type === "Database"
1270
+ ? toPageNumber(at, amount)
1271
+ : cipher.records.track(at, buffer);
1272
+ if (pageNumber != null) {
1273
+ // A connection that opened the file while it was empty keeps
1274
+ // SQLite's default page size, so it reads page 1 whole at that
1275
+ // size, learns the size page 1 stores, and reads it again. Page 1
1276
+ // is decrypted at the size it stores, and the read gets its first
1277
+ // bytes, with zeros past it.
1278
+ const storedSize = storedPageSize(buffer);
1279
+ const pageSize =
1280
+ cipher.type === "Database" &&
1281
+ pageNumber === 1 &&
1282
+ isPageSize(storedSize)
1283
+ ? storedSize
1284
+ : amount;
1285
+ const page =
1286
+ pageSize === amount ? buffer : new Uint8Array(pageSize);
1287
+ const pageRead =
1288
+ page === buffer
1289
+ ? read
1290
+ : handle.read(page, { at: sahPoolHeaderSize });
1291
+ // No ciphertext reaches SQLite as data. A short read ends a
1292
+ // journal, and one that reads nothing is past the end, as page 1
1293
+ // of an empty file is. SQLite reads a database's page only within
1294
+ // the file, so one that ends early was cut off or torn, and fails
1295
+ // as a page that does not authenticate, or zeros would pass as its
1296
+ // data.
1297
+ const complete = pageRead === pageSize;
1298
+ if (!complete && (cipher.type === "Journal" || pageRead === 0)) {
1299
+ buffer.fill(0);
1300
+ return SQLITE_IOERR_SHORT_READ;
1301
+ }
1302
+ if (complete && cipher.codec.decryptPage(pageNumber, page)) {
1303
+ if (page === buffer) return SQLITE_OK;
1304
+ buffer.fill(0);
1305
+ buffer.set(page.subarray(0, amount));
1306
+ page.fill(0);
1307
+ return shortRead;
1308
+ }
1309
+ buffer.fill(0);
1310
+ // With the key proven, a record's page that fails to authenticate
1311
+ // ends the journal, as a checksum that differs ends it in SQLite,
1312
+ // whose rollback takes a short read as the end. A crash with
1313
+ // synchronous = OFF can leave a stale page after a new page number.
1314
+ if (cipher.type === "Journal" && cipher.codec.isKeyProven())
1315
+ return SQLITE_IOERR_SHORT_READ;
1316
+ recordFailure("xRead", path, {
1317
+ type: "SqlitePageAuthenticationError",
1318
+ pageNumber,
1319
+ } satisfies SqlitePageAuthenticationError);
1320
+ return pageNumber === 1 ? SQLITE_NOTADB : SQLITE_CORRUPT;
1321
+ }
1322
+ if (cipher.type === "Journal") return shortRead;
1323
+ // Part of page 1: decrypted when it authenticates, as stored
1324
+ // otherwise. Its first bytes up to the end of the page size.
1325
+ const header = new Uint8Array(18);
1326
+ if (handle.read(header, { at: sahPoolHeaderSize }) !== header.length)
1327
+ return shortRead;
1328
+ const pageSize = storedPageSize(header);
1329
+ if (!isPageSize(pageSize)) return shortRead;
1330
+ if (at + amount > pageSize) {
1331
+ buffer.fill(0);
1332
+ recordFailure("xRead", path, encryptionUnsupported);
1333
+ return SQLITE_IOERR_READ;
1334
+ }
1335
+ const page = new Uint8Array(pageSize);
1336
+ if (
1337
+ handle.read(page, { at: sahPoolHeaderSize }) === pageSize &&
1338
+ cipher.codec.decryptPage(1, page)
1339
+ )
1340
+ buffer.set(page.subarray(at, at + amount));
1341
+ page.fill(0);
1342
+ return shortRead;
1343
+ });
1344
+ },
1345
+ xWrite: (
1346
+ pFile: SqliteFilePtr,
1347
+ pBuf: WasmPtr,
1348
+ amount: number,
1349
+ offset: bigint,
1350
+ ) => {
1351
+ const file = getOpenFile(pFile);
1352
+ const at = Number(offset);
1353
+ const bytes = sqliteWasm.getHeapU8().subarray(pBuf, pBuf + amount);
1354
+ const { cipher } = file;
1355
+ // What to store, or null for what the pool cannot keep encrypted.
1356
+ const sealed = trySync(
1357
+ (): Uint8Array<ArrayBuffer> | null => {
1358
+ if (cipher == null) return bytes;
1359
+ const pageNumber =
1360
+ cipher.type === "Database"
1361
+ ? toPageNumber(at, amount)
1362
+ : cipher.records.track(at, bytes);
1363
+ if (pageNumber != null)
1364
+ return cipher.codec.encryptPage(pageNumber, bytes);
1365
+ // A journal's headers, page numbers and checksums.
1366
+ return cipher.type === "Journal" ? bytes : null;
1367
+ },
1368
+ (error) => error,
1369
+ );
1370
+ if (!sealed.ok || sealed.value == null) {
1371
+ recordFailure(
1372
+ "xWrite",
1373
+ file.path,
1374
+ sealed.ok ? encryptionUnsupported : sealed.error,
1375
+ );
1376
+ return SQLITE_IOERR_WRITE;
1377
+ }
1378
+ const written = writeBytes(
1379
+ file.slot.handle,
1380
+ sealed.value,
1381
+ sahPoolHeaderSize + at,
1382
+ );
1383
+ if (written.ok) return SQLITE_OK;
1384
+ recordFailure("xWrite", file.path, written.error);
1385
+ return isStorageFull(written.error) ? SQLITE_FULL : SQLITE_IOERR_WRITE;
1386
+ },
1387
+ xTruncate: (pFile: SqliteFilePtr, size: bigint) => {
1388
+ const file = getOpenFile(pFile);
1389
+ // SQLite truncates a database that shrank right after deleting its
1390
+ // journal, so the delete must reach the disk first, as unix orders an
1391
+ // unlink before a later truncate.
1392
+ const ordered = flushUnflushed();
1393
+ if (!ordered.ok) {
1394
+ recordFailure("xTruncate", file.path, ordered.error);
1395
+ return SQLITE_IOERR_TRUNCATE;
1396
+ }
1397
+ return runIo("xTruncate", file, SQLITE_IOERR_TRUNCATE, (handle) => {
1398
+ handle.truncate(sahPoolHeaderSize + Number(size));
1399
+ return SQLITE_OK;
1400
+ });
1401
+ },
1402
+ xSync: (pFile: SqliteFilePtr) =>
1403
+ runIo("xSync", getOpenFile(pFile), SQLITE_IOERR_FSYNC, (handle) => {
1404
+ handle.flush();
1405
+ return SQLITE_OK;
1406
+ }),
1407
+ xFileSize: (pFile: SqliteFilePtr, pSize: WasmPtr) =>
1408
+ runIo("xFileSize", getOpenFile(pFile), SQLITE_IOERR_FSTAT, (handle) => {
1409
+ const size = handle.getSize() - sahPoolHeaderSize;
1410
+ sqliteWasm.getHeapDataView().setBigInt64(pSize, BigInt(size), true);
1411
+ return SQLITE_OK;
1412
+ }),
1413
+ xLock: (pFile: SqliteFilePtr, level: SqliteLockLevel) => {
1414
+ const file = getOpenFile(pFile);
1415
+ if (file.lock >= level) return SQLITE_OK;
1416
+ const pathLock = lockByPath.get(file.path) ?? {
1417
+ level: SQLITE_LOCK_NONE,
1418
+ shared: 0,
1419
+ };
1420
+ // Another connection's lock precludes it.
1421
+ if (
1422
+ file.lock !== pathLock.level &&
1423
+ (pathLock.level >= SQLITE_LOCK_PENDING || level > SQLITE_LOCK_SHARED)
1424
+ )
1425
+ return SQLITE_BUSY;
1426
+ if (level === SQLITE_LOCK_EXCLUSIVE && pathLock.shared > 1) {
1427
+ // A failed step from RESERVED leaves PENDING, which admits no new
1428
+ // SHARED lock, as unixLock does.
1429
+ if (file.lock === SQLITE_LOCK_RESERVED) {
1430
+ lockByPath.set(file.path, {
1431
+ ...pathLock,
1432
+ level: SQLITE_LOCK_PENDING,
1433
+ });
1434
+ openFiles.set(pFile, { ...file, lock: SQLITE_LOCK_PENDING });
1435
+ }
1436
+ return SQLITE_BUSY;
1437
+ }
1438
+ lockByPath.set(
1439
+ file.path,
1440
+ level === SQLITE_LOCK_SHARED
1441
+ ? {
1442
+ // Beside another SHARED or a RESERVED, the path keeps its level.
1443
+ level:
1444
+ pathLock.level === SQLITE_LOCK_NONE ? level : pathLock.level,
1445
+ shared: pathLock.shared + 1,
1446
+ }
1447
+ : { ...pathLock, level },
1448
+ );
1449
+ openFiles.set(pFile, { ...file, lock: level });
1450
+ return SQLITE_OK;
1451
+ },
1452
+ xUnlock: (pFile: SqliteFilePtr, level: SqliteLockLevel) => {
1453
+ unlockFile(pFile, level);
1454
+ return SQLITE_OK;
1455
+ },
1456
+ xCheckReservedLock: (pFile: SqliteFilePtr, pResOut: WasmPtr) => {
1457
+ const pathLock = lockByPath.get(getOpenFile(pFile).path);
1458
+ sqliteWasm
1459
+ .getHeapDataView()
1460
+ .setInt32(
1461
+ pResOut,
1462
+ pathLock != null && pathLock.level > SQLITE_LOCK_SHARED ? 1 : 0,
1463
+ true,
1464
+ );
1465
+ return SQLITE_OK;
1466
+ },
1467
+ xFileControl: (pFile: SqliteFilePtr, op: number, pArg: WasmPtr) => {
1468
+ if (op !== SQLITE_FCNTL_PRAGMA) return SQLITE_NOTFOUND;
1469
+ const file = getOpenFile(pFile);
1470
+ if (file.cipher?.type !== "Database") return SQLITE_NOTFOUND;
1471
+ // The pragma's name and value, or NULL for a query, after the slot
1472
+ // for its result or error message.
1473
+ const view = sqliteWasm.getHeapDataView();
1474
+ const zValue = view.getUint32(pArg + 8, true) as CStringPtr | 0;
1475
+ if (
1476
+ readCString({ sqliteWasm })(
1477
+ view.getUint32(pArg + 4, true) as CStringPtr,
1478
+ ).toLowerCase() !== "secure_delete" ||
1479
+ zValue === 0 ||
1480
+ secureDeleteOnValues.has(
1481
+ readCString({ sqliteWasm })(zValue).toLowerCase(),
1482
+ )
1483
+ )
1484
+ return SQLITE_NOTFOUND;
1485
+ // SQLite frees the message.
1486
+ const message = allocCString({ sqliteWasm })(
1487
+ "Cannot turn secure_delete off for an encrypted database, because a freed page it does not write would fail to authenticate.",
1488
+ );
1489
+ if (!message.ok) return SQLITE_NOMEM;
1490
+ // A fresh view, because the allocation can grow memory, which detaches
1491
+ // the one taken before it.
1492
+ sqliteWasm.getHeapDataView().setUint32(pArg, message.value, true);
1493
+ recordFailure("xFileControl", file.path, encryptionUnsupported);
1494
+ return SQLITE_ERROR;
1495
+ },
1496
+ };
1497
+ registration.setMethods(methods);
1498
+ disposer.defer(() => {
1499
+ registration.setMethods(null);
1500
+ });
1501
+
1502
+ const pool = disposable<SahPool>(
1503
+ {
1504
+ vfsName,
1505
+ registerKey: (path, key) => {
1506
+ assert(
1507
+ !keyByPath.has(path),
1508
+ `A key is already registered for ${path}.`,
1509
+ );
1510
+ const copy = key.slice();
1511
+ keyByPath.set(path, copy);
1512
+ using keyDisposer = new DisposableStack();
1513
+ keyDisposer.defer(() => {
1514
+ copy.fill(0);
1515
+ if (keyByPath.get(path) === copy) keyByPath.delete(path);
1516
+ });
1517
+ return disposable<Disposable>({}, keyDisposer);
1518
+ },
1519
+ getPaths: () => [...slotByPath.keys()],
1520
+ read: (path, offset, byteLength) => {
1521
+ const slot = slotByPath.get(path);
1522
+ if (slot == null) return err({ type: "SahPoolFileNotFound", path });
1523
+ const bytes = new Uint8Array(byteLength);
1524
+ const read = trySync(
1525
+ () => slot.handle.read(bytes, { at: sahPoolHeaderSize + offset }),
1526
+ (cause): SqliteVfsIoError => ({ type: "SqliteVfsIoError", cause }),
1527
+ );
1528
+ if (!read.ok) return read;
1529
+ // Fewer at the end of the file.
1530
+ return ok(
1531
+ read.value === byteLength ? bytes : bytes.slice(0, read.value),
1532
+ );
1533
+ },
1534
+ unlink: (path) => {
1535
+ // The database first, and its free header is flushed before the
1536
+ // journal's is written, so neither a failure nor a power loss leaves
1537
+ // it without the journal that would roll it back.
1538
+ const paths = [path, `${path}-journal`, `${path}-wal`];
1539
+ for (const file of openFiles.values())
1540
+ if (paths.includes(file.path))
1541
+ throw new Error(`Cannot unlink ${path}, which is open.`);
1542
+ let found = false;
1543
+ for (const [index, filePath] of paths.entries()) {
1544
+ const deleted = deletePath(filePath);
1545
+ if (!deleted.ok)
1546
+ return err({ type: "SqliteVfsIoError", cause: deleted.error });
1547
+ if (index === 0) found = deleted.value;
1548
+ }
1549
+ return ok(found);
1550
+ },
1551
+ getFailure: () => failure,
1552
+ clearFailure: () => {
1553
+ failure = null;
1554
+ },
1555
+ },
1556
+ disposer,
1557
+ );
1558
+ return ok({
1559
+ ...pool,
1560
+ [Symbol.dispose]: () => {
1561
+ // A Disposable that throws is still disposed, so this checks first.
1562
+ if (openFiles.size > 0)
1563
+ throw new Error(
1564
+ "Cannot dispose a SahPool with an open file, because closing its handle would corrupt SQLite's state.",
1565
+ );
1566
+ pool[Symbol.dispose]();
1567
+ },
1568
+ });
1569
+ };
1570
+
1571
+ /** Why {@link openSahPool} failed. */
1572
+ export type SahPoolError =
1573
+ SahPoolAlreadyOpenError | SahPoolHeldError | SahPoolSetupError;
1574
+
1575
+ /**
1576
+ * This instance has a pool in the directory open, or is opening one. A retry
1577
+ * succeeds once that pool is disposed or its open failed.
1578
+ */
1579
+ export interface SahPoolAlreadyOpenError extends Typed<"SahPoolAlreadyOpenError"> {
1580
+ /** The {@link SahPoolOptions.directory}. */
1581
+ readonly directory: NonEmptyReadonlyArray<OpfsName>;
1582
+ }
1583
+
1584
+ /**
1585
+ * Another context or instance holds a slot of the pool. The pool wrote nothing,
1586
+ * and a retry succeeds once the slot is released.
1587
+ *
1588
+ * In WebKit, the error can also mean that the context opening the pool is
1589
+ * stopping, because WebKit reports that with the same `InvalidStateError` as a
1590
+ * held file, so retries need a bound.
1591
+ */
1592
+ export interface SahPoolHeldError extends Typed<"SahPoolHeldError"> {
1593
+ /** The slot's file name in {@link sahPoolOpaqueDirectoryName}. */
1594
+ readonly fileName: string;
1595
+ /**
1596
+ * What `createSyncAccessHandle` threw: `NoModificationAllowedError`, or
1597
+ * `InvalidStateError` in WebKit.
1598
+ */
1599
+ readonly cause: unknown;
1600
+ }
1601
+
1602
+ /**
1603
+ * OPFS failed while the pool was being set up, or its VFS could not be
1604
+ * registered.
1605
+ */
1606
+ export interface SahPoolSetupError extends Typed<"SahPoolSetupError"> {
1607
+ /**
1608
+ * What OPFS threw, a {@link SqliteShortWriteError}, or a
1609
+ * {@link SqliteNoMemError} or {@link SqliteWasmInitializeError} from
1610
+ * registering the VFS.
1611
+ */
1612
+ readonly cause: unknown;
1613
+ }
1614
+
1615
+ /** The pool has no file with the path. */
1616
+ export interface SahPoolFileNotFoundError extends Typed<"SahPoolFileNotFound"> {
1617
+ readonly path: string;
1618
+ }
1619
+
1620
+ /**
1621
+ * No slot of the pool is free for a new file, which `xOpen` records when it
1622
+ * fails with SQLITE_CANTOPEN.
1623
+ */
1624
+ export interface SahPoolFullError extends Typed<"SahPoolFull"> {
1625
+ /** The number of slots, none of them free. */
1626
+ readonly capacity: number;
1627
+ }
1628
+
1629
+ /**
1630
+ * A name `xOpen`, `xDelete` or `xAccess` cannot map to a pool path, because the
1631
+ * suffix SQLite appends would land in its query, fragment or host, which would
1632
+ * map a database and its journal to the same file. `xOpen` also records it for
1633
+ * a path that would not fit a slot's header, or a database's path whose
1634
+ * super-journal's path would not.
1635
+ */
1636
+ export interface SahPoolInvalidPathError extends Typed<"SahPoolInvalidPath"> {
1637
+ readonly name: string;
1638
+ }
1639
+
1640
+ /**
1641
+ * An access to an encrypted database the pool cannot keep encrypted: a read or
1642
+ * write that is not a whole page, other than a read within page 1, or a page 1
1643
+ * that reserves other than 80 bytes or stores another page size than its
1644
+ * length, as a VACUUM that would change the page size writes it. `xRead`
1645
+ * records it when it fails with SQLITE_IOERR_READ and `xWrite` with
1646
+ * SQLITE_IOERR_WRITE. `xOpen` records it when it fails with SQLITE_CANTOPEN to
1647
+ * open the journal of an encrypted database opened without its key, which would
1648
+ * read the journal as stored, and `xFileControl` when it refuses to turn
1649
+ * `secure_delete` off for an encrypted database, which would leave freed pages
1650
+ * that fail to authenticate.
1651
+ */
1652
+ export interface SahPoolEncryptionUnsupportedError extends Typed<"SahPoolEncryptionUnsupported"> {}
1653
+
1654
+ /**
1655
+ * Access to the OPFS root, which `navigator.storage` provides in a dedicated
1656
+ * worker.
1657
+ *
1658
+ * It and the handle interfaces below declare only the part of the File System
1659
+ * API the pool uses, which the browser's objects implement, so a test can pass
1660
+ * a fake.
1661
+ */
1662
+ export interface OpfsRoot {
1663
+ readonly getDirectory: () => Promise<OpfsDirectoryHandle>;
1664
+ }
1665
+
1666
+ /** The part of `FileSystemDirectoryHandle` the pool uses. */
1667
+ export interface OpfsDirectoryHandle {
1668
+ readonly kind: "directory";
1669
+ readonly getDirectoryHandle: (
1670
+ name: string,
1671
+ options: { readonly create: true },
1672
+ ) => Promise<OpfsDirectoryHandle>;
1673
+ readonly getFileHandle: (
1674
+ name: string,
1675
+ options: { readonly create: true },
1676
+ ) => Promise<OpfsFileHandle>;
1677
+ readonly values: () => AsyncIterable<OpfsDirectoryHandle | OpfsFileHandle>;
1678
+ }
1679
+
1680
+ /** The part of `FileSystemFileHandle` the pool uses. */
1681
+ export interface OpfsFileHandle {
1682
+ readonly kind: "file";
1683
+ readonly name: string;
1684
+ readonly createSyncAccessHandle: () => Promise<OpfsSyncAccessHandle>;
1685
+ }
1686
+
1687
+ /** The part of `FileSystemSyncAccessHandle` the pool uses. */
1688
+ export interface OpfsSyncAccessHandle {
1689
+ readonly read: (
1690
+ buffer: Uint8Array<ArrayBuffer>,
1691
+ options: { readonly at: number },
1692
+ ) => number;
1693
+ readonly write: (
1694
+ buffer: Uint8Array<ArrayBuffer>,
1695
+ options: { readonly at: number },
1696
+ ) => number;
1697
+ readonly truncate: (newSize: number) => void;
1698
+ readonly getSize: () => number;
1699
+ readonly flush: () => void;
1700
+ readonly close: () => void;
1701
+ }
1702
+
1703
+ /** Dependency wrapper for {@link OpfsRoot}. */
1704
+ export interface OpfsRootDep {
1705
+ readonly opfsRoot: OpfsRoot;
1706
+ }
1707
+
1708
+ /** Error returned when a string is not a valid {@link OpfsName}. */
1709
+ export interface OpfsNameError extends TypeError<"OpfsName"> {
1710
+ readonly value: string;
1711
+ }
1712
+
1713
+ /**
1714
+ * The name of a file or directory in OPFS, a [valid file
1715
+ * name](https://fs.spec.whatwg.org/#valid-file-name) of the File System
1716
+ * Standard on every platform in one spelling: not empty, not `.` or `..`,
1717
+ * without `/` or `\`, which is a path separator on Windows, without NUL or a
1718
+ * lone surrogate, and in Unicode NFC.
1719
+ *
1720
+ * Names that differ only in case are different names, but in WebKit on macOS,
1721
+ * whose file system ignores case by default, they name one directory, and two
1722
+ * pools can hold it at once, because WebKit locks a file by its path as
1723
+ * spelled. Spell each directory in one case.
1724
+ */
1725
+ export const OpfsName = /*#__PURE__*/ brand(
1726
+ "OpfsName",
1727
+ String,
1728
+ (value) =>
1729
+ value === "" ||
1730
+ value === "." ||
1731
+ value === ".." ||
1732
+ // SQLite reads a VFS name only up to its first NUL, and Chromium a file
1733
+ // name, and the File System API and TextEncoder replace a lone surrogate
1734
+ // with U+FFFD, so such a name would share a VFS or directory with another.
1735
+ /[/\\\0\p{Cs}]/u.test(value) ||
1736
+ // WebKit on macOS keeps OPFS as files of a file system that ignores
1737
+ // Unicode normalization, but locks a file by its path as spelled, so two
1738
+ // normalizations of a name would both hold one directory.
1739
+ value.normalize("NFC") !== value
1740
+ ? err<OpfsNameError>({ type: "OpfsName", value })
1741
+ : ok(),
1742
+ (error) =>
1743
+ `The value ${safelyStringifyUnknownValue(error.value)} is not a valid OpfsName.`,
1744
+ );
1745
+ export type OpfsName = typeof OpfsName.Output;
1746
+
1747
+ /** The size of a slot's header, where the file's data starts. */
1748
+ export const sahPoolHeaderSize = 4096;
1749
+
1750
+ /** The bytes reserved for the NUL-padded path at the start of the header. */
1751
+ export const sahPoolHeaderPathSize = 512;
1752
+
1753
+ /**
1754
+ * The longest path of a database a pool opens, in UTF-8 bytes, 498. SQLite
1755
+ * names the other files of a database by appending a suffix to its path, at
1756
+ * most the 12 bytes of a super-journal's `-mjXXXXXX9XX`, and each path must fit
1757
+ * a slot's header with its NUL, as opfs-sahpool requires, which allows 510
1758
+ * bytes.
1759
+ */
1760
+ export const sahPoolMaxDatabasePathSize = sahPoolHeaderPathSize - 2 - 12;
1761
+
1762
+ /** Where the header's big-endian u32 flags start, after the path. */
1763
+ export const sahPoolHeaderFlagsOffset = 512;
1764
+
1765
+ /** The size of the path and flags, which the digest covers. */
1766
+ export const sahPoolHeaderCorpusSize = 516;
1767
+
1768
+ /** Where the header's digest, two platform-endian u32 values, starts. */
1769
+ export const sahPoolHeaderDigestOffset = 516;
1770
+
1771
+ /**
1772
+ * The flag that marks a header digest as version 2.
1773
+ *
1774
+ * Opfs-sahpool before SQLite 3.50 computed every digest as `[0, 0]` because of
1775
+ * an overflow. The fix repurposed SQLITE_OPEN_MEMORY, which never reaches a
1776
+ * VFS, to mark headers written with the fixed digest, so older headers stay
1777
+ * valid.
1778
+ */
1779
+ export const sahPoolDigestV2Flag = SQLITE_OPEN_MEMORY;
1780
+
1781
+ /**
1782
+ * The file types that persist across sessions: SQLITE_OPEN_MAIN_DB,
1783
+ * SQLITE_OPEN_MAIN_JOURNAL, SQLITE_OPEN_SUPER_JOURNAL and SQLITE_OPEN_WAL,
1784
+ * combined. A slot with another type is freed at setup.
1785
+ */
1786
+ export const sahPoolPersistentFileTypes =
1787
+ SQLITE_OPEN_MAIN_DB |
1788
+ SQLITE_OPEN_MAIN_JOURNAL |
1789
+ SQLITE_OPEN_SUPER_JOURNAL |
1790
+ SQLITE_OPEN_WAL;
1791
+
1792
+ /** The pool subdirectory that holds the slots. Renaming it orphans them. */
1793
+ export const sahPoolOpaqueDirectoryName = ".opaque";
1794
+
1795
+ // What opfs-sahpool's xSectorSize returns.
1796
+ const sahPoolSectorSize = 4096;
1797
+
1798
+ /** The number of slots setup ensures: a database, its journal, and spares. */
1799
+ export const sahPoolDefaultCapacity = 6;
1800
+
1801
+ /**
1802
+ * Computes the digest of a header's first {@link sahPoolHeaderCorpusSize} bytes,
1803
+ * as opfs-sahpool does.
1804
+ *
1805
+ * Without {@link sahPoolDigestV2Flag} in the flags, the digest is the legacy
1806
+ * `[0, 0]`. Otherwise `h1` starts at `0xdeadbeef` and `h2` at `0x41c6ce57`; for
1807
+ * each byte, `h1 = Math.imul(h1 ^ byte, 2654435761)` and `h2 = Math.imul(h2 ^
1808
+ * byte, 104729)`; the digest is `[h1 >>> 0, h2 >>> 0]`. The result's bytes are
1809
+ * written to the header as they are.
1810
+ */
1811
+ export const computeSahPoolDigest = (
1812
+ corpus: Uint8Array,
1813
+ flags: number,
1814
+ ): Uint32Array<ArrayBuffer> => {
1815
+ if ((flags & sahPoolDigestV2Flag) === 0) return new Uint32Array(2);
1816
+ let h1 = 0xdeadbeef;
1817
+ let h2 = 0x41c6ce57;
1818
+ for (const byte of corpus) {
1819
+ h1 = Math.imul(h1 ^ byte, 2654435761);
1820
+ h2 = Math.imul(h2 ^ byte, 104729);
1821
+ }
1822
+ return Uint32Array.of(h1 >>> 0, h2 >>> 0);
1823
+ };
1824
+
1825
+ const utf8Encoder = /*#__PURE__*/ new TextEncoder();
1826
+ // Keeps a leading byte order mark, which the default decoder strips, so a
1827
+ // header's path decodes as written.
1828
+ const utf8Decoder = /*#__PURE__*/ new TextDecoder("utf-8", { ignoreBOM: true });
1829
+
1830
+ /** A file of the pool and its sync access handle. */
1831
+ interface Slot {
1832
+ readonly fileName: string;
1833
+ readonly handle: OpfsSyncAccessHandle;
1834
+ }
1835
+
1836
+ /** The names of the VFS and I/O methods that need a pool's files. */
1837
+ type SahPoolMethodName =
1838
+ | "xOpen"
1839
+ | "xDelete"
1840
+ | "xAccess"
1841
+ | "xClose"
1842
+ | "xRead"
1843
+ | "xWrite"
1844
+ | "xTruncate"
1845
+ | "xSync"
1846
+ | "xFileSize"
1847
+ | "xLock"
1848
+ | "xUnlock"
1849
+ | "xCheckReservedLock"
1850
+ | "xFileControl";
1851
+
1852
+ /**
1853
+ * The methods that need a pool's files, with the arguments SQLite passes,
1854
+ * including the VFS or the file.
1855
+ */
1856
+ type SahPoolMethods = Readonly<
1857
+ Record<SahPoolMethodName, SqliteWasmFunction["fn"]>
1858
+ >;
1859
+
1860
+ /** A file SQLite opened. */
1861
+ interface OpenFile {
1862
+ readonly path: string;
1863
+ readonly slot: Slot;
1864
+ readonly lock: SqliteLockLevel;
1865
+ /** The name SQLite passed to `xOpen`, which a journal's name leads back to. */
1866
+ readonly zName: CStringPtr | 0;
1867
+ /** How the file is encrypted, or null for a file stored as SQLite writes it. */
1868
+ readonly cipher: FileCipher | null;
1869
+ }
1870
+
1871
+ /** How an encrypted file is encrypted. */
1872
+ type FileCipher = DatabaseCipher | JournalCipher;
1873
+
1874
+ /** A main database, encrypted page by page. */
1875
+ interface DatabaseCipher extends Typed<"Database"> {
1876
+ readonly codec: SahPoolCodec;
1877
+ }
1878
+
1879
+ /** A rollback journal, whose records' pages its database's codec encrypts. */
1880
+ interface JournalCipher extends Typed<"Journal"> {
1881
+ readonly codec: SahPoolCodec;
1882
+ readonly records: JournalRecords;
1883
+ }
1884
+
1885
+ /**
1886
+ * The lock of a path, as `unixInodeInfo` in `os_unix.c` keeps it: the level of
1887
+ * the connection that holds the most, and how many hold SHARED or more.
1888
+ */
1889
+ interface PathLock {
1890
+ readonly level: SqliteLockLevel;
1891
+ readonly shared: number;
1892
+ }
1893
+
1894
+ /** The VFS of a pool directory, registered once per instance. */
1895
+ interface SahPoolRegistration {
1896
+ /** The `sqlite3_io_methods` struct a successful `xOpen` sets. */
1897
+ readonly ioMethods: WasmPtr;
1898
+ /** Hands the VFS to a pool's methods, or pauses it with null. */
1899
+ readonly setMethods: (methods: SahPoolMethods | null) => void;
1900
+ }
1901
+
1902
+ // The VFS names of the pools each instance has open or is opening.
1903
+ const claimedVfsNamesByTable = /*#__PURE__*/ new WeakMap<
1904
+ WebAssembly.Table,
1905
+ Set<string>
1906
+ >();
1907
+
1908
+ // The registered VFS of each pool directory, by the instance's function table.
1909
+ const registrationsByTable = /*#__PURE__*/ new WeakMap<
1910
+ WebAssembly.Table,
1911
+ Map<string, SahPoolRegistration>
1912
+ >();
1913
+
1914
+ /**
1915
+ * Allocates and registers a VFS whose methods call the active pool's, never to
1916
+ * be freed.
1917
+ */
1918
+ const registerSahPoolVfs = (
1919
+ deps: RandomBytesDep & ReportDefectDep & SqliteWasmDep & TimeDep,
1920
+ vfsName: string,
1921
+ ): Result<
1922
+ SahPoolRegistration,
1923
+ SqliteNoMemError | SqliteWasmInitializeError
1924
+ > => {
1925
+ const { randomBytes, sqliteWasm, time } = deps;
1926
+
1927
+ // Null while paused, when no file can be open.
1928
+ let methods: SahPoolMethods | null = null;
1929
+
1930
+ const callMethod = (
1931
+ name: SahPoolMethodName,
1932
+ args: ReadonlyArray<unknown>,
1933
+ ): number => {
1934
+ assert(methods != null, "The pool is paused.");
1935
+ return (methods[name] as (...args: ReadonlyArray<unknown>) => number)(
1936
+ ...args,
1937
+ );
1938
+ };
1939
+ const forward =
1940
+ (name: SahPoolMethodName) =>
1941
+ (...args: ReadonlyArray<unknown>): number =>
1942
+ callMethod(name, args);
1943
+
1944
+ const { members } = sqlite3_vfs_layout;
1945
+ const vfsMethods: NonEmptyReadonlyArray<
1946
+ readonly [keyof typeof members, SqliteWasmFunction["fn"]]
1947
+ > = [
1948
+ [
1949
+ "xOpen",
1950
+ (
1951
+ vfs: SqliteVfsPtr,
1952
+ zName: CStringPtr,
1953
+ pFile: SqliteFilePtr,
1954
+ flags: number,
1955
+ pOutFlags: WasmPtr | 0,
1956
+ ) => {
1957
+ // A failed open leaves it NULL, so SQLite does not call xClose.
1958
+ sqliteWasm
1959
+ .getHeapDataView()
1960
+ .setInt32(
1961
+ pFile + sqlite3_file_layout.members.pMethods.offset,
1962
+ 0,
1963
+ true,
1964
+ );
1965
+ if (methods == null) return SQLITE_CANTOPEN;
1966
+ return callMethod("xOpen", [vfs, zName, pFile, flags, pOutFlags]);
1967
+ },
1968
+ ],
1969
+ [
1970
+ "xDelete",
1971
+ (...args: ReadonlyArray<unknown>) =>
1972
+ methods == null ? SQLITE_IOERR_DELETE : callMethod("xDelete", args),
1973
+ ],
1974
+ [
1975
+ "xAccess",
1976
+ (
1977
+ vfs: SqliteVfsPtr,
1978
+ zName: CStringPtr,
1979
+ flags: number,
1980
+ pResOut: WasmPtr,
1981
+ ) => {
1982
+ if (methods != null)
1983
+ return callMethod("xAccess", [vfs, zName, flags, pResOut]);
1984
+ sqliteWasm.getHeapDataView().setInt32(pResOut, 0, true);
1985
+ return SQLITE_OK;
1986
+ },
1987
+ ],
1988
+ [
1989
+ "xFullPathname",
1990
+ (_vfs: SqliteVfsPtr, zName: CStringPtr, nOut: number, pOut: WasmPtr) => {
1991
+ const heap = sqliteWasm.getHeapU8();
1992
+ // With its NUL.
1993
+ const length = heap.indexOf(0, zName) - zName + 1;
1994
+ if (length > nOut) return SQLITE_CANTOPEN;
1995
+ heap.copyWithin(pOut, zName, zName + length);
1996
+ return SQLITE_OK;
1997
+ },
1998
+ ],
1999
+ [
2000
+ "xRandomness",
2001
+ (_vfs: SqliteVfsPtr, byteLength: number, pOut: WasmPtr) => {
2002
+ // In chunks of at most 65536 bytes, as crypto.getRandomValues requires.
2003
+ for (let offset = 0; offset < byteLength; offset += 65536)
2004
+ sqliteWasm
2005
+ .getHeapU8()
2006
+ .set(
2007
+ randomBytes.create(Math.min(65536, byteLength - offset)),
2008
+ pOut + offset,
2009
+ );
2010
+ return byteLength;
2011
+ },
2012
+ ],
2013
+ // Contention can only come from connections in the same thread, which
2014
+ // sleeping cannot resolve.
2015
+ ["xSleep", () => 0],
2016
+ [
2017
+ "xCurrentTime",
2018
+ (_vfs: SqliteVfsPtr, pTime: WasmPtr) => {
2019
+ sqliteWasm
2020
+ .getHeapDataView()
2021
+ .setFloat64(pTime, time.now() / 86_400_000 + 2_440_587.5, true);
2022
+ return SQLITE_OK;
2023
+ },
2024
+ ],
2025
+ ["xGetLastError", () => 0],
2026
+ [
2027
+ "xCurrentTimeInt64",
2028
+ (_vfs: SqliteVfsPtr, pTime: WasmPtr) => {
2029
+ // Milliseconds since the Julian day epoch, noon in Greenwich on
2030
+ // November 24, 4714 BC.
2031
+ sqliteWasm
2032
+ .getHeapDataView()
2033
+ .setBigInt64(pTime, BigInt(time.now()) + 210_866_760_000_000n, true);
2034
+ return SQLITE_OK;
2035
+ },
2036
+ ],
2037
+ ];
2038
+ const ioMembers = sqlite3_io_methods_layout.members;
2039
+ const ioMethods: NonEmptyReadonlyArray<
2040
+ readonly [keyof typeof ioMembers, SqliteWasmFunction["fn"]]
2041
+ > = [
2042
+ ["xClose", forward("xClose")],
2043
+ ["xRead", forward("xRead")],
2044
+ ["xWrite", forward("xWrite")],
2045
+ ["xTruncate", forward("xTruncate")],
2046
+ ["xSync", forward("xSync")],
2047
+ ["xFileSize", forward("xFileSize")],
2048
+ ["xLock", forward("xLock")],
2049
+ ["xUnlock", forward("xUnlock")],
2050
+ ["xCheckReservedLock", forward("xCheckReservedLock")],
2051
+ ["xFileControl", forward("xFileControl")],
2052
+ ["xSectorSize", () => sahPoolSectorSize],
2053
+ // opfs-sahpool returns SQLITE_IOCAP_UNDELETABLE_WHEN_OPEN, which makes
2054
+ // SQLite keep a PERSIST or TRUNCATE journal open after it unlocks, but
2055
+ // xDelete frees the slot of an open file.
2056
+ ["xDeviceCharacteristics", () => 0],
2057
+ ];
2058
+ const name = utf8Encoder.encode(`${vfsName}\0`);
2059
+ // The VFS struct, the I/O methods struct and the name, in one block,
2060
+ // allocated through call, so a broken instance throws before anything is
2061
+ // allocated or installed.
2062
+ const allocated = sqliteWasm.call(() =>
2063
+ allocWasm(deps)(
2064
+ sqlite3_vfs_layout.sizeof +
2065
+ sqlite3_io_methods_layout.sizeof +
2066
+ name.length,
2067
+ ),
2068
+ );
2069
+ if (!allocated.ok) return allocated;
2070
+ const vfs = allocated.value as WasmPtr as SqliteVfsPtr;
2071
+ const ioMethodsPtr = (vfs + sqlite3_vfs_layout.sizeof) as WasmPtr;
2072
+ const zName = ioMethodsPtr + sqlite3_io_methods_layout.sizeof;
2073
+
2074
+ const installed = installWasmFunctions(deps)([
2075
+ ...mapArray(vfsMethods, ([member, fn]) => ({
2076
+ signature: members[member].signature,
2077
+ fn,
2078
+ })),
2079
+ ...mapArray(ioMethods, ([member, fn]) => ({
2080
+ signature: ioMembers[member].signature,
2081
+ fn,
2082
+ })),
2083
+ ]);
2084
+
2085
+ return sqliteWasm.call(() => {
2086
+ const heap = sqliteWasm.getHeapU8();
2087
+ heap.fill(0, vfs, zName);
2088
+ heap.set(name, zName);
2089
+ const view = sqliteWasm.getHeapDataView();
2090
+ for (const [member, value] of [
2091
+ ["iVersion", 2],
2092
+ ["szOsFile", sqlite3_file_layout.sizeof],
2093
+ ["mxPathname", sahPoolHeaderPathSize],
2094
+ ["zName", zName],
2095
+ ] as const)
2096
+ view.setInt32(vfs + members[member].offset, value, true);
2097
+ for (const [[member], pointer] of zipArray([
2098
+ vfsMethods,
2099
+ installed.pointers.slice(0, vfsMethods.length),
2100
+ ]))
2101
+ view.setInt32(vfs + members[member].offset, pointer, true);
2102
+ view.setInt32(ioMethodsPtr + ioMembers.iVersion.offset, 1, true);
2103
+ for (const [[member], pointer] of zipArray([
2104
+ ioMethods,
2105
+ installed.pointers.slice(vfsMethods.length),
2106
+ ]))
2107
+ view.setInt32(ioMethodsPtr + ioMembers[member].offset, pointer, true);
2108
+
2109
+ const code = sqliteWasm.exports.sqlite3_vfs_register(vfs, 0);
2110
+ if (code !== SQLITE_OK) {
2111
+ installed[Symbol.dispose]();
2112
+ sqliteWasm.exports.sqlite3_free(allocated.value);
2113
+ return err({ type: "SqliteWasmInitializeError", code });
2114
+ }
2115
+ return ok({
2116
+ ioMethods: ioMethodsPtr,
2117
+ setMethods: (newMethods) => {
2118
+ methods = newMethods;
2119
+ },
2120
+ });
2121
+ });
2122
+ };
2123
+
2124
+ /**
2125
+ * Writes a slot's header: its path, flags and digest, failing as
2126
+ * {@link writeBytes} does.
2127
+ */
2128
+ const writeHeader = (
2129
+ handle: OpfsSyncAccessHandle,
2130
+ path: string,
2131
+ flags: number,
2132
+ ): Result<void, unknown> => {
2133
+ const header = new Uint8Array(sahPoolHeaderDigestOffset + 8);
2134
+ utf8Encoder.encodeInto(path, header.subarray(0, sahPoolHeaderPathSize));
2135
+ new DataView(header.buffer).setUint32(sahPoolHeaderFlagsOffset, flags);
2136
+ header.set(
2137
+ new Uint8Array(
2138
+ computeSahPoolDigest(header.subarray(0, sahPoolHeaderCorpusSize), flags)
2139
+ .buffer,
2140
+ ),
2141
+ sahPoolHeaderDigestOffset,
2142
+ );
2143
+ return writeBytes(handle, header, 0);
2144
+ };
2145
+
2146
+ /**
2147
+ * Whether a path fits a slot's header with its NUL, as opfs-sahpool requires,
2148
+ * which allows 510 UTF-8 bytes.
2149
+ */
2150
+ const fitsHeader = (path: string): boolean =>
2151
+ utf8Encoder.encode(path).length < sahPoolHeaderPathSize - 1;
2152
+
2153
+ /** Returns a random name of letters and digits, as opfs-sahpool creates them. */
2154
+ const createRandomName = (random: Random): string =>
2155
+ random.next().toString(36).slice(2);
2156
+
2157
+ /** Whether a value is an error with the name, such as a `DOMException`. */
2158
+ const hasName = (error: unknown, name: string): boolean =>
2159
+ typeof error === "object" &&
2160
+ error !== null &&
2161
+ "name" in error &&
2162
+ error.name === name;
2163
+
2164
+ /**
2165
+ * Whether acquiring a sync access handle failed because another context or
2166
+ * instance holds the file.
2167
+ */
2168
+ const isHeldError = (error: unknown): boolean =>
2169
+ // WebKit rejects a held file with InvalidStateError, other engines with
2170
+ // NoModificationAllowedError, as the spec says
2171
+ // (https://bugs.webkit.org/show_bug.cgi?id=326135). WebKit also uses
2172
+ // InvalidStateError for a closed or invalid handle and a stopped context. A
2173
+ // retry acquires fresh handles from a new listing, and a stopped context
2174
+ // ends with its worker, so a bounded retry of those is harmless.
2175
+ hasName(error, "NoModificationAllowedError") ||
2176
+ hasName(error, "InvalidStateError");
2177
+
2178
+ /**
2179
+ * Writes all the bytes, failing with what the handle threw, or with a
2180
+ * {@link SqliteShortWriteError} when it wrote another count.
2181
+ */
2182
+ const writeBytes = (
2183
+ handle: OpfsSyncAccessHandle,
2184
+ bytes: Uint8Array<ArrayBuffer>,
2185
+ at: number,
2186
+ ): Result<void, unknown> => {
2187
+ const written = trySync(
2188
+ () => handle.write(bytes, { at }),
2189
+ (error) => error,
2190
+ );
2191
+ if (!written.ok) return written;
2192
+ if (written.value === bytes.length) return ok();
2193
+ return err({
2194
+ type: "SqliteShortWrite",
2195
+ requested: bytes.length,
2196
+ written: written.value,
2197
+ } satisfies SqliteShortWriteError);
2198
+ };
2199
+
2200
+ /**
2201
+ * Whether a write failed because storage is full: an error named
2202
+ * `QuotaExceededError`, matched by name so subclasses and non-DOMException
2203
+ * errors count, or a {@link SqliteShortWriteError}.
2204
+ */
2205
+ const isStorageFull = (error: unknown): boolean =>
2206
+ hasName(error, "QuotaExceededError") || isShortWrite(error);
2207
+
2208
+ /** Whether a write failed with a {@link SqliteShortWriteError}. */
2209
+ const isShortWrite = (error: unknown): error is SqliteShortWriteError =>
2210
+ typeof error === "object" &&
2211
+ error !== null &&
2212
+ "type" in error &&
2213
+ error.type === "SqliteShortWrite";
2214
+
2215
+ /**
2216
+ * Maps a name to the pool's path, as the module documentation describes.
2217
+ *
2218
+ * A name such as `//[` is not a valid URL. A query, a fragment or a host would
2219
+ * swallow the suffix SQLite appends, so `/a?b.db` and its journal
2220
+ * `/a?b.db-journal` would both be `/a`, and deleting the journal at commit
2221
+ * would delete the database.
2222
+ */
2223
+ const nameToPath = (name: string): Result<string, unknown> => {
2224
+ // The paths are compared rather than the host read, because engines parse
2225
+ // the host of a file URL differently: Chromium keeps localhost, so every
2226
+ // name has a host there (https://github.com/whatwg/url/issues/618), and
2227
+ // Firefox discards any host, so `//evolu.db` is `/` without one
2228
+ // (https://bugzilla.mozilla.org/show_bug.cgi?id=1507354).
2229
+ const paths = trySync(
2230
+ () =>
2231
+ [
2232
+ new URL(name, "file://localhost/").pathname,
2233
+ new URL(`${name}-journal`, "file://localhost/").pathname,
2234
+ ] as const,
2235
+ (error) => error,
2236
+ );
2237
+ if (!paths.ok) return paths;
2238
+ const [path, journalPath] = paths.value;
2239
+ if (journalPath !== `${path}-journal`)
2240
+ return err({
2241
+ type: "SahPoolInvalidPath",
2242
+ name,
2243
+ } satisfies SahPoolInvalidPathError);
2244
+ return ok(path);
2245
+ };
2246
+
2247
+ /** The encryption of a database file, over keys it zeroes when disposed. */
2248
+ interface SahPoolCodec extends Disposable {
2249
+ /**
2250
+ * Encrypts a whole page into a buffer of the codec, which stays valid until
2251
+ * the next call, or returns null for a page 1 that reserves other than the 80
2252
+ * bytes of the IV and the HMAC or stores another page size than its length.
2253
+ */
2254
+ readonly encryptPage: (
2255
+ pageNumber: number,
2256
+ page: Uint8Array,
2257
+ ) => Uint8Array<ArrayBuffer> | null;
2258
+
2259
+ /**
2260
+ * Authenticates a whole page and decrypts it in place, returning false and
2261
+ * leaving it as it was when its HMAC differs or, for page 1, when it does not
2262
+ * reserve 80 bytes. A page 1 that authenticates gives the codec its salt.
2263
+ */
2264
+ readonly decryptPage: (pageNumber: number, page: Uint8Array) => boolean;
2265
+
2266
+ /**
2267
+ * Takes the salt a database's page 1 stores, read as stored, until a page 1
2268
+ * that authenticates stores another.
2269
+ */
2270
+ readonly useSalt: (salt: Uint8Array) => void;
2271
+
2272
+ /**
2273
+ * Whether a page 1, of the database or of a journal record, has
2274
+ * authenticated, which proves the key right.
2275
+ */
2276
+ readonly isKeyProven: () => boolean;
2277
+ }
2278
+
2279
+ /** Creates the codec of a database file, which takes ownership of the key. */
2280
+ const createSahPoolCodec = ({
2281
+ key,
2282
+ randomBytes,
2283
+ }: {
2284
+ key: Uint8Array<ArrayBuffer>;
2285
+ randomBytes: RandomBytes;
2286
+ }): SahPoolCodec => {
2287
+ // Null until the codec reads or writes a page 1.
2288
+ let salted: SaltHmacKey | null = null;
2289
+ let output = new Uint8Array(0);
2290
+ // Never reset, because the key is fixed.
2291
+ let isPage1Authenticated = false;
2292
+ using disposer = new DisposableStack();
2293
+ disposer.defer(() => {
2294
+ key.fill(0);
2295
+ salted?.hmacKey.fill(0);
2296
+ });
2297
+
2298
+ // The codec's salt and HMAC key for its own salt, and otherwise a copy of
2299
+ // the salt with an HMAC key derived anew, which the caller adopts or zeroes.
2300
+ const hmacKeyForSalt = (salt: Uint8Array): SaltHmacKey => {
2301
+ if (salted != null && eqUint8Array(salted.salt, salt)) return salted;
2302
+ const copy = salt.slice();
2303
+ return {
2304
+ salt: copy,
2305
+ hmacKey: pbkdf2(sha512)(
2306
+ key,
2307
+ copy.map((byte) => byte ^ hmacSaltMask),
2308
+ { c: 2, dkLen: 32 },
2309
+ ),
2310
+ };
2311
+ };
2312
+
2313
+ // Makes a salt and its HMAC key the codec's, zeroing the previous HMAC key.
2314
+ const adoptSalt = (next: SaltHmacKey): void => {
2315
+ if (next === salted) return;
2316
+ salted?.hmacKey.fill(0);
2317
+ salted = next;
2318
+ };
2319
+
2320
+ // HMAC-SHA512 of the encrypted bytes, the IV and the page number.
2321
+ const computeHmac = (
2322
+ pageNumber: number,
2323
+ page: Uint8Array,
2324
+ pageHmacKey: Uint8Array,
2325
+ out: Uint8Array,
2326
+ ): void => {
2327
+ const pageNumberLe = new Uint8Array(4);
2328
+ new DataView(pageNumberLe.buffer).setUint32(0, pageNumber, true);
2329
+ hmac
2330
+ .create(sha512, pageHmacKey)
2331
+ .update(page.subarray(encryptedStart(pageNumber), page.length - hmacSize))
2332
+ .update(pageNumberLe)
2333
+ .digestInto(out);
2334
+ };
2335
+
2336
+ return disposable<SahPoolCodec>(
2337
+ {
2338
+ encryptPage: (pageNumber, page) => {
2339
+ // Byte 20 is the reserved bytes. A VACUUM that changes the page size
2340
+ // writes page 1 with the new size in pages of the old one.
2341
+ if (
2342
+ pageNumber === 1 &&
2343
+ (page[20] !== reservedSize || storedPageSize(page) !== page.length)
2344
+ )
2345
+ return null;
2346
+ if (output.length !== page.length) output = new Uint8Array(page.length);
2347
+ const start = encryptedStart(pageNumber);
2348
+ const end = page.length - reservedSize;
2349
+ salted ??= hmacKeyForSalt(randomBytes.create(saltSize));
2350
+ const { salt, hmacKey } = salted;
2351
+ const iv = randomBytes.create(ivSize);
2352
+ encryptCbcCs3(
2353
+ key,
2354
+ iv,
2355
+ page.subarray(start, end),
2356
+ output.subarray(start, end),
2357
+ );
2358
+ if (pageNumber === 1) {
2359
+ output.set(salt, 0);
2360
+ output.set(page.subarray(saltSize, encryptedStart(1)), saltSize);
2361
+ }
2362
+ output.set(iv, end);
2363
+ computeHmac(pageNumber, output, hmacKey, output.subarray(end + ivSize));
2364
+ return output;
2365
+ },
2366
+ decryptPage: (pageNumber, page) => {
2367
+ // The HMAC does not cover byte 20, the reserved bytes, with which
2368
+ // SQLite would read every page at another usable size.
2369
+ if (pageNumber === 1 && page[20] !== reservedSize) return false;
2370
+ const pageSalted =
2371
+ pageNumber === 1
2372
+ ? hmacKeyForSalt(page.subarray(0, saltSize))
2373
+ : salted;
2374
+ if (pageSalted == null) return false;
2375
+ const start = encryptedStart(pageNumber);
2376
+ const end = page.length - reservedSize;
2377
+ const expected = new Uint8Array(hmacSize);
2378
+ computeHmac(pageNumber, page, pageSalted.hmacKey, expected);
2379
+ // In constant time.
2380
+ let difference = 0;
2381
+ for (const [index, byte] of expected.entries())
2382
+ difference |= byte ^ page[end + ivSize + index];
2383
+ // A page 1 that fails to authenticate, such as a stale page in a
2384
+ // journal or one a power loss tore, would give its salt to every page
2385
+ // written later.
2386
+ if (difference !== 0) {
2387
+ if (pageSalted !== salted) pageSalted.hmacKey.fill(0);
2388
+ return false;
2389
+ }
2390
+ adoptSalt(pageSalted);
2391
+ decryptCbcCs3(
2392
+ key,
2393
+ page.slice(end, end + ivSize),
2394
+ page.subarray(start, end),
2395
+ page.subarray(start, end),
2396
+ );
2397
+ if (pageNumber === 1) {
2398
+ page.set(sqliteHeaderString);
2399
+ isPage1Authenticated = true;
2400
+ }
2401
+ return true;
2402
+ },
2403
+ useSalt: (salt) => {
2404
+ adoptSalt(hmacKeyForSalt(salt));
2405
+ },
2406
+ isKeyProven: () => isPage1Authenticated,
2407
+ },
2408
+ disposer,
2409
+ );
2410
+ };
2411
+
2412
+ /** A salt page 1 stores and the HMAC key derived from it and the key. */
2413
+ interface SaltHmacKey {
2414
+ readonly salt: Uint8Array<ArrayBuffer>;
2415
+ readonly hmacKey: Uint8Array;
2416
+ }
2417
+
2418
+ /**
2419
+ * Encrypts with AES-256-CBC, stealing ciphertext for a last partial block as
2420
+ * CBC-CS3 does. The output must not overlap the input.
2421
+ */
2422
+ const encryptCbcCs3 = (
2423
+ key: Uint8Array,
2424
+ iv: Uint8Array,
2425
+ input: Uint8Array,
2426
+ output: Uint8Array,
2427
+ ): void => {
2428
+ const tail = input.length % aesBlockSize;
2429
+ const whole = input.length - tail;
2430
+ cbc(key, iv, { disablePadding: true }).encrypt(
2431
+ input.subarray(0, whole),
2432
+ output.subarray(0, whole),
2433
+ );
2434
+ if (tail === 0) return;
2435
+ // The last whole block C becomes the encryption of C XOR the partial block
2436
+ // padded with zeros, computed in place, and the partial block the first bytes
2437
+ // of C, so no plaintext stays outside the input.
2438
+ const last = output.subarray(whole - aesBlockSize, whole);
2439
+ output.copyWithin(whole, whole - aesBlockSize, whole - aesBlockSize + tail);
2440
+ for (let index = 0; index < tail; index++)
2441
+ last[index] ^= input[whole + index];
2442
+ cbc(key, zeroIv, { disablePadding: true }).encrypt(last, last);
2443
+ };
2444
+
2445
+ /** Decrypts what {@link encryptCbcCs3} encrypted. The output may be the input. */
2446
+ const decryptCbcCs3 = (
2447
+ key: Uint8Array,
2448
+ iv: Uint8Array,
2449
+ input: Uint8Array,
2450
+ output: Uint8Array,
2451
+ ): void => {
2452
+ const tail = input.length % aesBlockSize;
2453
+ if (tail === 0) {
2454
+ cbc(key, iv, { disablePadding: true }).decrypt(input, output);
2455
+ return;
2456
+ }
2457
+ const whole = input.length - tail;
2458
+ // The ciphertext with the last whole block C restored: the partial block holds
2459
+ // its first bytes, and the last whole block decrypts to C XOR the partial
2460
+ // block padded with zeros, decrypted into the output, so no plaintext stays
2461
+ // outside it.
2462
+ const blocks = input.slice(0, whole);
2463
+ blocks.set(input.subarray(whole), whole - aesBlockSize);
2464
+ const last = output.subarray(whole - aesBlockSize, whole);
2465
+ cbc(key, zeroIv, { disablePadding: true }).decrypt(
2466
+ input.subarray(whole - aesBlockSize, whole),
2467
+ last,
2468
+ );
2469
+ blocks.set(last.subarray(tail), whole - aesBlockSize + tail);
2470
+ for (let index = 0; index < tail; index++)
2471
+ output[whole + index] = last[index] ^ blocks[whole - aesBlockSize + index];
2472
+ cbc(key, iv, { disablePadding: true }).decrypt(
2473
+ blocks,
2474
+ output.subarray(0, whole),
2475
+ );
2476
+ };
2477
+
2478
+ /** Tells which reads and writes of a rollback journal are a record's page. */
2479
+ interface JournalRecords {
2480
+ /**
2481
+ * Takes the offset and the bytes of a read, after it, or of a write, before
2482
+ * it, and returns the number of the page they are, or null.
2483
+ */
2484
+ readonly track: (at: number, bytes: Uint8Array) => number | null;
2485
+ }
2486
+
2487
+ /**
2488
+ * Creates the {@link JournalRecords} of a journal: a page's size at offset P +
2489
+ * 4, right after 4 bytes at P, is the page those bytes number, unless they were
2490
+ * the checksum right after the previous record's page.
2491
+ */
2492
+ const createJournalRecords = (): JournalRecords => {
2493
+ // Where the page whose number was just read or written starts, and the
2494
+ // number, 0 for none.
2495
+ let pageAt = -1;
2496
+ let pageNumber = 0;
2497
+ // Where the checksum after the page just read or written starts.
2498
+ let checksumAt = -1;
2499
+ return {
2500
+ track: (at, bytes) => {
2501
+ const number = pageNumber;
2502
+ const isPage = number > 0 && at === pageAt && isPageSize(bytes.length);
2503
+ const isNumber = bytes.length === 4 && at !== checksumAt;
2504
+ pageAt = isNumber ? at + 4 : -1;
2505
+ pageNumber = isNumber
2506
+ ? new DataView(bytes.buffer, bytes.byteOffset, 4).getUint32(0)
2507
+ : 0;
2508
+ checksumAt = isPage ? at + bytes.length : -1;
2509
+ return isPage ? number : null;
2510
+ },
2511
+ };
2512
+ };
2513
+
2514
+ /**
2515
+ * Authenticates a copy of the page of a rollback journal's first page-1 record,
2516
+ * which proves the key and gives the codec its salt, and zeroes the copy.
2517
+ * Returns false when the page fails to authenticate or the journal has no such
2518
+ * record.
2519
+ *
2520
+ * It reads the journal much as SQLite reads a hot one: the sector and page
2521
+ * sizes from the first header, and segments that each start with a header at a
2522
+ * multiple of the sector size, with SQLite's magic and the number of records
2523
+ * that follow it, where 0xFFFFFFFF counts the records up to the end of the
2524
+ * file. A count of 0 does too, although a hot journal's rollback takes it as
2525
+ * none: any page 1 that authenticates stores the database's salt, so a page 1
2526
+ * past the records SQLite would play back still gives the right salt. A record
2527
+ * is the page number in 4 bytes, big-endian, the page and a 4-byte checksum.
2528
+ */
2529
+ const authenticateJournalPage1 = (
2530
+ handle: OpfsSyncAccessHandle,
2531
+ codec: SahPoolCodec,
2532
+ ): boolean => {
2533
+ const size = handle.getSize() - sahPoolHeaderSize;
2534
+ const read = (bytes: Uint8Array<ArrayBuffer>, at: number): void => {
2535
+ handle.read(bytes, { at: sahPoolHeaderSize + at });
2536
+ };
2537
+ // A journal shorter than a header has zeros for both sizes.
2538
+ const header = new Uint8Array(28);
2539
+ const view = new DataView(header.buffer);
2540
+ read(header, 0);
2541
+ const sectorSize = view.getUint32(20);
2542
+ const pageSize = view.getUint32(24);
2543
+ if (
2544
+ sectorSize < 32 ||
2545
+ sectorSize > 65536 ||
2546
+ (sectorSize & (sectorSize - 1)) !== 0 ||
2547
+ !isPageSize(pageSize)
2548
+ )
2549
+ return false;
2550
+ const recordSize = pageSize + 8;
2551
+ const pageNumber = new Uint8Array(4);
2552
+ for (let at = 0; at + sectorSize <= size;) {
2553
+ read(header, at);
2554
+ if (!eqUint8Array(header.subarray(0, 8), journalMagic)) break;
2555
+ const count = view.getUint32(8);
2556
+ const end =
2557
+ count === 0 || count === 0xffffffff
2558
+ ? size
2559
+ : Math.min(size, at + sectorSize + count * recordSize);
2560
+ let recordAt = at + sectorSize;
2561
+ for (; recordAt + recordSize <= end; recordAt += recordSize) {
2562
+ read(pageNumber, recordAt);
2563
+ if (new DataView(pageNumber.buffer).getUint32(0) !== 1) continue;
2564
+ const page = new Uint8Array(pageSize);
2565
+ read(page, recordAt + 4);
2566
+ const authenticated = codec.decryptPage(1, page);
2567
+ page.fill(0);
2568
+ return authenticated;
2569
+ }
2570
+ at = Math.ceil(recordAt / sectorSize) * sectorSize;
2571
+ }
2572
+ return false;
2573
+ };
2574
+
2575
+ /**
2576
+ * The number of the page a database read or write of whole page is, null for
2577
+ * one that is not.
2578
+ */
2579
+ const toPageNumber = (at: number, byteLength: number): number | null =>
2580
+ isPageSize(byteLength) && at % byteLength === 0 ? at / byteLength + 1 : null;
2581
+
2582
+ /**
2583
+ * The page size page 1 stores unencrypted in bytes 16 and 17, big-endian, with
2584
+ * 1 for 65536, which may be no page size.
2585
+ */
2586
+ const storedPageSize = (page1: Uint8Array): number => {
2587
+ const size = (page1[16] << 8) | page1[17];
2588
+ return size === 1 ? 65536 : size;
2589
+ };
2590
+
2591
+ /** Whether a byte count is a page size: a power of two from 512 to 65536. */
2592
+ const isPageSize = (byteLength: number): boolean =>
2593
+ byteLength >= 512 &&
2594
+ byteLength <= 65536 &&
2595
+ (byteLength & (byteLength - 1)) === 0;
2596
+
2597
+ /** Where the encrypted bytes of a page start. */
2598
+ const encryptedStart = (pageNumber: number): number =>
2599
+ pageNumber === 1 ? 24 : 0;
2600
+
2601
+ const aesBlockSize = 16;
2602
+ const saltSize = 16;
2603
+ const ivSize = 16;
2604
+ const hmacSize = 64;
2605
+ // The IV and the HMAC at the end of every page.
2606
+ const reservedSize = ivSize + hmacSize;
2607
+ const hmacSaltMask = 0x3a;
2608
+ const zeroIv = /*#__PURE__*/ new Uint8Array(aesBlockSize);
2609
+ const encryptionUnsupported: SahPoolEncryptionUnsupportedError = {
2610
+ type: "SahPoolEncryptionUnsupported",
2611
+ };
2612
+ // The values of PRAGMA secure_delete an encrypted database takes, in lower
2613
+ // case, as SQLite compares them without case.
2614
+ const secureDeleteOnValues = /*#__PURE__*/ new Set(["on", "yes", "true", "1"]);
2615
+ const sqliteHeaderString =
2616
+ /*#__PURE__*/ utf8Encoder.encode("SQLite format 3\0");
2617
+ // What starts every header of a rollback journal.
2618
+ const journalMagic = /*#__PURE__*/ Uint8Array.of(
2619
+ 0xd9,
2620
+ 0xd5,
2621
+ 0x05,
2622
+ 0xf9,
2623
+ 0x20,
2624
+ 0xa1,
2625
+ 0x63,
2626
+ 0xd7,
2627
+ );