diff --git a/package.json b/package.json index eb6224e..3c0e236 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "interscript", - "version": "3.0.0", + "version": "3.1.0", "description": "Interscript TypeScript runtime — interoperable script conversion", "type": "module", "main": "./dist/index.js", diff --git a/src/ml/imf/registry.ts b/src/ml/imf/registry.ts index 658e266..87bb4d4 100644 --- a/src/ml/imf/registry.ts +++ b/src/ml/imf/registry.ts @@ -3,6 +3,10 @@ * Python and Ruby runtimes): resolve id -> channel URL, verify a cached * copy against the index sha256, or download -> verify -> install. * + * The index itself is a GitHub Release asset (never raw.githubusercontent): + * DEFAULT_INDEX_URL points at models-index.yaml on an index-vN tag, and + * HTTP fetches always verify the sibling .sha256 sidecar before parsing. + * * Node persists to ~/.cache/interscript/models// (fs, atomic * rename); browsers keep the verified bytes in memory (the Cache API * integration is future work). Overrides: SECRYST_INDEX, @@ -12,7 +16,7 @@ import { load as loadYaml } from "js-yaml" export const DEFAULT_INDEX_URL = - "https://raw.githubusercontent.com/interscript/interscript-ml/main/models.yaml" + "https://github.com/interscript/interscript-ml/releases/download/index-v1/models-index.yaml" export interface Part { url: string @@ -49,11 +53,36 @@ function cacheDir(): string { return process.env["SECRYST_CACHE"] ?? `${home}/.cache/interscript` } +async function fetchHttpBytes(url: string): Promise { + const res = await fetch(url) + if (!res.ok) throw new RegistryError(`fetch failed: ${url} -> ${res.status}`) + return new Uint8Array(await res.arrayBuffer()) +} + async function fetchIndex(source: string): Promise> { - const text = - source.startsWith("http://") || source.startsWith("https://") - ? await (await fetch(source)).text() - : new TextDecoder().decode((await nodeFs())!.readFileSync(source)) + let text: string + if (source.startsWith("http://") || source.startsWith("https://")) { + const bytes = await fetchHttpBytes(source) + const sidecarRes = await fetch(`${source}.sha256`) + if (!sidecarRes.ok) { + throw new RegistryError( + `index sha256 sidecar missing: ${source}.sha256 -> ${sidecarRes.status}`, + ) + } + const expected = (await sidecarRes.text()).trim().split(/\s+/)[0] + if (!expected || !/^[0-9a-f]{64}$/i.test(expected)) { + throw new RegistryError(`index sha256 sidecar malformed: ${source}.sha256`) + } + const actual = await sha256Hex(bytes) + if (actual !== expected.toLowerCase()) { + throw new RegistryError( + `index sha256 mismatch: got ${actual}, sidecar says ${expected.toLowerCase()}`, + ) + } + text = new TextDecoder().decode(bytes) + } else { + text = new TextDecoder().decode((await nodeFs())!.readFileSync(source)) + } const raw = loadYaml(text) as { version?: number models?: Record> diff --git a/test/imf.test.ts b/test/imf.test.ts index 17fe0f9..1de48cb 100644 --- a/test/imf.test.ts +++ b/test/imf.test.ts @@ -158,6 +158,60 @@ describe("registry", () => { rmSync(dir, { recursive: true, force: true }) } }) + it("DEFAULT_INDEX_URL pins a GitHub Release asset, never raw", async () => { + const { DEFAULT_INDEX_URL } = await import("../src/ml/imf/registry.js") + expect(DEFAULT_INDEX_URL).toMatch( + /^https:\/\/github\.com\/interscript\/interscript-ml\/releases\/download\/index-v\d+\/models-index\.yaml$/, + ) + expect(DEFAULT_INDEX_URL).not.toMatch(/raw\.githubusercontent/) + }) + + it("HTTP index fetch verifies the .sha256 sidecar before parsing", async () => { + const { createHash } = await import("node:crypto") + const { createServer } = await import("node:http") + const body = "version: 1\nmodels: {}\n" + const good = createHash("sha256").update(body).digest("hex") + const bad = "0".repeat(64) + + async function withServer(sidecar: string | null, fn: (base: string) => Promise) { + const server = createServer((req, res) => { + if (req.url === "/models-index.yaml") { + res.writeHead(200, { "content-type": "text/yaml" }) + res.end(body) + return + } + if (req.url === "/models-index.yaml.sha256") { + if (sidecar === null) { + res.writeHead(404) + res.end("missing") + return + } + res.writeHead(200, { "content-type": "text/plain" }) + res.end(`${sidecar} models-index.yaml\n`) + return + } + res.writeHead(404) + res.end() + }) + await new Promise((r) => server.listen(0, "127.0.0.1", r)) + const { port } = server.address() as { port: number } + try { + await fn(`http://127.0.0.1:${port}/models-index.yaml`) + } finally { + await new Promise((r) => server.close(() => r())) + } + } + + await withServer(good, async (url) => { + await expect(resolve("nope", url)).rejects.toThrow(/unknown model id/) + }) + await withServer(bad, async (url) => { + await expect(resolve("nope", url)).rejects.toThrow(/index sha256 mismatch/) + }) + await withServer(null, async (url) => { + await expect(resolve("nope", url)).rejects.toThrow(/index sha256/) + }) + }) }) const e2eZip = process.env["SECRYST_E2E_ZIP"]