import { createServer } from 'node:https' import { readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { runProcess } from '../../src/shared/child-process/run-process' /** * A real TLS origin for the artifact's images, because interception could not measure them. * * The rig used to answer `https://…invalid` through Playwright's route interception. On Chrome 152 * the sandboxed `srcdoc` frame is isolated into its own target, and the parser-inserted `` is * the first fetch the document makes -- earlier than interception is attached to that target. The * request escaped to the real network, `.invalid` did not resolve, and the rig recorded nothing * while the document's own resource timing showed the fetch. A listener that is already accepting * before the page is created cannot be raced that way: the request either arrives or it does not, * and either answer is the measurement. * * `img-src 'self' data: https:` matches on scheme, so `https://127.0.0.1:` exercises the same * directive any other https host would. */ /** A 1x1 PNG, the smallest body that lets an admitted image request finish rather than error. */ const PNG_1X1 = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==', 'base64' ) /** * A throwaway certificate, generated per run into the suite's own scratch directory. * * Never committed and never reused: the key exists for the lifetime of one temp directory, and the * context that talks to it is created with `ignoreHTTPSErrors`, so nothing here is trusted by * anything. `-subj` and `-days 1` keep it obviously disposable. */ async function generateCertificate(scratch) { const keyPath = join(scratch, 'artifact-assets-key.pem') const certPath = join(scratch, 'artifact-assets-cert.pem') const result = await runProcess({ program: 'openssl', args: [ 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', keyPath, '-out', certPath, '-days', '1', '-subj', '/CN=127.0.0.1', '-addext', 'subjectAltName=IP:127.0.0.1' ] }) if (result.code !== 0) { throw new Error(`openssl could not generate a test certificate: ${result.stderr.slice(0, 400)}`) } await writeFile(join(scratch, '.gitignore'), '*\n') return { key: await readFile(keyPath), cert: await readFile(certPath) } } /** * Starts the listener and hands back what it saw. * * Recorded server-side, the way the cleartext origin in this rig already is: a hit is a request that * arrived, and a referrer is the header that came with it, neither of them mediated by anything the * browser might attach late. Requests carry each arm's nonce, so one list serves every arm and an * arm reads only its own. */ export async function startArtifactAssetServer(scratch) { const credentials = await generateCertificate(scratch) const hits = [] const server = createServer(credentials, (request, response) => { const url = new URL(request.url ?? '/', 'https://127.0.0.1') hits.push({ path: url.pathname, query: url.search, referer: request.headers.referer ?? null }) // Without this every cross-origin resource-timing field reads zero, so a healthy request and a // failed one are indistinguishable from inside the frame -- measured, not assumed. const timingVisible = { 'timing-allow-origin': '*' } if (url.pathname.endsWith('.png')) { response.writeHead(200, { 'content-type': 'image/png', ...timingVisible }) response.end(PNG_1X1) return } if (url.pathname.endsWith('.woff2')) { response.writeHead(200, { 'content-type': 'font/woff2', ...timingVisible }) response.end(Buffer.alloc(0)) return } response.writeHead(200, { 'content-type': 'text/html', ...timingVisible }) response.end('ASSET') }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) return { server, origin: `https://127.0.0.1:${String(server.address().port)}`, /** This arm's requests only, by nonce, as paths. */ hitsFor: (nonce) => hits.filter((one) => one.query.includes(`n=${nonce}`)).map((one) => one.path), referersFor: (nonce) => hits.filter((one) => one.query.includes(`n=${nonce}`)).map((one) => one.referer), /** Whether one exact path arrived, for the probe that issues a URL nothing can have cached. */ saw: (path) => hits.some((one) => one.path === path) } }