// Turn a verified dist/ into the two distributable archives. // // Invoked by script/package, which runs `make build` first so that dist/ has // already been checked against the build's own receipt (see the Build Receipts // section of README.md). This program does not build anything and does not // write into dist/: it reads the emitted tree and writes release/. // // release/autistmask-chrome-.zip loaded via chrome://extensions // release/autistmask-firefox-.xpi an UNSIGNED add-on, see README // release/SHA256SUMS // // Self-containment is checked rather than assumed, because the layout invites // exactly one mistake: build.js emits dist/styles.css at the dist/ ROOT, // outside both browser directories, and copies it into each of them as // src/popup/styles.css. A naive `zip -r dist/chrome` is therefore correct only // by accident, and would stop being correct the moment a reference pointed up // and out. So every path the manifest and the popup HTML reference is resolved // and required to be inside the archive, a reference that escapes the browser // directory is a hard failure, and anything sitting at the dist/ root is // listed as deliberately not shipped rather than silently dropped. // // The archive is then read back and compared byte for byte against the // directory it was built from. An archive nobody opened is a claim, not an // artifact. "use strict"; const crypto = require("crypto"); const fs = require("fs"); const path = require("path"); const { readZip, writeZip } = require("./zip"); const { resolveVersion } = require("./version"); const ROOT = path.resolve(__dirname, "..", ".."); const DIST = path.join(ROOT, "dist"); const RELEASE = path.join(ROOT, "release"); const TARGETS = [ { dir: "chrome", ext: "zip" }, // .xpi rather than .zip: it is the same container, but Firefox's install // flow keys off the extension. { dir: "firefox", ext: "xpi" }, ]; // Strings in a manifest that name a file the extension loads. Matched by // shape, not by a list of manifest keys, so a key added in a later manifest // version is covered the day it appears rather than the day someone remembers // to extend a list here. Nothing else in either manifest looks like this: the // CSP strings, "", the version and the base64 key all fail it. const MANIFEST_PATH_RE = /^[A-Za-z0-9._][A-Za-z0-9._/-]*\.(?:js|css|html|json|png|svg|woff2?)$/; // Local references out of an HTML document. Enough for what this repo emits — // one stylesheet link and one script tag — and anything it does not understand // is reported rather than passed over, see htmlReferences(). const HTML_REF_RE = /(?:src|href)\s*=\s*["']([^"']+)["']/gi; function fail(message) { throw new Error(message); } function sha256(buf) { return crypto.createHash("sha256").update(buf).digest("hex"); } // Every regular file under dir, as archive-root-relative forward-slashed // paths. A symlink is refused rather than followed: build.js emits regular // files only, so a link under dist/ is not something the build produced, and // dereferencing one would put bytes from outside dist/ into the artifact. function listFiles(dir, prefix = "") { const out = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const rel = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.isSymbolicLink()) { fail( `${dir}/${entry.name} is a symlink. The build emits regular ` + `files only, so this is not something it produced and it ` + `will not be archived.`, ); } else if (entry.isDirectory()) { out.push(...listFiles(path.join(dir, entry.name), rel)); } else if (entry.isFile()) { out.push(rel); } else { fail( `${dir}/${entry.name} is neither a regular file nor a ` + `directory, so it is not something the build emitted`, ); } } return out.sort(); } // Collect every string anywhere in the manifest that looks like a file it // loads, plus every string that tries to reach outside the extension root. // The second half is the point: "../styles.css" never matches // MANIFEST_PATH_RE, so without an explicit check an escaping reference would // read as "not a path" and the missing file would be found only by a user // whose popup rendered unstyled. function manifestReferences(value, found = new Set()) { if (typeof value === "string") { if (value.split("/").includes("..")) { fail( `the manifest references ${JSON.stringify(value)}, which ` + `points outside the extension root. Everything the ` + `browser loads has to be inside the archive; nothing ` + `above it is shipped.`, ); } if (MANIFEST_PATH_RE.test(value)) found.add(value); } else if (Array.isArray(value)) { for (const v of value) manifestReferences(v, found); } else if (value && typeof value === "object") { for (const v of Object.values(value)) manifestReferences(v, found); } return found; } // Local references out of one HTML member, resolved against that member's own // directory and returned archive-relative. Absolute URLs, data: URIs and // in-page anchors are not files and are skipped; a relative reference that // climbs out of the archive root is a failure for the same reason as above. function htmlReferences(member, text) { const base = path.posix.dirname(member); const out = new Set(); for (const match of text.matchAll(HTML_REF_RE)) { const ref = match[1].trim(); if (ref === "" || ref.startsWith("#") || ref.startsWith("//")) continue; if (/^[a-z][a-z0-9+.-]*:/i.test(ref)) continue; if (ref.startsWith("/")) { fail( `${member} references ${JSON.stringify(ref)} from the ` + `extension root. Nothing here emits root-absolute ` + `references and this packager does not resolve them.`, ); } const resolved = path.posix.normalize(path.posix.join(base, ref)); if (resolved.startsWith("..")) { fail( `${member} references ${JSON.stringify(ref)}, which resolves ` + `outside the extension root. build.js copies the ` + `compiled stylesheet into each browser directory for ` + `exactly this reason: dist/styles.css lives at the dist/ ` + `root and is not part of either archive.`, ); } out.add(resolved); } return out; } // Everything the browser is told to load, and the assertion that all of it is // in the archive. function checkSelfContained(target, members, read) { if (!members.includes("manifest.json")) { fail(`dist/${target} has no manifest.json at its root`); } const manifest = JSON.parse(read("manifest.json").toString("utf8")); const referenced = new Set(manifestReferences(manifest)); for (const member of members) { if (!member.endsWith(".html")) continue; for (const ref of htmlReferences( member, read(member).toString("utf8"), )) { referenced.add(ref); } } const missing = [...referenced].filter((r) => !members.includes(r)); if (missing.length > 0) { fail( `the ${target} archive would not be self-contained: it is told ` + `to load ${missing.join(", ")}, which ${ missing.length === 1 ? "is" : "are" } not in it`, ); } return { manifest, referenced }; } function main() { const version = resolveVersion(ROOT); if (!fs.existsSync(DIST)) { fail( "there is no dist/ to package. script/package runs make build " + "first; run it rather than this program.", ); } fs.rmSync(RELEASE, { recursive: true, force: true }); fs.mkdirSync(RELEASE, { recursive: true }); // Files the build emits at the dist/ root, outside both browser // directories. Printed rather than ignored: dist/styles.css is the // Tailwind output that build.js then copies into each browser directory, // so leaving it out is correct — but "correct and stated" and "dropped by // a glob" are different things, and only one of them survives the next // change to the build. const rootOnly = fs .readdirSync(DIST, { withFileTypes: true }) .filter((e) => !e.isDirectory()) .map((e) => e.name) .sort(); if (rootOnly.length > 0) { console.log( `Not shipped (dist/ root, outside every browser directory, and ` + `referenced by nothing inside one): ${rootOnly.join(", ")}`, ); } const sums = []; for (const { dir, ext } of TARGETS) { const targetDir = path.join(DIST, dir); if (!fs.existsSync(targetDir)) { fail(`dist/${dir} does not exist; run make build`); } const members = listFiles(targetDir); const readFromDir = (member) => fs.readFileSync(path.join(targetDir, member)); const { manifest } = checkSelfContained(dir, members, readFromDir); if (manifest.version !== version) { fail( `dist/${dir}/manifest.json says version ${manifest.version} ` + `but this tree is ${version}. dist/ is stale: run make ` + `build.`, ); } const archive = writeZip( members.map((name) => ({ name, data: readFromDir(name) })), ); const name = `autistmask-${dir}-${version}.${ext}`; const outPath = path.join(RELEASE, name); fs.writeFileSync(outPath, archive); // Read the artifact back off disk, not the buffer that was just // written: what ships is the file. const written = fs.readFileSync(outPath); const entries = readZip(written); const inArchive = entries.map((e) => e.name).sort(); if (inArchive.join("\n") !== members.join("\n")) { fail( `${name} does not hold the same members as dist/${dir}: ` + `archive has ${inArchive.length}, directory has ` + `${members.length}`, ); } for (const entry of entries) { const onDisk = readFromDir(entry.name); if (sha256(entry.data) !== sha256(onDisk)) { fail(`${name} member ${entry.name} differs from dist/${dir}`); } } // Re-run the self-containment check against the ARCHIVE's own // contents. The directory passing it is not the claim being made. const byName = new Map(entries.map((e) => [e.name, e.data])); checkSelfContained(dir, inArchive, (m) => byName.get(m)); const digest = sha256(written); sums.push(`${digest} ${name}`); console.log( `${name}: ${entries.length} member(s), ${written.length} bytes, ` + `sha256 ${digest}`, ); } fs.writeFileSync( path.join(RELEASE, "SHA256SUMS"), sums.map((l) => `${l}\n`).join(""), ); console.log(`Wrote release/ for version ${version}`); } // Only when run as a program. The reference-resolving helpers are what decide // whether an archive is self-contained, so tests/packaging.test.js exercises // them directly and must be able to require this file without packaging // anything. if (require.main === module) { try { main(); } catch (err) { console.error(`package: ${err && err.message ? err.message : err}`); process.exit(1); } } module.exports = { checkSelfContained, htmlReferences, manifestReferences };