import test from 'node:test'; import assert from 'node:assert/strict'; import { request } from 'node:http'; import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { startPreview, parsePort } from '../scripts/preview.mjs'; const repository = new URL('../', import.meta.url); // Small scanner for this site's static markup, not a general-purpose HTML validator. function attributes(tag) { return new Map([...tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu)] .map(([, name, double, single]) => [name.toLowerCase(), double ?? single])); } function text(html) { return html.replace(/<[^>]*>/gu, ' ').replace(/&/gu, '&') .replace(/&(?:nbsp|#160);/gu, ' ').replace(/\s+/gu, ' ').trim(); } function links(html) { return [...html.matchAll(/]*)>([\s\S]*?)<\/a>/giu)] .map(([, tag, inner]) => ({ attrs: attributes(tag), inner })); } function get(server, path = '/', method = 'GET', headers = {}) { // Raw request paths preserve traversal probes that fetch/URL would normalize away. return new Promise((accept, reject) => { const req = request({ host: '127.0.0.1', port: server.address().port, path, method, headers, agent: false }, res => { const chunks = []; res.on('data', chunk => chunks.push(chunk)); res.on('error', reject); res.on('end', () => accept({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) })); }); req.on('error', reject); req.setTimeout(3_000, () => req.destroy(new Error('Preview request timed out'))); req.end(); }); } async function preview(t, options) { const server = await startPreview({ ...options, port: 0 }); t.after(() => new Promise((accept, reject) => { server.close(error => error ? reject(error) : accept()); server.closeAllConnections(); })); assert.equal(server.address().address, '127.0.0.1'); return server; } async function homepage(t) { const response = await get(await preview(t)); assert.equal(response.status, 200); return response.body.toString('utf8'); } test('homepage assembles only the three common includes, with complete metadata and JSON-LD', async t => { const html = await homepage(t); assert.doesNotMatch(html, /`).join('\n')); const response = await get(server); assert.equal(response.status, 200); assert.doesNotMatch(response.body.toString(), /', '', '', '', ]) { await writeFile(join(root, 'index.html'), directive); const rejected = await get(server); assert.equal(rejected.status, 500); assert.doesNotMatch(rejected.body.toString(), /outside-public-root|should-never-execute/u); } await writeFile(join(root, 'index.html'), ''); await writeFile(join(root, 'common', 'header.html'), ''); assert.equal((await get(server)).status, 500); }); test('realpath containment blocks directory symlinks/junctions, including common SSI directories', async t => { const { directory, root } = await fixture(t); const outside = join(directory, 'outside'); await mkdir(outside); await writeFile(join(outside, 'header.html'), 'outside-public-root'); try { await symlink(outside, join(root, 'escape-dir'), process.platform === 'win32' ? 'junction' : 'dir'); } catch (error) { if (['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) { t.skip(`Directory symlinks unavailable on this host (${error.code}).`); return; } throw error; } const server = await preview(t, { publicRoot: root }); assert.equal((await get(server, '/escape-dir/header.html')).status, 403); await rm(join(root, 'common'), { recursive: true }); await symlink(outside, join(root, 'common'), process.platform === 'win32' ? 'junction' : 'dir'); await writeFile(join(root, 'index.html'), ''); const response = await get(server); assert.equal(response.status, 403); assert.doesNotMatch(response.body.toString(), /outside-public-root/u); }); test('realpath containment blocks file symlinks and hidden targets, including SSI', async t => { const { directory, root } = await fixture(t); await writeFile(join(root, '.hidden.txt'), 'hidden-target'); try { await symlink(join(directory, 'private.txt'), join(root, 'escape.txt'), 'file'); await symlink(join(root, '.hidden.txt'), join(root, 'alias.txt'), 'file'); await symlink(join(directory, 'private.txt'), join(root, 'common', 'header.html'), 'file'); } catch (error) { if (['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) { t.skip(`File symlinks unavailable on this host (${error.code}); run on a symlink-capable host too.`); return; } throw error; } await writeFile(join(root, 'index.html'), ''); const server = await preview(t, { publicRoot: root }); for (const path of ['/escape.txt', '/alias.txt', '/']) { const response = await get(server, path); assert.equal(response.status, 403, path); assert.doesNotMatch(response.body.toString(), /outside-public-root|hidden-target/u); } }); test('port parsing defaults to 8082 and accepts only numeric ports in range', () => { assert.equal(parsePort(), 8082); assert.equal(parsePort('0'), 0); assert.equal(parsePort('65535'), 65535); for (const value of ['', '-1', '65536', '1.5', '8082x', ' 8082', 'Infinity', '127.0.0.1:8082']) { assert.throws(() => parsePort(value), /Port must be numeric/u); } });