feat: add toolbox contract shell and testkit

This commit is contained in:
2026-07-20 17:53:56 +02:00
commit ce9bddb218
53 changed files with 8950 additions and 0 deletions

View File

@@ -0,0 +1,482 @@
import {
loadToolboxContext,
parseToolboxApp,
type ToolboxAppManifest,
} from "@add-ideas/toolbox-contract";
import { createReadStream } from "node:fs";
import { lstat, readFile, realpath, stat } from "node:fs/promises";
import { createServer, type Server } from "node:http";
import { extname, relative, resolve, sep } from "node:path";
const DEEP_PREFIX = "/__toolbox-check__/deep/nested/app/";
const CATALOG_PATH = "/toolbox.catalog.json";
const SEMVER_PATTERN =
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
export class ToolboxCheckError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "ToolboxCheckError";
}
}
export interface ToolboxCheckOptions {
manifestName?: string;
}
export interface ToolboxCheckReport {
root: string;
manifestPath: string;
app: ToolboxAppManifest;
checkedFiles: readonly string[];
smoke: {
standaloneUrl: string;
contextualUrl: string;
fetchedUrls: readonly string[];
};
}
interface CheckedReference {
reference: string;
path: string;
}
function decodedReference(reference: string, label: string): string {
let decoded = reference;
try {
for (let count = 0; count < 3; count += 1) {
const next = decodeURIComponent(decoded);
if (next === decoded) break;
decoded = next;
}
} catch (cause) {
throw new ToolboxCheckError(`${label} contains invalid URL encoding`, {
cause,
});
}
return decoded;
}
function localReference(
root: string,
reference: string,
label: string,
): CheckedReference {
const decoded = decodedReference(reference, label);
const pathPart = decoded.split(/[?#]/u, 1)[0] ?? "";
if (
pathPart.startsWith("/") ||
pathPart.includes("\\") ||
/^[a-z][a-z\d+.-]*:/iu.test(pathPart) ||
pathPart.split("/").includes("..") ||
pathPart.includes("\0")
) {
throw new ToolboxCheckError(`${label} uses an unsafe path: ${reference}`);
}
let filePath = resolve(root, pathPart || ".");
if (pathPart.endsWith("/") || pathPart === "." || pathPart === "./") {
filePath = resolve(filePath, "index.html");
}
const fromRoot = relative(root, filePath);
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`)) {
throw new ToolboxCheckError(`${label} escapes the distribution directory`);
}
return { reference, path: filePath };
}
async function assertRegularContainedFile(
root: string,
item: CheckedReference,
label: string,
): Promise<string> {
try {
await lstat(item.path);
} catch (cause) {
throw new ToolboxCheckError(`${label} does not exist: ${item.reference}`, {
cause,
});
}
const [realRoot, realFile] = await Promise.all([
realpath(root),
realpath(item.path),
]);
const fromRealRoot = relative(realRoot, realFile);
if (fromRealRoot === ".." || fromRealRoot.startsWith(`..${sep}`)) {
throw new ToolboxCheckError(`${label} resolves outside the distribution`);
}
const info = await stat(realFile);
if (!info.isFile()) {
throw new ToolboxCheckError(`${label} is not a file: ${item.reference}`);
}
return item.path;
}
function contentType(path: string): string {
switch (extname(path).toLowerCase()) {
case ".html":
return "text/html; charset=utf-8";
case ".json":
return "application/json; charset=utf-8";
case ".css":
return "text/css; charset=utf-8";
case ".js":
case ".mjs":
return "text/javascript; charset=utf-8";
case ".svg":
return "image/svg+xml";
case ".png":
return "image/png";
default:
return "application/octet-stream";
}
}
function encodedPath(path: string): string {
return path.split("/").map(encodeURIComponent).join("/");
}
function catalogDocument(manifestName: string): object {
return {
schemaVersion: 1,
id: "de.add-ideas.toolbox-check",
name: "Toolbox check",
home: "/",
theme: { mode: "system", brand: "Toolbox check" },
apps: [
{
manifest: `${DEEP_PREFIX}${encodedPath(manifestName)}`,
enabled: true,
},
],
};
}
function staticServer(root: string, manifestName: string): Server {
return createServer((request, response) => {
void (async () => {
const requestUrl = new URL(request.url ?? "/", "http://localhost");
if (requestUrl.pathname === CATALOG_PATH) {
response.writeHead(200, {
"content-type": "application/json; charset=utf-8",
});
response.end(JSON.stringify(catalogDocument(manifestName)));
return;
}
if (!requestUrl.pathname.startsWith(DEEP_PREFIX)) {
response.writeHead(404).end("Not found");
return;
}
let relativePath: string;
try {
relativePath = decodeURIComponent(
requestUrl.pathname.slice(DEEP_PREFIX.length),
);
} catch {
response.writeHead(400).end("Bad path");
return;
}
const checked = localReference(
root,
relativePath || "./",
"Request path",
);
try {
await assertRegularContainedFile(root, checked, "Requested file");
} catch {
response.writeHead(404).end("Not found");
return;
}
response.writeHead(200, { "content-type": contentType(checked.path) });
createReadStream(checked.path).pipe(response);
})().catch(() => {
if (!response.headersSent) response.writeHead(500);
response.end("Server error");
});
});
}
async function listen(server: Server): Promise<number> {
await new Promise<void>((resolvePromise, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolvePromise());
});
const address = server.address();
if (address === null || typeof address === "string") {
throw new ToolboxCheckError("Could not determine smoke-test server port");
}
return address.port;
}
async function close(server: Server): Promise<void> {
await new Promise<void>((resolvePromise, reject) => {
server.close((error) => (error ? reject(error) : resolvePromise()));
});
}
async function requireOk(url: URL, label: string): Promise<Response> {
const response = await fetch(url);
if (!response.ok) {
throw new ToolboxCheckError(`${label} returned HTTP ${response.status}`);
}
return response;
}
interface HtmlResource {
kind: "icon" | "image" | "manifest" | "script" | "stylesheet";
reference: string;
}
function attributes(source: string): Map<string, string> {
const result = new Map<string, string>();
const pattern = /([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gu;
for (const match of source.matchAll(pattern)) {
const name = match[1]?.toLowerCase();
const value = match[2] ?? match[3] ?? match[4];
if (name !== undefined && value !== undefined) result.set(name, value);
}
return result;
}
function htmlResources(html: string): readonly HtmlResource[] {
const resources: HtmlResource[] = [];
const tagPattern = /<(script|link|img)\b([^>]*)>/giu;
for (const match of html.matchAll(tagPattern)) {
const tag = match[1]?.toLowerCase();
const values = attributes(match[2] ?? "");
if (tag === "script") {
const reference = values.get("src");
if (reference) resources.push({ kind: "script", reference });
} else if (tag === "img") {
const reference = values.get("src");
if (reference) resources.push({ kind: "image", reference });
} else if (tag === "link") {
const reference = values.get("href");
const relationships = new Set(
(values.get("rel") ?? "").toLowerCase().split(/\s+/u).filter(Boolean),
);
if (!reference) continue;
if (relationships.has("manifest")) {
resources.push({ kind: "manifest", reference });
} else if (relationships.has("stylesheet")) {
resources.push({ kind: "stylesheet", reference });
} else if (
relationships.has("icon") ||
relationships.has("apple-touch-icon")
) {
resources.push({ kind: "icon", reference });
} else if (
relationships.has("modulepreload") ||
relationships.has("preload")
) {
resources.push({ kind: "script", reference });
}
}
}
return resources;
}
function nestedLocalUrl(
reference: string,
base: URL,
label: string,
): URL | undefined {
let url: URL;
try {
url = new URL(reference, base);
} catch (cause) {
throw new ToolboxCheckError(`${label} has an invalid URL: ${reference}`, {
cause,
});
}
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
if (url.origin !== base.origin) return undefined;
if (!url.pathname.startsWith(DEEP_PREFIX)) {
throw new ToolboxCheckError(
`${label} is not relocatable under the nested prefix: ${reference}`,
);
}
return url;
}
async function inspectWebManifest(
response: Response,
manifestUrl: URL,
): Promise<readonly string[]> {
let document: unknown;
try {
document = await response.json();
} catch (cause) {
throw new ToolboxCheckError("Linked web manifest is not valid JSON", {
cause,
});
}
if (typeof document !== "object" || document === null) return [];
const icons = (document as { icons?: unknown }).icons;
if (icons === undefined) return [];
if (!Array.isArray(icons)) {
throw new ToolboxCheckError("Linked web manifest icons must be an array");
}
const fetched: string[] = [];
for (const [index, icon] of icons.entries()) {
if (typeof icon !== "object" || icon === null) {
throw new ToolboxCheckError(
`Web manifest icon ${index} must be an object`,
);
}
const source = (icon as { src?: unknown }).src;
if (typeof source !== "string" || source.length === 0) {
throw new ToolboxCheckError(`Web manifest icon ${index} needs a src`);
}
const iconUrl = nestedLocalUrl(source, manifestUrl, "Web manifest icon");
if (iconUrl !== undefined) {
await requireOk(iconUrl, "Web manifest icon");
fetched.push(iconUrl.href);
}
}
return fetched;
}
async function inspectEntryHtml(
html: string,
entryUrl: URL,
): Promise<readonly string[]> {
const fetched: string[] = [];
for (const resource of htmlResources(html)) {
const label = `HTML ${resource.kind}`;
const url = nestedLocalUrl(resource.reference, entryUrl, label);
if (url === undefined) continue;
const response = await requireOk(url, label);
fetched.push(url.href);
if (resource.kind === "manifest") {
fetched.push(...(await inspectWebManifest(response, url)));
}
}
return fetched;
}
async function smokeCheck(
root: string,
manifest: ToolboxAppManifest,
manifestName: string,
): Promise<ToolboxCheckReport["smoke"]> {
const server = staticServer(root, manifestName);
const port = await listen(server);
try {
const appBase = new URL(DEEP_PREFIX, `http://127.0.0.1:${port}/`);
const standaloneUrl = new URL(manifest.entry, appBase);
const standaloneResponse = await requireOk(
standaloneUrl,
"Standalone entry",
);
const fetchedUrls = await inspectEntryHtml(
await standaloneResponse.text(),
standaloneUrl,
);
const standalone = await loadToolboxContext({ location: standaloneUrl });
if (standalone.status !== "standalone") {
throw new ToolboxCheckError(
"The standalone smoke URL discovered a toolbox",
);
}
const contextualUrl = new URL(standaloneUrl);
contextualUrl.searchParams.set("toolbox", CATALOG_PATH);
await requireOk(contextualUrl, "Contextual entry");
const contextual = await loadToolboxContext({ location: contextualUrl });
if (contextual.status !== "ready") {
const reason =
contextual.status === "error" ? `: ${contextual.error.message}` : "";
throw new ToolboxCheckError(
`Toolbox context smoke check failed${reason}`,
);
}
const loaded = contextual.context.catalog.apps.find(
(entry) => entry.kind === "manifest",
);
if (loaded?.kind !== "manifest" || loaded.app.manifest.id !== manifest.id) {
throw new ToolboxCheckError(
"Smoke catalog did not resolve the built app",
);
}
return {
standaloneUrl: standaloneUrl.href,
contextualUrl: contextualUrl.href,
fetchedUrls,
};
} finally {
await close(server);
}
}
export async function checkToolboxDist(
dist: string,
options: ToolboxCheckOptions = {},
): Promise<ToolboxCheckReport> {
const root = resolve(dist);
let rootInfo;
try {
rootInfo = await stat(root);
} catch (cause) {
throw new ToolboxCheckError(
`Distribution directory does not exist: ${root}`,
{
cause,
},
);
}
if (!rootInfo.isDirectory()) {
throw new ToolboxCheckError(
`Distribution path is not a directory: ${root}`,
);
}
const manifestName = options.manifestName ?? "toolbox-app.json";
const manifestReference = localReference(root, manifestName, "Manifest");
const manifestPath = await assertRegularContainedFile(
root,
manifestReference,
"Manifest",
);
let document: unknown;
try {
document = JSON.parse(await readFile(manifestPath, "utf8"));
} catch (cause) {
throw new ToolboxCheckError(`Manifest is not valid JSON: ${manifestPath}`, {
cause,
});
}
let app: ToolboxAppManifest;
try {
app = parseToolboxApp(document);
} catch (cause) {
throw new ToolboxCheckError("Manifest does not satisfy toolbox app v1", {
cause,
});
}
if (!SEMVER_PATTERN.test(app.version)) {
throw new ToolboxCheckError(
`Manifest version is not SemVer: ${app.version}`,
);
}
const references: Array<[string, string]> = [
[app.entry, "Application entry"],
[app.icon, "Application icon"],
...(app.assets ?? []).map((asset): [string, string] => [
asset,
"Application asset",
]),
];
const checkedFiles = await Promise.all(
references.map(([reference, label]) =>
assertRegularContainedFile(
root,
localReference(root, reference, label),
label,
),
),
);
const smoke = await smokeCheck(root, app, manifestName);
return { root, manifestPath, app, checkedFiles, smoke };
}

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env node
import { checkToolboxDist } from "./check.js";
function usage(): void {
console.log("Usage: toolbox-check <dist>");
}
const arguments_ = process.argv.slice(2);
if (arguments_.includes("--help") || arguments_.includes("-h")) {
usage();
} else if (arguments_.length !== 1) {
usage();
process.exitCode = 2;
} else {
checkToolboxDist(arguments_[0] as string)
.then((report) => {
console.log(
`toolbox-check: ${report.app.id}@${report.app.version} passed (${report.checkedFiles.length} files, standalone + contextual smoke checks)`,
);
})
.catch((error: unknown) => {
console.error(
`toolbox-check: ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,2 @@
export { checkToolboxDist, ToolboxCheckError } from "./check.js";
export type { ToolboxCheckOptions, ToolboxCheckReport } from "./check.js";