diff --git a/src/main/plugins/__mocks__/plugin-git-ssh-remote-fixture.ts b/src/main/plugins/__mocks__/plugin-git-ssh-remote-fixture.ts new file mode 100644 index 00000000000..6e8602852ea --- /dev/null +++ b/src/main/plugins/__mocks__/plugin-git-ssh-remote-fixture.ts @@ -0,0 +1,20 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { vi } from 'vitest' + +/** Serves local repositories at `ssh://git@` through a GIT_SSH_COMMAND shim, + * because plugin downloads refuse the file transport even behind an insteadOf rewrite. */ +export async function serveRepositoriesOverTestSsh( + shimDirectory: string, + repositories: Record +): Promise { + const shim = join(shimDirectory, 'git-ssh-shim.cjs') + await writeFile( + shim, + await readFile(join(import.meta.dirname, '..', 'plugin-private-marketplace-ssh-shim.cjs')) + ) + const node = process.execPath.replaceAll('\\', '/') + vi.stubEnv('GIT_SSH_COMMAND', `"${node}" "${shim.replaceAll('\\', '/')}"`) + vi.stubEnv('GIT_SSH_VARIANT', 'ssh') + vi.stubEnv('ORCA_TEST_SSH_REPOSITORIES', JSON.stringify(repositories)) +} diff --git a/src/main/plugins/plugin-git-repository.integration.test.ts b/src/main/plugins/plugin-git-repository.integration.test.ts new file mode 100644 index 00000000000..07098b4e54b --- /dev/null +++ b/src/main/plugins/plugin-git-repository.integration.test.ts @@ -0,0 +1,127 @@ +import { randomBytes } from 'node:crypto' +import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, expect, it, vi } from 'vitest' +import { serveRepositoriesOverTestSsh } from './__mocks__/plugin-git-ssh-remote-fixture' +import { checkoutPluginGitSource, runPluginGit } from './plugin-git-repository' + +const PLUGIN_URL = 'ssh://git@plugins.example.invalid/sample.git' +// Why not on Windows: the planted hooks are POSIX shell scripts; the no-checkout path is shared. +const PLANT_HOOKS = process.platform !== 'win32' +const roots: string[] = [] + +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +function nodeCommand(code: string): string { + const encoded = Buffer.from(code).toString('base64') + return `"${process.execPath.replaceAll('\\', '/')}" -e "eval(Buffer.from('${encoded}','base64').toString())"` +} + +async function fixture(initArgs: string[] = []) { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-git-')) + roots.push(root) + const repository = join(root, 'source') + const destination = join(root, 'download') + const config = join(root, 'config') + const hooks = join(root, 'hooks') + const filtered = join(root, 'filter-executed') + const hooked = join(root, 'hook-executed') + await mkdir(hooks) + await writeFile(config, '') + vi.stubEnv('GIT_CONFIG_GLOBAL', config) + vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1') + const configure = (key: string, value: string) => + runPluginGit(['config', '--file', config, key, value], root) + // Why a user-level allow: proves the plugin download's own protocol policy outranks it. + await configure('protocol.file.allow', 'always') + await configure('core.hooksPath', hooks) + await serveRepositoriesOverTestSsh(root, { '/sample.git': repository }) + await runPluginGit(['init', '--quiet', '--template=', ...initArgs, repository], root) + const git = (args: string[]) => runPluginGit(args, repository) + await git(['config', 'user.name', 'Plugin fixture']) + await git(['config', 'user.email', 'plugins@example.invalid']) + const bytes = Buffer.from([0, 255, 13, 10, 65, 0, 128]) + await writeFile(join(repository, 'data.bin'), bytes) + await writeFile(join(repository, '.gitattributes'), '*.bin filter=plugin-test\n') + await git(['add', '--all']) + await git(['commit', '--quiet', '-m', 'Exact source bytes']) + await git(['tag', 'v1']) + // Planted after the fixture commit so only the download under test can trigger them. + for (const hook of PLANT_HOOKS ? ['post-checkout', 'reference-transaction'] : []) { + await writeFile( + join(hooks, hook), + `#!/bin/sh\n${nodeCommand(`require('node:fs').writeFileSync(${JSON.stringify(hooked)},'1')`)}\n`, + { mode: 0o755 } + ) + } + const checkout = (ref: string, url = PLUGIN_URL, maxDownloadBytes?: number) => + checkoutPluginGitSource({ + url, + ref, + destination, + workingDirectory: root, + ...(maxDownloadBytes ? { maxDownloadBytes } : {}) + }) + return { root, destination, bytes, filtered, hooked, git, checkout, configure, repository } +} + +it.each(['tag', 'exact commit'])( + 'materializes exact bytes through a %s without hooks or smudge filters', + async (mode) => { + const f = await fixture() + const commit = await f.git(['rev-parse', 'HEAD']) + expect(await f.checkout(mode === 'tag' ? 'v1' : commit)).toBe(commit) + expect(await readFile(join(f.destination, 'data.bin'))).toEqual(f.bytes) + await expect(access(f.filtered)).rejects.toThrow() + if (PLANT_HOOKS) { + await expect(access(f.hooked)).rejects.toThrow() + } + expect(await readdir(f.destination)).not.toContain('.git') + } +) + +it.each([ + ['file', (repository: string) => pathToFileURL(repository).href], + ['http', () => 'http://127.0.0.1:9/sample.git'] +])('refuses a %s transport reached through an insteadOf rewrite', async (name, target) => { + const f = await fixture() + const url = 'https://plugins.example.invalid/rewritten.git' + await f.configure(`url.${target(f.repository)}.insteadOf`, url) + // Why this also covers redirects: Git hands the same allowlist to curl's redirect protocols. + await expect(f.checkout('v1', url)).rejects.toThrow(`transport '${name}' not allowed`) +}) + +it('rejects symlink objects before writing any file', async () => { + const f = await fixture() + await writeFile(join(f.root, 'link-target'), '../outside.txt') + const object = await f.git(['hash-object', '-w', join(f.root, 'link-target')]) + await f.git(['update-index', '--add', '--cacheinfo', `120000,${object},link`]) + await f.git(['commit', '--quiet', '-m', 'Symbolic link']) + await expect(f.checkout(await f.git(['rev-parse', 'HEAD']))).rejects.toThrow('symlinks') + expect(await readdir(f.destination)).toEqual([]) +}) + +it('aborts a download that grows past its size limit', async () => { + const f = await fixture() + await writeFile(join(f.root, 'source', 'large.bin'), randomBytes(4 * 1024 * 1024)) + await f.git(['add', 'large.bin']) + await f.git(['commit', '--quiet', '-m', 'Large']) + await expect(f.checkout('', PLUGIN_URL, 1024 * 1024)).rejects.toThrow( + 'exceeds the download size limit' + ) + expect(await readdir(f.destination)).toEqual([]) +}) + +it('rejects SHA-256 repositories', async () => { + const f = await fixture(['--object-format=sha256']).catch(() => null) + if (!f) { + return // Git before 2.29 cannot create the fixture. + } + await expect(f.checkout('v1')).rejects.toThrow('SHA-256 plugin repositories are not supported') + await expect(f.checkout('a'.repeat(64))).rejects.toThrow('SHA-256') +}) diff --git a/src/main/plugins/plugin-git-repository.ts b/src/main/plugins/plugin-git-repository.ts index dfe7b7375a0..a2bda90aeec 100644 --- a/src/main/plugins/plugin-git-repository.ts +++ b/src/main/plugins/plugin-git-repository.ts @@ -1,46 +1,166 @@ +import { lstat, mkdir, mkdtemp, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { isAllowedPluginGitUrl, PLUGIN_COMMIT_PATTERN } from '../../shared/plugins/plugin-install-lockfile' +import { + pluginGitCloneArgs, + pluginGitConfigArgs, + pluginGitFetchCommitArgs +} from '../../shared/plugins/plugin-git-arguments' import { gitExecFileAsync } from '../git/runner' +import { MAX_PLUGIN_TOTAL_BYTES } from './plugin-content-hash' +import { materializePluginGitTree } from './plugin-git-tree' const PLUGIN_GIT_TIMEOUT_MS = 120_000 +// Why headroom over the tree cap: a depth-1 pack of incompressible files carries per-object overhead. +export const PLUGIN_GIT_DOWNLOAD_MAX_BYTES = MAX_PLUGIN_TOTAL_BYTES + 16 * 1024 * 1024 +const DOWNLOAD_POLL_MS = 200 +const SHA1_COMMIT_PATTERN = /^[0-9a-f]{40}$/ -/** Use system Git credentials while retaining argv-only invocation and restricted remote protocols. */ -export async function runPluginGit(args: string[], cwd: string): Promise { +/** Runs system Git with argv-only invocation so credential helpers and SSH + * remotes work without exposing an executable remote-helper surface. */ +export async function runPluginGit( + args: string[], + cwd: string, + signal?: AbortSignal +): Promise { const { stdout } = await gitExecFileAsync(args, { cwd, - timeout: PLUGIN_GIT_TIMEOUT_MS + timeout: PLUGIN_GIT_TIMEOUT_MS, + // Why: abort must stop remote-https and index-pack too, and settle only once they exit. + ...(signal ? { signal, terminationBarrier: true } : {}) }) return stdout.trim() } +async function directoryBytes(path: string): Promise { + let total = 0 + for (const entry of await readdir(path, { withFileTypes: true }).catch(() => [])) { + const child = join(path, entry.name) + total += entry.isDirectory() + ? await directoryBytes(child) + : await lstat(child).then( + (stat) => stat.size, + () => 0 + ) + } + return total +} + +/** Git has no client-side fetch size limit, so poll the pack landing on disk and stop it. + * The cap is approximate: a fast link can overshoot by one poll interval of transfer. */ +async function runBoundedDownload( + args: string[], + cwd: string, + repository: string, + maxBytes: number +): Promise { + const controller = new AbortController() + let exceeded = false + let measuring = false + const timer = setInterval(() => { + if (measuring) { + return + } + measuring = true + void directoryBytes(repository) + .then((bytes) => { + if (bytes > maxBytes && !controller.signal.aborted) { + exceeded = true + controller.abort() + } + }) + .finally(() => { + measuring = false + }) + }, DOWNLOAD_POLL_MS) + try { + await runPluginGit(args, cwd, controller.signal) + } catch (error) { + if (exceeded) { + throw new Error('plugin repository exceeds the download size limit') + } + throw error + } finally { + clearInterval(timer) + } + if ((await directoryBytes(repository)) > maxBytes) { + throw new Error('plugin repository exceeds the download size limit') + } +} + +/** Downloads one ref without a checkout, hands the exact commit to `read`, then deletes it. */ +export async function readPluginGitCommit( + input: { url: string; ref: string; workingDirectory: string; maxDownloadBytes: number }, + read: (repository: string, commit: string) => Promise +): Promise<{ commit: string; value: T }> { + if (!isAllowedPluginGitUrl(input.url)) { + throw new Error('plugin Git URL must use HTTPS or SSH') + } + const ref = input.ref.trim() + const exactCommit = PLUGIN_COMMIT_PATTERN.test(ref) + if (exactCommit && !SHA1_COMMIT_PATTERN.test(ref)) { + throw new Error('SHA-256 plugin repositories are not supported') + } + const temporaryRoot = await mkdtemp(join(tmpdir(), 'orca-plugin-git-')) + const repository = join(temporaryRoot, 'repository') + const hooks = join(temporaryRoot, 'no-hooks') + const config = pluginGitConfigArgs(hooks) + try { + await mkdir(hooks) + await mkdir(repository) + if (exactCommit) { + await runPluginGit([...config, 'init', '--quiet', '--template=', repository], temporaryRoot) + await runPluginGit([...config, 'remote', 'add', 'origin', input.url], repository) + await runBoundedDownload( + [...config, ...pluginGitFetchCommitArgs(ref)], + repository, + repository, + input.maxDownloadBytes + ) + } else { + const args = [...config, ...pluginGitCloneArgs(input.url, ref, repository)] + await runBoundedDownload(args, input.workingDirectory, repository, input.maxDownloadBytes) + } + const commit = await runPluginGit( + ['rev-parse', '--verify', `${exactCommit ? 'FETCH_HEAD' : 'HEAD'}^{commit}`], + repository + ) + if (!SHA1_COMMIT_PATTERN.test(commit)) { + throw new Error( + PLUGIN_COMMIT_PATTERN.test(commit) + ? 'SHA-256 plugin repositories are not supported' + : 'Git resolved an invalid commit identity' + ) + } + if (exactCommit && commit !== ref) { + throw new Error('Git fetched a different commit than requested') + } + return { commit, value: await read(repository, commit) } + } finally { + await rm(temporaryRoot, { recursive: true, force: true }) + } +} + +/** Writes one ref's exact files into an empty destination and returns its commit. */ export async function checkoutPluginGitSource(input: { url: string ref: string destination: string workingDirectory: string + maxDownloadBytes?: number }): Promise { - if (!isAllowedPluginGitUrl(input.url)) { - throw new Error('plugin Git URL must use HTTPS or SSH') + await mkdir(input.destination, { recursive: true }) + if ((await readdir(input.destination)).length > 0) { + throw new Error('plugin Git destination must be empty') } - const ref = input.ref.trim() - if (PLUGIN_COMMIT_PATTERN.test(ref)) { - await runPluginGit(['init', '--quiet', input.destination], input.workingDirectory) - await runPluginGit(['remote', 'add', 'origin', input.url], input.destination) - await runPluginGit(['fetch', '--quiet', '--depth', '1', 'origin', ref], input.destination) - await runPluginGit(['checkout', '--quiet', 'FETCH_HEAD'], input.destination) - } else { - const args = ['clone', '--quiet', '--depth', '1'] - if (ref.length > 0) { - args.push('--branch', ref) - } - args.push('--', input.url, input.destination) - await runPluginGit(args, input.workingDirectory) - } - const resolvedCommit = await runPluginGit(['rev-parse', 'HEAD'], input.destination) - if (!PLUGIN_COMMIT_PATTERN.test(resolvedCommit)) { - throw new Error('Git resolved an invalid commit identity') - } - return resolvedCommit + const { commit } = await readPluginGitCommit( + { ...input, maxDownloadBytes: input.maxDownloadBytes ?? PLUGIN_GIT_DOWNLOAD_MAX_BYTES }, + (repository, resolved) => + materializePluginGitTree({ repository, commit: resolved, destination: input.destination }) + ) + return commit } diff --git a/src/main/plugins/plugin-git-tree.test.ts b/src/main/plugins/plugin-git-tree.test.ts new file mode 100644 index 00000000000..adcca40c1ef --- /dev/null +++ b/src/main/plugins/plugin-git-tree.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { parsePluginGitTree, readPluginGitBatchBlobs } from './plugin-git-tree' + +const OBJECT = 'a'.repeat(40) + +function tree(...records: string[]): Buffer { + return Buffer.from(records.map((record) => `${record}\0`).join('')) +} + +describe('parsePluginGitTree', () => { + it('parses directories and files with their executable bit', () => { + expect( + parsePluginGitTree( + tree(`040000 tree ${OBJECT} -\tbin`, `100755 blob ${OBJECT} 12\tbin/run`) + ) + ).toEqual([ + { kind: 'tree', path: 'bin' }, + { kind: 'blob', path: 'bin/run', object: OBJECT, size: 12, executable: true } + ]) + }) + + it.each([ + ['a symlink', `120000 blob ${OBJECT} 5\tlink`, 'symlinks'], + ['a submodule', `160000 commit ${OBJECT} -\tmodule`, 'submodules'], + ['a SHA-256 object', `100644 blob ${'a'.repeat(64)} 5\tfile`, 'unknown entries'], + ['a .git directory', `040000 tree ${OBJECT} -\t.GIT`, 'unsafe plugin path'], + ['a .git name hidden by a zero-width joiner', `100644 blob ${OBJECT} 5\t.g\u200dit`, 'unsafe'], + ['an 8.3 .git alias', `100644 blob ${OBJECT} 5\tGIT~1`, 'unsafe plugin path'], + ['a Windows device name', `100644 blob ${OBJECT} 5\tcon.txt`, 'unsafe plugin path'], + ['a file without its directory', `100644 blob ${OBJECT} 5\tdir/file`, 'missing directory'] + ])('rejects %s', (_label, record, message) => { + expect(() => parsePluginGitTree(tree(record))).toThrow(message) + }) + + it('rejects a .git directory below the root', () => { + expect(() => + parsePluginGitTree(tree(`040000 tree ${OBJECT} -\tsub`, `040000 tree ${OBJECT} -\tsub/.git`)) + ).toThrow('unsafe plugin path') + }) + + it('rejects paths that collide on case-insensitive filesystems', () => { + expect(() => + parsePluginGitTree(tree(`100644 blob ${OBJECT} 1\tREADME`, `100644 blob ${OBJECT} 1\treadme`)) + ).toThrow('colliding') + }) + + it('rejects trees over the byte budget before any blob is read', () => { + expect(() => parsePluginGitTree(tree(`100644 blob ${OBJECT} 99999999999\tbig`))).toThrow( + 'byte limit' + ) + }) +}) + +describe('readPluginGitBatchBlobs', () => { + const entries = parsePluginGitTree(tree(`100644 blob ${OBJECT} 3\tfile`)) + + it('returns exact blob bytes', () => { + const blobs = readPluginGitBatchBlobs(Buffer.from(`${OBJECT} blob 3\nabc\n`), entries) + expect(blobs.get(OBJECT)?.toString()).toBe('abc') + }) + + it.each([ + ['a size mismatch', `${OBJECT} blob 4\nabcd\n`], + ['a different object', `${'b'.repeat(40)} blob 3\nabc\n`], + ['truncated data', `${OBJECT} blob 3\nab`], + ['trailing data', `${OBJECT} blob 3\nabc\nextra`] + ])('rejects %s', (_label, output) => { + expect(() => readPluginGitBatchBlobs(Buffer.from(output), entries)).toThrow() + }) +}) diff --git a/src/main/plugins/plugin-git-tree.ts b/src/main/plugins/plugin-git-tree.ts new file mode 100644 index 00000000000..ea06cdc6228 --- /dev/null +++ b/src/main/plugins/plugin-git-tree.ts @@ -0,0 +1,161 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { gitExecFileAsyncBuffer } from '../git/runner' +import { pluginGitTreeArgs } from '../../shared/plugins/plugin-git-arguments' +import { pluginRelativePathError } from '../../shared/plugins/plugin-path-safety' +import { MAX_PLUGIN_FILES, MAX_PLUGIN_TOTAL_BYTES } from './plugin-content-hash' + +const MAX_PATH_BYTES = 1024 +const MAX_PATH_DEPTH = 64 +const TREE_METADATA_MAX_BYTES = MAX_PLUGIN_FILES * (MAX_PATH_BYTES + 128) +const PLUGIN_GIT_TREE_TIMEOUT_MS = 120_000 + +export type PluginGitTreeEntry = + | { kind: 'tree'; path: string } + | { kind: 'blob'; path: string; object: string; size: number; executable: boolean } + +// Why: case-insensitive and NFC-normalizing filesystems (macOS, Windows) merge these; the +// upper-then-lower round trip approximates full case folding (e.g. ß/ẞ, Kelvin sign). +function portablePathIdentity(path: string): string { + return path.normalize('NFC').toUpperCase().toLowerCase() +} + +function validatePath(path: string): void { + const segments = path.split('/') + if ( + pluginRelativePathError(path) || + path.includes('\\') || + Buffer.byteLength(path, 'utf8') > MAX_PATH_BYTES || + segments.length > MAX_PATH_DEPTH || + segments.some((segment) => { + // Why: HFS+ ignores these code points and NTFS 8.3 names alias `.git`. + const name = portablePathIdentity(segment).replace(/[‌-‏‪-‮-]/g, '') + return name === '.git' || /^\.?git~[0-9]+$/.test(name) + }) + ) { + throw new Error(`unsafe plugin path: ${path}`) + } +} + +/** Parses `git ls-tree -r -t -l -z` output; anything but plain files and directories is refused. */ +export function parsePluginGitTree(bytes: Buffer): PluginGitTreeEntry[] { + if (bytes.length > TREE_METADATA_MAX_BYTES) { + throw new Error('plugin Git tree metadata exceeds its size limit') + } + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + if (text && !text.endsWith('\0')) { + throw new Error('truncated plugin Git tree metadata') + } + const records = text ? text.slice(0, -1).split('\0') : [] + if (records.length > MAX_PLUGIN_FILES) { + throw new Error(`plugin exceeds the ${MAX_PLUGIN_FILES}-entry limit`) + } + const entries: PluginGitTreeEntry[] = [] + const kinds = new Map() + const portablePaths = new Set() + let totalBytes = 0 + for (const record of records) { + const match = /^(040000|100644|100755) (tree|blob) ([0-9a-f]{40}) +(-|[0-9]+)\t(.+)$/s.exec( + record + ) + if (!match) { + throw new Error('plugin Git trees cannot contain symlinks, submodules or unknown entries') + } + const [, mode, type, object, sizeText, path] = match + validatePath(path) + const identity = portablePathIdentity(path) + if (portablePaths.has(identity)) { + throw new Error(`colliding plugin paths: ${path}`) + } + portablePaths.add(identity) + const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : null + if (parent !== null && kinds.get(parent) !== 'tree') { + throw new Error(`missing directory in plugin Git tree: ${path}`) + } + let entry: PluginGitTreeEntry + if (mode === '040000' && type === 'tree' && sizeText === '-') { + entry = { kind: 'tree', path } + } else if (mode !== '040000' && type === 'blob' && sizeText !== '-') { + const size = Number(sizeText) + totalBytes += size + if (!Number.isSafeInteger(size) || totalBytes > MAX_PLUGIN_TOTAL_BYTES) { + throw new Error(`plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit`) + } + entry = { kind: 'blob', path, object, size, executable: mode === '100755' } + } else { + throw new Error('invalid plugin Git entry mode, type or size') + } + kinds.set(path, entry.kind) + entries.push(entry) + } + return entries +} + +/** Splits `git cat-file --batch` output, requesting each distinct blob once in entry order. */ +export function readPluginGitBatchBlobs( + bytes: Buffer, + entries: readonly PluginGitTreeEntry[] +): Map { + const blobs = new Map() + let offset = 0 + for (const entry of entries) { + if (entry.kind !== 'blob' || blobs.has(entry.object)) { + continue + } + const end = bytes.indexOf(10, offset) + if (end < offset || end - offset > 128) { + throw new Error('missing plugin Git blob header') + } + if (bytes.subarray(offset, end).toString('ascii') !== `${entry.object} blob ${entry.size}`) { + throw new Error('plugin Git blob identity or size changed') + } + const start = end + 1 + const next = start + entry.size + if (next >= bytes.length || bytes[next] !== 10) { + throw new Error('truncated plugin Git blob') + } + blobs.set(entry.object, bytes.subarray(start, next)) + offset = next + 1 + } + if (offset !== bytes.length) { + throw new Error('unexpected trailing plugin Git blob data') + } + return blobs +} + +/** Writes a commit's exact blobs without `checkout`, so no filter, hook or attribute runs. */ +export async function materializePluginGitTree(input: { + repository: string + commit: string + destination: string +}): Promise { + const options = { cwd: input.repository, timeout: PLUGIN_GIT_TREE_TIMEOUT_MS } + const metadata = await gitExecFileAsyncBuffer(pluginGitTreeArgs(input.commit), { + ...options, + maxBuffer: TREE_METADATA_MAX_BYTES + }) + const entries = parsePluginGitTree(metadata.stdout) + const objects = [ + ...new Set(entries.flatMap((entry) => (entry.kind === 'blob' ? [entry.object] : []))) + ] + const data = objects.length + ? await gitExecFileAsyncBuffer(['cat-file', '--batch'], { + ...options, + maxBuffer: MAX_PLUGIN_TOTAL_BYTES + MAX_PLUGIN_FILES * 128, + stdin: `${objects.join('\n')}\n` + }) + : { stdout: Buffer.alloc(0) } + const blobs = readPluginGitBatchBlobs(data.stdout, entries) + for (const entry of entries) { + const target = join(input.destination, ...entry.path.split('/')) + if (entry.kind === 'tree') { + await mkdir(target) + continue + } + const bytes = blobs.get(entry.object) + if (!bytes || bytes.length !== entry.size) { + throw new Error('missing plugin Git blob') + } + await writeFile(target, bytes, { flag: 'wx', mode: entry.executable ? 0o755 : 0o644 }) + } +} diff --git a/src/main/plugins/plugin-install-staging.ts b/src/main/plugins/plugin-install-staging.ts index 93ec8a902f9..6909c0847b0 100644 --- a/src/main/plugins/plugin-install-staging.ts +++ b/src/main/plugins/plugin-install-staging.ts @@ -9,6 +9,10 @@ import { type PluginManifest } from '../../shared/plugins/plugin-manifest' import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { + pluginCatalogReleaseError, + type PluginCatalogRelease +} from '../../shared/plugins/plugin-catalog-release' import type { PluginInstallSource, PluginLockEntry @@ -84,6 +88,8 @@ export async function inspectPluginInstallTree(input: { rootDir: string hostVersion: string expectedPluginKey?: string + /** Catalog-pinned release these bytes must match exactly. */ + release?: { record: PluginCatalogRelease; resolvedCommit: string | null } }): Promise { const sourceManifest = await readInstallManifest(input.rootDir, input.hostVersion) if (!sourceManifest.ok) { @@ -104,6 +110,17 @@ export async function inspectPluginInstallTree(input: { if (!treeHash.ok) { return { ok: false, error: treeHash.error } } + const releaseError = + input.release && + pluginCatalogReleaseError({ + release: input.release.record, + manifest: sourceManifest.manifest, + resolvedCommit: input.release.resolvedCommit, + contentHash: treeHash.hash + }) + if (releaseError) { + return { ok: false, error: releaseError } + } return { ok: true, manifest: sourceManifest.manifest, @@ -120,6 +137,7 @@ export async function installStagedPluginTree(input: { source: PluginInstallSource resolvedCommit: string | null expectedPluginKey?: string + release?: PluginCatalogRelease /** Trusted bundled bytes may restore an immutable directory damaged on disk. */ repairCorruptedVersion?: boolean blockedPluginReason?: (pluginKey: string) => string | null @@ -127,7 +145,10 @@ export async function installStagedPluginTree(input: { const sourceInspection = await inspectPluginInstallTree({ rootDir: input.stagingDir, hostVersion: input.hostVersion, - ...(input.expectedPluginKey ? { expectedPluginKey: input.expectedPluginKey } : {}) + ...(input.expectedPluginKey ? { expectedPluginKey: input.expectedPluginKey } : {}), + ...(input.release + ? { release: { record: input.release, resolvedCommit: input.resolvedCommit } } + : {}) }) if (!sourceInspection.ok) { return sourceInspection diff --git a/src/main/plugins/plugin-install.test.ts b/src/main/plugins/plugin-install.test.ts index 1bc2d9a5d79..39791ed55b2 100644 --- a/src/main/plugins/plugin-install.test.ts +++ b/src/main/plugins/plugin-install.test.ts @@ -11,7 +11,6 @@ import { } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { pathToFileURL } from 'node:url' import { promisify } from 'node:util' import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -21,6 +20,7 @@ import { readPluginLockfile, removeInstalledPlugin } from './plugin-install' +import { serveRepositoriesOverTestSsh } from './__mocks__/plugin-git-ssh-remote-fixture' import { PLUGIN_MANIFEST_MAX_BYTES } from './plugin-manifest-file' import { readPluginCurrentPointer } from './plugin-current-pointer' import { writePluginLockfile } from './plugin-install-lockfile-store' @@ -65,6 +65,7 @@ async function writePluginSource( } afterEach(async () => { + vi.unstubAllEnvs() vi.restoreAllMocks() await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) @@ -446,34 +447,21 @@ describe('installPluginFromGit', () => { cwd: sourcePath }) - const configKeys = ['GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_0', 'GIT_CONFIG_VALUE_0'] as const - const previous = Object.fromEntries(configKeys.map((key) => [key, process.env[key]])) - process.env.GIT_CONFIG_COUNT = '1' - process.env.GIT_CONFIG_KEY_0 = `url.${pathToFileURL(sourcePath).href}.insteadOf` - process.env.GIT_CONFIG_VALUE_0 = 'https://plugin.test/demo.git' - try { - const result = await installPluginFromGit({ - pluginsDir, - url: 'https://plugin.test/demo.git', - ref: 'v1.0.0', - hostVersion: '1.4.0' - }) + await serveRepositoriesOverTestSsh(await tempRoot('orca-plugin-ssh-shim-'), { + '/demo.git': sourcePath + }) + const result = await installPluginFromGit({ + pluginsDir, + url: 'ssh://git@plugin.test/demo.git', + ref: 'v1.0.0', + hostVersion: '1.4.0' + }) - expect(result).toMatchObject({ ok: true, resolvedCommit: commitStdout.trim() }) - if (result.ok) { - await expect( - readFile(join(pluginsDir, result.pluginKey, result.contentHash, 'panel.html'), 'utf8') - ).resolves.toBe('

