diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5422afb..18e0374 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: LITHE_CARGO_INTEGRATION: '1' run: | mkdir -p .artifacts - node scripts/verify-sdk.mjs + node scripts/verify-sdk.mjs --download node scripts/test.mjs - name: Assemble package run: node scripts/package.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d52287b..58f41f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: env: LITHE_CARGO_INTEGRATION: '1' run: | - node scripts/verify-sdk.mjs + node scripts/verify-sdk.mjs --download if [ -f scripts/test.mjs ]; then node scripts/test.mjs else @@ -69,15 +69,33 @@ jobs: cat > "$RUNNER_TEMP/notes.md" <<'NOTES' macOS Cargo integration for a Lithe preview with API 3 buildSystems 1, executableResolution 1, settings 2, buildProviders 1, serverProcess 1 and structured plugin diagnostics (litheLogging 1). Stable Lithe 0.6.7 does not offer litheLogging 1 and cannot start this version; keep using 0.1.1 there. - Changes in 0.1.2: - - Structured diagnostics: the bundled Node SDK records one INFO for each successful metadata request and one ERROR for each failure on the plugins/io.github.lithe-idea.cargo-build/host channel of Lithe's log window, with duration and a stable failure code. Cargo output, metadata JSON, source and environment values never enter the log. The duplicate raw stderr and JSON error logging is removed. - - The Cargo path setting becomes the first of the host's ordered executable candidates (executableResolution 1), ahead of PATH discovery. A missing, non-executable or too-old selection still fails instead of falling back. + Changes in 0.1.3: + - The bundled Node.js SDK is now the officially released SDK 0.3.0, pinned by tag (sdk-node-v0.3.0) and SHA-256 in sdk.lock.json instead of a local copy. The vendored files are the release's files without local edits. Plugin behavior is unchanged. + - It still requires a Lithe preview build offering litheLogging 1; stable Lithe 0.6.7 users stay on 0.1.1. Requires Cargo 1.70+ and Node.js 20.3+. Not listed in Lithe Store yet; download the ZIP and install it from disk. Preview status: automated integration and contract checks pass; native installation and full UI acceptance are pending. Windows is not supported. Published ZIP bytes must not be replaced. NOTES gh release create "$RELEASE_TAG" dist/*.zip dist/*.sha256 --verify-tag --prerelease --title "Cargo $RELEASE_TAG" --notes-file "$RUNNER_TEMP/notes.md" + - name: Require the release to be a prerelease + env: + GH_TOKEN: ${{ github.token }} + run: | + # Every release of this workflow needs a host offering litheLogging 1, + # so it must stay a prerelease: Lithe Store skips prereleases. A + # release has ended up as a normal one despite --prerelease before, + # so read the flag back, correct it, and fail if it still is not set. + is_prerelease() { gh release view "$RELEASE_TAG" --json isPrerelease --jq .isPrerelease; } + if [ "$(is_prerelease)" != "true" ]; then + echo "::warning::Release $RELEASE_TAG is not marked as a prerelease; correcting it." + gh release edit "$RELEASE_TAG" --prerelease + if [ "$(is_prerelease)" != "true" ]; then + echo "::error::Release $RELEASE_TAG is still not marked as a prerelease." + exit 1 + fi + fi + echo "Release $RELEASE_TAG is a prerelease." - uses: actions/upload-artifact@v4 if: always() with: diff --git a/NOTICE b/NOTICE index 9579fdd..b32d898 100644 --- a/NOTICE +++ b/NOTICE @@ -2,10 +2,12 @@ Cargo build plugin for Lithe Copyright 2026 Lithe contributors This project includes the Apache-2.0 licensed Lithe Node plugin SDK and -test support. The SDK comes from Lithe-IDEA/lithe-plugin-docs; historical -test support comes from 1lck/Lithe-IDEA. Exact upstream paths, Git revisions -and SHA-256 hashes are recorded in sdk.lock.json. The SDK is copied into -the plugin at build time; test support is never shipped in the package. +test support. The SDK is the released Node.js SDK 0.3.0 from +Lithe-IDEA/lithe-plugin-docs (tag sdk-node-v0.3.0); test support comes from +the Lithe host repository. Exact upstream paths, release archive SHA-256, Git +revisions and per-file SHA-256 hashes are recorded in sdk.lock.json. The SDK is +copied into the plugin at build time; test support is never shipped in the +package. Cargo, Rust and Node.js are user-provided tools and are not redistributed. diff --git a/README.md b/README.md index fd2d9ad..a2132fd 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,14 @@ Cargo is a community build plugin for Lithe. It uses Lithe's native Cargo tool window and Build Output to build, check, test, clean, and run Rust projects. -**0.1.2 is a preview release.** Native disk-installation and full UI acceptance +**0.1.3 is a preview release.** Native disk-installation and full UI acceptance are still pending. Automated integration and host compatibility run in this repository's CI. The plugin requires a Lithe preview with API 3 `buildSystems: 1`, `executableResolution: 1`, `settings: 2`, `buildProviders: 1`, `serverProcess: 1` and structured plugin diagnostics (`litheLogging: 1`). -Stable Lithe 0.6.7 does not offer `litheLogging: 1` and cannot start 0.1.2. Windows is not supported yet. +Stable Lithe 0.6.7 does not offer `litheLogging: 1` and cannot start 0.1.3. Windows is not supported yet. +It uses the released Node.js SDK 0.3.0, pinned by tag and SHA-256 in `sdk.lock.json`; +that SDK reports structured diagnostics and so needs a Lithe build offering `litheLogging: 1`. ## Install @@ -16,12 +18,12 @@ Install Rust/Cargo 1.70+ and Node.js 20.3+ first. Node runs the metadata provide Cargo and rust-analyzer are not bundled. Existing Rust language intelligence continues to use Lithe's Rust language service. -Download `cargo-build-0.1.2-darwin.zip` from [Releases](https://github.com/Lithe-IDEA/lithe-plugin-cargo/releases). +Download `cargo-build-0.1.3-darwin.zip` from [Releases](https://github.com/Lithe-IDEA/lithe-plugin-cargo/releases). Hosts without `litheLogging: 1` can keep using the 0.1.1 preview, which keeps its older host requirements. In Lithe, choose Settings → Plugins → Install from Disk, select the ZIP, review Cargo/Node and its permissions, and restart. Enable Cargo, open the Cargo workspace root, then open the Cargo tool window and load the project model. -0.1.2 is not listed in [Lithe Store](https://github.com/Lithe-IDEA/Lithe-store) yet: the Store cannot +0.1.3 is not listed in [Lithe Store](https://github.com/Lithe-IDEA/Lithe-store) yet: the Store cannot tell stable hosts without `litheLogging: 1` apart, so install it from disk. ## Cargo path @@ -34,7 +36,7 @@ same executable. A missing, non-executable or too-old selection fails visibly instead of silently choosing a different Cargo installation. The executable's parent directory is placed first on PATH for companion tools. -Version 0.1.2 requires a preview host with `buildSystems: 1`, `executableResolution: 1`, `settings: 2` and `litheLogging: 1`. +Version 0.1.3 requires a preview host with `buildSystems: 1`, `executableResolution: 1`, `settings: 2` and `litheLogging: 1`. Its exact tested host revision is recorded in `sdk.lock.json`; older hosts keep using the compatible 0.1.0 release through the Store. Existing published ZIPs are immutable and are never replaced. @@ -60,8 +62,8 @@ members or target sources outside the selected build root are refused. The host' ## Diagnostic logs -Version 0.1.2 requires a host offering `litheLogging: 1`. The vendored official -Node SDK sends typed `lithe/log` notifications to the host's v2 catalog and +Version 0.1.3 requires a host offering `litheLogging: 1`. The vendored official +Node SDK 0.3.0 sends typed `lithe/log` notifications to the host's v2 catalog and writer, in the current session's `plugins/io.github.lithe-idea.cargo-build/host` channel. The SDK records one INFO terminal on a successful metadata request and one ERROR on a failed @@ -73,9 +75,9 @@ or raw exceptions into logs. Build, check, test and run process outcomes use the host's `run` channel; their output stays in Build Output. Use Lithe's log window and diagnostic export for host evidence. No plugin writer, log directory or idle task is -added. The SDK source base revision and local logging changes are identified -by `sdk.lock.json`, including exact file hashes; they do not claim a released -SDK artifact. Windows still uses v1 logging; no Windows v2 acceptance is claimed. +added. The vendored SDK files are byte-identical to the released Node.js SDK +0.3.0 archive, as pinned by `sdk.lock.json`. Windows still uses v1 logging; no +Windows v2 acceptance is claimed. ## Develop and verify @@ -88,11 +90,14 @@ node scripts/test.mjs node scripts/package.mjs ``` +`node scripts/verify-sdk.mjs --download` additionally downloads the released SDK +archive, checks its SHA-256 against `sdk.lock.json` and requires every vendored +SDK file to match the archive byte for byte. CI and release verification run it. + Full integration compiles a test-only bridge to Lithe's public `execute_json` API. CI uses the host repository and `preview` ref in `sdk.lock.json.coreSource`, -then records the exact resolved commit and source digest. The companion host -logging change must land first. See **Core integration source** below for -verified local working-tree preparation and the complete command. +then records the exact resolved commit and source digest. See **Core integration +source** below for verified local working-tree preparation and the complete command. Cargo downloads the host's locked Rust dependencies on the first build; the Cargo fixture itself runs offline. `.artifacts/core-target` contains only @@ -113,13 +118,21 @@ is `dist/io.github.lithe-idea.cargo-build/`. The archive serves both Apple Silic and Intel Macs. CI builds on Linux to conserve native runner usage; the local integration suite was also run on macOS. -The zero-dependency Node SDK comes from `Lithe-IDEA/lithe-plugin-docs` at the -base revision and local structured logging changes recorded in `sdkSource`. -Historical test helpers retain their original source identity. Every vendor -file has an exact SHA-256; packaging refuses changed or unpinned inputs and -symbolic links. The separate `coreSource` chooses the integration host preview, -so SDK provenance is never confused with the host's current source identity. -No SDK or runtime is downloaded when the installed plugin starts. +The zero-dependency Node SDK is the released Node.js SDK 0.3.0 from +`Lithe-IDEA/lithe-plugin-docs`: `sdk.lock.json.sdk` pins its tag +`sdk-node-v0.3.0`, commit, release asset URL and SHA-256, and vendors the files +unchanged. The SDK test harness is not part of the release asset; it is pinned +to the same tagged commit. Test-support helpers keep their own source revision +(`testSupport`). Every vendor file has an exact SHA-256; packaging refuses +changed or unpinned inputs and symbolic links. The separate `coreSource` chooses +the integration host preview, so SDK provenance is never confused with the +host's current source identity. No SDK or runtime is downloaded when the +installed plugin starts. + +To update the SDK, take a newer released Node.js SDK archive, replace the +vendored files with its contents (no local edits), update the `sdk` release +fields and every file hash in `sdk.lock.json`, and run +`node scripts/verify-sdk.mjs --download`. ## Runtime and build ownership @@ -140,8 +153,10 @@ runs in the read-only plugin package. ## Release Update `plugin.json`, commit, and push `v` matching the manifest. -The release workflow verifies the SDK, runs the offline fixture integration, -builds the ZIP and SHA-256, and publishes a **pre-release**. Native UI acceptance +The release workflow verifies the SDK (including the released archive download), +runs the offline fixture integration, builds the ZIP and SHA-256, and publishes a +**pre-release**, then reads the release back and corrects the pre-release flag if +GitHub did not apply it. Native UI acceptance must be completed before changing the workflow to publish stable releases. Published ZIP bytes are immutable: fixes require a new version. @@ -149,7 +164,7 @@ The Store index records the repository URL, API requirements, release URL and archive SHA-256. First inclusion and pre-releases use a reviewed Store pull request. Version 0.1.2 migrates custom Cargo selection to the shared ordered executable -candidates. Published 0.1.0/0.1.1 preview archives remain immutable. A compatible +candidates. Published 0.1.0 to 0.1.2 preview archives remain immutable. A compatible host must advertise `executableResolution: 1`; the setting is a candidate ahead of automatic PATH discovery, with empty values skipped and explicit failures reported by the host. Maven/Gradle-style automatic candidates need no path setting. @@ -157,12 +172,12 @@ reported by the host. Maven/Gradle-style automatic candidates need no path setti ## Core integration source CI and release verification explicitly check out `sdk.lock.json.coreSource.ref` -(`preview`) from the verified host repository. **Merge the companion host -structured logging change into preview before this plugin change**; preparation -rejects a host missing the typed operation catalog rather than attempting an -old-protocol fallback. It records the resolved commit and exact source SHA-256; -this moving test ref is not presented as a pinned release artifact. SDK vendor -hashes and the historical test-support source remain independently pinned. +(`preview`) from the verified host repository. The host must offer +`litheLogging: 1`; preparation rejects a host missing the typed operation +catalog rather than attempting an old-protocol fallback. It records the resolved +commit and exact source SHA-256; this moving test ref is not presented as a +pinned release artifact. SDK vendor hashes and the test-support source remain +independently pinned. Local validation can select a particular real host checkout and revision: diff --git a/io.github.lithe-idea.cargo-build/backend/main.mjs b/io.github.lithe-idea.cargo-build/backend/main.mjs index 2547799..3795371 100644 --- a/io.github.lithe-idea.cargo-build/backend/main.mjs +++ b/io.github.lithe-idea.cargo-build/backend/main.mjs @@ -2,6 +2,6 @@ import { createBackend, PluginError } from './vendor/lithe-plugin-sdk/index.mjs'; import { registerCargoProvider } from './provider.mjs'; -const backend = createBackend({ name: 'cargo-build', version: '0.1.2' }); +const backend = createBackend({ name: 'cargo-build', version: '0.1.3' }); registerCargoProvider(backend, PluginError); await backend.listen(); diff --git a/io.github.lithe-idea.cargo-build/plugin.json b/io.github.lithe-idea.cargo-build/plugin.json index e5a15d4..4c25349 100644 --- a/io.github.lithe-idea.cargo-build/plugin.json +++ b/io.github.lithe-idea.cargo-build/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 2, "id": "io.github.lithe-idea.cargo-build", "displayName": "Cargo", - "version": "0.1.2", + "version": "0.1.3", "pluginAPIVersion": 3, "requires": { "buildProviders": 1, "buildSystems": 1, "executableResolution": 1, "serverProcess": 1, "settings": 2 }, "vendor": { "id": "io.github.lithe-idea", "displayName": "Lithe Community", "signatureRequirement": "none" }, diff --git a/scripts/verify-sdk.mjs b/scripts/verify-sdk.mjs index 9ec368a..c6f5632 100644 --- a/scripts/verify-sdk.mjs +++ b/scripts/verify-sdk.mjs @@ -1,42 +1,166 @@ +#!/usr/bin/env node import { createHash } from 'node:crypto'; import { lstat, readFile, readdir } from 'node:fs/promises'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { gunzipSync } from 'node:zlib'; export const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -/** Verify vendored upstream inputs before testing or assembling a release. */ -export async function verifySdk(root = repository) { - const lock = JSON.parse(await readFile(join(root, 'sdk.lock.json'), 'utf8')); - if (lock.schemaVersion !== 1 || !/^[0-9a-f]{40}$/.test(lock.source?.revision) || !Array.isArray(lock.files) || !/^[0-9a-f]{40}$/.test(lock.sdkSource?.revision) || lock.sdkSource?.repository !== 'https://github.com/Lithe-IDEA/lithe-plugin-docs') { - throw new Error('sdk.lock.json must pin an exact upstream Git revision and file hashes.'); +const sdkRepository = 'https://github.com/Lithe-IDEA/lithe-plugin-docs'; +const sdkPackage = '@lithe/plugin-sdk'; +const origins = new Set(['sdkArchive', 'sdkCommit', 'testSupport']); +const entryKeys = ['origin', 'path', 'sha256', 'upstream']; +const isSha256 = (value) => /^[0-9a-f]{64}$/.test(value); +const isCommit = (value) => /^[0-9a-f]{40}$/.test(value); +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const fail = (message) => { throw new Error(message); }; + +/** Reject a path that could leave its directory or name something other than a plain relative file. */ +function isPlainRelativePath(path) { + return typeof path === 'string' && !isAbsolute(path) && !path.includes('\\') + && !path.split('/').some((part) => !part || part === '.' || part === '..'); +} + +/** + * Read the pinned release identity and per-file hashes from sdk.lock.json. + * The lock names the released Node.js SDK archive (tag, commit, URL, SHA-256) + * and records where every vendored file comes from. + */ +function validateLock(lock) { + const sdk = lock?.sdk; + if (lock?.schemaVersion !== 2 || !sdk || !Array.isArray(lock.files)) fail('Invalid SDK lock: expected schemaVersion 2 with an sdk release and files.'); + if (sdk.package !== sdkPackage || sdk.repository !== sdkRepository || !/^\d+\.\d+\.\d+$/.test(sdk.version) + || sdk.tag !== `sdk-node-v${sdk.version}` || !isCommit(sdk.commit) || !isSha256(sdk.sha256) + || sdk.url !== `${sdkRepository}/releases/download/${sdk.tag}/lithe-plugin-sdk-node-${sdk.version}.tgz`) { + fail('Invalid SDK lock: sdk must pin the released Node.js SDK by version, tag, commit, release URL and SHA-256.'); + } + const testSupport = lock.testSupport; + if (!testSupport || typeof testSupport.repository !== 'string' || !testSupport.repository.startsWith('https://github.com/') || !isCommit(testSupport.revision)) { + fail('Invalid SDK lock: testSupport must pin an exact source revision.'); } - const expected = new Set(); + const paths = new Set(); for (const entry of lock.files) { - if (!entry.path?.startsWith('vendor/') || isAbsolute(entry.path) || entry.path.split('/').some(part => !part || part === '.' || part === '..') - || entry.path.includes('\\') || !/^[0-9a-f]{64}$/.test(entry.sha256) || expected.has(entry.path) - || (entry.path.startsWith('vendor/lithe-plugin-sdk/src/') && entry.source !== 'sdkSource') - || (entry.localChanges === true && (!Object.hasOwn(entry, 'upstreamSha256') || (entry.upstreamSha256 !== null && !/^[0-9a-f]{64}$/.test(entry.upstreamSha256))))) { - throw new Error('Invalid or duplicate SDK lock entry.'); + const keys = Object.keys(entry).sort(); + if (keys.length !== entryKeys.length || keys.some((key, index) => key !== entryKeys[index])) fail(`Invalid SDK lock entry: ${entry.path ?? '(no path)'} may only record ${entryKeys.join(', ')}; vendored SDK files carry no local changes.`); + if (!origins.has(entry.origin) || !isPlainRelativePath(entry.path) || !entry.path.startsWith('vendor/') || !isPlainRelativePath(entry.upstream) + || !isSha256(entry.sha256) || paths.has(entry.path) + || ((entry.path === 'vendor/LICENSE' || entry.path.startsWith('vendor/lithe-plugin-sdk/src/')) && entry.origin !== 'sdkArchive')) { + fail(`Invalid or duplicate SDK lock entry: ${entry.path}`); } - expected.add(entry.path); + paths.add(entry.path); + } + return { sdk, files: lock.files, paths }; +} + +async function readLock(root) { + return JSON.parse(await readFile(join(root, 'sdk.lock.json'), 'utf8')); +} + +/** Offline check: the lock is well formed and every vendored file matches its recorded SHA-256. */ +export async function verifySdk(root = repository) { + const { sdk, files, paths } = validateLock(await readLock(root)); + const expected = new Set(paths); + for (const entry of files) { const file = join(root, entry.path); - if (!(await lstat(file)).isFile()) throw new Error(`SDK input is not a regular file: ${entry.path}`); - const actual = createHash('sha256').update(await readFile(file)).digest('hex'); - if (actual !== entry.sha256) throw new Error(`SDK SHA-256 mismatch: ${entry.path}`); + if (!(await lstat(file)).isFile()) fail(`SDK input is not a regular file: ${entry.path}`); + if (sha256(await readFile(file)) !== entry.sha256) fail(`SDK SHA-256 mismatch: ${entry.path}`); } async function walk(directory, prefix) { for (const entry of await readdir(directory, { withFileTypes: true })) { const name = `${prefix}/${entry.name}`; if (entry.isDirectory()) await walk(join(directory, entry.name), name); - else if (!expected.delete(name)) throw new Error(`Unpinned SDK input: ${name}`); + else if (!expected.delete(name)) fail(`Unpinned SDK input: ${name}`); } } await walk(join(root, 'vendor'), 'vendor'); - if (expected.size) throw new Error('A pinned SDK input is missing.'); - return lock.source; + if (expected.size) fail('A pinned SDK input is missing.'); + return sdk; +} + +/** Unpack the regular files of a gzip-compressed ustar archive in memory; nothing is written to disk. */ +export function readTarGz(archive) { + const tar = gunzipSync(archive, { maxOutputLength: 32 * 1024 * 1024 }); + const files = new Map(); + let offset = 0; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) break; + const text = (start, length) => { + const field = header.subarray(start, start + length); + const end = field.indexOf(0); + return field.subarray(0, end < 0 ? length : end).toString('utf8'); + }; + const checksum = header.reduce((total, byte, index) => total + (index >= 148 && index < 156 ? 32 : byte), 0); + if (checksum !== Number.parseInt(text(148, 8).trim(), 8)) fail('SDK archive has a corrupt tar header.'); + const size = Number.parseInt(text(124, 12).trim(), 8); + const name = text(0, 100); + const type = header[156] === 0 ? '0' : String.fromCharCode(header[156]); + offset += 512; + if (!Number.isSafeInteger(size) || size < 0 || offset + size > tar.length) fail(`SDK archive entry is truncated: ${name}`); + if (type === '0') { + if (!isPlainRelativePath(name) || files.has(name)) fail(`SDK archive entry has an unsafe or duplicate path: ${name}`); + files.set(name, Buffer.from(tar.subarray(offset, offset + size))); + } else if (type !== '5') { + fail(`SDK archive entry has an unsupported type: ${name}`); + } + offset += Math.ceil(size / 512) * 512; + } + return files; +} + +/** Fetch a small HTTPS asset with a bounded size and timeout, retrying transient failures. */ +export async function downloadBytes(url, { attempts = 3, timeoutMs = 60000, maximumBytes = 8 * 1024 * 1024 } = {}) { + if (!url.startsWith('https://')) fail(`Refusing to download a non-HTTPS URL: ${url}`); + let lastError; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length > maximumBytes) throw new Error('response is larger than expected'); + return bytes; + } catch (error) { + lastError = error; + if (attempt < attempts) await new Promise((done) => setTimeout(done, 1000 * attempt)); + } + } + throw new Error(`Could not download ${url}: ${lastError.message}`); +} + +/** + * Online check: download the released SDK archive, require its SHA-256 to match the lock, and + * require every vendored file that came from it (or from the tagged commit) to match byte for byte. + * `download` is injectable so tests never touch the network. + */ +export async function verifySdkDownload(root = repository, download = downloadBytes) { + const sdk = await verifySdk(root); + const { files } = validateLock(await readLock(root)); + const archive = await download(sdk.url); + if (sha256(archive) !== sdk.sha256) fail(`SDK archive SHA-256 mismatch: expected ${sdk.sha256}, got ${sha256(archive)} (${sdk.url})`); + const contents = readTarGz(archive); + const manifest = JSON.parse(contents.get('package/package.json')?.toString('utf8') ?? 'null'); + if (manifest?.name !== sdkPackage || manifest.version !== sdk.version) fail('SDK archive is not the locked package version.'); + const repositoryPath = new URL(sdk.repository).pathname.slice(1); + for (const entry of files) { + if (entry.origin === 'testSupport') continue; + const vendored = await readFile(join(root, entry.path)); + const upstream = entry.origin === 'sdkArchive' + ? contents.get(entry.upstream) + : await download(`https://raw.githubusercontent.com/${repositoryPath}/${sdk.commit}/${entry.upstream}`); + if (!upstream) fail(`SDK archive does not contain ${entry.upstream}.`); + if (!vendored.equals(upstream)) fail(`Vendored file does not match the released SDK: ${entry.path}`); + } + return sdk; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - console.log('Verified SDK from', await verifySdk()); + const args = process.argv.slice(2); + if (args.some((arg) => arg !== '--download')) { + console.error('Usage: node scripts/verify-sdk.mjs [--download]'); + process.exit(2); + } + const download = args.includes('--download'); + const sdk = download ? await verifySdkDownload() : await verifySdk(); + console.log(`Verified ${sdk.package} ${sdk.version} (${sdk.tag}, ${sdk.commit.slice(0, 7)})${download ? ` against ${sdk.url}` : ' offline'}`); } diff --git a/sdk.lock.json b/sdk.lock.json index c0d0f6d..9063b58 100644 --- a/sdk.lock.json +++ b/sdk.lock.json @@ -1,108 +1,103 @@ { - "schemaVersion": 1, - "source": { - "repository": "https://github.com/1lck/Lithe-IDEA", - "revision": "d06d5bf9f41729de1051e7f73c1c10648bc3a691" + "schemaVersion": 2, + "sdk": { + "package": "@lithe/plugin-sdk", + "version": "0.3.0", + "repository": "https://github.com/Lithe-IDEA/lithe-plugin-docs", + "tag": "sdk-node-v0.3.0", + "commit": "940394fcd8455186ee985e6c34a319032414607f", + "url": "https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/download/sdk-node-v0.3.0/lithe-plugin-sdk-node-0.3.0.tgz", + "sha256": "d69cb09c4e87aeb0e01ea46e8b1dc80871de56de3729de451f6d9a64a18c4227" }, "files": [ { - "upstream": "LICENSE", + "origin": "sdkArchive", + "upstream": "package/LICENSE", "path": "vendor/LICENSE", "sha256": "93ff573f89d32394501fcc82767e73b1824add861f088bbd3eec9e0afae20d21" }, { - "upstream": "sdk/node/src/backend.mjs", - "source": "sdkSource", + "origin": "sdkArchive", + "upstream": "package/src/backend.mjs", "path": "vendor/lithe-plugin-sdk/src/backend.mjs", - "sha256": "bc31d80064c9dc72e850e5b4f5ea739068da343c0628562088fa00d866e3e807", - "localChanges": true, - "upstreamSha256": "a69780df4b6fba6c9535d56a9647aaf1b39faa866bf95cc8e52377ef619ef108" + "sha256": "f4b32d7785ae75618633ff881a4b712fe83e5f28cf71196fe27b8b208bbea339" }, { - "upstream": "sdk/node/src/diagnostics.mjs", - "source": "sdkSource", + "origin": "sdkArchive", + "upstream": "package/src/diagnostics.mjs", "path": "vendor/lithe-plugin-sdk/src/diagnostics.mjs", - "sha256": "2ecac45d5f5dadcc0b9ea47a5fb6e1f148a4df48eac31a46b580c5c0fd8ac619", - "localChanges": true, - "upstreamSha256": null + "sha256": "2735350b0e9658729311d18e9632cb38f93a01576ed0de61c66eefc8dd89e73f" }, { - "upstream": "sdk/node/src/errors.mjs", - "source": "sdkSource", + "origin": "sdkArchive", + "upstream": "package/src/errors.mjs", "path": "vendor/lithe-plugin-sdk/src/errors.mjs", - "sha256": "e756aa0a88f1807ffdc985f3aed307f283c2a59902a287e22dd408dae5179bd2", - "localChanges": true, - "upstreamSha256": "785cfe4f63e42e82285860050c9251b96a6c8cddcb36579292178b8d21289e47" + "sha256": "e756aa0a88f1807ffdc985f3aed307f283c2a59902a287e22dd408dae5179bd2" }, { - "upstream": "sdk/node/src/framing.mjs", - "source": "sdkSource", + "origin": "sdkArchive", + "upstream": "package/src/framing.mjs", "path": "vendor/lithe-plugin-sdk/src/framing.mjs", "sha256": "ec745be1ecf4cf24d521c5aa3108da2bbbfb9156f2197fcb5158de1f1bc9cfd1" }, { - "upstream": "sdk/node/src/host.mjs", - "source": "sdkSource", + "origin": "sdkArchive", + "upstream": "package/src/host.mjs", "path": "vendor/lithe-plugin-sdk/src/host.mjs", - "sha256": "5fbb3a03292fe72bb6b8c7169cecdf0263008526a1252e46e9f0afe98610e4ec", - "localChanges": true, - "upstreamSha256": "0bdf879d7ac97a04981798842a5d87ba1e11776c5abd08f88082ee25038dbef0" - }, - { - "upstream": "sdk/node/src/index.d.ts", - "source": "sdkSource", - "path": "vendor/lithe-plugin-sdk/src/index.d.ts", - "sha256": "e6ec8c1467f695330214efafe7a670dae2f843e61266868a348a0673ea4137be", - "localChanges": true, - "upstreamSha256": "560d2691da3a6f859e992a092e0b2d4cc921f16c53b10a0246e620bc10070a1b" + "sha256": "5fbb3a03292fe72bb6b8c7169cecdf0263008526a1252e46e9f0afe98610e4ec" }, { - "upstream": "sdk/node/src/index.mjs", - "source": "sdkSource", + "origin": "sdkArchive", + "upstream": "package/src/index.mjs", "path": "vendor/lithe-plugin-sdk/src/index.mjs", "sha256": "79042db2ac6e0ac896ff0a1643ee540217b7b84fab4d1b1b82ad4246263d16af" }, { - "upstream": "sdk/node/src/process.mjs", - "source": "sdkSource", + "origin": "sdkArchive", + "upstream": "package/src/process.mjs", "path": "vendor/lithe-plugin-sdk/src/process.mjs", "sha256": "a72f78cbfbb8d98b2d44ebea7f869038a3e34ebe7ee000f63cdd36fb2aaece66" }, { - "upstream": "sdk/node/src/protocol.mjs", - "source": "sdkSource", + "origin": "sdkArchive", + "upstream": "package/src/protocol.mjs", "path": "vendor/lithe-plugin-sdk/src/protocol.mjs", "sha256": "31de386e386e8ec276657f151f62b2d0e7a4ae70700072eefeac624a6cde70a6" }, { - "upstream": "sdk/node/src/state.mjs", - "source": "sdkSource", + "origin": "sdkArchive", + "upstream": "package/src/state.mjs", "path": "vendor/lithe-plugin-sdk/src/state.mjs", "sha256": "946618214fbcb736c76ee85c87e0b969fe853fbab215f7c192990f2edf2035a4" }, { + "origin": "sdkArchive", + "upstream": "package/src/index.d.ts", + "path": "vendor/lithe-plugin-sdk/src/index.d.ts", + "sha256": "83671ee619dffaca042df3f2276be2a7b68aaff6fe3e104ce67b6ecb6ec27dea" + }, + { + "origin": "sdkCommit", "upstream": "sdk/node/test/harness.mjs", - "source": "sdkSource", "path": "vendor/lithe-plugin-sdk/test/harness.mjs", - "sha256": "2a7624a70d8fd46b41c16f2904f1783ce26f7770ec3b45081fbd6ff197884559", - "localChanges": true, - "upstreamSha256": "ae636e3236c40b9bb957748fbf7622914a87d5c6aaf5a358ea80b2d4628af53c" + "sha256": "2a7624a70d8fd46b41c16f2904f1783ce26f7770ec3b45081fbd6ff197884559" }, { + "origin": "testSupport", "upstream": ".agents/skills/write-stable-tests/scripts/generate-test-report.mjs", "path": "vendor/lithe-testing/generate-test-report.mjs", "sha256": "ef4170b6380077d48dcf11a54faa423c2071291b2202a7f89df86d4b93853824" }, { + "origin": "testSupport", "upstream": ".agents/skills/write-stable-tests/scripts/test-timing-lib.mjs", "path": "vendor/lithe-testing/test-timing-lib.mjs", "sha256": "6475279234c07cb8d420e03dbfc5e87aa64fd1703c872798684ee56cd2bc3425" } ], - "sdkSource": { - "repository": "https://github.com/Lithe-IDEA/lithe-plugin-docs", - "revision": "29b245c0e1d77c761d9032a3144a301d1818bfc7", - "localChanges": "Structured logging lithe/log implementation from the accompanying SDK working tree; exact reviewed source hashes below. No published release is claimed." + "testSupport": { + "repository": "https://github.com/1lck/Lithe-IDEA", + "revision": "d06d5bf9f41729de1051e7f73c1c10648bc3a691" }, "coreSource": { "repository": "https://github.com/1lck/Lithe-IDEA", @@ -110,6 +105,6 @@ "requiredCapabilities": [ "litheLogging:1" ], - "prerequisite": "Merge the companion structured plugin logging host change into preview before plugin CI/release. The resolved revision and exact source digest are recorded by prepare-core." + "prerequisite": "The selected host preview must offer litheLogging 1 (the typed plugin operation catalog). prepare-core rejects a host without it and records the resolved revision and exact source digest." } } diff --git a/test/sdk-lock.test.mjs b/test/sdk-lock.test.mjs new file mode 100644 index 0000000..1b7f72b --- /dev/null +++ b/test/sdk-lock.test.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { gzipSync } from 'node:zlib'; +import { readTarGz, repository, verifySdk, verifySdkDownload } from '../scripts/verify-sdk.mjs'; + +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); + +/** A minimal ustar writer so the tests can build archives without the tar binary. */ +function tarGz(entries) { + const blocks = []; + for (const { name, data = Buffer.alloc(0), type = '0' } of entries) { + const header = Buffer.alloc(512); + header.write(name, 0, 100, 'utf8'); + header.write('0000644\0', 100); + header.write(`${data.length.toString(8).padStart(11, '0')}\0`, 124); + header.write(' ', 148); + header.write(type, 156); + header.write('ustar\0', 257); + const sum = header.reduce((total, byte) => total + byte, 0); + header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148); + blocks.push(header, data, Buffer.alloc((512 - (data.length % 512)) % 512)); + } + blocks.push(Buffer.alloc(1024)); + return gzipSync(Buffer.concat(blocks)); +} + +const commit = '0123456789abcdef0123456789abcdef01234567'; +const repositoryURL = 'https://github.com/Lithe-IDEA/lithe-plugin-docs'; +const packageJSON = Buffer.from(JSON.stringify({ name: '@lithe/plugin-sdk', version: '9.9.9' })); + +/** A small plugin checkout whose lock pins a synthetic release archive. */ +async function fixture(t, { archiveFiles = {}, vendored = {} } = {}) { + const root = await mkdtemp(join(tmpdir(), 'lithe-sdk-lock-')); + t.after(() => rm(root, { recursive: true, force: true })); + const sources = { + 'package/LICENSE': Buffer.from('license'), + 'package/src/index.mjs': Buffer.from('export {};'), + ...archiveFiles, + }; + const archive = tarGz([{ name: 'package/package.json', data: packageJSON }, ...Object.entries(sources).map(([name, data]) => ({ name, data }))]); + const harness = Buffer.from('harness'); + const placed = { + 'vendor/LICENSE': sources['package/LICENSE'], + 'vendor/lithe-plugin-sdk/src/index.mjs': sources['package/src/index.mjs'], + 'vendor/lithe-plugin-sdk/test/harness.mjs': harness, + 'vendor/lithe-testing/test-timing-lib.mjs': Buffer.from('timing'), + ...vendored, + }; + for (const [path, data] of Object.entries(placed)) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), data); + } + const entry = (origin, upstream, path) => ({ origin, upstream, path, sha256: sha256(placed[path]) }); + const lock = { + schemaVersion: 2, + sdk: { + package: '@lithe/plugin-sdk', version: '9.9.9', repository: repositoryURL, tag: 'sdk-node-v9.9.9', commit, + url: `${repositoryURL}/releases/download/sdk-node-v9.9.9/lithe-plugin-sdk-node-9.9.9.tgz`, sha256: sha256(archive), + }, + files: [ + entry('sdkArchive', 'package/LICENSE', 'vendor/LICENSE'), + entry('sdkArchive', 'package/src/index.mjs', 'vendor/lithe-plugin-sdk/src/index.mjs'), + entry('sdkCommit', 'sdk/node/test/harness.mjs', 'vendor/lithe-plugin-sdk/test/harness.mjs'), + entry('testSupport', '.agents/skills/write-stable-tests/scripts/test-timing-lib.mjs', 'vendor/lithe-testing/test-timing-lib.mjs'), + ], + testSupport: { repository: 'https://github.com/example/host', revision: commit }, + }; + await writeFile(join(root, 'sdk.lock.json'), JSON.stringify(lock)); + const downloads = new Map([ + [lock.sdk.url, archive], + [`https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/${commit}/sdk/node/test/harness.mjs`, harness], + ]); + const download = async (url) => { + if (!downloads.has(url)) throw new Error(`unexpected download ${url}`); + return downloads.get(url); + }; + return { root, lock, archive, downloads, download }; +} + +async function rewriteLock(root, change) { + const lock = JSON.parse(await readFile(join(root, 'sdk.lock.json'), 'utf8')); + change(lock); + await writeFile(join(root, 'sdk.lock.json'), JSON.stringify(lock)); +} + +test('the checked-in lock pins the released Node.js SDK without local changes', { timeout: 5000 }, async () => { + const lock = JSON.parse(await readFile(join(repository, 'sdk.lock.json'), 'utf8')); + assert.equal(lock.schemaVersion, 2); + assert.equal(lock.sdk.repository, repositoryURL); + assert.equal(lock.sdk.tag, `sdk-node-v${lock.sdk.version}`); + assert.equal(lock.sdk.url, `${repositoryURL}/releases/download/${lock.sdk.tag}/lithe-plugin-sdk-node-${lock.sdk.version}.tgz`); + assert.match(lock.sdk.commit, /^[0-9a-f]{40}$/); + assert.match(lock.sdk.sha256, /^[0-9a-f]{64}$/); + for (const entry of lock.files) assert.deepEqual(Object.keys(entry).sort(), ['origin', 'path', 'sha256', 'upstream']); + assert.equal(JSON.stringify(lock).includes('localChanges'), false); + assert.equal((await verifySdk()).version, lock.sdk.version); +}); + +test('offline verification rejects a lock that does not name the official release', { timeout: 10000 }, async (t) => { + const { root } = await fixture(t); + assert.equal((await verifySdk(root)).version, '9.9.9'); + const rejected = [ + [/Invalid SDK lock: sdk/, (lock) => { lock.sdk.repository = 'https://github.com/example/fork'; }], + [/Invalid SDK lock: sdk/, (lock) => { lock.sdk.tag = 'sdk-node-v0.0.1'; }], + [/Invalid SDK lock: sdk/, (lock) => { lock.sdk.url = 'https://example.invalid/sdk.tgz'; }], + [/Invalid SDK lock: sdk/, (lock) => { lock.sdk.commit = 'main'; }], + [/Invalid SDK lock: sdk/, (lock) => { lock.sdk.sha256 = 'abc'; }], + [/Invalid SDK lock: expected/, (lock) => { lock.schemaVersion = 1; }], + [/carry no local changes/, (lock) => { lock.files[1].localChanges = true; }], + [/Invalid or duplicate/, (lock) => { lock.files[1].origin = 'testSupport'; }], + [/Invalid or duplicate/, (lock) => { lock.files[1].path = '../outside.mjs'; }], + [/Invalid or duplicate/, (lock) => { lock.files[1].path = lock.files[0].path; }], + ]; + for (const [pattern, change] of rejected) { + const bad = await fixture(t); + await rewriteLock(bad.root, change); + await assert.rejects(verifySdk(bad.root), pattern); + } +}); + +test('download verification accepts exactly the archive and tagged files the lock names', { timeout: 10000 }, async (t) => { + const { root, download } = await fixture(t); + assert.equal((await verifySdkDownload(root, download)).tag, 'sdk-node-v9.9.9'); +}); + +test('download verification rejects an archive whose SHA-256 differs from the lock', { timeout: 10000 }, async (t) => { + const { root, lock, downloads, download } = await fixture(t); + downloads.set(lock.sdk.url, tarGz([{ name: 'package/package.json', data: packageJSON }])); + await assert.rejects(verifySdkDownload(root, download), /archive SHA-256 mismatch/); +}); + +test('download verification rejects a vendored file that differs from the archive', { timeout: 10000 }, async (t) => { + // The lock hash matches the edited vendored bytes, so only the archive comparison can catch it. + const { root, download } = await fixture(t, { vendored: { 'vendor/lithe-plugin-sdk/src/index.mjs': Buffer.from('export const local = true;') } }); + await assert.rejects(verifySdkDownload(root, download), /does not match the released SDK: vendor\/lithe-plugin-sdk\/src\/index\.mjs/); +}); + +test('download verification rejects an archive that lacks a pinned file or a different package version', { timeout: 10000 }, async (t) => { + const missing = await fixture(t); + await rewriteLock(missing.root, (lock) => { lock.files[1].upstream = 'package/src/missing.mjs'; }); + await assert.rejects(verifySdkDownload(missing.root, missing.download), /does not contain package\/src\/missing\.mjs/); + const wrongVersion = await fixture(t); + const archive = tarGz([{ name: 'package/package.json', data: Buffer.from(JSON.stringify({ name: '@lithe/plugin-sdk', version: '1.0.0' })) }]); + wrongVersion.downloads.set(wrongVersion.lock.sdk.url, archive); + await rewriteLock(wrongVersion.root, (lock) => { lock.sdk.sha256 = sha256(archive); }); + await assert.rejects(verifySdkDownload(wrongVersion.root, wrongVersion.download), /not the locked package version/); +}); + +test('download verification rejects a tagged file that changed upstream', { timeout: 10000 }, async (t) => { + const { root, downloads, download } = await fixture(t); + downloads.set(`https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/${commit}/sdk/node/test/harness.mjs`, Buffer.from('changed')); + await assert.rejects(verifySdkDownload(root, download), /does not match the released SDK: vendor\/lithe-plugin-sdk\/test\/harness\.mjs/); +}); + +test('the archive reader accepts regular files and refuses unsafe entries', { timeout: 5000 }, () => { + const files = readTarGz(tarGz([{ name: 'package/', type: '5' }, { name: 'package/a.txt', data: Buffer.from('a') }])); + assert.deepEqual([...files.keys()], ['package/a.txt']); + assert.throws(() => readTarGz(tarGz([{ name: '../escape', data: Buffer.from('x') }])), /unsafe or duplicate/); + assert.throws(() => readTarGz(tarGz([{ name: '/absolute', data: Buffer.from('x') }])), /unsafe or duplicate/); + assert.throws(() => readTarGz(tarGz([{ name: 'package/a', data: Buffer.from('1') }, { name: 'package/a', data: Buffer.from('2') }])), /unsafe or duplicate/); + assert.throws(() => readTarGz(tarGz([{ name: 'package/link', type: '2' }])), /unsupported type/); +}); diff --git a/vendor/lithe-plugin-sdk/src/backend.mjs b/vendor/lithe-plugin-sdk/src/backend.mjs index 9c28138..950cd36 100644 --- a/vendor/lithe-plugin-sdk/src/backend.mjs +++ b/vendor/lithe-plugin-sdk/src/backend.mjs @@ -54,7 +54,7 @@ const VCS_TRAITS = Object.freeze(['push', 'staging']); * @param {string} [options.version] Reported as `serverInfo.version`. * @param {object} [options.capabilities] Standard LSP server capabilities, for a * backend that is also a language server. `experimental.litheBuild`, - * `experimental.litheFunctions`, and `experimental.litheVcs` are negotiated by + * `experimental.litheFunctions`, `experimental.litheVcs`, and `experimental.litheKeymapImport` are negotiated by * the SDK and cannot be set here. */ export function createBackend(options = {}) { @@ -70,6 +70,8 @@ class Backend { #version; #capabilities; #functions = new Map(); + #keymapProviders = new Map(); + #selectedKeymapProviders = new Set(); #buildHandlers = new Map(); #buildVersion = null; #functionsVersion = null; @@ -110,10 +112,10 @@ class Backend { constructor({ name = 'lithe-plugin-backend', version, capabilities = {} } = {}) { const experimental = capabilities.experimental ?? {}; - if ('litheLogging' in experimental || 'litheBuild' in experimental || 'litheFunctions' in experimental || 'litheVcs' in experimental) { + if ('litheLogging' in experimental || 'litheBuild' in experimental || 'litheFunctions' in experimental || 'litheVcs' in experimental || 'litheKeymapImport' in experimental) { throw new PluginSdkError( 'sdk.capabilities.reserved', - 'litheBuild, litheFunctions, and litheVcs are negotiated by the SDK; declare handlers instead.', + 'litheLogging, litheBuild, litheFunctions, litheVcs, and litheKeymapImport are negotiated by the SDK; declare handlers instead.', ); } this.#name = String(name); @@ -179,6 +181,16 @@ class Backend { return this; } + /** Declares an independent typed shortcut import provider. */ + keymapImportProvider(id, handler) { + if (!this.#beforeAnswer() || !validKeymapProvider(id) || typeof handler !== 'function' + || this.#keymapProviders.has(id) || this.#keymapProviders.size >= 8) { + throw new PluginSdkError('sdk.keymap.invalidRegistration', 'Invalid or duplicate shortcut import provider.'); + } + this.#keymapProviders.set(id, handler); + return this; + } + /** * Declares the VCS this backend provides and the operations it answers, * before `listen()`. Each handler gets the operation's arguments plus @@ -469,6 +481,10 @@ class Backend { this.#invokeFunction(id, params ?? {}); return; } + if (method === 'lithe/keymap/import') { + this.#invokeKeymap(id, params); + return; + } const build = this.#buildHandlers.get(method); if (build && this.#buildVersion >= BUILD_METHOD_VERSIONS[method]) { this.#invokeBuild(id, method, params ?? {}, build); @@ -583,6 +599,13 @@ class Backend { this.#diagnostics().degraded('initialize', 'functions_unavailable'); } const experimental = { ...(this.#capabilities.experimental ?? {}), litheLogging: { version: 1 } }; + if (offers(offered.litheKeymapImport, 1) && Array.isArray(offered.litheKeymapImport?.providers) + && offered.litheKeymapImport.providers.length <= 8) { + this.#selectedKeymapProviders = new Set(offered.litheKeymapImport.providers.filter((id) => this.#keymapProviders.has(id))); + if (this.#selectedKeymapProviders.size) { + experimental.litheKeymapImport = { version: 1, providers: [...this.#selectedKeymapProviders].sort() }; + } + } if (this.#negotiated.build) experimental.litheBuild = { version: this.#buildVersion }; if (this.#negotiated.vcs) { experimental.litheVcs = { version: VCS_VERSION, vcs: this.#vcs.id, capabilities: this.#vcs.capabilities }; @@ -649,6 +672,16 @@ class Backend { } : context), (result) => buildResult(method, result), () => { reporting = false; }, method, operationID); } + #invokeKeymap(id, params) { + if (!isObject(params) || Object.keys(params).length !== 2 || !validKeymapProvider(params.providerID) + || !validSnapshotToken(params.snapshotID) || !this.#selectedKeymapProviders.has(params.providerID)) { + this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: 'Invalid shortcut import request.' }); + return; + } + const handler = this.#keymapProviders.get(params.providerID); + this.#run(id, null, (context) => handler(keymapSnapshot(params, context.host), context), functionResult, undefined, 'lithe/keymap/import'); + } + #invokeFunction(id, { functionID, operationID, params, context: workspaceContext }) { const handler = this.#negotiated.functions ? this.#functions.get(functionID) : undefined; if (!handler) { @@ -954,6 +987,38 @@ function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } +function validKeymapProvider(id) { + return typeof id === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(id); +} + +function validSnapshotToken(id) { + return typeof id === 'string' && /^[a-zA-Z0-9_-]{1,128}$/.test(id); +} + +function keymapSnapshot({ providerID, snapshotID }, host) { + const scope = { providerID, snapshotID }; + const invalid = () => new PluginSdkError('sdk.keymap.invalidResponse', 'Invalid shortcut source response.'); + return Object.freeze({ + providerID, snapshotID, + async listInputs() { + const result = await host.request('lithe/host/keymapImport/listInputs', scope); + if (!Array.isArray(result?.files) || result.files.length > 128 || result.files.some((file) => + !validSnapshotToken(file?.id) || typeof file.path !== 'string' || !file.path || Buffer.byteLength(file.path) > 4096 + || !Number.isSafeInteger(file.byteCount) || file.byteCount < 0 || file.byteCount > 1048576)) throw invalid(); + return result.files.map(({ id, path, byteCount }) => ({ id, path, byteCount })); + }, + async readChunk(fileID, offset, maximumBytes) { + if (!validSnapshotToken(fileID) || !Number.isSafeInteger(offset) || offset < 0 || offset > 1048576 + || !Number.isInteger(maximumBytes) || maximumBytes < 1 || maximumBytes > 131072) throw invalid(); + const result = await host.request('lithe/host/keymapImport/readChunk', { ...scope, fileID, offset, maximumBytes }); + if (typeof result?.data !== 'string' || result.data.length > 174764) throw invalid(); + const bytes = Buffer.from(result.data, 'base64'); + if (bytes.length > maximumBytes || bytes.toString('base64') !== result.data) throw invalid(); + return bytes; + }, + }); +} + /** * A VCS reply must be a JSON object; Core validates its fields and refuses * anything else as `plugin.vcs.invalidOutput`, so it fails here with a log. diff --git a/vendor/lithe-plugin-sdk/src/diagnostics.mjs b/vendor/lithe-plugin-sdk/src/diagnostics.mjs index c168a5c..8fcdc99 100644 --- a/vendor/lithe-plugin-sdk/src/diagnostics.mjs +++ b/vendor/lithe-plugin-sdk/src/diagnostics.mjs @@ -5,6 +5,7 @@ export const KNOWN_OPERATIONS = new Set([ 'initialize', 'shutdown', 'notification', 'function-call', 'result-encoding', ...['model', 'dependencies', 'execute', 'test', 'discoverTests', 'prepareRun', 'prepareDebug'].map((name) => `lithe/build/${name}`), ...['status', 'baseContent', 'history', 'fileHistory', 'annotate', 'checkin', 'rollback', 'update', 'fileOperation', 'resolve', 'cleanup'].map((name) => `lithe/vcs/${name}`), + 'lithe/keymap/import', ]); const token = (value, maximum) => typeof value === 'string' && value.length > 0 && value.length <= maximum && /^[A-Za-z0-9._:/\-$]+$/.test(value); export function requestOperationId(operationId, requestId) { diff --git a/vendor/lithe-plugin-sdk/src/index.d.ts b/vendor/lithe-plugin-sdk/src/index.d.ts index e18c1e7..fe7585b 100644 --- a/vendor/lithe-plugin-sdk/src/index.d.ts +++ b/vendor/lithe-plugin-sdk/src/index.d.ts @@ -319,6 +319,21 @@ export interface FunctionContext extends RequestContext { export type FunctionHandler = (params: JsonObject, context: FunctionContext) => unknown | Promise; export type BuildHandler = (params: BuildRequestParams, context: RequestContext) => JsonObject | Promise; +/** A file captured under the selected shortcut source. `path` is a relative label. */ +export interface KeymapSourceFile { id: string; path: string; byteCount: number } +export interface KeymapImportSnapshot { + readonly providerID: string; + readonly snapshotID: string; + listInputs(): Promise; + readChunk(fileID: string, offset: number, maximumBytes: number): Promise; +} +export interface KeymapImportBinding { + commandID: string; + keys: Array<{ key: string; modifiers: Array<'control' | 'alt' | 'shift' | 'meta'> }>; +} +export type KeymapImportHandler = (snapshot: KeymapImportSnapshot, context: RequestContext) => + { bindings: KeymapImportBinding[] } | Promise<{ bindings: KeymapImportBinding[] }>; + /** Params of `lithe/build/model` and `lithe/build/dependencies`. */ export interface BuildRequestParams { /** Read-only host snapshot; missing producers remain unavailable. Requires protocol 6. */ @@ -473,7 +488,7 @@ export interface Negotiated { export interface BackendOptions { name?: string; version?: string; - /** Standard LSP server capabilities; `experimental.litheBuild`, `litheFunctions`, and `litheVcs` are reserved. */ + /** Standard LSP server capabilities; the SDK negotiates its experimental protocols. */ capabilities?: JsonObject; } @@ -506,6 +521,8 @@ export interface Backend { }; vcs(id: string, handlers: VcsHandlers, traits?: VcsTraits): Backend; function(id: string, handler: FunctionHandler): Backend; + /** Declares an independent shortcut import provider before initialize. */ + keymapImportProvider(id: string, handler: KeymapImportHandler): Backend; registerFunction(id: string, handler: FunctionHandler): Promise; unregisterFunction(id: string): Promise; onRequest(method: string, handler: (params: unknown, context: RequestContext) => unknown): Backend;