@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.
- package/LICENSE +21 -0
- package/README.md +55 -218
- package/dist/src/CApi.d.ts +2108 -0
- package/dist/src/CApi.d.ts.map +1 -0
- package/dist/src/CApi.js +1919 -0
- package/dist/src/Constants.d.ts +868 -0
- package/dist/src/Constants.d.ts.map +1 -0
- package/dist/src/Constants.js +602 -0
- package/dist/src/Database.d.ts +641 -0
- package/dist/src/Database.d.ts.map +1 -0
- package/dist/src/Database.js +1177 -0
- package/dist/src/Memory.d.ts +119 -0
- package/dist/src/Memory.d.ts.map +1 -0
- package/dist/src/Memory.js +207 -0
- package/dist/src/Pointer.d.ts +100 -0
- package/dist/src/Pointer.d.ts.map +1 -0
- package/dist/src/Pointer.js +15 -0
- package/dist/src/SahPool.d.ts +744 -0
- package/dist/src/SahPool.d.ts.map +1 -0
- package/dist/src/SahPool.js +1985 -0
- package/dist/src/Wasm.d.ts +315 -0
- package/dist/src/Wasm.d.ts.map +1 -0
- package/dist/src/Wasm.js +756 -0
- package/dist/src/WasmUrl.d.ts +21 -0
- package/dist/src/WasmUrl.d.ts.map +1 -0
- package/dist/src/WasmUrl.js +20 -0
- package/dist/src/c-api/index.d.ts +9 -0
- package/dist/src/c-api/index.d.ts.map +1 -0
- package/dist/src/c-api/index.js +8 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +13 -0
- package/dist/wasm/sqlite3.wasm +0 -0
- package/package.json +60 -60
- package/src/CApi.test.ts +217 -0
- package/src/CApi.ts +3294 -0
- package/src/Constants.ts +803 -0
- package/src/Database.ts +1714 -0
- package/src/Memory.test.ts +319 -0
- package/src/Memory.ts +237 -0
- package/src/Pointer.ts +121 -0
- package/src/SahPool.test.ts +180 -0
- package/src/SahPool.ts +2627 -0
- package/src/Wasm.test.ts +444 -0
- package/src/Wasm.ts +1026 -0
- package/src/WasmUrl.ts +26 -0
- package/src/c-api/index.ts +9 -0
- package/src/index.ts +15 -0
- package/bin/index.js +0 -110
- package/index.d.ts +0 -8118
- package/index.mjs +0 -7
- package/node.mjs +0 -3
- package/sqlite-wasm/jswasm/sqlite3-bundler-friendly.mjs +0 -13659
- package/sqlite-wasm/jswasm/sqlite3-node.mjs +0 -11671
- package/sqlite-wasm/jswasm/sqlite3-opfs-async-proxy.js +0 -691
- package/sqlite-wasm/jswasm/sqlite3-worker1-bundler-friendly.mjs +0 -35
- package/sqlite-wasm/jswasm/sqlite3-worker1-promiser.js +0 -193
- package/sqlite-wasm/jswasm/sqlite3-worker1-promiser.mjs +0 -187
- package/sqlite-wasm/jswasm/sqlite3-worker1.js +0 -46
- package/sqlite-wasm/jswasm/sqlite3.js +0 -13697
- package/sqlite-wasm/jswasm/sqlite3.mjs +0 -13661
- 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
|
+
);
|