|
| 1 | +import { normalizePoolDirectory } from './pool_lock.js'; |
| 2 | + |
| 3 | +// The constants below mirror the opaque-file header format of the pinned opfs-sahpool VFS |
| 4 | +// (`yarn-project/sqlite3mc-wasm/vendor/jswasm/sqlite3.mjs`, class `OpfsSAHPool`). The pool names its files randomly |
| 5 | +// under `.opaque/` and prepends a header that maps each one back to its logical SQLite path: |
| 6 | +// |
| 7 | +// bytes [0, 512) logical path, NUL-terminated UTF-8 (HEADER_MAX_PATH_SIZE) |
| 8 | +// bytes [512, 516) SQLite open-flags of the file, big-endian uint32 (HEADER_FLAGS_SIZE) |
| 9 | +// bytes [516, 524) digest over the preceding 516 bytes, two uint32 words (HEADER_DIGEST_SIZE) |
| 10 | +// |
| 11 | +// Database content starts at byte 4096 (the pool's SECTOR_SIZE). We re-read the header ourselves rather than asking |
| 12 | +// the VFS because the upstream pool "repairs" anything it cannot validate by disassociating the file — destroying |
| 13 | +// exactly the evidence this module exists to quarantine. The browser regression test writes a real pool through the |
| 14 | +// pinned VFS before duplicating an opaque file, so a vendor upgrade that changes the layout breaks detection loudly |
| 15 | +// rather than silently. |
| 16 | +const OPAQUE_DIRECTORY = '.opaque'; |
| 17 | +const HEADER_MAX_PATH_SIZE = 512; |
| 18 | +const HEADER_FLAGS_SIZE = 4; |
| 19 | +const HEADER_DIGEST_SIZE = 8; |
| 20 | +const HEADER_CORPUS_SIZE = HEADER_MAX_PATH_SIZE + HEADER_FLAGS_SIZE; |
| 21 | +const HEADER_SIZE = HEADER_CORPUS_SIZE + HEADER_DIGEST_SIZE; |
| 22 | + |
| 23 | +// Standard SQLite open-flag bit values (sqlite3.h). Restated as literals because the header stores them numerically |
| 24 | +// and this module runs on the main thread, without the sqlite3 WASM bundle that defines `capi.SQLITE_OPEN_*`. |
| 25 | +const SQLITE_OPEN_DELETEONCLOSE = 0x00000008; |
| 26 | +const SQLITE_OPEN_MEMORY = 0x00000080; |
| 27 | +const SQLITE_OPEN_MAIN_DB = 0x00000100; |
| 28 | +const SQLITE_OPEN_MAIN_JOURNAL = 0x00000800; |
| 29 | +const SQLITE_OPEN_SUPER_JOURNAL = 0x00004000; |
| 30 | +const SQLITE_OPEN_WAL = 0x00080000; |
| 31 | + |
| 32 | +// A live association must name one of the file types the pool persists; transient types (temp DBs, statement |
| 33 | +// journals) never survive in a valid header. |
| 34 | +const PERSISTENT_FILE_TYPES = |
| 35 | + SQLITE_OPEN_MAIN_DB | SQLITE_OPEN_MAIN_JOURNAL | SQLITE_OPEN_SUPER_JOURNAL | SQLITE_OPEN_WAL; |
| 36 | + |
| 37 | +// The upstream VFS repurposes SQLITE_OPEN_MEMORY — meaningless for a file that exists on disk — as a header version |
| 38 | +// marker: headers written with it set carry a real digest, while legacy headers leave it unset and store all-zero |
| 39 | +// digest words. |
| 40 | +const FLAG_COMPUTE_DIGEST_V2 = SQLITE_OPEN_MEMORY; |
| 41 | + |
| 42 | +export const OPFS_QUARANTINE_ROOT_DIRECTORY = '.aztec-sqlite-quarantine'; |
| 43 | + |
| 44 | +export interface DuplicatePoolAssociation { |
| 45 | + logicalPath: string; |
| 46 | + opaqueFileNames: string[]; |
| 47 | +} |
| 48 | + |
| 49 | +export interface PoolQuarantineMetadata { |
| 50 | + formatVersion: 1; |
| 51 | + originalPoolDirectory: string; |
| 52 | + quarantinedAt: string; |
| 53 | + duplicateAssociations: DuplicatePoolAssociation[]; |
| 54 | +} |
| 55 | + |
| 56 | +export interface PoolQuarantineResult extends PoolQuarantineMetadata { |
| 57 | + quarantineDirectory: string; |
| 58 | +} |
| 59 | + |
| 60 | +/** |
| 61 | + * Detects duplicate SAH logical-file associations and, if found, copies the complete pool into quarantine before |
| 62 | + * removing the original. The caller must hold the pool's exclusive Web Lock for the whole operation. |
| 63 | + */ |
| 64 | +export async function quarantineDuplicatePool(poolDirectory: string): Promise<PoolQuarantineResult | undefined> { |
| 65 | + poolDirectory = normalizePoolDirectory(poolDirectory); |
| 66 | + const root = await navigator.storage.getDirectory(); |
| 67 | + const source = await getDirectory(root, poolDirectory); |
| 68 | + if (!source) { |
| 69 | + return undefined; |
| 70 | + } |
| 71 | + const opaque = await getChildDirectory(source, OPAQUE_DIRECTORY); |
| 72 | + if (!opaque) { |
| 73 | + return undefined; |
| 74 | + } |
| 75 | + |
| 76 | + const duplicateAssociations = await findDuplicateAssociations(opaque); |
| 77 | + if (duplicateAssociations.length === 0) { |
| 78 | + return undefined; |
| 79 | + } |
| 80 | + |
| 81 | + const quarantineRoot = await root.getDirectoryHandle(OPFS_QUARANTINE_ROOT_DIRECTORY, { create: true }); |
| 82 | + const quarantineName = createQuarantineName(); |
| 83 | + const destination = await quarantineRoot.getDirectoryHandle(quarantineName, { create: true }); |
| 84 | + const metadata: PoolQuarantineMetadata = { |
| 85 | + formatVersion: 1, |
| 86 | + originalPoolDirectory: poolDirectory, |
| 87 | + quarantinedAt: new Date().toISOString(), |
| 88 | + duplicateAssociations, |
| 89 | + }; |
| 90 | + |
| 91 | + let quarantineComplete = false; |
| 92 | + try { |
| 93 | + await copyDirectory(source, destination); |
| 94 | + await verifyDirectoryCopy(source, destination); |
| 95 | + await writeJson(destination, 'quarantine.json', metadata); |
| 96 | + quarantineComplete = true; |
| 97 | + await removeDirectory(root, poolDirectory); |
| 98 | + } catch (err) { |
| 99 | + if (!quarantineComplete) { |
| 100 | + await quarantineRoot.removeEntry(quarantineName, { recursive: true }).catch(() => {}); |
| 101 | + } |
| 102 | + throw err; |
| 103 | + } |
| 104 | + |
| 105 | + return { |
| 106 | + ...metadata, |
| 107 | + quarantineDirectory: `${OPFS_QUARANTINE_ROOT_DIRECTORY}/${quarantineName}`, |
| 108 | + }; |
| 109 | +} |
| 110 | + |
| 111 | +async function findDuplicateAssociations(opaque: FileSystemDirectoryHandle): Promise<DuplicatePoolAssociation[]> { |
| 112 | + const associations = new Map<string, string[]>(); |
| 113 | + for await (const [opaqueName, handle] of opaque.entries()) { |
| 114 | + if (handle.kind !== 'file') { |
| 115 | + continue; |
| 116 | + } |
| 117 | + const logicalPath = await readAssociatedPath(handle as FileSystemFileHandle); |
| 118 | + if (logicalPath) { |
| 119 | + const names = associations.get(logicalPath) ?? []; |
| 120 | + names.push(opaqueName); |
| 121 | + associations.set(logicalPath, names); |
| 122 | + } |
| 123 | + } |
| 124 | + return [...associations.entries()] |
| 125 | + .filter(([, names]) => names.length > 1) |
| 126 | + .map(([logicalPath, opaqueFileNames]) => ({ logicalPath, opaqueFileNames: opaqueFileNames.sort() })) |
| 127 | + .sort((a, b) => a.logicalPath.localeCompare(b.logicalPath)); |
| 128 | +} |
| 129 | + |
| 130 | +/** |
| 131 | + * Returns the logical SQLite path an opaque SAH file is associated with, or undefined if the file is not a live, |
| 132 | + * valid association. Applies the same checks as the vendored pool's `getAssociatedPath`: a non-empty NUL-terminated |
| 133 | + * path, open-flags naming a persistent file type without DELETEONCLOSE, and a matching header digest. Files failing |
| 134 | + * any check are the pool's free-list or garbage entries — the VFS itself would disassociate them on open — so they |
| 135 | + * cannot participate in a duplicate mapping. |
| 136 | + */ |
| 137 | +async function readAssociatedPath(handle: FileSystemFileHandle): Promise<string | undefined> { |
| 138 | + const file = await handle.getFile(); |
| 139 | + if (file.size < HEADER_SIZE) { |
| 140 | + return undefined; |
| 141 | + } |
| 142 | + const header = new Uint8Array(await file.slice(0, HEADER_SIZE).arrayBuffer()); |
| 143 | + const pathEnd = header.subarray(0, HEADER_MAX_PATH_SIZE).indexOf(0); |
| 144 | + if (pathEnd <= 0) { |
| 145 | + return undefined; |
| 146 | + } |
| 147 | + |
| 148 | + const dataView = new DataView(header.buffer, header.byteOffset, header.byteLength); |
| 149 | + const flags = dataView.getUint32(HEADER_MAX_PATH_SIZE); |
| 150 | + if ((flags & SQLITE_OPEN_DELETEONCLOSE) !== 0 || (flags & PERSISTENT_FILE_TYPES) === 0) { |
| 151 | + return undefined; |
| 152 | + } |
| 153 | + if (!hasValidDigest(header, flags)) { |
| 154 | + return undefined; |
| 155 | + } |
| 156 | + |
| 157 | + try { |
| 158 | + return new TextDecoder('utf-8', { fatal: true }).decode(header.subarray(0, pathEnd)); |
| 159 | + } catch { |
| 160 | + return undefined; |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +/** |
| 165 | + * Byte-for-byte port of the vendored pool's `computeDigest`, checked against the digest words stored in the header. |
| 166 | + */ |
| 167 | +function hasValidDigest(header: Uint8Array, flags: number): boolean { |
| 168 | + let expected0 = 0; |
| 169 | + let expected1 = 0; |
| 170 | + if ((flags & FLAG_COMPUTE_DIGEST_V2) !== 0) { |
| 171 | + // These seeds (0xdeadbeef, 0x41c6ce57) and odd multipliers (2654435761, 104729) are the upstream author's choices |
| 172 | + // (a cyrb53-hash variant) and carry no meaning here beyond having to match the vendored implementation bit for |
| 173 | + // bit. |
| 174 | + expected0 = 0xdeadbeef; |
| 175 | + expected1 = 0x41c6ce57; |
| 176 | + for (const value of header.subarray(0, HEADER_CORPUS_SIZE)) { |
| 177 | + expected0 = Math.imul(expected0 ^ value, 2654435761); |
| 178 | + expected1 = Math.imul(expected1 ^ value, 104729); |
| 179 | + } |
| 180 | + expected0 >>>= 0; |
| 181 | + expected1 >>>= 0; |
| 182 | + } |
| 183 | + const dataView = new DataView(header.buffer, header.byteOffset, header.byteLength); |
| 184 | + return ( |
| 185 | + dataView.getUint32(HEADER_CORPUS_SIZE, true) === expected0 && |
| 186 | + dataView.getUint32(HEADER_CORPUS_SIZE + 4, true) === expected1 |
| 187 | + ); |
| 188 | +} |
| 189 | + |
| 190 | +async function getDirectory( |
| 191 | + root: FileSystemDirectoryHandle, |
| 192 | + path: string, |
| 193 | +): Promise<FileSystemDirectoryHandle | undefined> { |
| 194 | + let current = root; |
| 195 | + try { |
| 196 | + for (const segment of path.split('/')) { |
| 197 | + current = await current.getDirectoryHandle(segment); |
| 198 | + } |
| 199 | + return current; |
| 200 | + } catch (err) { |
| 201 | + if (err instanceof DOMException && err.name === 'NotFoundError') { |
| 202 | + return undefined; |
| 203 | + } |
| 204 | + throw err; |
| 205 | + } |
| 206 | +} |
| 207 | + |
| 208 | +async function getChildDirectory( |
| 209 | + parent: FileSystemDirectoryHandle, |
| 210 | + name: string, |
| 211 | +): Promise<FileSystemDirectoryHandle | undefined> { |
| 212 | + try { |
| 213 | + return await parent.getDirectoryHandle(name); |
| 214 | + } catch (err) { |
| 215 | + if (err instanceof DOMException && err.name === 'NotFoundError') { |
| 216 | + return undefined; |
| 217 | + } |
| 218 | + throw err; |
| 219 | + } |
| 220 | +} |
| 221 | + |
| 222 | +async function removeDirectory(root: FileSystemDirectoryHandle, path: string): Promise<void> { |
| 223 | + const segments = path.split('/'); |
| 224 | + const name = segments.pop()!; |
| 225 | + let parent = root; |
| 226 | + for (const segment of segments) { |
| 227 | + parent = await parent.getDirectoryHandle(segment); |
| 228 | + } |
| 229 | + await parent.removeEntry(name, { recursive: true }); |
| 230 | +} |
| 231 | + |
| 232 | +async function copyDirectory(source: FileSystemDirectoryHandle, destination: FileSystemDirectoryHandle): Promise<void> { |
| 233 | + for await (const [name, handle] of source.entries()) { |
| 234 | + if (handle.kind === 'directory') { |
| 235 | + const childDestination = await destination.getDirectoryHandle(name, { create: true }); |
| 236 | + await copyDirectory(handle as FileSystemDirectoryHandle, childDestination); |
| 237 | + continue; |
| 238 | + } |
| 239 | + |
| 240 | + const sourceFile = await (handle as FileSystemFileHandle).getFile(); |
| 241 | + const destinationHandle = await destination.getFileHandle(name, { create: true }); |
| 242 | + const writable = await destinationHandle.createWritable(); |
| 243 | + try { |
| 244 | + await writable.write(sourceFile); |
| 245 | + await writable.close(); |
| 246 | + } catch (err) { |
| 247 | + await writable.abort().catch(() => {}); |
| 248 | + throw err; |
| 249 | + } |
| 250 | + } |
| 251 | +} |
| 252 | + |
| 253 | +async function verifyDirectoryCopy( |
| 254 | + source: FileSystemDirectoryHandle, |
| 255 | + destination: FileSystemDirectoryHandle, |
| 256 | +): Promise<void> { |
| 257 | + const sourceEntries = await getSortedEntries(source); |
| 258 | + const destinationEntries = await getSortedEntries(destination); |
| 259 | + if ( |
| 260 | + sourceEntries.length !== destinationEntries.length || |
| 261 | + sourceEntries.some(([name, handle], index) => { |
| 262 | + const destinationEntry = destinationEntries[index]; |
| 263 | + return name !== destinationEntry[0] || handle.kind !== destinationEntry[1].kind; |
| 264 | + }) |
| 265 | + ) { |
| 266 | + throw new Error('Failed to verify quarantined OPFS directory structure'); |
| 267 | + } |
| 268 | + |
| 269 | + for (let i = 0; i < sourceEntries.length; i++) { |
| 270 | + const [, sourceHandle] = sourceEntries[i]; |
| 271 | + const [, destinationHandle] = destinationEntries[i]; |
| 272 | + if (sourceHandle.kind === 'directory') { |
| 273 | + await verifyDirectoryCopy( |
| 274 | + sourceHandle as FileSystemDirectoryHandle, |
| 275 | + destinationHandle as FileSystemDirectoryHandle, |
| 276 | + ); |
| 277 | + } else { |
| 278 | + await verifyFilesEqual(sourceHandle as FileSystemFileHandle, destinationHandle as FileSystemFileHandle); |
| 279 | + } |
| 280 | + } |
| 281 | +} |
| 282 | + |
| 283 | +async function getSortedEntries(directory: FileSystemDirectoryHandle): Promise<[string, FileSystemHandle][]> { |
| 284 | + const entries: [string, FileSystemHandle][] = []; |
| 285 | + for await (const entry of directory.entries()) { |
| 286 | + entries.push(entry); |
| 287 | + } |
| 288 | + return entries.sort(([a], [b]) => a.localeCompare(b)); |
| 289 | +} |
| 290 | + |
| 291 | +async function verifyFilesEqual(sourceHandle: FileSystemFileHandle, destinationHandle: FileSystemFileHandle) { |
| 292 | + const source = await sourceHandle.getFile(); |
| 293 | + const destination = await destinationHandle.getFile(); |
| 294 | + if (source.size !== destination.size) { |
| 295 | + throw new Error(`Failed to verify quarantined OPFS file "${source.name}"`); |
| 296 | + } |
| 297 | + |
| 298 | + const chunkSize = 1024 * 1024; |
| 299 | + for (let offset = 0; offset < source.size; offset += chunkSize) { |
| 300 | + const [sourceChunk, destinationChunk] = await Promise.all([ |
| 301 | + source.slice(offset, offset + chunkSize).arrayBuffer(), |
| 302 | + destination.slice(offset, offset + chunkSize).arrayBuffer(), |
| 303 | + ]); |
| 304 | + const sourceBytes = new Uint8Array(sourceChunk); |
| 305 | + const destinationBytes = new Uint8Array(destinationChunk); |
| 306 | + if (sourceBytes.some((value, index) => value !== destinationBytes[index])) { |
| 307 | + throw new Error(`Failed to verify quarantined OPFS file "${source.name}"`); |
| 308 | + } |
| 309 | + } |
| 310 | +} |
| 311 | + |
| 312 | +async function writeJson(directory: FileSystemDirectoryHandle, name: string, value: unknown): Promise<void> { |
| 313 | + const handle = await directory.getFileHandle(name, { create: true }); |
| 314 | + const writable = await handle.createWritable(); |
| 315 | + try { |
| 316 | + await writable.write(JSON.stringify(value, undefined, 2)); |
| 317 | + await writable.close(); |
| 318 | + } catch (err) { |
| 319 | + await writable.abort().catch(() => {}); |
| 320 | + throw err; |
| 321 | + } |
| 322 | +} |
| 323 | + |
| 324 | +function createQuarantineName(): string { |
| 325 | + const random = globalThis.crypto.getRandomValues(new Uint8Array(8)); |
| 326 | + return `${Date.now()}-${[...random].map(byte => byte.toString(16).padStart(2, '0')).join('')}`; |
| 327 | +} |
0 commit comments