You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
432 lines
21 KiB
432 lines
21 KiB
#!/usr/bin/env node
|
|
// Checks the static site in build/ after `npm run build` (plan §8, phase 1).
|
|
// Node only, no dependencies. It fails with a list of problems when:
|
|
//
|
|
// - a route of src/routes has no prerendered HTML page;
|
|
// - a page lacks the Content-Security-Policy <meta>, or the policy is not
|
|
// the one promised (default-src 'self', connect-src 'self' and the three
|
|
// relays of drand, object-src
|
|
// 'none', base-uri 'none', form-action 'none', scripts and styles from the
|
|
// origin only), allows another origin, a scheme or 'unsafe-*', or comes
|
|
// after anything the browser could fetch;
|
|
// - an inline script is missing from script-src, or script-src holds a hash
|
|
// of no inline script;
|
|
// - style-src-attr does not list exactly the hashes of the inline style
|
|
// attributes that the client bundle writes (SvelteKit's route announcer),
|
|
// with 'unsafe-hashes' and nothing else;
|
|
// - a page has an inline style, an event handler attribute or a URL to
|
|
// another origin, or a stylesheet imports or references one;
|
|
// - the official .dkc fixtures are not shipped byte for byte, or a secret of
|
|
// the fixtures (.dkk files, plaintexts, identities, payload identities,
|
|
// access material, CONTROL_CBOR, and the heads, salts, comments and paths
|
|
// of format 3) is anywhere in the build;
|
|
// - a page loads the Unicode tables of the paths of format 3 with its first
|
|
// load, not on demand;
|
|
// - a page loads the locator of datekeys.capsule (locator.ts, envelope.ts,
|
|
// ageio.ts, ipaddr.ts) with its first load, not on demand;
|
|
// - the client bundle holds tlock-js, drand-client or Babel's helpers, or a
|
|
// nested copy of a package other than the noble copy under
|
|
// @noble/post-quantum (plan of phase 2, section 3 and decision 5), as
|
|
// .svelte-kit/output/client-modules.json records it (vite.config.ts);
|
|
// - the client bundle holds a test, or a module of src/lib/dkc/testing/,
|
|
// whose helpers write what only a generator of test vectors may write
|
|
// (format 2, another security area than 32 KiB);
|
|
// - a page loads noble, @scure/base or age-encryption with the page instead
|
|
// of on demand, or a page of ON_DEMAND cannot load its code on demand: the
|
|
// opening on /inspect (plan of phase 2, section 9) and the writer on
|
|
// /create (plan of phase 3, section 3);
|
|
// - licenses.txt lacks the notice of a package in the client bundle, the
|
|
// copyright lines kept in the header of a module of src/ derived from
|
|
// another project, or the license of the site.
|
|
//
|
|
// It reports the JavaScript that each page loads, raw and gzip.
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { gzipSync } from 'node:zlib';
|
|
|
|
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
// The site directory: build/, or the first argument.
|
|
const BUILD = process.argv[2] === undefined ? join(ROOT, 'build') : resolve(process.argv[2]);
|
|
const ROUTES = join(ROOT, 'src', 'routes');
|
|
const FIXTURES = join(ROOT, 'testdata', 'fixtures');
|
|
|
|
const problems = [];
|
|
const fail = (msg) => problems.push(msg);
|
|
const rel = (p) => relative(ROOT, p).split(sep).join('/');
|
|
const inBuild = (p) => relative(BUILD, p).split(sep).join('/');
|
|
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
|
|
|
function walk(dir) {
|
|
const out = [];
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
const p = join(dir, e.name);
|
|
if (e.isDirectory()) out.push(...walk(p));
|
|
else out.push(p);
|
|
}
|
|
return out.sort();
|
|
}
|
|
|
|
if (!existsSync(BUILD)) {
|
|
console.error(`${BUILD} does not exist: run "npm run build" first.`);
|
|
process.exit(1);
|
|
}
|
|
const files = walk(BUILD);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Every route is prerendered.
|
|
|
|
const pages = walk(ROUTES)
|
|
.filter((p) => basename(p) === '+page.svelte')
|
|
.map((p) => relative(ROUTES, p).split(sep).slice(0, -1).join('/'));
|
|
const htmlFiles = pages.map((route) => join(BUILD, route === '' ? 'index.html' : `${route}.html`));
|
|
for (const [i, f] of htmlFiles.entries()) {
|
|
if (!existsSync(f)) fail(`route /${pages[i]} has no prerendered page ${rel(f)}`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The Content-Security-Policy of each page.
|
|
|
|
const unescapeHtml = (s) =>
|
|
s.replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
|
|
const REQUIRED = {
|
|
'default-src': ["'self'"],
|
|
'connect-src': ["'self'", 'https://api.drand.sh', 'https://api2.drand.sh', 'https://api3.drand.sh'],
|
|
'style-src': ["'self'"],
|
|
'img-src': ["'self'"],
|
|
'font-src': ["'self'"],
|
|
'manifest-src': ["'self'"],
|
|
'frame-src': ["'none'"],
|
|
'worker-src': ["'none'"],
|
|
'object-src': ["'none'"],
|
|
'base-uri': ["'none'"],
|
|
'form-action': ["'none'"],
|
|
};
|
|
const HASH = /^'sha256-[A-Za-z0-9+/]{43}='$/;
|
|
const b64sha256 = (text) => `'sha256-${createHash('sha256').update(text, 'utf8').digest('base64')}'`;
|
|
|
|
// The inline style attributes that the client bundle writes into the DOM
|
|
// (Svelte templates are HTML strings in the JavaScript): the only ones the
|
|
// policy may allow, by hash, in style-src-attr.
|
|
const bundleStyles = new Set();
|
|
for (const f of files.filter((p) => p.endsWith('.js'))) {
|
|
for (const [, value] of readFileSync(f, 'utf8').matchAll(/\sstyle="([^"]*)"/g)) bundleStyles.add(b64sha256(value));
|
|
}
|
|
if (bundleStyles.size !== 1) fail(`the client bundle writes ${bundleStyles.size} distinct inline style attributes, want 1 (the route announcer)`);
|
|
|
|
function checkPage(file) {
|
|
const name = rel(file);
|
|
const html = readFileSync(file, 'utf8');
|
|
const metas = [...html.matchAll(/<meta http-equiv="content-security-policy" content="([^"]*)">/gi)];
|
|
if (metas.length !== 1) {
|
|
fail(`${name}: ${metas.length} Content-Security-Policy <meta> elements, want 1`);
|
|
return;
|
|
}
|
|
const meta = metas[0];
|
|
// Nothing that loads a resource may come before the policy.
|
|
const head = html.slice(0, meta.index);
|
|
if (/<(script|link|style|img|iframe|object|embed|base)\b/i.test(head)) fail(`${name}: an element that loads resources precedes the CSP <meta>`);
|
|
|
|
const policy = new Map();
|
|
for (const part of unescapeHtml(meta[1]).split(';')) {
|
|
const [directive, ...sources] = part.trim().split(/\s+/);
|
|
if (!directive) continue;
|
|
if (policy.has(directive)) fail(`${name}: CSP directive ${directive} appears twice`);
|
|
policy.set(directive, sources);
|
|
}
|
|
for (const [directive, want] of Object.entries(REQUIRED)) {
|
|
const got = policy.get(directive);
|
|
if (got === undefined) fail(`${name}: CSP lacks ${directive}`);
|
|
else if (got.join(' ') !== want.join(' ')) fail(`${name}: CSP ${directive} is "${got.join(' ')}", want "${want.join(' ')}"`);
|
|
}
|
|
const scriptSrc = policy.get('script-src') ?? [];
|
|
if (scriptSrc[0] !== "'self'") fail(`${name}: CSP script-src must start with 'self'`);
|
|
const hashes = scriptSrc.slice(1);
|
|
for (const h of hashes) if (!HASH.test(h)) fail(`${name}: CSP script-src allows ${h}; only 'self' and SHA-256 hashes are allowed`);
|
|
const styleAttr = policy.get('style-src-attr') ?? [];
|
|
if (styleAttr[0] !== "'unsafe-hashes'") fail(`${name}: CSP style-src-attr must start with 'unsafe-hashes'`);
|
|
const attrHashes = styleAttr.slice(1);
|
|
for (const h of attrHashes) {
|
|
if (!HASH.test(h)) fail(`${name}: CSP style-src-attr allows ${h}; only SHA-256 hashes are allowed`);
|
|
else if (!bundleStyles.has(h)) fail(`${name}: style-src-attr hash ${h} matches no inline style of the bundle`);
|
|
}
|
|
for (const h of bundleStyles) if (!attrHashes.includes(h)) fail(`${name}: the bundle's inline style ${h} is not in style-src-attr`);
|
|
const known = new Set([...Object.keys(REQUIRED), 'script-src', 'style-src-attr']);
|
|
for (const d of policy.keys()) if (!known.has(d)) fail(`${name}: unexpected CSP directive ${d}`);
|
|
|
|
// Every inline script is allowed by its hash, and every hash is used.
|
|
const inline = [...html.matchAll(/<script(\s[^>]*)?>([\s\S]*?)<\/script>/gi)];
|
|
const used = new Set();
|
|
for (const [, attrs = '', body] of inline) {
|
|
if (/\ssrc=/i.test(attrs)) {
|
|
if (body.trim() !== '') fail(`${name}: a <script src> has a body`);
|
|
continue;
|
|
}
|
|
const h = `'sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}'`;
|
|
used.add(h);
|
|
if (!hashes.includes(h)) fail(`${name}: an inline script is not allowed by script-src (${h})`);
|
|
}
|
|
for (const h of hashes) if (!used.has(h)) fail(`${name}: script-src hash ${h} matches no inline script`);
|
|
|
|
// Styles only from files, no event handler attributes, no other origins.
|
|
if (/<style[\s>]/i.test(html)) fail(`${name}: inline <style> element`);
|
|
if (/\sstyle=/i.test(html)) fail(`${name}: inline style attribute`);
|
|
if (/\son[a-z]+=/i.test(html)) fail(`${name}: inline event handler attribute`);
|
|
for (const [, attr, url] of html.matchAll(/\s(src|href|action|srcset|poster|data)="([^"]*)"/gi)) {
|
|
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(url.trim())) fail(`${name}: ${attr}="${url}" points outside the site`);
|
|
}
|
|
return policy;
|
|
}
|
|
|
|
const policies = htmlFiles.filter(existsSync).map(checkPage);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stylesheets never reach another origin (no web fonts, no remote images).
|
|
|
|
for (const f of files.filter((p) => p.endsWith('.css'))) {
|
|
const css = readFileSync(f, 'utf8');
|
|
if (/@import/i.test(css)) fail(`${rel(f)}: @import`);
|
|
for (const [, url] of css.matchAll(/url\(\s*['"]?([^'")]*)/gi)) {
|
|
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(url)) fail(`${rel(f)}: url(${url}) points outside the site`);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The fixtures: every .dkc byte for byte, no secret anywhere.
|
|
|
|
const fixtureFiles = readdirSync(FIXTURES).sort();
|
|
const byHash = new Map();
|
|
for (const f of files) {
|
|
const h = sha256(readFileSync(f));
|
|
byHash.set(h, [...(byHash.get(h) ?? []), f]);
|
|
}
|
|
|
|
for (const name of fixtureFiles.filter((n) => n.endsWith('.dkc'))) {
|
|
const stem = name.slice(0, -'.dkc'.length);
|
|
const copies = byHash.get(sha256(readFileSync(join(FIXTURES, name)))) ?? [];
|
|
const shipped = copies.filter((p) => inBuild(p).startsWith('_app/immutable/assets/') && basename(p).startsWith(`${stem}.`));
|
|
if (shipped.length !== 1) fail(`fixture ${name}: ${shipped.length} byte-exact copies under build/_app/immutable/assets, want 1`);
|
|
}
|
|
|
|
// Secret values that must never be shipped.
|
|
const secrets = [];
|
|
const addSecret = (label, value) => {
|
|
if (typeof value === 'string' && value.length >= 16) secrets.push([label, value]);
|
|
};
|
|
for (const name of fixtureFiles) {
|
|
const p = join(FIXTURES, name);
|
|
const bytes = readFileSync(p);
|
|
if (name.endsWith('.dkk') || name.endsWith('.plaintext')) {
|
|
const copies = byHash.get(sha256(bytes)) ?? [];
|
|
if (bytes.length > 0 && copies.length > 0) fail(`${name} is shipped as ${copies.map(rel).join(', ')}`);
|
|
addSecret(`${name} (hex)`, bytes.toString('hex'));
|
|
addSecret(`${name} (base64)`, bytes.toString('base64').replace(/=+$/, ''));
|
|
if (name.endsWith('.plaintext')) addSecret(`${name} (text)`, bytes.toString('utf8').slice(0, 64));
|
|
}
|
|
if (name.endsWith('.json')) {
|
|
const record = JSON.parse(bytes.toString('utf8'));
|
|
for (const id of record.identities ?? []) addSecret(`${name} identities`, id);
|
|
addSecret(`${name} payload_identity`, record.payload_identity);
|
|
addSecret(`${name} access_material`, record.access_material);
|
|
addSecret(`${name} control_cbor`, record.control_cbor);
|
|
// Format 3: the head, its salt, the comment and the paths are the
|
|
// content of BODY, as secret as the files.
|
|
addSecret(`${name} head_cbor`, record.head_cbor);
|
|
addSecret(`${name} salt`, record.salt);
|
|
addSecret(`${name} comment`, record.comment);
|
|
for (const f of record.files ?? []) addSecret(`${name} path`, f.path);
|
|
}
|
|
}
|
|
if (secrets.length < 10) fail(`only ${secrets.length} fixture secrets collected; the fixture records changed shape`);
|
|
const TEXT = /\.(html|js|css|json|svg|txt|map|webmanifest)$/;
|
|
for (const f of files.filter((p) => TEXT.test(p))) {
|
|
const text = readFileSync(f, 'utf8');
|
|
for (const [label, value] of secrets) if (text.includes(value)) fail(`${rel(f)} contains the fixture secret ${label}`);
|
|
}
|
|
for (const f of files) {
|
|
if (/\.(dkk|plaintext)$/.test(f) || /fixtures?\/.*\.json$/.test(inBuild(f))) fail(`${rel(f)}: fixture file that must not be shipped`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// What the client bundle is made of.
|
|
|
|
const MODULES = join(ROOT, '.svelte-kit', 'output', 'client-modules.json');
|
|
const FORBIDDEN_PACKAGES = [/^tlock-js$/, /^drand-client$/, /^@babel\//];
|
|
// The modules that only the tests load: the tests and the helpers of testing/.
|
|
const TEST_ONLY = /\/src\/(?:lib\/dkc\/testing\/|.*\.test\.ts$)/;
|
|
const chunks = existsSync(MODULES) ? JSON.parse(readFileSync(MODULES, 'utf8')).chunks : {};
|
|
if (!existsSync(MODULES)) fail(`${rel(MODULES)} is missing: vite.config.ts writes it during "npm run build"`);
|
|
|
|
// The npm package of a module id, and the package it is nested under, if any.
|
|
function packageOf(id) {
|
|
const i = id.lastIndexOf('/node_modules/');
|
|
if (i < 0) return null;
|
|
const parts = id.slice(i + '/node_modules/'.length).split('/');
|
|
const name = parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0];
|
|
const outer = id.slice(0, i);
|
|
const j = outer.lastIndexOf('/node_modules/');
|
|
return { name, parent: j < 0 ? null : outer.slice(j + '/node_modules/'.length) };
|
|
}
|
|
|
|
const packages = new Set();
|
|
for (const [file, chunk] of Object.entries(chunks)) {
|
|
if (!existsSync(join(BUILD, file))) fail(`client chunk ${file} is not in the site`);
|
|
for (const id of Object.keys(chunk.modules)) {
|
|
if (TEST_ONLY.test(id)) fail(`${file} bundles ${id}, which only the tests may load`);
|
|
const pkg = packageOf(id);
|
|
if (pkg === null) continue;
|
|
if (FORBIDDEN_PACKAGES.some((re) => re.test(pkg.name))) fail(`${file} bundles ${pkg.name}: ${id}`);
|
|
if (pkg.parent !== null && pkg.parent !== '@noble/post-quantum') fail(`${file} bundles a copy of ${pkg.name} nested under ${pkg.parent}`);
|
|
packages.add(pkg.parent === null ? pkg.name : `${pkg.name} (under ${pkg.parent})`);
|
|
}
|
|
}
|
|
|
|
// The JavaScript of a page: the chunks it preloads and the entries its
|
|
// inline script imports, as SvelteKit writes them (relative to the page, with
|
|
// ../ below the root), with their static imports; and apart, what those load
|
|
// on demand, other than the nodes of other routes. A page whose scripts are
|
|
// not all chunks of the bundle fails, so that no guard below passes without
|
|
// looking at them.
|
|
function pageScripts(file) {
|
|
const html = readFileSync(file, 'utf8');
|
|
const inSite = (url) => inBuild(resolve(dirname(file), url));
|
|
const eager = new Set();
|
|
for (const [link] of html.matchAll(/<link\b[^>]*>/gi)) {
|
|
const href = /\brel="modulepreload"/i.test(link) ? link.match(/\bhref="([^"]+)"/i) : null;
|
|
if (href) eager.add(inSite(href[1]));
|
|
}
|
|
for (const [, src] of html.matchAll(/\bimport\("([^"]+)"\)/g)) eager.add(inSite(src));
|
|
if (eager.size === 0) fail(`${rel(file)}: no script of the page found`);
|
|
for (const f of eager) if (chunks[f] === undefined) fail(`${rel(file)}: script ${f} is not a chunk of the client bundle`);
|
|
const close = (set, seeds) => {
|
|
const queue = [...seeds];
|
|
while (queue.length > 0) {
|
|
for (const f of chunks[queue.pop()]?.imports ?? []) {
|
|
if (!set.has(f) && !eager.has(f)) {
|
|
set.add(f);
|
|
queue.push(f);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
close(eager, eager);
|
|
const lazy = new Set();
|
|
for (const f of eager) {
|
|
for (const d of chunks[f]?.dynamicImports ?? []) {
|
|
if (!eager.has(d) && !/^_app\/immutable\/(nodes|entry)\//.test(d)) lazy.add(d);
|
|
}
|
|
}
|
|
close(lazy, lazy);
|
|
return { eager, lazy };
|
|
}
|
|
const weight = (set) => {
|
|
let raw = 0;
|
|
let gzip = 0;
|
|
for (const f of set) {
|
|
const bytes = readFileSync(join(BUILD, f));
|
|
raw += bytes.length;
|
|
gzip += gzipSync(bytes, { level: 9 }).length;
|
|
}
|
|
return `${set.size} files, ${raw} bytes, ${gzip} gzip`;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The opening and the writer are loaded on demand: no page loads noble or
|
|
// age-encryption first, and each page of ON_DEMAND can load the packages of
|
|
// its code when the person opens or creates a capsule.
|
|
|
|
const OPENING_PACKAGES = /^(?:@noble\/|@scure\/|age-encryption$)/;
|
|
const ON_DEMAND = {
|
|
'inspect.html': ['age-encryption', '@noble/curves', '@noble/ciphers'],
|
|
'create.html': ['age-encryption', '@noble/curves', '@noble/ciphers', '@noble/hashes'],
|
|
};
|
|
for (const name of Object.keys(ON_DEMAND)) {
|
|
if (!htmlFiles.some((f) => basename(f) === name && existsSync(f))) fail(`${name}, a page that loads code on demand, is not in the site`);
|
|
}
|
|
const bundled = (set) => {
|
|
const names = new Set();
|
|
for (const f of set) {
|
|
for (const id of Object.keys(chunks[f]?.modules ?? {})) {
|
|
const pkg = packageOf(id);
|
|
if (pkg !== null) names.add(pkg.name);
|
|
}
|
|
}
|
|
return names;
|
|
};
|
|
for (const f of htmlFiles.filter(existsSync)) {
|
|
const { eager, lazy } = pageScripts(f);
|
|
const first = [...bundled(eager)].filter((n) => OPENING_PACKAGES.test(n));
|
|
if (first.length > 0) fail(`${rel(f)} loads ${first.join(', ')} with the page, not on demand`);
|
|
// The Unicode tables of the paths of format 3 (pathrule-tables.ts, some
|
|
// 120 KB) come with the opening and the writer, never with the page.
|
|
const holdsTables = (c) => Object.keys(chunks[c]?.modules ?? {}).some((id) => id.replaceAll('\\', '/').endsWith('src/lib/dkc/pathrule-tables.ts'));
|
|
if ([...eager].some(holdsTables)) fail(`${rel(f)} loads the tables of pathrule-tables.ts with the page, not on demand`);
|
|
// The locator, whose data brings the rules of the note and whose envelope
|
|
// brings noble, is never part of the first load of a page.
|
|
const holdsLocator = (c) =>
|
|
Object.keys(chunks[c]?.modules ?? {}).some((id) => /src\/lib\/dkc\/(?:locator|envelope|ageio|ipaddr)\.ts$/.test(id.replaceAll('\\', '/')));
|
|
if ([...eager].some(holdsLocator)) fail(`${rel(f)} loads the locator with the page, not on demand`);
|
|
if (ON_DEMAND[basename(f)] !== undefined && ![...lazy].some(holdsTables)) fail(`${rel(f)}: the code loaded on demand lacks pathrule-tables.ts`);
|
|
const later = bundled(lazy);
|
|
for (const n of ON_DEMAND[basename(f)] ?? []) {
|
|
if (!later.has(n)) fail(`${rel(f)}: the code loaded on demand lacks ${n}`);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// licenses.txt holds the notice of every npm package in the client bundle,
|
|
// of every module of src/ that keeps the notice of another project, and the
|
|
// license of the site (vite.config.ts writes it).
|
|
|
|
const NOTICES = join(BUILD, 'licenses.txt');
|
|
if (!existsSync(NOTICES)) {
|
|
fail('licenses.txt is missing from the site');
|
|
} else {
|
|
const notices = readFileSync(NOTICES, 'utf8');
|
|
for (const [file, chunk] of Object.entries(chunks)) {
|
|
for (const [id, size] of Object.entries(chunk.modules)) {
|
|
const i = id.lastIndexOf('/node_modules/');
|
|
const virtual = /^\0(vite|rolldown)\//.exec(id);
|
|
if (virtual !== null) {
|
|
// The bundler's own code: \0vite/preload-helper.js, \0rolldown/runtime.js.
|
|
const { version } = JSON.parse(readFileSync(join(ROOT, 'node_modules', virtual[1], 'package.json'), 'utf8'));
|
|
if (!notices.includes(`\n${virtual[1]} ${version} (`)) fail(`licenses.txt lacks ${virtual[1]} ${version}, bundled in ${file}`);
|
|
} else if (id.startsWith('\0')) {
|
|
if (size > 0) fail(`${file} bundles the virtual module ${JSON.stringify(id)}, of no known license`);
|
|
} else if (i >= 0) {
|
|
const pkg = packageOf(id);
|
|
const dir = `${id.slice(0, i)}/node_modules/${pkg.name}`;
|
|
const { version } = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
if (!notices.includes(`\n${pkg.name} ${version} (`)) fail(`licenses.txt lacks ${pkg.name} ${version}, bundled in ${file}`);
|
|
} else if (/\/src\/.*\.(ts|js|svelte)$/.test(id) && existsSync(id)) {
|
|
// The copyright lines of a notice kept in the module's header.
|
|
for (const [, line] of readFileSync(id, 'utf8').matchAll(/^\/\/ {3}(.*copyright.*)$/gim)) {
|
|
if (!notices.includes(line)) fail(`licenses.txt lacks "${line}" of ${relative(ROOT, id).split(sep).join('/')}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const own = readFileSync(join(ROOT, 'LICENSE'), 'utf8').trim();
|
|
if (!notices.includes(own)) fail('licenses.txt lacks the license of the site');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
if (problems.length > 0) {
|
|
console.error(`build check failed, ${problems.length} problems:`);
|
|
for (const p of problems) console.error(` - ${p}`);
|
|
process.exit(1);
|
|
}
|
|
const size = files.reduce((n, f) => n + statSync(f).size, 0);
|
|
console.log(`build check passed: ${htmlFiles.length} prerendered pages, ${files.length} files, ${size} bytes`);
|
|
for (const [i, f] of htmlFiles.entries()) {
|
|
const p = policies[i];
|
|
console.log(` ${rel(f)}: CSP ${[...p].map(([d, s]) => `${d} ${s.join(' ')}`).join('; ')}`);
|
|
const { eager, lazy } = pageScripts(f);
|
|
console.log(` ${rel(f)}: JavaScript ${weight(eager)}; on demand ${weight(lazy)}`);
|
|
}
|
|
console.log(` npm packages in the client bundle: ${[...packages].sort().join(', ') || 'none'}`);
|