#!/usr/bin/env bun /** * Validate v1/index.json. * * Two layers: * 1. Shape/rule validation of every field, matching what the host parser * accepts. The host SILENTLY DROPS an entry that fails validation, so a * typo here would ship as "the plugin just isn't in the store" with no * error anywhere. This validator is the only place that failure is loud. * 2. Live registry cross-check: the pinned version must exist upstream and * its dist.integrity must equal the pinned integrity. This is the check * that makes the pin meaningful -- an entry whose hash does not match the * registry is either stale or an attack. * * Usage: * bun tools/validate.ts [--offline] [--fix] [path/to/index.json] * * --offline skip layer 2 (no network). CI must NOT use this. * --fix rewrite the file in canonical formatting instead of erroring. */ import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; // --------------------------------------------------------------------------- // error collection // --------------------------------------------------------------------------- interface Problem { path: string; message: string; } const problems: Problem[] = []; function fail(path: string, message: string): void { problems.push({ path, message }); } // --------------------------------------------------------------------------- // primitive checks // --------------------------------------------------------------------------- function isPlainObject(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } /** * Reject any key we do not know about. Deliberately strict: the host ignores * unknown keys, so a snake_case slip like `min_host` would parse "fine" and * silently lose its meaning. Catching it here is the whole point. */ function checkKeys( path: string, obj: Record, required: readonly string[], optional: readonly string[] = [], ): void { const known = new Set([...required, ...optional]); for (const key of Object.keys(obj)) { if (!known.has(key)) { const hint = [...known].find( (k) => k.toLowerCase().replace(/[_-]/g, "") === key.toLowerCase().replace(/[_-]/g, ""), ); fail( `${path}.${key}`, `unknown key${hint ? ` -- did you mean "${hint}"? (keys are camelCase)` : ""}`, ); } } for (const key of required) { if (!(key in obj)) fail(`${path}.${key}`, "missing required key"); } } function requireString( path: string, value: unknown, opts: { min?: number; max?: number } = {}, ): string | undefined { if (typeof value !== "string") { fail(path, `must be a string, got ${value === undefined ? "nothing" : typeof value}`); return undefined; } const { min = 1, max } = opts; if (value.length < min) { fail(path, `must be at least ${min} character${min === 1 ? "" : "s"}`); return undefined; } if (max !== undefined && value.length > max) { fail(path, `must be at most ${max} characters (got ${value.length})`); return undefined; } return value; } function requireHttpsUrl(path: string, value: unknown): string | undefined { const s = requireString(path, value, { max: 2048 }); if (s === undefined) return undefined; let url: URL; try { url = new URL(s); } catch { fail(path, `not a valid URL: ${JSON.stringify(s)}`); return undefined; } if (url.protocol !== "https:") { fail(path, `must be https, got ${url.protocol.replace(":", "")}`); return undefined; } return s; } // --------------------------------------------------------------------------- // semver // --------------------------------------------------------------------------- /** Official semver.org recommended regex, anchored. Exact versions only. */ const EXACT_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; function requireExactVersion(path: string, value: unknown): string | undefined { const s = requireString(path, value, { max: 256 }); if (s === undefined) return undefined; if (!EXACT_SEMVER.test(s)) { const looksLikeRange = /[\^~*<>=|\s]|\.x$/i.test(s); fail( path, looksLikeRange ? `must be an EXACT semver version, not a range: ${JSON.stringify(s)}` : `not a valid semver version: ${JSON.stringify(s)}`, ); return undefined; } return s; } const PRE_IDENT = /^(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)$/; const BUILD_IDENT = /^[0-9a-zA-Z-]+$/; /** * Check that a string parses as a Rust `semver::VersionReq`. * * Mirrors the grammar of the `semver` crate: comma-separated comparators, each * an optional operator (= > >= < <= ~ ^) followed by a possibly-partial version * (major[.minor[.patch]]) with optional pre-release and build metadata, or a * wildcard (* / x / X). Numeric parts reject leading zeros and must fit u64, * exactly as the crate does. * * Where this differs from the crate it is STRICTER, never looser -- so anything * accepted here is guaranteed to parse host-side. */ function parseVersionReq(text: string): string | null { if (text.trim() === "") return "must not be empty"; const parts = text.split(","); for (const rawPart of parts) { const part = rawPart.trim(); if (part === "") return "empty comparator (stray or trailing comma)"; const m = /^(=|>=|<=|>|<|~|\^)?\s*(.*)$/.exec(part); if (!m) return `cannot parse comparator ${JSON.stringify(part)}`; const [, , versionText = ""] = m; if (versionText === "") { return `comparator ${JSON.stringify(part)} has an operator but no version`; } // Split off build metadata, then pre-release. let rest = versionText; let build: string | undefined; const plus = rest.indexOf("+"); if (plus !== -1) { build = rest.slice(plus + 1); rest = rest.slice(0, plus); } let pre: string | undefined; const dash = rest.indexOf("-"); if (dash !== -1) { pre = rest.slice(dash + 1); rest = rest.slice(0, dash); } const nums = rest.split("."); if (nums.length > 3) { return `too many version segments in ${JSON.stringify(versionText)}`; } let sawWildcard = false; for (const seg of nums) { if (seg === "*" || seg === "x" || seg === "X") { sawWildcard = true; continue; } if (sawWildcard) { return `${JSON.stringify(versionText)}: a numeric segment cannot follow a wildcard`; } if (seg === "") return `empty version segment in ${JSON.stringify(versionText)}`; if (!/^\d+$/.test(seg)) { return `version segment ${JSON.stringify(seg)} is not a number`; } if (seg.length > 1 && seg.startsWith("0")) { return `version segment ${JSON.stringify(seg)} has a leading zero`; } if (BigInt(seg) > 18446744073709551615n) { return `version segment ${JSON.stringify(seg)} does not fit in u64`; } } if (sawWildcard && (pre !== undefined || build !== undefined)) { return `${JSON.stringify(versionText)}: wildcards cannot carry pre-release or build metadata`; } if (pre !== undefined) { if (pre === "") return `empty pre-release in ${JSON.stringify(versionText)}`; for (const id of pre.split(".")) { if (!PRE_IDENT.test(id)) { return `invalid pre-release identifier ${JSON.stringify(id)}`; } } } if (build !== undefined) { if (build === "") return `empty build metadata in ${JSON.stringify(versionText)}`; for (const id of build.split(".")) { if (!BUILD_IDENT.test(id)) { return `invalid build identifier ${JSON.stringify(id)}`; } } } } return null; } // --------------------------------------------------------------------------- // entry-level validation // --------------------------------------------------------------------------- const ID_RE = /^[a-z][a-z0-9-]*$/; const ICON_RE = /^[a-z0-9-]{1,48}$/; /** npm scoped name. Scoped is mandatory: the scope is what maps to a registry. */ const PKG_RE = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/; const INTEGRITY_RE = /^sha512-[A-Za-z0-9+/]+={0,2}$/; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; const RFC3339_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/; const PLATFORMS = ["linux", "windows", "macos"] as const; const PLUGIN_REQUIRED = [ "id", "pkg", "registry", "title", "description", "icon", "author", "homepage", "license", "version", "integrity", "verification", ] as const; const PLUGIN_OPTIONAL = ["minHost", "platforms"] as const; interface CheckTarget { path: string; pkg: string; registry: string; version: string; integrity: string; } /** Returns a registry cross-check job if the entry had enough valid fields. */ function validatePlugin( path: string, entry: unknown, seenIds: Map, seenPkgs: Map, ): CheckTarget | undefined { if (!isPlainObject(entry)) { fail(path, "must be an object"); return undefined; } checkKeys(path, entry, PLUGIN_REQUIRED, PLUGIN_OPTIONAL); // id const id = requireString(`${path}.id`, entry.id, { max: 64 }); if (id !== undefined) { if (!ID_RE.test(id)) { fail( `${path}.id`, `must match ^[a-z][a-z0-9-]*$ (lowercase, starts with a letter): ${JSON.stringify(id)}`, ); } else { const prior = seenIds.get(id); if (prior) fail(`${path}.id`, `duplicate id ${JSON.stringify(id)}, already used by ${prior}`); else seenIds.set(id, path); } } // pkg const pkg = requireString(`${path}.pkg`, entry.pkg, { max: 214 }); if (pkg !== undefined && !PKG_RE.test(pkg)) { fail( `${path}.pkg`, pkg.startsWith("@") ? `not a valid npm package name: ${JSON.stringify(pkg)}` : `must be a SCOPED package name (@scope/name), got ${JSON.stringify(pkg)}`, ); } else if (pkg !== undefined) { const prior = seenPkgs.get(pkg); if (prior) fail(`${path}.pkg`, `duplicate package ${JSON.stringify(pkg)}, already listed at ${prior}`); else seenPkgs.set(pkg, path); } // registry const registry = requireHttpsUrl(`${path}.registry`, entry.registry); if (registry !== undefined && !registry.endsWith("/")) { fail( `${path}.registry`, "must end with a trailing slash so the package name can be appended safely", ); } // plain text fields requireString(`${path}.title`, entry.title, { max: 64 }); requireString(`${path}.description`, entry.description, { max: 280 }); requireString(`${path}.author`, entry.author, { max: 64 }); requireString(`${path}.license`, entry.license, { max: 64 }); const icon = requireString(`${path}.icon`, entry.icon, { max: 48 }); if (icon !== undefined && !ICON_RE.test(icon)) { fail( `${path}.icon`, `must be a lucide icon name matching ^[a-z0-9-]{1,48}$ (kebab-case, e.g. "gamepad-2"): ${JSON.stringify(icon)}`, ); } requireHttpsUrl(`${path}.homepage`, entry.homepage); const version = requireExactVersion(`${path}.version`, entry.version); // integrity const integrity = requireString(`${path}.integrity`, entry.integrity, { max: 200 }); if (integrity !== undefined) { if (!INTEGRITY_RE.test(integrity)) { fail( `${path}.integrity`, `must be a Subresource Integrity string of the form "sha512-": ${JSON.stringify(integrity)}`, ); } else { const raw = Buffer.from(integrity.slice("sha512-".length), "base64"); if (raw.length !== 64) { fail( `${path}.integrity`, `sha512 digest must decode to 64 bytes, got ${raw.length}`, ); } } } // verification if (!isPlainObject(entry.verification)) { if ("verification" in entry) fail(`${path}.verification`, "must be an object"); } else { checkKeys(`${path}.verification`, entry.verification, ["reviewedAt"]); const reviewedAt = requireString( `${path}.verification.reviewedAt`, entry.verification.reviewedAt, { max: 10 }, ); if (reviewedAt !== undefined) { if (!DATE_RE.test(reviewedAt)) { fail(`${path}.verification.reviewedAt`, `must be YYYY-MM-DD, got ${JSON.stringify(reviewedAt)}`); } else { const parsed = new Date(`${reviewedAt}T00:00:00Z`); if (Number.isNaN(parsed.getTime()) || !parsed.toISOString().startsWith(reviewedAt)) { fail(`${path}.verification.reviewedAt`, `not a real calendar date: ${reviewedAt}`); } else if (parsed.getTime() > Date.now() + 24 * 60 * 60 * 1000) { fail(`${path}.verification.reviewedAt`, `is in the future: ${reviewedAt}`); } } } } // minHost (optional) if (entry.minHost !== undefined) requireExactVersion(`${path}.minHost`, entry.minHost); // platforms (optional) if (entry.platforms !== undefined) { if (!Array.isArray(entry.platforms)) { fail(`${path}.platforms`, "must be an array"); } else { const seen = new Set(); entry.platforms.forEach((p, i) => { if (typeof p !== "string" || !(PLATFORMS as readonly string[]).includes(p)) { fail( `${path}.platforms[${i}]`, `must be one of ${PLATFORMS.join(" | ")}, got ${JSON.stringify(p)}`, ); return; } if (seen.has(p)) fail(`${path}.platforms[${i}]`, `duplicate platform ${JSON.stringify(p)}`); seen.add(p); }); } } if (pkg && registry && version && integrity && INTEGRITY_RE.test(integrity)) { return { path, pkg, registry, version, integrity }; } return undefined; } function validateSecurity(path: string, entry: unknown): { pkg?: string; req?: string } { if (!isPlainObject(entry)) { fail(path, "must be an object"); return {}; } checkKeys(path, entry, ["pkg", "versions", "reason"], ["url"]); const pkg = requireString(`${path}.pkg`, entry.pkg, { max: 214 }); if (pkg !== undefined && !PKG_RE.test(pkg)) { fail(`${path}.pkg`, `must be a scoped npm package name, got ${JSON.stringify(pkg)}`); } const versions = requireString(`${path}.versions`, entry.versions, { max: 256 }); if (versions !== undefined) { const err = parseVersionReq(versions); if (err) { fail(`${path}.versions`, `not a valid semver requirement (${err}): ${JSON.stringify(versions)}`); } else if (versions.trim() === "*") { // Guard rail: `*` revokes every version ever published. If that is really // intended, say so explicitly with a bounded range. fail( `${path}.versions`, 'refusing a bare "*" revocation -- it would revoke every version of the package; use an explicit bound', ); } } requireString(`${path}.reason`, entry.reason, { max: 280 }); if (entry.url !== undefined) requireHttpsUrl(`${path}.url`, entry.url); return { pkg, req: versions }; } // --------------------------------------------------------------------------- // live registry cross-check // --------------------------------------------------------------------------- /** * npm packument URL for a scoped package. The scope separator is percent- * encoded but the leading `@` is left literal -- this is the form Gitea's npm * API is known to answer. */ function packumentUrl(registry: string, pkg: string): string { return registry + pkg.replace("/", "%2f"); } async function crossCheck(target: CheckTarget): Promise { const url = packumentUrl(target.registry, target.pkg); let body: unknown; try { const res = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(20_000), }); if (!res.ok) { fail(target.path, `registry lookup failed: HTTP ${res.status} ${res.statusText} for ${url}`); return; } body = await res.json(); } catch (err) { fail(target.path, `registry lookup failed for ${url}: ${(err as Error).message}`); return; } if (!isPlainObject(body) || !isPlainObject(body.versions)) { fail(target.path, `registry returned no "versions" map for ${target.pkg}`); return; } const meta = body.versions[target.version]; if (meta === undefined) { const available = Object.keys(body.versions).slice(-8).join(", "); fail( target.path, `version ${target.version} of ${target.pkg} does not exist in the registry (latest seen: ${available || "none"})`, ); return; } if (!isPlainObject(meta) || !isPlainObject(meta.dist)) { fail(target.path, `registry entry for ${target.pkg}@${target.version} has no "dist" block`); return; } const upstream = meta.dist.integrity; if (typeof upstream !== "string" || upstream === "") { fail( target.path, `registry entry for ${target.pkg}@${target.version} has no dist.integrity -- cannot pin this package`, ); return; } if (upstream !== target.integrity) { fail( target.path, `INTEGRITY MISMATCH for ${target.pkg}@${target.version}\n` + ` pinned here: ${target.integrity}\n` + ` registry: ${upstream}\n` + " The pinned hash must be copied verbatim from the registry. If the registry\n" + " changed for a version that was already published, stop and investigate.", ); return; } console.log(` ok ${target.pkg}@${target.version} integrity matches registry`); } // --------------------------------------------------------------------------- // main // --------------------------------------------------------------------------- const args = process.argv.slice(2); const offline = args.includes("--offline"); const fixFormat = args.includes("--fix"); const positional = args.filter((a) => !a.startsWith("--")); const file = resolve(positional[0] ?? "v1/index.json"); let text: string; try { text = readFileSync(file, "utf8"); } catch (err) { console.error(`error: cannot read ${file}: ${(err as Error).message}`); process.exit(1); } let doc: unknown; try { doc = JSON.parse(text); } catch (err) { console.error(`${file}: not valid JSON: ${(err as Error).message}`); process.exit(1); } // Canonical formatting. The signature covers the exact bytes of this file, so // keeping it byte-stable makes review diffs minimal and re-signing predictable. const canonical = `${JSON.stringify(doc, null, 2)}\n`; if (text !== canonical) { if (fixFormat) { writeFileSync(file, canonical); console.log(`fixed formatting of ${file}`); text = canonical; } else { fail( "", "not in canonical formatting (2-space indent, trailing newline). Run: bun run validate -- --fix", ); } } if (!isPlainObject(doc)) { console.error(`${file}: top level must be a JSON object`); process.exit(1); } checkKeys("$", doc, ["schema", "name", "generated", "plugins", "security"]); if (doc.schema !== 1) { fail("$.schema", `must be exactly 1 (number), got ${JSON.stringify(doc.schema)}`); } requireString("$.name", doc.name, { max: 64 }); const generated = requireString("$.generated", doc.generated, { max: 40 }); if (generated !== undefined) { if (!RFC3339_UTC_RE.test(generated)) { fail("$.generated", `must be an RFC 3339 UTC timestamp like 2026-07-20T18:00:00Z, got ${JSON.stringify(generated)}`); } else if (Number.isNaN(new Date(generated).getTime())) { fail("$.generated", `not a real timestamp: ${generated}`); } } const targets: CheckTarget[] = []; const pinned = new Map(); // pkg -> pinned version if (!Array.isArray(doc.plugins)) { fail("$.plugins", "must be an array"); } else { const seenIds = new Map(); const seenPkgs = new Map(); doc.plugins.forEach((entry, i) => { const target = validatePlugin(`$.plugins[${i}]`, entry, seenIds, seenPkgs); if (target) { targets.push(target); pinned.set(target.pkg, target.version); } }); } if (!Array.isArray(doc.security)) { fail("$.security", "must be an array"); } else { doc.security.forEach((entry, i) => { const path = `$.security[${i}]`; const { pkg, req } = validateSecurity(path, entry); // Self-consistency: never ship a catalog that pins a version it also revokes. if (pkg && req && pinned.has(pkg)) { const version = pinned.get(pkg)!; if (satisfiesSimple(version, req)) { fail( path, `revokes ${pkg} ${req}, which matches the version pinned in $.plugins (${version}) -- bump the pin or narrow the revocation`, ); } } }); } /** * Minimal "does this exact version satisfy this requirement" check, used only * for the self-consistency guard above. Handles the comparator forms we allow; * on anything it cannot decide it returns false (no false alarms). */ function satisfiesSimple(version: string, req: string): boolean { const v = parseExact(version); if (!v) return false; return req.split(",").every((rawPart) => { const part = rawPart.trim(); const m = /^(=|>=|<=|>|<|~|\^)?\s*(.*)$/.exec(part); if (!m) return false; const op = m[1] ?? "^"; const bare = (m[2] ?? "").split("+")[0]!; if (/[*xX]/.test(bare)) return true; const parts = bare.split("-")[0]!.split(".").map((n) => Number(n)); const [major = 0, minor, patch] = parts; const lower: [number, number, number] = [major, minor ?? 0, patch ?? 0]; const cmp = compare(v, lower); switch (op) { case "=": return cmp === 0; case ">": return cmp > 0; case ">=": return cmp >= 0; case "<": return cmp < 0; case "<=": return cmp <= 0; case "~": { if (cmp < 0) return false; if (minor === undefined) return v[0] === major; return v[0] === major && v[1] === minor; } case "^": { if (cmp < 0) return false; if (major > 0) return v[0] === major; if (minor === undefined || minor > 0) return v[0] === 0 && v[1] === (minor ?? 0); return v[0] === 0 && v[1] === 0 && v[2] === (patch ?? 0); } default: return false; } }); } function parseExact(version: string): [number, number, number] | null { const m = EXACT_SEMVER.exec(version); if (!m) return null; return [Number(m[1]), Number(m[2]), Number(m[3])]; } function compare(a: [number, number, number], b: [number, number, number]): number { for (let i = 0; i < 3; i++) { if (a[i]! !== b[i]!) return a[i]! < b[i]! ? -1 : 1; } return 0; } // Layer 2: live registry. if (problems.length === 0 && !offline) { console.log(`checking ${targets.length} pinned package(s) against their registries...`); await Promise.all(targets.map(crossCheck)); } else if (offline) { console.log("skipping live registry cross-check (--offline)"); } if (problems.length > 0) { console.error(`\n${file}: ${problems.length} problem(s):\n`); for (const p of problems) console.error(` ${p.path}\n ${p.message}\n`); process.exit(1); } const pluginCount = Array.isArray(doc.plugins) ? doc.plugins.length : 0; const securityCount = Array.isArray(doc.security) ? doc.security.length : 0; console.log( `\nOK: ${file} is valid -- ${pluginCount} plugin(s), ${securityCount} security advisory(ies).`, );