diff --git a/integration/tests/12-electron-mail-index.spec.ts b/integration/tests/12-electron-mail-index.spec.ts new file mode 100644 index 00000000..c0a638e3 --- /dev/null +++ b/integration/tests/12-electron-mail-index.spec.ts @@ -0,0 +1,508 @@ +import { test, expect, _electron as electron } from '@playwright/test'; +import type { ElectronApplication, Page } from '@playwright/test'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { createServer } from 'node:net'; +import { get as httpGet } from 'node:http'; +import { createHash, randomBytes } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { ACCOUNTS, JMAP_URL } from './helpers/config'; +import { sendMail } from './helpers/smtp'; +import { JmapClient } from './helpers/jmap'; +import { expectFolderUnread } from './helpers/app'; + +/** + * The encrypted local search index (lib/mail-index/**) against the real + * Stalwart fixture. THREE tests, because no single configuration can cover the + * whole feature - the reasons are specific and worth reading before changing + * any of them. + * + * Constraint 1 - the renderer cannot reach this fixture from a production + * build. The renderer talks JMAP DIRECTLY to Stalwart, and this fixture's + * Stalwart is deliberately plain HTTP (integration/webmail.Dockerfile explains + * why). The production CSP pins `connect-src` to `'self' https: wss:`. Setting + * NODE_ENV=development at RUNTIME does not help: `next build` INLINES + * process.env.NODE_ENV into the compiled middleware, so proxy.ts's `isDev` is + * frozen at build time. Verified by watching a standalone server started with + * NODE_ENV=development still serve the production CSP, and the login fail with + * "Refused to connect ... violates connect-src 'self' https: wss:". + * + * Constraint 2 - the fd-3 key channel cannot survive `next dev`. `next dev` + * forks its server process with an IPC channel that claims fd 3, so adopting it + * fails with EEXIST; fd 4 in that process is not a pipe either (ENOTTY). Both + * were observed, not assumed. Extra file descriptors simply are not plumbed + * through `npx -> next dev -> forked server`. The real standalone server is a + * single process and has no such problem (test 3 proves it). + * + * So each test takes the configuration that lets it prove its own half: + * + * 1. PIPELINE - drives the REAL standalone server over HTTP from Node, with a + * real fd-3 key channel. CSP is irrelevant here because there is no + * browser: a Node client with a real session cookie exercises the real + * routes. This is the test that proves a real delivery becomes searchable + * by a word from its BODY, and that the file on disk is really encrypted. + * + * 2. TRIGGER - proves the EVENT-DRIVEN wiring: a real SMTP delivery makes the + * renderer POST /api/offline/reindex off the back of its live JMAP push. + * Runs against `next dev` (constraint 1), and asserts the request is made - + * the indexing itself is test 1's job. + * + * 3. WIRING - launches the REAL shell with no ELECTRON_LOAD_URL, so + * electron/main.ts boots the real standalone artifact and stands up the real + * fd-3 key service on real safeStorage. Asserts the index routes are + * REACHABLE in a real build (401 "sign in", not 404 "feature absent", not + * 503 "no native binding / no key channel"). + * + * Nothing is mocked anywhere: real SMTP, real Stalwart, real Electron, real + * SQLCipher, real safeStorage. + */ +const alice = ACCOUNTS.alice; +const projectRoot = path.resolve(__dirname, '../..'); + +/** Mirrors lib/mail-index/paths.ts's accountFileToken(). */ +function accountFileToken(accountId: string): string { + return createHash('sha256').update(accountId, 'utf8').digest('hex').slice(0, 32); +} + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address && typeof address === 'object') { + const { port } = address; + server.close(() => resolve(port)); + } else { + server.close(() => reject(new Error('Could not allocate a free localhost port'))); + } + }); + }); +} + +function waitForServerReady(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const attempt = () => { + const req = httpGet(url, (res) => { + res.resume(); + resolve(); + }); + req.on('error', () => { + if (Date.now() > deadline) { + reject(new Error(`Server never became reachable at ${url}`)); + return; + } + setTimeout(attempt, 300); + }); + }; + attempt(); + }); +} + +/** + * Serves the key protocol of electron/key-service.ts over the child's inherited + * fd. The key and the encryption are real; only safeStorage's wrapping of it is + * out of the picture here, which is what test 3 covers. + */ +function serveKeyChannel(child: ChildProcess, fdIndex: number, key: Buffer): void { + const channel = child.stdio[fdIndex] as NodeJS.ReadWriteStream | null; + if (!channel) throw new Error(`child has no fd ${fdIndex} - key channel missing`); + let buffer = ''; + channel.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf8'); + let newline: number; + while ((newline = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (!line.trim()) continue; + const req = JSON.parse(line) as { id?: number; op?: string }; + const reply = + req.op === 'getIndexKey' + ? { id: req.id, ok: true, key: key.toString('hex') } + : req.op === 'deleteIndexKey' + ? { id: req.id, ok: true } + : { id: req.id, ok: false, code: 'bad-request', error: 'unknown op' }; + channel.write(`${JSON.stringify(reply)}\n`); + } + }); +} + +/** Minimal cookie jar - the index routes are cookie-authenticated. */ +class Jar { + private cookies = new Map(); + + absorb(response: Response): void { + for (const raw of response.headers.getSetCookie()) { + const [pair] = raw.split(';'); + const eq = pair.indexOf('='); + if (eq <= 0) continue; + this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); + } + } + + header(): string { + return [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; '); + } +} + +interface SearchHit { + contentType: string; + id: string; + title: string; + snippet: string; +} + +interface SearchResponse { + ok?: boolean; + count?: number; + hits?: SearchHit[]; + contextBlock?: string; + stats?: Array<{ contentType: string; count: number }>; + error?: string; +} + +test.describe('Electron desktop shell - encrypted local search index', () => { + test('pipeline: a real delivery becomes searchable by a body word, and the file is encrypted', async () => { + const jmap = await JmapClient.connect(alice.email, alice.password); + await jmap.reset(); + + const stamp = Date.now(); + const subject = `IT index subject ${stamp}`; + // Appears ONLY in the body, so a hit proves the body was actually fetched + // and indexed - not merely the subject, which any list view already holds. + const bodyPhrase = `zurichlease${stamp}`; + + // Deliver BEFORE indexing, so the catch-up path has something real to find. + await sendMail({ + from: alice.email, + authPass: alice.password, + to: alice.email, + subject, + body: `Please review the ${bodyPhrase} renewal before September.`, + }); + + const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vncmail-index-it-')); + const key = randomBytes(32); + const port = await getFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const serverEntry = path.join(projectRoot, '.next', 'standalone', 'server.js'); + expect( + fs.existsSync(serverEntry), + `missing ${serverEntry} - run "npm run build:standalone" first`, + ).toBe(true); + + // The REAL standalone artifact, spawned exactly as electron/main.ts spawns + // it (including the fd-3 key channel), just with plain node rather than + // ELECTRON_RUN_AS_NODE - the server code is identical either way. + const server = spawn(process.execPath, [serverEntry], { + cwd: path.dirname(serverEntry), + env: { + ...process.env, + PORT: String(port), + HOSTNAME: '127.0.0.1', + NODE_ENV: 'production', + JMAP_SERVER_URL: JMAP_URL, + SESSION_SECRET: 'integration-not-a-real-secret-32-chars!!', + VNCMAIL_DESKTOP_STORE_DIR: storeDir, + VNCMAIL_DESKTOP_KEY_FD: '3', + }, + stdio: ['pipe', 'pipe', 'pipe', 'pipe'], + }); + server.stderr?.on('data', (chunk) => process.stderr.write(`[standalone] ${chunk}`)); + serveKeyChannel(server, 3, key); + + const jar = new Jar(); + const call = async (url: string, init?: RequestInit): Promise => { + const response = await fetch(`${baseUrl}${url}`, { + ...init, + headers: { ...(init?.headers ?? {}), cookie: jar.header() }, + }); + jar.absorb(response); + return response; + }; + const search = async (query: string, types?: string): Promise => { + const params = new URLSearchParams({ q: query, stats: 'true' }); + if (types) params.set('types', types); + const response = await call(`/api/offline/search?${params.toString()}`); + if (!response.ok) return { error: `HTTP ${response.status}: ${await response.text()}` }; + return (await response.json()) as SearchResponse; + }; + + try { + await waitForServerReady(baseUrl, 60000); + + // Server-side login. This route verifies the credentials against Stalwart + // from Node and writes BOTH the session cookie and the jmap_stalwart_ctx + // auth context the index routes read (app/api/auth/session/route.ts:94). + const login = await call('/api/auth/session?slot=0', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serverUrl: JMAP_URL, + username: alice.email, + password: alice.password, + slot: 0, + }), + }); + expect(login.status, `login failed: ${await login.text()}`).toBe(200); + + // The gate must be open and the native binding loaded, or every assertion + // below would fail for an unrelated reason. + const reachable = await call('/api/offline/search?stats=true&q='); + expect( + reachable.status, + `index routes unreachable: ${(await reachable.text()).slice(0, 300)}`, + ).toBe(200); + + // Index it. This is the catch-up shape (no ids), which is what the app + // runs at launch. + const reindex = await call('/api/offline/reindex', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ catchUp: true }), + }); + const reindexBody = await reindex.json(); + expect(reindex.status, JSON.stringify(reindexBody)).toBe(200); + expect( + reindexBody.written?.mail, + `no mail indexed: ${JSON.stringify(reindexBody)}`, + ).toBeGreaterThan(0); + + // THE assertion: found by a word that exists only in the message body. + const hit = await search(bodyPhrase); + expect(hit.error).toBeUndefined(); + expect(hit.count, `search for a body word found nothing: ${JSON.stringify(hit)}`) + .toBeGreaterThan(0); + expect(hit.hits?.[0].contentType).toBe('mail'); + expect(hit.hits?.[0].title).toBe(subject); + expect(hit.hits?.[0].snippet).toContain(bodyPhrase); + // The prompt-ready retrieval surface an AI feature would consume. + expect(hit.contextBlock).toContain('[EMAIL]'); + expect(hit.contextBlock).toContain(subject); + + // Also findable by sender address, which lives in the `people` column. + expect((await search(alice.email)).count).toBeGreaterThan(0); + + // Type filtering must filter, and a word in no message must not match - + // otherwise the hit above proves nothing about relevance. + expect((await search(bodyPhrase, 'calendar')).count).toBe(0); + expect((await search(bodyPhrase, 'mail')).count).toBeGreaterThan(0); + expect((await search(`absent${stamp}`)).count).toBe(0); + + // Catch-up must be idempotent: a second pass must not duplicate rows. + const before = ((await search(bodyPhrase)).stats ?? []) + .find((s) => s.contentType === 'mail')?.count ?? 0; + const second = await call('/api/offline/reindex', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ catchUp: true }), + }); + expect(second.status).toBe(200); + const after = ((await search(bodyPhrase)).stats ?? []) + .find((s) => s.contentType === 'mail')?.count ?? 0; + expect(after).toBe(before); + expect((await search(bodyPhrase)).count).toBe(1); + + // Calendar/contacts/files: assert they were ATTEMPTED and did not error, + // rather than asserting counts - this fixture provisions mailboxes only, + // so an empty calendar is the correct result and a count assertion would + // be testing the fixture rather than the code. + const errors = (reindexBody.errors ?? []) as Array<{ contentType: string; message: string }>; + expect(errors, `per-type failures during reindex: ${JSON.stringify(errors)}`).toEqual([]); + const attempted = Object.keys(reindexBody.written ?? {}); + const skipped = (reindexBody.skipped ?? []) as string[]; + expect( + [...attempted, ...skipped].sort(), + 'every content type must be either attempted or explicitly skipped', + ).toEqual(['calendar', 'contact', 'file', 'mail']); + } finally { + server.kill(); + // Let the process release its WAL files before reading them. + await new Promise((r) => setTimeout(r, 500)); + } + + // ── the file on disk is genuinely encrypted ────────────────────────────── + const accountId = `${alice.email}@${new URL(JMAP_URL).hostname}`; + const dbPath = path.join(storeDir, 'index', `${accountFileToken(accountId)}.db`); + expect(fs.existsSync(dbPath), `no index database at ${dbPath}`).toBe(true); + + // Read every file the store wrote, WAL included: the newest rows can still + // be sitting in the -wal, so checking only the main database could miss + // plaintext that is genuinely on disk. + const onDisk = Buffer.concat( + ['', '-wal', '-shm'] + .map((suffix) => `${dbPath}${suffix}`) + .filter((f) => fs.existsSync(f)) + .map((f) => fs.readFileSync(f)), + ); + expect(onDisk.length).toBeGreaterThan(0); + // The assertions that catch a silently-UNENCRYPTED store. `PRAGMA key` is a + // no-op on a non-SQLCipher binding - no error, working database, mailbox in + // cleartext - so every functional assertion above would pass either way. + expect( + fs.readFileSync(dbPath).subarray(0, 15).toString('latin1'), + 'the index file has a plain SQLite header - it is NOT encrypted', + ).not.toBe('SQLite format 3'); + expect( + onDisk.includes(bodyPhrase), + 'the message body is recoverable from the raw database bytes - not encrypted', + ).toBe(false); + expect( + onDisk.includes(subject), + 'the subject is recoverable from the raw database bytes - not encrypted', + ).toBe(false); + + fs.rmSync(storeDir, { recursive: true, force: true }); + }); + + test('trigger: a real delivery makes the renderer ask the index to update', async () => { + const jmap = await JmapClient.connect(alice.email, alice.password); + await jmap.reset(); + + const devPort = await getFreePort(); + const devUrl = `http://127.0.0.1:${devPort}`; + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vncmail-trigger-profile-')); + + // `next dev` for the CSP reason in the header comment. No key channel here: + // this test asserts the REQUEST is made, which is the wiring it owns; the + // indexing itself is test 1's job. (Extra fds don't survive next dev + // anyway - constraint 2 above.) + const devServer = spawn('npx', ['next', 'dev', '--turbopack', '-p', String(devPort)], { + cwd: projectRoot, + env: { + ...process.env, + JMAP_SERVER_URL: JMAP_URL, + SESSION_SECRET: 'integration-not-a-real-secret-32-chars!!', + NODE_ENV: 'development', + // Enough for the route to exist and pass its gate; it fails later on the + // absent key channel, which this test deliberately does not assert on. + VNCMAIL_DESKTOP_STORE_DIR: path.join(userDataDir, 'offline'), + VNCMAIL_DESKTOP_KEY_FD: '3', + }, + stdio: 'pipe', + }); + devServer.stderr?.on('data', (chunk) => process.stderr.write(`[next dev] ${chunk}`)); + + let electronApp: ElectronApplication | undefined; + try { + await waitForServerReady(devUrl, 90000); + + electronApp = await electron.launch({ + args: [projectRoot, `--user-data-dir=${userDataDir}`], + env: { ...process.env, ELECTRON_LOAD_URL: devUrl }, + }); + + const appWindow: Page = await electronApp.firstWindow(); + await appWindow.waitForLoadState('domcontentloaded'); + + await appWindow.locator('#username').waitFor({ state: 'visible', timeout: 60000 }); + await appWindow.fill('#username', alice.email); + await appWindow.fill('#password', alice.password); + await appWindow.click('button[type="submit"]'); + await appWindow + .locator('[data-testid="account-switcher"]') + .first() + .waitFor({ state: 'visible', timeout: 60000 }); + + // An actively-selected inbox is a precondition for the push handler's + // refresh, which is what schedules the index update - the same reason + // 11-electron-notification.spec.ts waits here. + await expectFolderUnread(appWindow, { role: 'inbox' }, 0); + + const reindexCalls: string[] = []; + appWindow.on('request', (request) => { + if (request.method() === 'POST' && request.url().includes('/api/offline/reindex')) { + reindexCalls.push(request.postData() ?? ''); + } + }); + // Let the launch-time catch-up land first so it is not mistaken for the + // delivery-driven call below. + await appWindow.waitForTimeout(8000); + const baseline = reindexCalls.length; + + await sendMail({ + from: alice.email, + authPass: alice.password, + to: alice.email, + subject: `IT index trigger ${Date.now()}`, + body: 'a delivery should make the renderer ask the index to update', + }); + + await expect + .poll(() => reindexCalls.length, { + timeout: 60000, + message: + 'a real delivery did not make the renderer POST /api/offline/reindex - ' + + 'the push -> handleStateChange -> indexOnStateChange wiring is broken', + }) + .toBeGreaterThan(baseline); + + // The delivery-driven call must name the mail type, rather than being an + // unconditional full catch-up. + const triggered = reindexCalls.slice(baseline); + expect( + triggered.some((body) => body.includes('"mail"')), + `no reindex call mentioned the mail type: ${JSON.stringify(triggered)}`, + ).toBe(true); + } finally { + await electronApp?.close(); + devServer.kill(); + fs.rmSync(userDataDir, { recursive: true, force: true }); + } + }); + + test('wiring: the real standalone boot reaches the index with a real safeStorage key', async () => { + // A FRESH profile is load-bearing, not hygiene: the 401 this test asserts is + // "nobody is signed in", and a leftover jmap_stalwart_ctx cookie from any + // previous run turns it into a 200. That actually happened while writing this. + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vncmail-wiring-profile-')); + const electronApp = await electron.launch({ + args: [projectRoot, `--user-data-dir=${userDataDir}`], + env: { + ...process.env, + JMAP_SERVER_URL: JMAP_URL, + SESSION_SECRET: 'integration-not-a-real-secret-32-chars!!', + }, + }); + + try { + const appWindow: Page = await electronApp.firstWindow(); + await appWindow.waitForLoadState('domcontentloaded'); + await appWindow.locator('#username').waitFor({ state: 'visible', timeout: 60000 }); + + // safeStorage must be usable, or main.ts deliberately refuses to enable + // the feature at all (electron/key-service.ts's checkEncryptionAvailable). + const encryptionAvailable = await electronApp.evaluate(({ safeStorage }) => + safeStorage.isEncryptionAvailable(), + ); + expect( + encryptionAvailable, + 'safeStorage reports no encryption available on this host, so main.ts ' + + 'correctly disabled the index - this assertion cannot pass here', + ).toBe(true); + + const probe = await appWindow.evaluate(async () => { + const response = await fetch('/api/offline/search?q=anything'); + return { status: response.status, body: (await response.text()).slice(0, 300) }; + }); + + // 401 = the gate opened, the native binding loaded and the fd-3 key + // channel is present; it refuses only because nobody is signed in (this + // build cannot log in against a plain-HTTP Stalwart - constraint 1). + // 404 => VNCMAIL_DESKTOP_STORE_DIR was never set (gate closed, or + // main.ts refused because no OS keyring is available) + // 503 => the native binding or the key channel is missing from the real + // artifact - the class of failure only a real build reveals + expect( + probe.status, + `expected 401 (reachable, unauthenticated) but got ${probe.status}: ${probe.body}`, + ).toBe(401); + } finally { + await electronApp.close(); + fs.rmSync(userDataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/lib/mail-index/jmap.ts b/lib/mail-index/jmap.ts index c918d2d6..35ae4acd 100644 --- a/lib/mail-index/jmap.ts +++ b/lib/mail-index/jmap.ts @@ -75,14 +75,45 @@ async function fetchWithTimeout(url: string, init: RequestInit): Promise { - const response = await fetchWithTimeout(`${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`, { - method: 'GET', - headers: { Authorization: authHeader }, - }); + const base = serverUrl.replace(/\/+$/, ''); + const origin = new URL(base).origin; + let currentUrl = `${base}/.well-known/jmap`; + let response: Response | undefined; + + // Redirects must be followed EXPLICITLY, not with `redirect: 'follow'`: we + // attach the user's credentials to every hop, so each one has to be checked to + // still be on the origin we authenticated against. A blind follow would hand + // the Authorization header to whatever host a misconfigured or hostile session + // pointed at. Same reasoning (and same bound) as lib/auth/verify-jmap-auth.ts. + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + response = await fetchWithTimeout(currentUrl, { + method: 'GET', + headers: { Authorization: authHeader }, + }); + if (response.status < 300 || response.status >= 400) break; + + const location = response.headers.get('location'); + if (!location) throw new JmapIndexError('JMAP session redirect had no Location header'); + const next = new URL(location, currentUrl); + if (next.origin !== origin) { + throw new JmapIndexError( + `JMAP session redirected off-origin (${next.origin}); refusing to send credentials there`, + ); + } + currentUrl = next.toString(); + } + + if (!response) throw new JmapIndexError('JMAP session fetch produced no response'); if (response.status === 401 || response.status === 403) { throw new JmapIndexError('JMAP authentication failed', 401); } + if (response.status >= 300 && response.status < 400) { + throw new JmapIndexError('Too many redirects fetching the JMAP session'); + } if (!response.ok) { throw new JmapIndexError(`JMAP session fetch failed (${response.status})`); } diff --git a/lib/mail-index/key.ts b/lib/mail-index/key.ts index 35c1318b..bded791a 100644 --- a/lib/mail-index/key.ts +++ b/lib/mail-index/key.ts @@ -36,21 +36,49 @@ interface Pending { timer: NodeJS.Timeout; } -let socket: net.Socket | null = null; -let nextId = 1; -const pending = new Map(); -let readBuffer = ''; +/** + * Channel state lives on `globalThis`, NOT in module scope. + * + * A file descriptor can be adopted as a socket exactly ONCE per process: a + * second `new net.Socket({ fd })` for an fd this process already owns throws + * `EEXIST` from libuv's uv_pipe_open. Module scope is not once-per-process - + * Next re-evaluates route modules (dev HMR, and separate module instances + * across route bundles), so a module-scoped `let socket` produced exactly that + * crash: `Could not open fd 3: Error: open EEXIST`, found by the integration + * test rather than by reading the code. + * + * A Symbol key on globalThis is the one place in a Node process that survives + * module re-evaluation, so adoption genuinely happens once. + */ +interface ChannelState { + socket: net.Socket | null; + nextId: number; + pending: Map; + readBuffer: string; +} -function failAll(error: Error): void { - for (const [, p] of pending) { +const STATE_KEY = Symbol.for('vncmail.mailIndex.keyChannel'); + +function state(): ChannelState { + const holder = globalThis as unknown as Record; + const existing = holder[STATE_KEY]; + if (existing) return existing; + const created: ChannelState = { socket: null, nextId: 1, pending: new Map(), readBuffer: '' }; + holder[STATE_KEY] = created; + return created; +} + +function failAll(s: ChannelState, error: Error): void { + for (const [, p] of s.pending) { clearTimeout(p.timer); p.reject(error); } - pending.clear(); + s.pending.clear(); } function getSocket(): net.Socket { - if (socket && !socket.destroyed) return socket; + const s = state(); + if (s.socket && !s.socket.destroyed) return s.socket; const raw = process.env[KEY_FD_ENV]?.trim(); const fd = raw ? Number(raw) : NaN; @@ -73,12 +101,12 @@ function getSocket(): net.Socket { created.unref(); created.on('data', (chunk: Buffer) => { - readBuffer += chunk.toString('utf8'); - if (readBuffer.length > 64 * 1024) readBuffer = ''; + s.readBuffer += chunk.toString('utf8'); + if (s.readBuffer.length > 64 * 1024) s.readBuffer = ''; let newline: number; - while ((newline = readBuffer.indexOf('\n')) >= 0) { - const line = readBuffer.slice(0, newline); - readBuffer = readBuffer.slice(newline + 1); + while ((newline = s.readBuffer.indexOf('\n')) >= 0) { + const line = s.readBuffer.slice(0, newline); + s.readBuffer = s.readBuffer.slice(newline + 1); if (!line.trim()) continue; let msg: { id?: unknown; ok?: unknown; key?: unknown; code?: unknown; error?: unknown }; try { @@ -88,9 +116,9 @@ function getSocket(): net.Socket { } const id = typeof msg.id === 'number' ? msg.id : null; if (id === null) continue; - const p = pending.get(id); + const p = s.pending.get(id); if (!p) continue; - pending.delete(id); + s.pending.delete(id); clearTimeout(p.timer); if (msg.ok === true) { p.resolve({ key: typeof msg.key === 'string' ? msg.key : undefined }); @@ -102,32 +130,33 @@ function getSocket(): net.Socket { }); const onGone = (error?: Error) => { - socket = null; - readBuffer = ''; - failAll(error ?? new IndexKeyError('no-channel', 'Key service channel closed')); + s.socket = null; + s.readBuffer = ''; + failAll(s, error ?? new IndexKeyError('no-channel', 'Key service channel closed')); }; created.on('close', () => onGone()); created.on('error', (error) => onGone(new IndexKeyError('no-channel', String(error)))); - socket = created; + s.socket = created; return created; } function request(op: 'getIndexKey' | 'deleteIndexKey', accountId: string): Promise<{ key?: string }> { const sock = getSocket(); - const id = nextId++; + const s = state(); + const id = s.nextId++; return new Promise<{ key?: string }>((resolve, reject) => { const timer = setTimeout(() => { - pending.delete(id); + s.pending.delete(id); reject(new IndexKeyError('timeout', `Key service did not answer within ${REQUEST_TIMEOUT_MS}ms`)); }, REQUEST_TIMEOUT_MS); // Don't let a pending key request keep the process alive either. timer.unref?.(); - pending.set(id, { resolve, reject, timer }); + s.pending.set(id, { resolve, reject, timer }); try { sock.write(`${JSON.stringify({ id, op, accountId })}\n`); } catch (error) { - pending.delete(id); + s.pending.delete(id); clearTimeout(timer); reject(new IndexKeyError('no-channel', `Could not write to the key service: ${String(error)}`)); } diff --git a/playwright.integration-electron.config.ts b/playwright.integration-electron.config.ts index 3fc7c093..7a0a7b2f 100644 --- a/playwright.integration-electron.config.ts +++ b/playwright.integration-electron.config.ts @@ -21,7 +21,11 @@ import { defineConfig } from '@playwright/test'; */ export default defineConfig({ testDir: './integration/tests', - testMatch: '11-electron-notification.spec.ts', + // 11 asserts the native notification bridge fires from a real push; 12 + // asserts a real delivery reaches the encrypted local search index. 12 runs + // the REAL standalone-server boot (no ELECTRON_LOAD_URL), because that boot + // is what wires the index's store directory and its fd-3 key channel. + testMatch: /1[12]-electron-.*\.spec\.ts/, timeout: 90_000, expect: { timeout: 20_000 }, fullyParallel: false, diff --git a/playwright.integration.config.ts b/playwright.integration.config.ts index a2a071d7..0aeb9979 100644 --- a/playwright.integration.config.ts +++ b/playwright.integration.config.ts @@ -24,13 +24,13 @@ const VIDEO: VideoMode = VIDEO_MODES.includes(process.env.IT_VIDEO as VideoMode) export default defineConfig({ testDir: './integration/tests', - // Electron's own spec runs under playwright.integration-electron.config.ts + // The Electron specs run under playwright.integration-electron.config.ts // instead (see that file's header comment for why): the dockerized run // this config drives (integration/run-tests.sh, inside the official // Playwright image) has no Electron binary compatible with that - // container's platform, so it must never be swept in by this config's + // container's platform, so they must never be swept in by this config's // default testDir glob. - testIgnore: '11-electron-notification.spec.ts', + testIgnore: ['11-electron-notification.spec.ts', '12-electron-mail-index.spec.ts'], // next dev compiles routes lazily and each test logs in fresh, so give // individual tests and their polling assertions generous headroom. timeout: 90_000, diff --git a/scripts/assemble-standalone.mjs b/scripts/assemble-standalone.mjs index 4422327f..e404beaf 100644 --- a/scripts/assemble-standalone.mjs +++ b/scripts/assemble-standalone.mjs @@ -26,4 +26,36 @@ const staticDest = path.join(standaloneDir, ".next", "static"); rmSync(staticDest, { recursive: true, force: true }); cpSync(staticSrc, staticDest, { recursive: true }); +// The native SQLCipher prebuilds for the local search index (lib/mail-index/**). +// +// Next's output file tracing DOES pick up @signalapp/sqlcipher's JS +// (package.json + dist/index.cjs) and its node-gyp-build dependency, but NOT +// the prebuilds/ directory holding the actual .node binaries - node-gyp-build +// resolves those by scanning the directory at runtime, which no static tracer +// can follow. Verified by inspecting a real `build:standalone` output: the +// package was present, `prebuilds/` was absent, so `require()` would have +// failed at runtime in every packaged build. +// +// Copying the WHOLE prebuilds directory (all six platform/arch pairs, ~11 MB) +// rather than just this host's is deliberate: electron-builder cross-builds the +// x64 and arm64 macOS targets from one runner (electron-builder.config.js), so +// the artifact has to contain a prebuild for an arch this machine isn't. +// +// Skipped silently when absent - the package is an OPTIONAL dependency and is +// legitimately missing on musl/Alpine, where both Dockerfiles build. +const sqlcipherSrc = path.join(rootDir, "node_modules", "@signalapp", "sqlcipher", "prebuilds"); +if (existsSync(sqlcipherSrc)) { + const sqlcipherDest = path.join( + standaloneDir, "node_modules", "@signalapp", "sqlcipher", "prebuilds", + ); + rmSync(sqlcipherDest, { recursive: true, force: true }); + cpSync(sqlcipherSrc, sqlcipherDest, { recursive: true }); + console.log("Copied @signalapp/sqlcipher prebuilds into the standalone output"); +} else { + console.log( + "@signalapp/sqlcipher not installed (optional dependency) - " + + "the encrypted local index will be disabled at runtime", + ); +} + console.log("Assembled standalone server at", standaloneDir);