|
| 1 | +#!/usr/bin/env node |
| 2 | +// verify-vendored-skill.cjs — integrity guard for the vendored |
| 3 | +// light-kanban-worker Skill snapshot (skills/light-kanban-worker/). |
| 4 | +// |
| 5 | +// Reads skills/manifest.json and verifies every listed file is present and |
| 6 | +// byte-identical (SHA-256) to the pinned upstream snapshot. Wired into |
| 7 | +// `make check` so the vendored copy can never drift from the upstream tag |
| 8 | +// without the gate failing. |
| 9 | +// |
| 10 | +// Usage: |
| 11 | +// node scripts/verify-vendored-skill.cjs # verify the snapshot |
| 12 | +// node scripts/verify-vendored-skill.cjs --self-test |
| 13 | +// # positive + negative assertions: a temp copy passes; a tampered |
| 14 | +// # temp copy fails (non-zero assertion count, exit 0 only when the |
| 15 | +// # guard itself behaves correctly) |
| 16 | + |
| 17 | +"use strict"; |
| 18 | + |
| 19 | +const fs = require("fs"); |
| 20 | +const path = require("path"); |
| 21 | +const crypto = require("crypto"); |
| 22 | +const os = require("os"); |
| 23 | + |
| 24 | +const REPO_ROOT = path.resolve(__dirname, ".."); |
| 25 | +const SKILL_DIR = path.join(REPO_ROOT, "skills", "light-kanban-worker"); |
| 26 | +const MANIFEST_PATH = path.join(REPO_ROOT, "skills", "manifest.json"); |
| 27 | + |
| 28 | +function sha256(file) { |
| 29 | + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); |
| 30 | +} |
| 31 | + |
| 32 | +// verifyManifest(rootDir, manifestPath) -> { files, failures } |
| 33 | +// rootDir is the directory that contains manifest.json and the package dir. |
| 34 | +function verifyManifest(rootDir, manifestPath) { |
| 35 | + let manifest; |
| 36 | + try { |
| 37 | + manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); |
| 38 | + } catch (err) { |
| 39 | + return { files: 0, failures: [`manifest unreadable: ${err.message}`] }; |
| 40 | + } |
| 41 | + const failures = []; |
| 42 | + const pkg = manifest.vendor && manifest.vendor.package; |
| 43 | + if (!pkg) failures.push("manifest is missing vendor.package"); |
| 44 | + if (!manifest.files || !Array.isArray(manifest.files) || manifest.files.length === 0) { |
| 45 | + failures.push("manifest has no file list"); |
| 46 | + return { files: 0, failures }; |
| 47 | + } |
| 48 | + const pkgDir = path.join(rootDir, "skills", pkg); |
| 49 | + for (const entry of manifest.files) { |
| 50 | + const file = path.join(pkgDir, entry.path); |
| 51 | + if (!fs.existsSync(file)) { |
| 52 | + failures.push(`missing file: ${entry.path}`); |
| 53 | + continue; |
| 54 | + } |
| 55 | + const actual = sha256(file); |
| 56 | + if (actual !== entry.sha256) failures.push(`hash mismatch: ${entry.path}`); |
| 57 | + } |
| 58 | + return { files: manifest.files.length, failures }; |
| 59 | +} |
| 60 | + |
| 61 | +function main() { |
| 62 | + if (process.argv.includes("--self-test")) { |
| 63 | + let assertions = 0; |
| 64 | + const failures = []; |
| 65 | + const assert = (cond, label) => { |
| 66 | + assertions += 1; |
| 67 | + if (!cond) failures.push(label); |
| 68 | + }; |
| 69 | + |
| 70 | + // Positive fixture: a pristine temp copy of the vendored snapshot passes. |
| 71 | + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "lk-vendor-")); |
| 72 | + fs.cpSync(path.join(REPO_ROOT, "skills"), path.join(tmp, "skills"), { recursive: true }); |
| 73 | + const ok = verifyManifest(tmp, path.join(tmp, "skills", "manifest.json")); |
| 74 | + assert(ok.files === 10, `pristine copy must verify 10 files (got ${ok.files})`); |
| 75 | + assert(ok.failures.length === 0, `pristine copy must pass (got: ${ok.failures.join("; ")})`); |
| 76 | + |
| 77 | + // Negative fixture: a tampered copy fails with a hash mismatch. |
| 78 | + const tampered = path.join(tmp, "skills", "light-kanban-worker", "SKILL.md"); |
| 79 | + fs.appendFileSync(tampered, "\n# tampered\n"); |
| 80 | + const bad = verifyManifest(tmp, path.join(tmp, "skills", "manifest.json")); |
| 81 | + assert(bad.failures.some((f) => f.includes("hash mismatch: SKILL.md")), "tampered copy must fail with a SKILL.md hash mismatch"); |
| 82 | + |
| 83 | + // Negative fixture: a deleted file fails with a missing-file error. |
| 84 | + fs.rmSync(tampered); |
| 85 | + const missing = verifyManifest(tmp, path.join(tmp, "skills", "manifest.json")); |
| 86 | + assert(missing.failures.some((f) => f.includes("missing file: SKILL.md")), "deleted file must fail with a missing-file error"); |
| 87 | + |
| 88 | + fs.rmSync(tmp, { recursive: true, force: true }); |
| 89 | + if (failures.length > 0) { |
| 90 | + console.error(`VENDOR_SELF_TEST=FAIL (${failures.length} failures, ${assertions} assertions)`); |
| 91 | + for (const f of failures) console.error(`FAIL: ${f}`); |
| 92 | + process.exit(1); |
| 93 | + } |
| 94 | + console.log(`VENDOR_SELF_TEST=PASS (${assertions} assertions)`); |
| 95 | + return; |
| 96 | + } |
| 97 | + |
| 98 | + const { files, failures } = verifyManifest(REPO_ROOT, MANIFEST_PATH); |
| 99 | + if (failures.length > 0) { |
| 100 | + console.error(`VENDOR_SKILL=FAIL (${failures.length} failures, ${files} files checked)`); |
| 101 | + for (const f of failures) console.error(`FAIL: ${f}`); |
| 102 | + console.error("Re-vendor from the upstream LightDevCoder/skills tag and regenerate skills/manifest.json — do not edit the snapshot in place."); |
| 103 | + process.exit(1); |
| 104 | + } |
| 105 | + console.log(`VENDOR_SKILL=PASS (${files} files match skills/manifest.json)`); |
| 106 | +} |
| 107 | + |
| 108 | +main(); |
0 commit comments