Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
39 changes: 34 additions & 5 deletions src/ml/imf/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/ (fs, atomic
* rename); browsers keep the verified bytes in memory (the Cache API
* integration is future work). Overrides: SECRYST_INDEX,
Expand All @@ -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
Expand Down Expand Up @@ -49,11 +53,36 @@ function cacheDir(): string {
return process.env["SECRYST_CACHE"] ?? `${home}/.cache/interscript`
}

async function fetchHttpBytes(url: string): Promise<Uint8Array> {
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<Record<string, IndexEntry>> {
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<string, Record<string, string | Part[]>>
Expand Down
54 changes: 54 additions & 0 deletions test/imf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>) {
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<void>((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<void>((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"]
Expand Down
Loading