diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e6459eb..23fabdd 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -56,7 +56,7 @@ jobs: - name: Check the schema copy matches Lithe run: | - url="https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/preview/shared/contracts/plugins/v1/plugin-index.schema.json" + url="https://raw.githubusercontent.com/1lck/Lithe-IDEA/preview/shared/contracts/plugins/v1/plugin-index.schema.json" status=$(curl -sS -o "$RUNNER_TEMP/upstream-schema.json" -w '%{http_code}' "$url") if [ "$status" = "404" ]; then echo "::notice::Lithe preview does not publish plugin-index.schema.json yet; skipping drift check." diff --git a/docs/publishing.md b/docs/publishing.md index 00a90a4..1aa0132 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -26,6 +26,11 @@ **不能**使用 `dev.lithe` 或 `dev.lithe.*`。 - `vendor.signatureRequirement` 必须是 `"none"`(社区包不带 Lithe 签名)。 - `version` 是 `MAJOR.MINOR.PATCH`,每次发布都必须递增,已发布的版本号不能复用。 +- `pluginAPIVersion: 3` 必须有非空 `requires`,逐项声明包实际使用的能力及其契约版本。 + 例如只使用声明式构建系统的包为 `"requires": { "buildSystems": 1 }`;不要无故增加 + `buildProviders` 等能力。API 1/2 继续使用原有格式,不能带 `requires`。 +- `requires` 的键是小写字母开头、后接英文字母或数字、最长 64 个字符的能力 ID, + 值是正整数。索引允许未知能力 ID,由宿主能力表决定可安装性;这不表示当前宿主支持它。 - 包内路径只用 `/`,不能有 `..`、绝对路径或符号链接。 - 可以附带可执行文件(例如打包好的语言服务器),但它们必须在 `plugin.json` 中声明; 用户安装前会看到完整的程序列表并确认。版本更新新增了程序时,用户需要重新确认。 @@ -42,6 +47,10 @@ zig-support-2.0.0-darwin-arm64.zip └── … ``` +同一插件版本的所有平台包必须使用相同的 `version`、`pluginAPIVersion` 和 `requires`。 +能力表按键和值比较,键的书写顺序可以不同。API 3 索引版本项必须逐项复制包清单的 +`requires`;不同平台需要不同能力时,应使用不同的插件版本,不能共用一个版本项。 + Release 资产名必须以平台结尾,自动追踪才能识别: | 资产名结尾 | 对应平台 | @@ -99,6 +108,16 @@ shasum -a 256 zig-support-2.0.0-darwin-arm64.zip 字段说明见 [schema/plugin-index.schema.json](../schema/plugin-index.schema.json)。`homepage`、 `localizedDescriptions`、`tags`、`icon` 可选;`icon` 只能是同一仓库的 raw 文件。 + API 3 的版本项还必须在 `pluginAPIVersion` 旁加入与包内相同的能力表,例如: + + ```json + "pluginAPIVersion": 3, + "requires": { "buildSystems": 1 } + ``` + + 提高 API 等级或能力版本时发布新的插件版本,并保留最后一个旧要求的版本项, + 让旧宿主仍能选择可安装版本。当前 Core 对未来 API 等级(4 及以上)的索引项允许 + 省略 `requires`,提供时仍校验能力表;这只是索引格式的前向兼容,不承诺宿主可安装。 2. 统一格式并本地校验(CI 要求索引使用统一格式:两格缩进,纯值列表写在一行): @@ -107,8 +126,14 @@ shasum -a 256 zig-support-2.0.0-darwin-arm64.zip node scripts/validate-index.mjs --verify-archives ``` -3. 提交 PR,按模板填写。CI 会下载每个新增的包,核对 SHA-256、包内 `plugin.json` 的 - `id`、`version`、`pluginAPIVersion` 和 `signatureRequirement`。维护者审查后合并。 +3. 提交 PR,按模板填写。CI 会下载每个新增或校验预期发生变化的包,核对 SHA-256、 + 包内 `plugin.json` 的 `id`、`version`、`pluginAPIVersion`、`requires` 和 + `signatureRequirement`。即使 URL 和 SHA-256 不变,修改 ID、版本、API 等级或能力表 + 也会重新校验;完整校验不使用基线跳过。维护者审查后合并。 + +索引与归档检查只覆盖上述发布字段、社区信任声明和现有路径检查,不替代完整的 Core +清单校验或插件运行验收。安装端仍由 Core 验证能力是否与清单实际内容相符,并由宿主 +检查能力支持情况。没有运行这些验证时,PR 应明确标记为待验证。 也可以先开一个 [插件收录申请 issue](../../issues/new?template=plugin-submission.yml),由维护者帮你提 PR。 @@ -118,15 +143,21 @@ shasum -a 256 zig-support-2.0.0-darwin-arm64.zip 1. 在你的仓库递增 `plugin.json` 的 `version`,打包,发布新的正式 Release(资产命名同上)。 2. `Track plugin releases` 工作流每 6 小时扫描一次已收录插件最近 20 个 Release, - 把新版本写进索引(版本号、`pluginAPIVersion` 取自包内 `plugin.json`,发布日期取 Release - 发布时间,SHA-256 由 CI 计算),然后开一个 `chore(index): 追踪插件新版本` PR。 + 把新版本写进索引(版本号、`pluginAPIVersion`、`requires` 取自包内 `plugin.json`, + 能力表按键排序写入;发布日期取 Release 发布时间,SHA-256 由 CI 计算), + 然后开一个 `chore(index): 追踪插件新版本` PR。 3. 维护者审查合并后,Lithe 用户刷新插件市场就能看到更新。 想更快上线,可以在 issue 里请维护者手动触发工作流,或者自己提 PR 在 `versions` 里加一项 (保持新版本在前)。 -自动追踪会跳过并在日志中说明原因的情况:没有符合命名的 zip、包内 `plugin.json` 不匹配、 -多个资产声明了不同的版本号、版本号已被别的资产占用。 +自动追踪只收录正式 Release,继续跳过 draft 和 pre-release;预发布版本需要手工提交 +索引条目并接受相同的校验,不能靠调整追踪策略自动收录。Maven、Gradle 的 `0.1.0` +预发布收录属于这种手工流程,条目是否存在不代表发布或运行验证已经完成。 + +自动追踪会在日志中说明归档无法收录的原因:没有符合命名的 zip、包内 `plugin.json` +不匹配或缺失/畸形的能力表、多个资产声明了不同的版本号、API 等级或能力表、 +版本号已被别的资产占用。单个发布存在分歧时拒绝整个版本,不只保留其中一个平台包。 ## 5. 修改信息、转移或下架 diff --git a/schema/plugin-index.schema.json b/schema/plugin-index.schema.json index a046305..4d4f53a 100644 --- a/schema/plugin-index.schema.json +++ b/schema/plugin-index.schema.json @@ -39,12 +39,27 @@ "type": "object", "additionalProperties": false, "required": ["version", "pluginAPIVersion", "releaseDate", "downloads"], + "if": { "properties": { "pluginAPIVersion": { "const": 3 } } }, + "then": { "required": ["requires"] }, "properties": { "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, "pluginAPIVersion": { "type": "integer", "minimum": 1 }, + "requires": { + "description": "API level 3: the package manifest's requires, including buildProviders: 1 when any buildSystems[].provider is present (own or shared language server), so hosts can tell before downloading whether they run every capability it needs. Capability IDs this contract does not define are allowed; such a version is never available.", + "type": "object", + "minProperties": 1, + "propertyNames": { "pattern": "^[a-z][A-Za-z0-9]{0,63}$" }, + "additionalProperties": { "type": "integer", "minimum": 1 } + }, "releaseDate": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, "downloads": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/download" } } - } + }, + "allOf": [ + { + "if": { "properties": { "pluginAPIVersion": { "maximum": 2 } } }, + "then": { "not": { "required": ["requires"] } } + } + ] }, "download": { "type": "object", diff --git a/scripts/fixtures/invalid-index-entries.json b/scripts/fixtures/invalid-index-entries.json index 44d0240..8d183cf 100644 --- a/scripts/fixtures/invalid-index-entries.json +++ b/scripts/fixtures/invalid-index-entries.json @@ -22,6 +22,9 @@ { "name": "homepage with embedded credentials", "edits": [{ "op": "set", "path": "/homepage", "value": "https://user:secret@example.com" }], "expectedCode": "plugin.index.invalid" }, { "name": "tag with whitespace", "edits": [{ "op": "set", "path": "/tags", "value": ["two words"] }], "expectedCode": "plugin.index.invalid" }, { "name": "localized description with a malformed language tag", "edits": [{ "op": "set", "path": "/localizedDescriptions", "value": { "Chinese": "Zig 语言服务器集成。" } }], "expectedCode": "plugin.index.invalid" }, - { "name": "empty localized description", "edits": [{ "op": "set", "path": "/localizedDescriptions", "value": { "zh-Hans": " " } }], "expectedCode": "plugin.index.invalid" } + { "name": "empty localized description", "edits": [{ "op": "set", "path": "/localizedDescriptions", "value": { "zh-Hans": " " } }], "expectedCode": "plugin.index.invalid" }, + { "name": "requires below plugin API 3", "edits": [{ "op": "set", "path": "/versions/0/requires", "value": { "languages": 1 } }], "expectedCode": "plugin.index.invalid" }, + { "name": "plugin API 3 version without requires", "edits": [{ "op": "set", "path": "/versions/0/pluginAPIVersion", "value": 3 }], "expectedCode": "plugin.index.invalid" }, + { "name": "capability version is not a positive integer", "edits": [{ "op": "set", "path": "/versions/0/pluginAPIVersion", "value": 3 }, { "op": "set", "path": "/versions/0/requires", "value": { "languages": 0 } }], "expectedCode": "plugin.index.invalid" } ] } diff --git a/scripts/store.test.mjs b/scripts/store.test.mjs index 0798127..a424c5d 100644 --- a/scripts/store.test.mjs +++ b/scripts/store.test.mjs @@ -1,11 +1,12 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { addVersion, platformsForAsset } from "./track-releases.mjs"; -import { formatIndex, inspectArchive, validateEntry, validateIndex } from "./validate-index.mjs"; +import { addVersion, platformsForAsset, versionFromRelease } from "./track-releases.mjs"; +import { checkManifest, formatIndex, inspectArchive, validateEntry, validateIndex, verifyArchives } from "./validate-index.mjs"; // Fixtures are copied from Lithe-IDEA shared/contracts/plugins/v1/fixtures/marketplace // so this validator reports the same code as Lithe's Rust Core for each case. @@ -43,6 +44,215 @@ for (const { name, edits, expectedCode } of invalidCases) { }); } +function capabilityEntry() { + const entry = validEntry(); + entry.versions = [entry.versions[1]]; + entry.versions[0].pluginAPIVersion = 3; + entry.versions[0].requires = { buildSystems: 1 }; + return entry; +} + +// Manifest fragments cover publishing metadata; Core owns full declarative validation. +function manifestFor(entry) { + const version = entry.versions[0]; + return { + schemaVersion: 2, + id: entry.id, + version: version.version, + pluginAPIVersion: version.pluginAPIVersion, + ...(Object.hasOwn(version, "requires") ? { requires: structuredClone(version.requires) } : {}), + vendor: { signatureRequirement: "none" }, + }; +} + +// These bytes represent manifest data only. The injected reader never invokes unzip. +function releaseFixture(entry, manifests) { + const assets = manifests.map((manifest, position) => ({ + name: `fixture-${position === 0 ? "darwin-arm64" : "windows-x64"}.zip`, + browser_download_url: `${entry.repository}/releases/download/v2.0.0/fixture-${position === 0 ? "darwin-arm64" : "windows-x64"}.zip`, + bytes: Buffer.from(JSON.stringify(manifest)), + })); + return { + release: { published_at: "2026-10-18T00:00:00Z", assets }, + downloadArchive: async (url) => { + const asset = assets.find((candidate) => candidate.browser_download_url === url); + assert.ok(asset, `unexpected fixture download: ${url}`); + return asset.bytes; + }, + readManifests: (bytes, pluginID) => { + assert.equal(pluginID, entry.id); + return { manifests: [JSON.parse(bytes.toString())], unsafe: [] }; + }, + }; +} + +test("API 3 requires a non-empty capability map and accepts future capability IDs", () => { + const entry = capabilityEntry(); + assert.equal(codeOf(entry), "ok"); + entry.versions[0].requires = { futureFeature: 99 }; + assert.equal(codeOf(entry), "ok"); + delete entry.versions[0].requires; + assert.equal(codeOf(entry), "plugin.index.invalid"); + for (const requires of [null, {}, [], "buildSystems", { buildSystems: 0 }, { buildSystems: -1 }, + { buildSystems: 1.5 }, { buildSystems: "1" }, { buildSystems: true }, { "build-systems": 1 }, + { BuildSystems: 1 }, { "1buildSystems": 1 }, { ["a".repeat(65)]: 1 }]) { + entry.versions[0].requires = requires; + assert.equal(codeOf(entry), "plugin.index.invalid", JSON.stringify(requires)); + } + entry.versions[0].requires = { ["a".repeat(64)]: 1 }; + assert.equal(codeOf(entry), "ok"); +}); + +test("API 1/2 remain valid without requires and reject it when supplied", () => { + for (const api of [1, 2]) { + const entry = validEntry(); + entry.versions[0].pluginAPIVersion = api; + assert.equal(codeOf(entry), "ok"); + for (const requires of [{ buildSystems: 1 }, null]) { + entry.versions[0].requires = requires; + assert.equal(codeOf(entry), "plugin.index.invalid"); + } + } +}); + +test("future API levels may omit requires but validate a supplied map", () => { + const entry = capabilityEntry(); + entry.versions[0].pluginAPIVersion = 4; + delete entry.versions[0].requires; + assert.equal(codeOf(entry), "ok"); + entry.versions[0].requires = { futureFeature: 1 }; + assert.equal(codeOf(entry), "ok"); + entry.versions[0].requires = {}; + assert.equal(codeOf(entry), "plugin.index.invalid"); +}); + +test("package and index requirements agree regardless of key order", () => { + const entry = capabilityEntry(); + const version = entry.versions[0]; + version.requires = { settings: 1, buildSystems: 1 }; + const manifest = { ...manifestFor(entry), requires: { buildSystems: 1, settings: 1 } }; + assert.deepEqual(checkManifest([manifest], entry, version), []); + for (const requires of [{ buildSystems: 2, settings: 1 }, { buildSystems: 1 }, + { buildSystems: 1, settings: 1, futureFeature: 1 }]) { + assert.match(checkManifest([{ ...manifest, requires }], entry, version).join(), /requires != index requires/); + } + delete manifest.requires; + assert.match(checkManifest([manifest], entry, version).join(), /must list its capabilities/); +}); + +test("package checks retain identity, API and community trust checks", () => { + const entry = capabilityEntry(); + const version = entry.versions[0]; + const manifest = manifestFor(entry); + for (const [edit, expected] of [ + [{ id: "io.example.other" }, /plugin\.package\.mismatch/], + [{ version: "9.0.0" }, /plugin\.package\.mismatch/], + [{ pluginAPIVersion: 4 }, /pluginAPIVersion/], + [{ vendor: { signatureRequirement: "publisherPackage" } }, /signatureRequirement/], + ]) { + assert.match(checkManifest([{ ...manifest, ...edit }], entry, version).join(), expected); + } + for (const api of [1, 2]) { + const legacyEntry = validEntry(); + legacyEntry.versions[0].pluginAPIVersion = api; + const legacyManifest = manifestFor(legacyEntry); + assert.deepEqual(checkManifest([legacyManifest], legacyEntry, legacyEntry.versions[0]), []); + assert.match(checkManifest([{ ...legacyManifest, requires: { languages: 1 } }], legacyEntry).join(), /forbidden/); + } +}); + +test("release tracking copies API 3 requires and normalizes its key order", { timeout: 5_000 }, async () => { + const entry = capabilityEntry(); + const manifest = { ...manifestFor(entry), requires: { settings: 1, buildSystems: 1 } }; + const fixture = releaseFixture(entry, [manifest, { ...manifest, requires: { buildSystems: 1, settings: 1 } }]); + const result = await versionFromRelease(entry, fixture.release, fixture); + assert.equal(result.problems, undefined); + assert.deepEqual(result.version.requires, { buildSystems: 1, settings: 1 }); + assert.deepEqual(Object.keys(result.version.requires), ["buildSystems", "settings"]); + assert.equal(result.version.pluginAPIVersion, 3); + assert.equal(result.version.downloads.length, 2); + assert.equal(result.version.downloads[0].sha256, createHash("sha256").update(fixture.release.assets[0].bytes).digest("hex")); +}); + +test("release tracking rejects malformed or conflicting requirements across platforms", { timeout: 5_000 }, async () => { + const entry = capabilityEntry(); + const manifest = manifestFor(entry); + const missing = { ...manifest }; + delete missing.requires; + for (const [other, expected] of [ + [{ ...manifest, requires: { buildSystems: 2 } }, /disagree.*requires/], + [{ ...manifest, requires: { buildSystems: 1, settings: 1 } }, /disagree.*requires/], + [{ ...manifest, requires: {} }, /non-empty/], + [missing, /must list its capabilities/], + [{ ...manifest, version: "3.0.0" }, /disagree.*version/], + [{ ...manifest, pluginAPIVersion: 4 }, /disagree.*pluginAPIVersion/], + ]) { + const fixture = releaseFixture(entry, [manifest, other]); + const result = await versionFromRelease(entry, fixture.release, fixture); + assert.equal(result.version, undefined); + assert.match(result.problems.join(), expected); + } +}); + +test("release tracking preserves API 1/2 releases without adding requires", { timeout: 5_000 }, async () => { + for (const api of [1, 2]) { + const entry = validEntry(); + entry.versions[0].pluginAPIVersion = api; + const fixture = releaseFixture(entry, [manifestFor(entry)]); + const result = await versionFromRelease(entry, fixture.release, fixture); + assert.equal(result.problems, undefined); + assert.equal(result.version.pluginAPIVersion, api); + assert.equal(Object.hasOwn(result.version, "requires"), false); + } +}); + +test("archive verification rechecks changed manifest expectations for the same URL and hash", { timeout: 5_000 }, async () => { + const entry = capabilityEntry(); + const manifest = manifestFor(entry); + const bytes = Buffer.from(JSON.stringify(manifest)); + for (const download of entry.versions[0].downloads) download.sha256 = createHash("sha256").update(bytes).digest("hex"); + const index = { schemaVersion: 1, plugins: [entry] }; + let downloadCount = 0; + const dependencies = { + download: async () => { downloadCount += 1; return bytes; }, + readManifests: () => ({ manifests: [manifest], unsafe: [] }), + log: () => {}, + }; + assert.deepEqual(await verifyArchives(index, structuredClone(index), dependencies), []); + assert.equal(downloadCount, 0); + for (const editBase of [ + (oldEntry) => { oldEntry.versions[0].requires = { buildSystems: 2 }; }, + (oldEntry) => { oldEntry.versions[0].pluginAPIVersion = 4; }, + (oldEntry) => { oldEntry.versions[0].version = "1.0.0"; }, + (oldEntry) => { oldEntry.id = "io.example.other"; }, + ]) { + const base = structuredClone(index); + editBase(base.plugins[0]); + downloadCount = 0; + assert.deepEqual(await verifyArchives(index, base, dependencies), []); + assert.equal(downloadCount, entry.versions[0].downloads.length); + } + const changed = structuredClone(index); + changed.plugins[0].versions[0].requires = { buildSystems: 2 }; + assert.match((await verifyArchives(changed, index, dependencies)).join(), /requires != index requires/); +}); + +test("archive verification compares requires by value and checks hashes before manifests", { timeout: 5_000 }, async () => { + const entry = capabilityEntry(); + entry.versions[0].requires = { settings: 1, buildSystems: 1 }; + const index = { schemaVersion: 1, plugins: [entry] }; + const base = structuredClone(index); + base.plugins[0].versions[0].requires = { buildSystems: 1, settings: 1 }; + const forbidden = () => { assert.fail("unchanged archives must not be downloaded or inspected"); }; + assert.deepEqual(await verifyArchives(index, base, { download: forbidden, readManifests: forbidden }), []); + const problems = await verifyArchives(index, undefined, { + download: async () => Buffer.from("wrong checksum"), + readManifests: forbidden, + }); + assert.equal(problems.length, entry.versions[0].downloads.length); + assert.ok(problems.every((problem) => problem.includes("plugin.archive.checksumMismatch"))); +}); + test("the published index is valid", () => { const index = JSON.parse(readFileSync(new URL("../lithe-plugin-index.json", import.meta.url))); assert.deepEqual(validateIndex(index), []); diff --git a/scripts/track-releases.mjs b/scripts/track-releases.mjs index 086b4cf..25e49a9 100644 --- a/scripts/track-releases.mjs +++ b/scripts/track-releases.mjs @@ -8,17 +8,15 @@ // - one `.zip` asset per platform named `--.zip` // (for example `zig-support-2.0.0-darwin-arm64.zip`), or `-.zip` // for one archive that serves every architecture of that OS; -// - every archive contains the same plugin.json `version`. +// - every archive agrees on plugin.json `version`, `pluginAPIVersion`, and `requires`. // // Usage: node scripts/track-releases.mjs [index.json] [--plugin ] [--dry-run] // GITHUB_TOKEN is used for API requests when set. import { createHash } from "node:crypto"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { checkManifest, formatIndex, readPackageManifests, validateIndex } from "./validate-index.mjs"; +import { checkManifest, formatIndex, readArchiveBytes, requirementsKey, validateIndex } from "./validate-index.mjs"; export const KNOWN_ARCHITECTURES = { darwin: ["arm64", "x64"], windows: ["x64", "arm64"], linux: ["x64", "arm64"] }; const ASSET_PLATFORM = /-(darwin|windows|linux)(?:-(arm64|x64))?\.zip$/; @@ -66,12 +64,15 @@ async function download(url) { return Buffer.from(await response.arrayBuffer()); } -/** Builds an index version from one release, or returns problems explaining why it cannot be listed. */ -async function versionFromRelease(entry, release) { +/** Builds a version; injected archive I/O keeps unit tests independent of network and unzip. */ +export async function versionFromRelease(entry, release, { + downloadArchive = download, readManifests = readArchiveBytes, +} = {}) { const problems = []; const downloads = []; const versions = new Set(); const apiVersions = new Set(); + const capabilityRequirements = new Map(); const claimed = new Set(); for (const asset of release.assets ?? []) { const platforms = platformsForAsset(asset.name); @@ -80,12 +81,9 @@ async function versionFromRelease(entry, release) { problems.push(`${asset.name}: a platform is already served by another asset`); continue; } - const workDir = mkdtempSync(join(tmpdir(), "lithe-track-")); try { - const bytes = await download(asset.browser_download_url); - const archive = join(workDir, "plugin.zip"); - writeFileSync(archive, bytes); - const { manifests, unsafe } = readPackageManifests(archive, entry.id); + const bytes = await downloadArchive(asset.browser_download_url); + const { manifests, unsafe } = readManifests(bytes, entry.id); const assetProblems = [...unsafe.map((name) => `unsafe path ${name}`), ...checkManifest(manifests, entry)]; if (assetProblems.length > 0) { problems.push(...assetProblems.map((problem) => `${asset.name}: ${problem}`)); @@ -93,6 +91,7 @@ async function versionFromRelease(entry, release) { } versions.add(manifests[0].version); apiVersions.add(manifests[0].pluginAPIVersion); + capabilityRequirements.set(requirementsKey(manifests[0].requires), manifests[0].requires); platforms.forEach((platform) => claimed.add(platform)); downloads.push({ platforms, @@ -101,22 +100,24 @@ async function versionFromRelease(entry, release) { }); } catch (error) { problems.push(`${asset.name}: ${error.message}`); - } finally { - rmSync(workDir, { recursive: true, force: true }); } } if (downloads.length === 0) problems.push("no `-[-].zip` plugin archive in this release"); if (versions.size > 1) problems.push(`archives disagree on plugin.json version: ${[...versions].join(", ")}`); if (apiVersions.size > 1) problems.push(`archives disagree on pluginAPIVersion: ${[...apiVersions].join(", ")}`); + if (capabilityRequirements.size > 1) problems.push("archives disagree on plugin.json requires"); if (problems.length > 0) return { problems }; - return { - version: { - version: [...versions][0], - pluginAPIVersion: [...apiVersions][0], - releaseDate: (release.published_at ?? release.created_at).slice(0, 10), - downloads: downloads.sort((left, right) => left.platforms[0].localeCompare(right.platforms[0])), - }, + const requires = capabilityRequirements.values().next().value; + const version = { + version: [...versions][0], + pluginAPIVersion: [...apiVersions][0], + ...(requires === undefined ? {} : { requires: Object.fromEntries(Object.keys(requires).sort().map((id) => [id, requires[id]])) }), + releaseDate: (release.published_at ?? release.created_at).slice(0, 10), + downloads: downloads.sort((left, right) => left.platforms[0].localeCompare(right.platforms[0])), }; + // Refuse an invalid release before it can affect other plugins' tracked updates. + const versionProblems = validateIndex({ schemaVersion: 1, plugins: [{ ...entry, versions: [version] }] }); + return versionProblems.length > 0 ? { problems: versionProblems } : { version }; } async function main(argv) { diff --git a/scripts/validate-index.mjs b/scripts/validate-index.mjs index f6bf708..bbd52aa 100644 --- a/scripts/validate-index.mjs +++ b/scripts/validate-index.mjs @@ -12,8 +12,8 @@ // node scripts/validate-index.mjs [index.json] --verify-archives [--base ] // // --verify-archives downloads every archive (or, with --base, only archives -// whose download URL is not in the base index), checks its SHA-256, and checks -// the plugin.json inside matches the index entry and is a community package. +// whose URL, checksum, or expected manifest metadata changed), checks its SHA-256, +// and checks the plugin.json inside matches the index entry and is a community package. import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; @@ -26,7 +26,7 @@ const ENTRY_FIELDS = new Set([ "id", "name", "author", "repository", "homepage", "description", "localizedDescriptions", "tags", "license", "icon", "versions", ]); -const VERSION_FIELDS = new Set(["version", "pluginAPIVersion", "releaseDate", "downloads"]); +const VERSION_FIELDS = new Set(["version", "pluginAPIVersion", "requires", "releaseDate", "downloads"]); const DOWNLOAD_FIELDS = new Set(["platforms", "downloadURL", "sha256"]); const REPOSITORY = /^https:\/\/github\.com\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/?$/; const DOWNLOAD_URL = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/releases\/download\/[^/]+\/[^/]+\.zip$/; @@ -68,6 +68,39 @@ function isCalendarDate(text) { return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === text; } +/** Validates the index-level API and capability shape, without deciding host support. */ +function validateVersionRequirements(version) { + if (!Number.isInteger(version.pluginAPIVersion) || version.pluginAPIVersion < 1) { + throw invalid("`pluginAPIVersion` must be a positive integer"); + } + const present = Object.hasOwn(version, "requires"); + if (version.pluginAPIVersion < 3 && present) { + throw invalid("`requires` is forbidden below pluginAPIVersion 3"); + } + if (version.pluginAPIVersion === 3 && !present) { + throw invalid("pluginAPIVersion 3 must list its capabilities in `requires`"); + } + // Core permits future API levels without requires, but validates it when supplied. + if (!present) return; + if (!isObject(version.requires) || Object.keys(version.requires).length === 0) { + throw invalid("`requires` must be a non-empty capability object"); + } + for (const [id, capabilityVersion] of Object.entries(version.requires)) { + if (id.length > 64 || !/^[a-z][A-Za-z0-9]*$/.test(id)) { + throw invalid(`malformed capability ID \`${id}\` in requires`); + } + if (!Number.isInteger(capabilityVersion) || capabilityVersion < 1) { + throw invalid(`\`requires.${id}\` must be a positive integer`); + } + } +} + +/** Stable equality key: capability-map property order has no contract meaning. */ +export function requirementsKey(requires) { + if (requires === undefined) return null; + return JSON.stringify(Object.keys(requires).sort().map((id) => [id, requires[id]])); +} + /** Throws IndexError when Lithe would reject this entry from a third-party source. */ export function validateEntry(entry) { if (!isObject(entry)) throw invalid("plugin entry must be an object"); @@ -130,9 +163,7 @@ export function validateEntry(entry) { } if (seenVersions.has(version.version)) throw invalid(`version ${version.version} is listed twice`); seenVersions.add(version.version); - if (!Number.isInteger(version.pluginAPIVersion) || version.pluginAPIVersion < 1) { - throw invalid(`${version.version}: \`pluginAPIVersion\` must be a positive integer`); - } + validateVersionRequirements(version); if (typeof version.releaseDate !== "string" || !isCalendarDate(version.releaseDate)) { throw invalid(`${version.version}: \`releaseDate\` must be a YYYY-MM-DD date`); } @@ -250,12 +281,24 @@ export function checkManifest(manifests, entry, expected) { } const [manifest] = manifests; const problems = []; + if (manifest.id !== entry.id) { + problems.push(`plugin.package.mismatch: plugin.json id ${manifest.id} != index ${entry.id}`); + } + try { + validateVersionRequirements(manifest); + } catch (error) { + if (!(error instanceof IndexError)) throw error; + problems.push(`plugin.json: ${error.message}`); + } if (expected && manifest.version !== expected.version) { problems.push(`plugin.package.mismatch: plugin.json version ${manifest.version} != index ${expected.version}`); } if (expected && manifest.pluginAPIVersion !== expected.pluginAPIVersion) { problems.push(`plugin.json pluginAPIVersion ${manifest.pluginAPIVersion} != index ${expected.pluginAPIVersion}`); } + if (expected && problems.length === 0 && requirementsKey(manifest.requires) !== requirementsKey(expected.requires)) { + problems.push("plugin.json requires != index requires"); + } if (manifest.vendor?.signatureRequirement !== "none") { problems.push('community packages must declare vendor.signatureRequirement "none"'); } @@ -268,40 +311,66 @@ export function inspectArchive(archive, entry, version) { return [...unsafe.map((name) => `unsafe path in archive: ${name}`), ...checkManifest(manifests, entry, version)]; } -async function verifyArchives(index, baseIndex) { +/** Reads package data without extracting or executing the archive's files. */ +export function readArchiveBytes(bytes, pluginID) { + const workDir = mkdtempSync(join(tmpdir(), "lithe-store-")); + try { + const archive = join(workDir, "plugin.zip"); + writeFileSync(archive, bytes); + return readPackageManifests(archive, pluginID); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } +} + +async function downloadArchive(url) { + const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) }); + if (!response.ok) throw new Error(`download failed with HTTP ${response.status}`); + return Buffer.from(await response.arrayBuffer()); +} + +function archiveVerificationKey(entry, version, download) { + return JSON.stringify([ + entry.id, version.version, version.pluginAPIVersion, requirementsKey(version.requires), + download.downloadURL, download.sha256, + ]); +} + +/** Dependencies let tests exercise checksum and manifest checks with in-memory archives. */ +export async function verifyArchives(index, baseIndex, { + download = downloadArchive, readManifests = readArchiveBytes, log = console.log, +} = {}) { const known = new Set(); - for (const entry of baseIndex?.plugins ?? []) { - for (const version of entry.versions ?? []) { - for (const download of version.downloads ?? []) known.add(`${download.downloadURL}#${download.sha256}`); + // An invalid old index cannot establish that a package was already checked. + if (baseIndex && validateIndex(baseIndex).length === 0) { + for (const entry of baseIndex.plugins) { + for (const version of entry.versions) { + for (const download of version.downloads) known.add(archiveVerificationKey(entry, version, download)); + } } } const problems = []; for (const entry of index.plugins) { for (const version of entry.versions) { - for (const download of version.downloads) { - if (known.has(`${download.downloadURL}#${download.sha256}`)) continue; - const label = `${entry.id}@${version.version} ${download.downloadURL}`; - const workDir = mkdtempSync(join(tmpdir(), "lithe-store-")); + for (const archiveDownload of version.downloads) { + if (known.has(archiveVerificationKey(entry, version, archiveDownload))) continue; + const label = `${entry.id}@${version.version} ${archiveDownload.downloadURL}`; try { - const response = await fetch(download.downloadURL, { redirect: "follow", signal: AbortSignal.timeout(120_000) }); - if (!response.ok) { - problems.push(`${label}: download failed with HTTP ${response.status}`); - continue; - } - const bytes = Buffer.from(await response.arrayBuffer()); + const bytes = await download(archiveDownload.downloadURL); const digest = createHash("sha256").update(bytes).digest("hex"); - if (digest !== download.sha256) { + if (digest !== archiveDownload.sha256) { problems.push(`${label}: plugin.archive.checksumMismatch: got ${digest}`); continue; } - const archive = join(workDir, "plugin.zip"); - writeFileSync(archive, bytes); - problems.push(...inspectArchive(archive, entry, version).map((problem) => `${label}: ${problem}`)); - console.log(`verified ${label}`); + const { manifests, unsafe } = readManifests(bytes, entry.id); + const archiveProblems = [ + ...unsafe.map((name) => `unsafe path in archive: ${name}`), + ...checkManifest(manifests, entry, version), + ]; + problems.push(...archiveProblems.map((problem) => `${label}: ${problem}`)); + if (archiveProblems.length === 0) log(`verified ${label}`); } catch (error) { problems.push(`${label}: ${error.message}`); - } finally { - rmSync(workDir, { recursive: true, force: true }); } } }