Panel

') - } - } finally { - for (const key of configKeys) { - const value = previous[key] - if (value === undefined) { - delete process.env[key] - } else { - process.env[key] = value - } - } + expect(result).toMatchObject({ ok: true, resolvedCommit: commitStdout.trim() }) + if (result.ok) { + await expect( + readFile(join(pluginsDir, result.pluginKey, result.contentHash, 'panel.html'), 'utf8') + ).resolves.toBe('

Panel

') } }) }) diff --git a/src/main/plugins/plugin-install.ts b/src/main/plugins/plugin-install.ts index 683560dcac3..e76ad36d0ea 100644 --- a/src/main/plugins/plugin-install.ts +++ b/src/main/plugins/plugin-install.ts @@ -13,6 +13,7 @@ import { pluginInstallSourceSchema, removePluginLock } from '../../shared/plugins/plugin-install-lockfile' +import type { PluginCatalogRelease } from '../../shared/plugins/plugin-catalog-release' import { readPluginLockfile, writePluginLockfile } from './plugin-install-lockfile-store' import { inspectPluginInstallTree, @@ -142,6 +143,7 @@ export async function installPluginFromMarketplace(input: { expectedResolvedCommit: string marketplace: { url: string; ref: string; resolvedCommit: string } plugin: { url: string; ref: string } + release?: PluginCatalogRelease blockedPluginReason?: (pluginKey: string) => string | null }): Promise { const source = pluginInstallSourceSchema.parse({ @@ -160,7 +162,7 @@ export async function installPluginFromMarketplace(input: { try { const resolvedCommit = await checkoutPluginGitSource({ url: input.plugin.url, - ref: input.plugin.ref, + ref: input.release?.commit ?? input.plugin.ref, destination: stagingDir, workingDirectory: tmpdir() }) @@ -174,6 +176,7 @@ export async function installPluginFromMarketplace(input: { source, resolvedCommit, expectedPluginKey: input.expectedPluginKey, + ...(input.release ? { release: input.release } : {}), blockedPluginReason: input.blockedPluginReason }) } catch (error) { diff --git a/src/main/plugins/plugin-marketplace-fetch.integration.test.ts b/src/main/plugins/plugin-marketplace-fetch.integration.test.ts new file mode 100644 index 00000000000..c8fc5b4ad19 --- /dev/null +++ b/src/main/plugins/plugin-marketplace-fetch.integration.test.ts @@ -0,0 +1,48 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { gitExecFileAsync } from '../git/runner' +import { serveRepositoriesOverTestSsh } from './__mocks__/plugin-git-ssh-remote-fixture' +import { fetchPluginMarketplace } from './plugin-marketplace-fetch' + +const roots: string[] = [] +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +it('reads only the catalog index, prefers the v2 file, and ignores plugin size limits', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-marketplace-fetch-')) + roots.push(root) + const repository = join(root, 'catalog') + const git = async (args: string[]) => (await gitExecFileAsync(args, { cwd: repository })).stdout + const entry = { + id: 'samples.demo', + source: { kind: 'git', url: 'https://example.com/demo.git', ref: 'v1' }, + categories: [] + } + const catalog = (name: string, plugins: unknown[]) => + JSON.stringify({ name, owner: 'samples', plugins }) + // More files than one plugin may hold: the catalog download has its own limits. + await mkdir(join(repository, 'packs'), { recursive: true }) + for (let index = 0; index <= 2_000; index += 1) { + await writeFile(join(repository, 'packs', `${index}.txt`), String(index)) + } + await writeFile(join(repository, 'orca-marketplace.json'), catalog('Old', [entry])) + await writeFile(join(repository, 'orca-marketplace.v2.json'), catalog('New', [])) + await git(['init', '--quiet']) + await git(['add', '--all']) + await git(['-c', 'user.name=t', '-c', 'user.email=t@example.invalid', 'commit', '-qm', 'c']) + await git(['tag', 'catalog']) + await serveRepositoriesOverTestSsh(root, { '/catalog.git': repository }) + + const fetched = await fetchPluginMarketplace({ + id: 'a'.repeat(32), + source: { kind: 'git', url: 'ssh://git@catalog.example.invalid/catalog.git', ref: 'catalog' }, + addedAt: 0 + }) + + expect(fetched.marketplace.name).toBe('New') + expect(fetched.marketplaceCommit).toBe((await git(['rev-parse', 'HEAD'])).trim()) +}) diff --git a/src/main/plugins/plugin-marketplace-fetch.test.ts b/src/main/plugins/plugin-marketplace-fetch.test.ts new file mode 100644 index 00000000000..5f91aaea204 --- /dev/null +++ b/src/main/plugins/plugin-marketplace-fetch.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { + PLUGIN_MARKETPLACE_FILENAME, + PLUGIN_MARKETPLACE_V2_FILENAME +} from '../../shared/plugins/plugin-marketplace' +import { parsePluginMarketplaceIndex } from './plugin-marketplace-fetch' + +const entry = { + id: 'samples.demo', + source: { kind: 'git', url: 'https://example.com/demo.git', ref: 'v1.0.0' }, + categories: [] +} +const release = { + version: '1.0.0', + commit: 'c'.repeat(40), + contentHash: 'f'.repeat(64), + execution: 'data', + minimumOrca: '1.4.0', + types: ['interface-language'] +} + +function parse(filename: string, catalog: unknown) { + return parsePluginMarketplaceIndex(filename, Buffer.from(JSON.stringify(catalog))) +} + +describe('parsePluginMarketplaceIndex', () => { + it('keeps valid v2 entries next to ones this client cannot install', () => { + const marketplace = parse(PLUGIN_MARKETPLACE_V2_FILENAME, { + name: 'New', + owner: 'samples', + futureHeader: true, + plugins: [ + { ...entry, id: 'samples.theme', release: { ...release, types: ['app-theme'] } }, + { ...entry, id: 'samples.broken', release: { ...release, commit: 'nope' } }, + { ...entry, release: { ...release, futureField: 1 }, futureField: 1 } + ] + }) + expect(marketplace).toEqual({ name: 'New', owner: 'samples', plugins: [{ ...entry, release }] }) + }) + + it('rejects duplicate ids even when one copy is unreadable', () => { + expect(() => + parse(PLUGIN_MARKETPLACE_V2_FILENAME, { + name: 'New', + owner: 'samples', + plugins: [ + { ...entry, release }, + { ...entry, release: { ...release, commit: 'nope' } } + ] + }) + ).toThrow('duplicate plugin id') + }) + + it('keeps release entries out of the frozen v1 file', () => { + expect(() => + parse(PLUGIN_MARKETPLACE_FILENAME, { + name: 'Old', + owner: 'samples', + plugins: [{ ...entry, release }] + }) + ).toThrow(`invalid ${PLUGIN_MARKETPLACE_FILENAME}`) + }) +}) diff --git a/src/main/plugins/plugin-marketplace-fetch.ts b/src/main/plugins/plugin-marketplace-fetch.ts index 53347815066..b89b55fa591 100644 --- a/src/main/plugins/plugin-marketplace-fetch.ts +++ b/src/main/plugins/plugin-marketplace-fetch.ts @@ -1,16 +1,19 @@ -import { createReadStream } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' import { PLUGIN_MARKETPLACE_FILENAME, + PLUGIN_MARKETPLACE_V2_FILENAME, pluginMarketplaceSchema, + pluginMarketplaceV2Schema, type PluginMarketplace } from '../../shared/plugins/plugin-marketplace' -import { checkoutPluginGitSource } from './plugin-git-repository' +import { gitExecFileAsyncBuffer } from '../git/runner' +import { readPluginGitCommit } from './plugin-git-repository' import type { PluginMarketplaceRegisteredSource } from './plugin-marketplace-store' const MARKETPLACE_INDEX_MAX_BYTES = 16 * 1024 * 1024 +// Why its own cap: a catalog repository may hold far more than one plugin, and only its index is read. +const MARKETPLACE_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024 +const MARKETPLACE_GIT_TIMEOUT_MS = 120_000 export type PluginMarketplaceFetchResult = { marketplaceCommit: string @@ -21,47 +24,67 @@ export type PluginMarketplaceFetchResult = { export async function fetchPluginMarketplace( source: PluginMarketplaceRegisteredSource ): Promise { - const stagingDirectory = await mkdtemp(join(tmpdir(), 'orca-plugin-marketplace-')) - try { - const marketplaceCommit = await checkoutPluginGitSource({ + const { commit, value } = await readPluginGitCommit( + { url: source.source.url, ref: source.source.ref, - destination: stagingDirectory, - workingDirectory: tmpdir() - }) - const marketplace = await readPluginMarketplaceIndex(stagingDirectory) - return { marketplaceCommit, marketplace } - } finally { - await rm(stagingDirectory, { recursive: true, force: true }) - } -} - -export async function readPluginMarketplaceIndex( - rootDirectory: string -): Promise { - const raw = await readMarketplaceJson(rootDirectory, PLUGIN_MARKETPLACE_FILENAME) - return parseMarketplaceFile(PLUGIN_MARKETPLACE_FILENAME, () => pluginMarketplaceSchema.parse(raw)) -} - -async function readMarketplaceJson(rootDirectory: string, filename: string): Promise { - const chunks: Buffer[] = [] - let totalBytes = 0 - for await (const chunk of createReadStream(join(rootDirectory, filename))) { - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) - totalBytes += bytes.byteLength - if (totalBytes > MARKETPLACE_INDEX_MAX_BYTES) { - throw new Error(`${filename} exceeds its size limit`) - } - chunks.push(bytes) - } - return parseMarketplaceFile(filename, () => - JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8')) + workingDirectory: tmpdir(), + maxDownloadBytes: MARKETPLACE_DOWNLOAD_MAX_BYTES + }, + readMarketplaceAtCommit ) + return { marketplaceCommit: commit, marketplace: value } } -function parseMarketplaceFile(filename: string, parse: () => T): T { +/** Reads only the catalog index blobs, so the rest of the repository is never written out. + * Prefers the release-pinned v2 catalog; repositories that only publish v1 still load. */ +async function readMarketplaceAtCommit( + repository: string, + commit: string +): Promise { + const options = { cwd: repository, timeout: MARKETPLACE_GIT_TIMEOUT_MS } + const listing = await gitExecFileAsyncBuffer( + [ + 'ls-tree', + '-l', + '-z', + commit, + '--', + PLUGIN_MARKETPLACE_V2_FILENAME, + PLUGIN_MARKETPLACE_FILENAME + ], + { ...options, maxBuffer: 64 * 1024 } + ) + const blobs = new Map() + for (const record of listing.stdout.toString('utf8').split('\0')) { + const match = /^100(?:644|755) blob ([0-9a-f]{40}) +([0-9]+)\t(.+)$/s.exec(record) + if (match) { + blobs.set(match[3]!, { object: match[1]!, size: Number(match[2]) }) + } + } + const filename = blobs.has(PLUGIN_MARKETPLACE_V2_FILENAME) + ? PLUGIN_MARKETPLACE_V2_FILENAME + : PLUGIN_MARKETPLACE_FILENAME + const blob = blobs.get(filename) + if (!blob) { + throw new Error(`${PLUGIN_MARKETPLACE_FILENAME} not found`) + } + if (blob.size > MARKETPLACE_INDEX_MAX_BYTES) { + throw new Error(`${filename} exceeds its size limit`) + } + const bytes = await gitExecFileAsyncBuffer(['cat-file', 'blob', blob.object], { + ...options, + maxBuffer: MARKETPLACE_INDEX_MAX_BYTES + }) + return parsePluginMarketplaceIndex(filename, bytes.stdout) +} + +export function parsePluginMarketplaceIndex(filename: string, bytes: Buffer): PluginMarketplace { try { - return parse() + const raw: unknown = JSON.parse(bytes.toString('utf8')) + return filename === PLUGIN_MARKETPLACE_V2_FILENAME + ? pluginMarketplaceV2Schema.parse(raw) + : pluginMarketplaceSchema.parse(raw) } catch (error) { throw new Error( `invalid ${filename}: ${error instanceof Error ? error.message : String(error)}` diff --git a/src/main/plugins/plugin-marketplace-installer.test.ts b/src/main/plugins/plugin-marketplace-installer.test.ts index 589a90699ef..14f6cd86c98 100644 --- a/src/main/plugins/plugin-marketplace-installer.test.ts +++ b/src/main/plugins/plugin-marketplace-installer.test.ts @@ -2,19 +2,33 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PluginCatalogRelease } from '../../shared/plugins/plugin-catalog-release' import type { PluginMarketplace } from '../../shared/plugins/plugin-marketplace' import { readPluginLockfile } from './plugin-install' import { PluginMarketplaceInstaller } from './plugin-marketplace-installer' import { PluginMarketplaceService } from './plugin-marketplace-service' -const git = vi.hoisted(() => ({ - checkout: vi.fn(), - version: '1.0.0', - publisher: 'community', - id: 'notes', - commit: 'a'.repeat(40), - payload: 'first' -})) +const git = vi.hoisted( + (): { + checkout: ReturnType + version: string + publisher: string + id: string + commit: string + payload: string + v2: boolean + release: PluginCatalogRelease | undefined + } => ({ + checkout: vi.fn(), + version: '1.0.0', + publisher: 'community', + id: 'notes', + commit: 'a'.repeat(40), + payload: 'first', + v2: false, + release: undefined + }) +) vi.mock('./plugin-git-repository', () => ({ checkoutPluginGitSource: git.checkout @@ -40,7 +54,8 @@ function marketplace(): PluginMarketplace { url: 'https://github.com/community/notes.git', ref: 'stable' }, - categories: ['productivity'] + categories: ['productivity'], + ...(git.release ? { release: git.release } : {}) } ] } @@ -51,7 +66,13 @@ async function writeCurrentPlugin(destination: string): Promise { await writeFile( join(destination, 'orca-plugin.json'), JSON.stringify({ - manifestVersion: 1, + ...(git.v2 + ? { + manifestVersion: 2, + execution: 'instructional', + contributes: { commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }] } + } + : { manifestVersion: 1 }), id: git.id, publisher: git.publisher, name: 'Notes', @@ -98,6 +119,8 @@ beforeEach(() => { git.id = 'notes' git.commit = 'a'.repeat(40) git.payload = 'first' + git.v2 = false + git.release = undefined git.checkout.mockReset() git.checkout.mockImplementation(async ({ destination }: { destination: string }) => { await writeCurrentPlugin(destination) @@ -156,6 +179,38 @@ describe('PluginMarketplaceInstaller', () => { }) }) + it('installs only the exact bytes a catalog release pins', async () => { + git.v2 = true + const unpinnedSetup = await setup() + const unpinned = await unpinnedSetup.installer.preview( + unpinnedSetup.sourceId, + 'community.notes' + ) + git.release = { + version: '1.0.0', + commit: git.commit, + contentHash: unpinned.contentHash, + execution: 'instructional', + minimumOrca: '1.0.0', + types: ['instructions'] + } + const { installer, sourceId } = await setup() + const preview = await installer.preview(sourceId, 'community.notes') + // The pinned commit is fetched directly, so the listed branch is display text only. + expect(git.checkout).toHaveBeenLastCalledWith(expect.objectContaining({ ref: git.commit })) + await expect(installer.install(preview)).resolves.toMatchObject({ ok: true }) + expect(git.checkout).toHaveBeenLastCalledWith(expect.objectContaining({ ref: git.commit })) + git.payload = 'tampered at the same commit' + + await expect(installer.install(preview)).resolves.toEqual({ + ok: false, + error: 'plugin bytes do not match the catalog release' + }) + await expect(installer.preview(sourceId, 'community.notes')).rejects.toThrow( + 'plugin bytes do not match the catalog release' + ) + }) + it('rejects a source whose manifest identity differs from its listing', async () => { const { installer, sourceId } = await setup() git.publisher = 'attacker' diff --git a/src/main/plugins/plugin-marketplace-installer.ts b/src/main/plugins/plugin-marketplace-installer.ts index 179e687682c..aac1875c91b 100644 --- a/src/main/plugins/plugin-marketplace-installer.ts +++ b/src/main/plugins/plugin-marketplace-installer.ts @@ -65,14 +65,16 @@ export class PluginMarketplaceInstaller { try { const resolvedCommit = await checkoutPluginGitSource({ url: listing.source.url, - ref: listing.source.ref, + // Why: a pinned release names its commit; the listed ref is only what the author calls it. + ref: listing.release?.commit ?? listing.source.ref, destination: stagingDirectory, workingDirectory: tmpdir() }) const inspection = await inspectPluginInstallTree({ rootDir: stagingDirectory, hostVersion: this.hostVersion, - expectedPluginKey: pluginKey + expectedPluginKey: pluginKey, + ...(listing.release ? { release: { record: listing.release, resolvedCommit } } : {}) }) if (!inspection.ok) { throw new Error(inspection.error) @@ -124,6 +126,7 @@ export class PluginMarketplaceInstaller { resolvedCommit: preview.marketplaceCommit }, plugin: { url: listing.source.url, ref: listing.source.ref }, + ...(listing.release ? { release: listing.release } : {}), blockedPluginReason: this.blockedPluginReason }) } diff --git a/src/main/plugins/plugin-marketplace-projection.ts b/src/main/plugins/plugin-marketplace-projection.ts index c411801d8d9..b5779a8314c 100644 --- a/src/main/plugins/plugin-marketplace-projection.ts +++ b/src/main/plugins/plugin-marketplace-projection.ts @@ -2,6 +2,7 @@ import type { PluginMarketplaceEntry, PluginMarketplaceGitSource } from '../../shared/plugins/plugin-marketplace' +import type { PluginCatalogRelease } from '../../shared/plugins/plugin-catalog-release' export type PluginMarketplaceSourceState = { id: string @@ -30,4 +31,6 @@ export type PluginMarketplaceListing = { official: boolean bundled: boolean blockedByKillList?: { reason: string; advisoryUrl?: string } + /** Exact release the catalog vouches for; absent for v1 catalogs. */ + release?: PluginCatalogRelease } diff --git a/src/main/plugins/plugin-marketplace-service.ts b/src/main/plugins/plugin-marketplace-service.ts index a7f8b63dfc1..a638e512090 100644 --- a/src/main/plugins/plugin-marketplace-service.ts +++ b/src/main/plugins/plugin-marketplace-service.ts @@ -277,6 +277,7 @@ export class PluginMarketplaceService { categories: entry.categories, official, bundled: false, + ...(entry.release ? { release: entry.release } : {}), ...(blocked ? { blockedByKillList: { diff --git a/src/main/plugins/plugin-marketplace-store.test.ts b/src/main/plugins/plugin-marketplace-store.test.ts index 4607037334b..a52a6b0d84e 100644 --- a/src/main/plugins/plugin-marketplace-store.test.ts +++ b/src/main/plugins/plugin-marketplace-store.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -82,4 +82,56 @@ describe('PluginMarketplaceStore', () => { await expect(store.readSnapshot('../outside')).rejects.toThrow() await expect(store.removeSource('../outside')).rejects.toThrow() }) + + it('keeps release-bearing snapshots out of the key older builds read', async () => { + const root = await tempRoot() + const store = new PluginMarketplaceStore(root) + const registered = await store.addSource(source(), 123) + const snapshots = join(root, 'marketplaces', 'snapshots') + const legacy = { + schemaVersion: 1, + sourceId: registered.id, + source: source(), + marketplaceCommit: 'a'.repeat(40), + fetchedAt: 1, + marketplace: { name: 'Old', owner: 'community', plugins: [] } + } + await mkdir(snapshots, { recursive: true }) + await writeFile(join(snapshots, `${registered.id}.json`), JSON.stringify(legacy)) + await expect(store.readSnapshot(registered.id)).resolves.toEqual(legacy) + + await store.writeSnapshot({ + source: registered, + marketplaceCommit: 'b'.repeat(40), + marketplace: { + name: 'New', + owner: 'community', + plugins: [ + { + id: 'community.notes', + source: { kind: 'git', url: 'https://github.com/community/notes.git', ref: 'v1' }, + categories: [], + release: { + version: '1.0.0', + commit: 'c'.repeat(40), + contentHash: 'f'.repeat(64), + execution: 'data', + minimumOrca: '1.4.0', + types: ['interface-language'] + } + } + ] + } + }) + + await expect(store.readSnapshot(registered.id)).resolves.toMatchObject({ + marketplace: { name: 'New' } + }) + // An older build reads only `.json`, which still holds v1-shaped data it can parse. + expect(JSON.parse(await readFile(join(snapshots, `${registered.id}.json`), 'utf8'))).toEqual( + legacy + ) + await store.removeSource(registered.id) + expect(await readdir(snapshots)).toEqual([]) + }) }) diff --git a/src/main/plugins/plugin-marketplace-store.ts b/src/main/plugins/plugin-marketplace-store.ts index 437f6148f46..aabf13c8fe0 100644 --- a/src/main/plugins/plugin-marketplace-store.ts +++ b/src/main/plugins/plugin-marketplace-store.ts @@ -6,6 +6,7 @@ import { z } from 'zod' import { PLUGIN_COMMIT_PATTERN } from '../../shared/plugins/plugin-install-lockfile' import { pluginMarketplaceGitSourceSchema, + pluginMarketplaceListingSchema, pluginMarketplaceSchema, type PluginMarketplace, type PluginMarketplaceGitSource @@ -25,17 +26,29 @@ const sourceFileSchema = z.strictObject({ schemaVersion: z.literal(1), sources: z.array(registeredSourceSchema).max(PLUGIN_MARKETPLACE_SOURCE_LIMIT) }) -const cachedSnapshotSchema = z.strictObject({ - schemaVersion: z.literal(1), +const snapshotFields = { sourceId: sourceIdSchema, source: pluginMarketplaceGitSourceSchema, marketplaceCommit: z.string().regex(PLUGIN_COMMIT_PATTERN), - fetchedAt: z.number().finite().nonnegative(), + fetchedAt: z.number().finite().nonnegative() +} +// Why two cache keys: builds that predate release records parse `.json` strictly, so a +// downgrade must find only v1-shaped data there and refetch rather than fail. +const legacySnapshotSchema = z.strictObject({ + schemaVersion: z.literal(1), + ...snapshotFields, marketplace: pluginMarketplaceSchema }) +const cachedSnapshotSchema = z.strictObject({ + schemaVersion: z.literal(2), + ...snapshotFields, + marketplace: pluginMarketplaceListingSchema +}) export type PluginMarketplaceRegisteredSource = z.infer -export type PluginMarketplaceCachedSnapshot = z.infer +export type PluginMarketplaceCachedSnapshot = + | z.infer + | z.infer const SOURCE_FILE_MAX_BYTES = 2 * 1024 * 1024 const SNAPSHOT_FILE_MAX_BYTES = 16 * 1024 * 1024 @@ -97,6 +110,7 @@ export class PluginMarketplaceStore { }) if (removed) { await rm(this.snapshotPath(parsedId), { force: true }) + await rm(this.legacySnapshotPath(parsedId), { force: true }) } return removed } @@ -108,7 +122,7 @@ export class PluginMarketplaceStore { marketplace: PluginMarketplace }): Promise { const snapshot = cachedSnapshotSchema.parse({ - schemaVersion: 1, + schemaVersion: 2, sourceId: input.source.id, source: input.source.source, marketplaceCommit: input.marketplaceCommit, @@ -121,17 +135,29 @@ export class PluginMarketplaceStore { async readSnapshot(sourceId: string): Promise { const parsedId = sourceIdSchema.parse(sourceId) + return ( + (await this.readSnapshotFile(this.snapshotPath(parsedId), parsedId, cachedSnapshotSchema)) ?? + (await this.readSnapshotFile( + this.legacySnapshotPath(parsedId), + parsedId, + legacySnapshotSchema + )) + ) + } + + private async readSnapshotFile( + path: string, + sourceId: string, + schema: z.ZodType + ): Promise { try { - const raw = JSON.parse( - await readBoundedText(this.snapshotPath(parsedId), SNAPSHOT_FILE_MAX_BYTES) - ) - const parsed = cachedSnapshotSchema.parse(raw) - if (parsed.sourceId !== parsedId) { + const parsed = schema.parse(JSON.parse(await readBoundedText(path, SNAPSHOT_FILE_MAX_BYTES))) + if (parsed.sourceId !== sourceId) { throw new Error('marketplace snapshot source identity does not match its cache path') } return parsed } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { return null } throw new Error( @@ -188,6 +214,10 @@ export class PluginMarketplaceStore { } private snapshotPath(sourceId: string): string { + return join(this.snapshotDirectory, `${sourceId}.v2.json`) + } + + private legacySnapshotPath(sourceId: string): string { return join(this.snapshotDirectory, `${sourceId}.json`) } } diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index 373dd40a67e..87788208150 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -45,6 +45,7 @@ import { registerGitRepositoryRegistrationBinaryCompatibilityCase } from './git- import { registerGitLocalBaseBranchBinaryCompatibilityCase } from './git-local-base-branch-binary-compatibility.test-cases' import { registerGitResolutionBinaryCompatibilityCases } from './git-resolution-binary-compatibility.test-cases' import { registerGitWorktreeBinaryCompatibilityCases } from './git-worktree-binary-compatibility.test-cases' +import { registerPluginGitDownloadBinaryCompatibilityCase } from './plugin-git-download-binary-compatibility.test-cases' const image = process.env.ORCA_GIT_COMPAT_IMAGE const binary = process.env.ORCA_GIT_COMPAT_BINARY @@ -816,6 +817,7 @@ describeBinaryCompatibility('real Git binary compatibility', () => { }) registerGitLocalBaseBranchBinaryCompatibilityCase(runGit, () => repoPath, Boolean(image)) + registerPluginGitDownloadBinaryCompatibilityCase(runGit, () => repoPath) registerGitResolutionBinaryCompatibilityCases(runGit, (name) => image ? `/repo/${name}.git` : join(repoPath, `${name}.git`) ) diff --git a/src/shared/plugin-git-download-binary-compatibility.test-cases.ts b/src/shared/plugin-git-download-binary-compatibility.test-cases.ts new file mode 100644 index 00000000000..cdab48b97e8 --- /dev/null +++ b/src/shared/plugin-git-download-binary-compatibility.test-cases.ts @@ -0,0 +1,66 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { + pluginGitCloneArgs, + pluginGitConfigArgs, + pluginGitFetchCommitArgs, + pluginGitTreeArgs +} from './plugins/plugin-git-arguments' + +export function registerPluginGitDownloadBinaryCompatibilityCase( + runGit: ( + args: string[], + env?: NodeJS.ProcessEnv, + stdin?: string + ) => Promise<{ stdout: string; stderr: string }>, + getRepoPath: () => string +): void { + // Why pin this: plugin downloads pass every `-c` key and flag below to the user's Git, and a + // local path counts as the file transport, so the protocol policy is observable offline. + it('downloads a plugin commit with the exact no-checkout arguments', async () => { + const source = 'compat-plugin-source' + await mkdir(join(getRepoPath(), source, 'bin'), { recursive: true }) + await writeFile(join(getRepoPath(), source, 'bin', 'run'), 'plugin\n', { mode: 0o755 }) + await runGit(['init', '-q', source]) + await runGit(['-C', source, 'add', '--all']) + // Why: Windows ignores the 0o755 file mode, so record the executable bit in the index directly. + await runGit(['-C', source, 'update-index', '--chmod=+x', 'bin/run']) + // Why inline identity: a fresh repository does not inherit the fixture repository's config. + const identity = [ + '-c', + 'user.name=Compatibility Test', + '-c', + 'user.email=compatibility@example.invalid' + ] + await runGit(['-C', source, ...identity, 'commit', '-qm', 'plugin']) + await runGit(['-C', source, 'tag', 'v1']) + const commit = (await runGit(['-C', source, 'rev-parse', 'HEAD'])).stdout.trim() + const config = pluginGitConfigArgs('compat-no-hooks') + const allowLocal = ['-c', 'protocol.file.allow=always'] + + await expect( + runGit([...config, ...pluginGitCloneArgs(source, 'v1', 'refused')]) + ).rejects.toThrow("transport 'file' not allowed") + + await runGit([...config, ...allowLocal, ...pluginGitCloneArgs(source, 'v1', 'compat-clone')]) + await runGit([...config, 'init', '--quiet', '--template=', 'compat-fetch']) + await runGit(['-C', 'compat-fetch', ...config, 'remote', 'add', 'origin', `../${source}`]) + await runGit([ + '-C', + 'compat-fetch', + ...config, + ...allowLocal, + ...pluginGitFetchCommitArgs(commit) + ]) + for (const repository of ['compat-clone', 'compat-fetch']) { + const tree = await runGit(['-C', repository, ...pluginGitTreeArgs(commit)]) + const [directory, file] = tree.stdout.split('\0') + expect(directory).toMatch(/^040000 tree [0-9a-f]{40} +-\tbin$/) + expect(file).toMatch(/^100755 blob [0-9a-f]{40} +7\tbin\/run$/) + const object = file?.slice(12, 52) ?? '' + const blob = await runGit(['-C', repository, 'cat-file', '--batch'], {}, `${object}\n`) + expect(blob.stdout).toBe(`${object} blob 7\nplugin\n\n`) + } + }) +} diff --git a/src/shared/plugins/plugin-catalog-release.test.ts b/src/shared/plugins/plugin-catalog-release.test.ts new file mode 100644 index 00000000000..3c5244a61e7 --- /dev/null +++ b/src/shared/plugins/plugin-catalog-release.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { + pluginCatalogReleaseError, + pluginCatalogReleaseSchema, + type PluginCatalogRelease +} from './plugin-catalog-release' +import { parsePluginManifest, type PluginManifest } from './plugin-manifest' + +const COMMIT = 'c'.repeat(40) +const HASH = 'f'.repeat(64) + +function manifest(overrides: Record = {}): PluginManifest { + const parsed = parsePluginManifest({ + manifestVersion: 2, + execution: 'data', + id: 'demo', + publisher: 'samples', + name: 'Demo', + version: '1.2.0', + engines: { orca: '>=1.4.0' }, + pluginApi: 1, + contributes: { languagePacks: [{ locale: 'pt-BR', path: 'pt.json' }] }, + ...overrides + }) + if (!parsed.ok) { + throw new Error(parsed.error) + } + return parsed.manifest +} + +function release(overrides: Partial = {}): PluginCatalogRelease { + return pluginCatalogReleaseSchema.parse({ + version: '1.2.0', + commit: COMMIT, + contentHash: HASH, + execution: 'data', + minimumOrca: '1.4.0', + types: ['interface-language'], + ...overrides + }) +} + +function check(record: PluginCatalogRelease, subject = manifest()): string | null { + return pluginCatalogReleaseError({ + release: record, + manifest: subject, + resolvedCommit: COMMIT, + contentHash: HASH + }) +} + +function manifestInput(): Record { + return { + manifestVersion: 2, + execution: 'data', + id: 'demo', + publisher: 'samples', + name: 'Demo', + version: '1.2.0', + engines: { orca: '>=1.4.0' }, + pluginApi: 1 + } +} + +describe('catalog releases', () => { + it('accepts a release that matches the exact bytes and manifest', () => { + expect(check(release())).toBeNull() + }) + + it('strips unknown optional fields instead of rejecting the release', () => { + expect(pluginCatalogReleaseSchema.parse({ ...release(), futureField: 1 })).toEqual(release()) + }) + + it('rejects a release type its profile cannot run or this client cannot install', () => { + expect(() => release({ types: ['worker'] })).toThrow('data cannot declare worker') + expect( + pluginCatalogReleaseSchema.safeParse({ ...release(), types: ['app-theme'] }).success + ).toBe(false) + }) + + it.each([ + ['commit', { resolvedCommit: 'd'.repeat(40) }, 'commit'], + ['bytes', { contentHash: 'e'.repeat(64) }, 'bytes'] + ])('rejects a changed %s', (_label, override, message) => { + expect( + pluginCatalogReleaseError({ + release: release(), + manifest: manifest(), + resolvedCommit: COMMIT, + contentHash: HASH, + ...override + }) + ).toContain(message) + }) + + it.each([ + ['version', release({ version: '1.3.0' }), 'version'], + ['profile', release({ execution: 'instructional' }), 'execution profile'], + ['compatibility', release({ minimumOrca: '1.5.0' }), 'compatibility'] + ])('rejects a manifest whose %s differs from the release', (_label, record, message) => { + expect(check(record)).toContain(message) + }) + + it('rejects declared contributions the manifest does not carry', () => { + const record = release({ + execution: 'instructional', + types: ['interface-language', 'instructions'] + }) + expect(check(record, manifest({ execution: 'instructional' }))).toContain('contributions') + }) + + it('requires a v2 manifest', () => { + const v1 = parsePluginManifest({ ...manifestInput(), manifestVersion: 1, execution: undefined }) + if (!v1.ok) { + throw new Error(v1.error) + } + expect(check(release(), v1.manifest)).toContain('version 2 manifest') + }) +}) diff --git a/src/shared/plugins/plugin-catalog-release.ts b/src/shared/plugins/plugin-catalog-release.ts new file mode 100644 index 00000000000..23a474f8b8a --- /dev/null +++ b/src/shared/plugins/plugin-catalog-release.ts @@ -0,0 +1,106 @@ +import { z } from 'zod' +import { + pluginExecutionAllows, + pluginExecutionProfileSchema, + type PluginExecutionProfile +} from './plugin-execution-profile' +import type { PluginManifest } from './plugin-manifest' + +// Why only installable types: a catalog entry naming any other type fails to parse, so the +// v2 catalog drops it instead of listing a plugin this client cannot install. +export const PLUGIN_CATALOG_TYPES = [ + 'interface-language', + 'instructions', + 'panel', + 'worker' +] as const + +export type PluginCatalogType = (typeof PLUGIN_CATALOG_TYPES)[number] + +const CATALOG_TYPE_PROFILE: Partial> = { + instructions: 'instructional', + panel: 'sandboxed-ui', + worker: 'trusted-worker' +} + +/** The exact release a catalog vouches for. Unknown keys are stripped so a newer catalog + * can add optional fields without breaking clients that predate them. */ +export const pluginCatalogReleaseSchema = z + .object({ + version: z.string().min(1).max(128), + // Why SHA-1 only: plugin materialization rejects SHA-256 repositories. + commit: z.string().regex(/^[0-9a-f]{40}$/), + contentHash: z.string().regex(/^[0-9a-f]{64}$/), + execution: pluginExecutionProfileSchema, + minimumOrca: z.string().regex(/^\d+\.\d+\.\d+$/), + types: z + .array(z.enum(PLUGIN_CATALOG_TYPES)) + .min(1) + .max(PLUGIN_CATALOG_TYPES.length) + .refine((types) => new Set(types).size === types.length, 'duplicate contribution type') + }) + .superRefine((release, ctx) => { + for (const [index, type] of release.types.entries()) { + const required = CATALOG_TYPE_PROFILE[type] + if (required && !pluginExecutionAllows(release.execution, required)) { + ctx.addIssue({ + code: 'custom', + path: ['types', index], + message: `${release.execution} cannot declare ${type}` + }) + } + } + }) + +export type PluginCatalogRelease = z.infer + +export function pluginCatalogTypes(manifest: PluginManifest): PluginCatalogType[] { + const c = manifest.contributes + const types: PluginCatalogType[] = [] + if (c.languagePacks.length > 0) { + types.push('interface-language') + } + if (c.commands.length || c.keybindings.length || c.vmRecipes.length || c.agents.length) { + types.push('instructions') + } + if (c.panels.length > 0) { + types.push('panel') + } + if (manifest.main !== undefined) { + types.push('worker') + } + return types +} + +/** Why null-or-message: install and preview surface the first mismatch verbatim. */ +export function pluginCatalogReleaseError(input: { + release: PluginCatalogRelease + manifest: PluginManifest + resolvedCommit: string | null + contentHash: string +}): string | null { + const { release, manifest } = input + if (input.resolvedCommit !== release.commit) { + return 'plugin source no longer matches the catalog release commit' + } + if (input.contentHash !== release.contentHash) { + return 'plugin bytes do not match the catalog release' + } + if (manifest.manifestVersion !== 2) { + return 'catalog releases require a version 2 manifest' + } + if (manifest.version !== release.version) { + return 'manifest version does not match the catalog release' + } + if (manifest.execution !== release.execution) { + return 'execution profile does not match the catalog release' + } + if (manifest.engines.orca !== `>=${release.minimumOrca}`) { + return 'Orca compatibility does not match the catalog release' + } + const declared = [...release.types].sort().join() + if (pluginCatalogTypes(manifest).sort().join() !== declared) { + return 'contributions do not match the catalog release' + } + return null +} diff --git a/src/shared/plugins/plugin-execution-profile.test.ts b/src/shared/plugins/plugin-execution-profile.test.ts new file mode 100644 index 00000000000..23dd9bde892 --- /dev/null +++ b/src/shared/plugins/plugin-execution-profile.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { derivePluginExecutionProfile } from './plugin-execution-profile' +import { parsePluginManifest } from './plugin-manifest' + +function manifestInput(): Record { + return { + manifestVersion: 2, + execution: 'data', + id: 'demo', + publisher: 'samples', + name: 'Demo', + version: '1.2.0', + engines: { orca: '>=1.4.0' }, + pluginApi: 1 + } +} + +describe('execution profiles', () => { + const empty = { panels: [], commands: [], events: [], keybindings: [], vmRecipes: [], agents: [] } + it.each([ + [{ main: 'main.mjs' }, {}, 'trusted-worker'], + [{}, { panels: [{}] }, 'sandboxed-ui'], + [{}, { commands: [{}] }, 'instructional'], + [{}, { vmRecipes: [{}] }, 'instructional'], + [{}, {}, 'data'] + ])('infers the least profile for a v1 manifest', (top, contributes, expected) => { + expect( + derivePluginExecutionProfile({ + capabilities: [], + ...top, + contributes: { ...empty, ...contributes } + }) + ).toBe(expected) + }) + + it('rejects v2 contributions beyond the declared profile', () => { + const parsed = parsePluginManifest({ + ...manifestInput(), + execution: 'data', + contributes: { commands: [{ id: 'open', title: 'Open', action: 'view.tasks' }] } + }) + expect(parsed).toMatchObject({ ok: false, error: expect.stringContaining('execution') }) + }) + + it('requires a Node entry for trusted-worker and rejects unknown v2 keys', () => { + expect(parsePluginManifest({ ...manifestInput(), execution: 'trusted-worker' })).toMatchObject({ + ok: false, + error: expect.stringContaining('main') + }) + expect(parsePluginManifest({ ...manifestInput(), surprise: true })).toMatchObject({ ok: false }) + }) + + it('keeps v1 manifests valid without a declared profile', () => { + expect(parsePluginManifest({ ...manifestInput(), manifestVersion: 1 })).toMatchObject({ + ok: true + }) + }) +}) diff --git a/src/shared/plugins/plugin-execution-profile.ts b/src/shared/plugins/plugin-execution-profile.ts new file mode 100644 index 00000000000..c17f4348c02 --- /dev/null +++ b/src/shared/plugins/plugin-execution-profile.ts @@ -0,0 +1,86 @@ +import { z } from 'zod' +import type { RefinementCtx } from 'zod' + +/** Ordered from least to most authority; each profile may carry everything below it. */ +export const PLUGIN_EXECUTION_PROFILES = [ + 'data', + 'instructional', + 'sandboxed-ui', + 'trusted-worker' +] as const + +export const pluginExecutionProfileSchema = z.enum(PLUGIN_EXECUTION_PROFILES) +export type PluginExecutionProfile = z.infer + +type ExecutionSubject = { + main?: string + capabilities: readonly unknown[] + contributes: { + panels: readonly unknown[] + commands: readonly unknown[] + events: readonly unknown[] + keybindings: readonly unknown[] + vmRecipes: readonly unknown[] + agents: readonly unknown[] + } +} + +function rank(profile: PluginExecutionProfile): number { + return PLUGIN_EXECUTION_PROFILES.indexOf(profile) +} + +export function pluginExecutionAllows( + profile: PluginExecutionProfile, + required: PluginExecutionProfile +): boolean { + return rank(profile) >= rank(required) +} + +/** Least profile that can run what a manifest declares; v1 manifests use this as their profile. */ +export function derivePluginExecutionProfile(manifest: ExecutionSubject): PluginExecutionProfile { + const c = manifest.contributes + if (manifest.main !== undefined || c.events.length > 0) { + return 'trusted-worker' + } + if (c.panels.length > 0) { + return 'sandboxed-ui' + } + if ( + c.commands.length > 0 || + c.keybindings.length > 0 || + c.vmRecipes.length > 0 || + c.agents.length > 0 || + manifest.capabilities.length > 0 + ) { + return 'instructional' + } + return 'data' +} + +export function pluginExecutionProfile( + manifest: ExecutionSubject & { execution?: PluginExecutionProfile } +): PluginExecutionProfile { + return manifest.execution ?? derivePluginExecutionProfile(manifest) +} + +/** A v2 manifest declares its profile, so contributions beyond it fail validation. */ +export function validatePluginExecutionProfile( + manifest: ExecutionSubject & { execution: PluginExecutionProfile }, + ctx: RefinementCtx +): void { + const required = derivePluginExecutionProfile(manifest) + if (!pluginExecutionAllows(manifest.execution, required)) { + ctx.addIssue({ + code: 'custom', + path: ['execution'], + message: `${manifest.execution} cannot carry contributions that need ${required}` + }) + } + if (manifest.execution === 'trusted-worker' && manifest.main === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['main'], + message: 'trusted-worker requires a Node entry' + }) + } +} diff --git a/src/shared/plugins/plugin-git-arguments.ts b/src/shared/plugins/plugin-git-arguments.ts new file mode 100644 index 00000000000..331ff01cc49 --- /dev/null +++ b/src/shared/plugins/plugin-git-arguments.ts @@ -0,0 +1,50 @@ +/** Exact Git argv for plugin downloads, shared with the real-binary compatibility matrix. */ + +const PLUGIN_GIT_SETTINGS = [ + // Why per-protocol keys too: a user's `protocol..allow` outranks `protocol.allow`, and + // an insteadOf rewrite or a redirect must not reach file, ext or plain-http transports. + 'protocol.allow=never', + 'protocol.https.allow=always', + 'protocol.ssh.allow=always', + ...['file', 'ext', 'git', 'http', 'ftp', 'ftps'].map((name) => `protocol.${name}.allow=never`), + 'submodule.recurse=false', + 'fetch.recurseSubmodules=false', + 'gc.auto=0', + 'maintenance.auto=false', + 'init.defaultObjectFormat=sha1', + // Why: keeps every fetched object in one pack so the size watcher sees it grow. + 'fetch.unpackLimit=0' +] + +/** An empty hooksPath and template stop user or global hooks (e.g. reference-transaction); + * unknown keys are ignored by older Git, so these stay 2.25-safe. */ +export function pluginGitConfigArgs(emptyHooksPath: string): string[] { + return [`core.hooksPath=${emptyHooksPath}`, ...PLUGIN_GIT_SETTINGS].flatMap((setting) => [ + '-c', + setting + ]) +} + +export function pluginGitCloneArgs(url: string, ref: string, repository: string): string[] { + return [ + 'clone', + '--quiet', + '--no-checkout', + '--no-local', + '--template=', + '--depth', + '1', + ...(ref.length > 0 ? ['--branch', ref] : []), + '--', + url, + repository + ] +} + +export function pluginGitFetchCommitArgs(commit: string): string[] { + return ['fetch', '--quiet', '--no-tags', '--depth', '1', 'origin', commit] +} + +export function pluginGitTreeArgs(commit: string): string[] { + return ['ls-tree', '-r', '-t', '-l', '-z', '--full-tree', commit] +} diff --git a/src/shared/plugins/plugin-manifest.ts b/src/shared/plugins/plugin-manifest.ts index 36ad34275ab..c8139ad85f0 100644 --- a/src/shared/plugins/plugin-manifest.ts +++ b/src/shared/plugins/plugin-manifest.ts @@ -16,9 +16,22 @@ import { pluginRelativePathSchema } from './plugin-manifest-fields' import { validatePluginManifestContributions } from './plugin-manifest-contribution-validation' +import { + pluginExecutionProfileSchema, + validatePluginExecutionProfile +} from './plugin-execution-profile' -/** Shared orca-plugin.json validation keeps desktop, serve, relay and CLI behavior aligned. Contribution names mirror - * Electron conventions for future adapters. Experimental until pluginApi v1 freezes. */ +/** + * Plugin manifest v1 and v2 (`orca-plugin.json` at the plugin root). The + * `contributes` key names deliberately mirror common Electron-ecosystem + * manifest conventions so future adapters stay cheap. + * + * Lives in `shared` so the desktop app, the headless `orca serve` runtime, + * the relay, and the CLI validate manifests identically (SSH/remote parity). + * + * Everything here is EXPERIMENTAL: no compatibility promises until pluginApi + * v1 freezes (see the plugin roadmap). + */ const SEMVER_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ @@ -106,9 +119,18 @@ const pluginManifestV1Object = z.object({ capabilities: z.array(pluginCapabilitySchema).max(32).default([]) }) -export const pluginManifestSchema = pluginManifestV1Object.superRefine( - validatePluginManifestContributions -) +// Why strict: v2 declares its execution profile, so unknown keys must not smuggle in behavior. +const pluginManifestV2Object = pluginManifestV1Object + .extend({ manifestVersion: z.literal(2), execution: pluginExecutionProfileSchema }) + .strict() + +export const pluginManifestSchema = z.discriminatedUnion('manifestVersion', [ + pluginManifestV1Object.superRefine(validatePluginManifestContributions), + pluginManifestV2Object.superRefine((manifest, ctx) => { + validatePluginManifestContributions(manifest, ctx) + validatePluginExecutionProfile(manifest, ctx) + }) +]) export type PluginManifest = z.infer export type PluginPanelContribution = z.infer diff --git a/src/shared/plugins/plugin-marketplace.ts b/src/shared/plugins/plugin-marketplace.ts index 2f241e3b193..602770c8767 100644 --- a/src/shared/plugins/plugin-marketplace.ts +++ b/src/shared/plugins/plugin-marketplace.ts @@ -1,8 +1,12 @@ import { z } from 'zod' import { isAllowedPluginGitUrl } from './plugin-install-lockfile' import { isQualifiedPluginKey } from './plugin-manifest' +import { pluginCatalogReleaseSchema } from './plugin-catalog-release' export const PLUGIN_MARKETPLACE_FILENAME = 'orca-marketplace.json' +// Why a second file: v1 entries are strict, so shipped clients reject any new field. +// Release-bearing entries live here and the v1 file stays frozen for those clients. +export const PLUGIN_MARKETPLACE_V2_FILENAME = 'orca-marketplace.v2.json' export const PLUGIN_MARKETPLACE_ENTRY_LIMIT = 2_048 export const PLUGIN_MARKETPLACE_CATEGORY_LIMIT = 16 @@ -95,6 +99,16 @@ export const pluginMarketplaceEntrySchema = z .strictObject(marketplaceEntryFields) .superRefine(rejectDuplicateCategories) +/** v2 entries strip unknown keys so later optional fields reach older clients safely. */ +const pluginMarketplaceV2EntrySchema = z + .object({ ...marketplaceEntryFields, release: pluginCatalogReleaseSchema }) + .superRefine(rejectDuplicateCategories) + +/** Normalized entry held in memory and in the snapshot cache, from either catalog file. */ +const pluginMarketplaceListingEntrySchema = z + .strictObject({ ...marketplaceEntryFields, release: pluginCatalogReleaseSchema.optional() }) + .superRefine(rejectDuplicateCategories) + const marketplaceHeaderFields = { name: z.string().min(1).max(256), owner: marketplaceOwnerSchema @@ -107,6 +121,38 @@ export const pluginMarketplaceSchema = z }) .superRefine(rejectDuplicatePluginIds) +export const pluginMarketplaceListingSchema = z + .strictObject({ + ...marketplaceHeaderFields, + plugins: z.array(pluginMarketplaceListingEntrySchema).max(PLUGIN_MARKETPLACE_ENTRY_LIMIT) + }) + .superRefine(rejectDuplicatePluginIds) + +/** Why entries parse one by one: an entry this client cannot install (a newer contribution + * type) is skipped instead of hiding every other plugin in the catalog. Duplicate ids are + * still checked across every raw entry, matching the v1 rule. */ +export const pluginMarketplaceV2Schema = z + .object({ + ...marketplaceHeaderFields, + plugins: z.array(z.unknown()).max(PLUGIN_MARKETPLACE_ENTRY_LIMIT) + }) + .superRefine((catalog, context) => + rejectDuplicatePluginIds({ plugins: catalog.plugins.map(rawPluginIdentity) }, context) + ) + .transform((catalog) => ({ + ...catalog, + plugins: catalog.plugins.flatMap((entry) => { + const parsed = pluginMarketplaceV2EntrySchema.safeParse(entry) + return parsed.success ? [parsed.data] : [] + }) + })) + +function rawPluginIdentity(entry: unknown, index: number): { id: string } { + const id = typeof entry === 'object' && entry !== null && 'id' in entry ? entry.id : undefined + // Why the NUL placeholder: entries without a string id can never collide with each other. + return { id: typeof id === 'string' ? id : `\0${index}` } +} + /** Host-derived trust metadata. Marketplace JSON cannot self-award either bit. */ export const pluginMarketplaceTrustMetadataSchema = z .strictObject({ @@ -118,8 +164,8 @@ export const pluginMarketplaceTrustMetadataSchema = z path: ['bundled'] }) -export type PluginMarketplace = z.infer -export type PluginMarketplaceEntry = z.infer +export type PluginMarketplace = z.infer +export type PluginMarketplaceEntry = z.infer export type PluginMarketplaceGitSource = z.infer export type PluginMarketplaceTrustMetadata = z.infer diff --git a/tests/e2e/plugin-marketplace-content.spec.ts b/tests/e2e/plugin-marketplace-content.spec.ts index 279f1d1209f..0d6fa770107 100644 --- a/tests/e2e/plugin-marketplace-content.spec.ts +++ b/tests/e2e/plugin-marketplace-content.spec.ts @@ -3,14 +3,26 @@ import { execFile } from 'node:child_process' import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join, sep } from 'node:path' -import { pathToFileURL } from 'node:url' +import { join } from 'node:path' import { promisify } from 'node:util' import type { Page, TestInfo } from '@stablyai/playwright-test' import { expect, test } from '@stablyai/playwright-test' import { createRestartSession } from './helpers/orca-restart' const execFileAsync = promisify(execFile) +const SSH_SHIM = join( + process.cwd(), + 'src', + 'main', + 'plugins', + 'plugin-private-marketplace-ssh-shim.cjs' +) +const FIXTURE_REPOSITORIES = [ + 'orca-plugins', + 'orca-portuguese', + 'orca-multipass-recipes', + 'orca-navigation-shortcuts' +] type MarketplaceFixture = { root: string @@ -82,10 +94,17 @@ async function configureFixtureGit( GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' } - const repositoryBaseUrl = pathToFileURL(`${repositories}${sep}`).href + // Why SSH: plugin downloads refuse the file transport even behind an insteadOf rewrite, so the + // fixture repositories are served through the same GIT_SSH_COMMAND shim the unit tests use. + gitEnvironment.GIT_SSH_COMMAND = `"${process.execPath.replaceAll('\\', '/')}" "${SSH_SHIM.replaceAll('\\', '/')}"` + gitEnvironment.GIT_SSH_VARIANT = 'ssh' + gitEnvironment.ORCA_TEST_SSH_REPOSITORIES = JSON.stringify( + Object.fromEntries( + FIXTURE_REPOSITORIES.map((name) => [`/${name}.git`, join(repositories, `${name}.git`)]) + ) + ) const entries = [ - [`url.${repositoryBaseUrl}.insteadOf`, 'https://github.com/stablyai/'], - ['protocol.file.allow', 'always'], + ['url.ssh://git@fixtures.invalid/.insteadOf', 'https://github.com/stablyai/'], ['commit.gpgSign', 'false'], ['tag.gpgSign', 'false'], ['core.hooksPath', hooksDirectory]