diff --git a/config/scripts/generate-plugin-authoring-skill-references.mjs b/config/scripts/generate-plugin-authoring-skill-references.mjs index 1b60a2ef02b..73a31bfd89d 100644 --- a/config/scripts/generate-plugin-authoring-skill-references.mjs +++ b/config/scripts/generate-plugin-authoring-skill-references.mjs @@ -164,12 +164,17 @@ function renderTesting({ testing }) { ['## `TestPanel` (from `mount`)', '', ...members('TestPanel'), ''], ['## `TestWorker` (from `mountWorker`)', '', ...members('TestWorker'), ''], ['## `MockHost` (from `mockHost`)', '', ...members('MockHost'), ''], + ['## `MockEditorHost` (from `mockEditorHost`)', '', ...members('MockEditorHost'), ''], [ '## Options', '', - ...['MountOptions', 'MountWorkerOptions', 'MockHostOptions', 'CheckScenario'].map( - (name) => `- ${code(testing.typeDeclaration(name))}` - ) + ...[ + 'MountOptions', + 'MountWorkerOptions', + 'MockHostOptions', + 'MockEditorHostOptions', + 'CheckScenario' + ].map((name) => `- ${code(testing.typeDeclaration(name))}`) ] ] ) diff --git a/docs/reference/plugin-authoring.md b/docs/reference/plugin-authoring.md index 2df2ea98ba9..6d0bb4a1072 100644 --- a/docs/reference/plugin-authoring.md +++ b/docs/reference/plugin-authoring.md @@ -373,6 +373,31 @@ Theme changes do not reload the frame: the shell updates the `--orca-*` variable `light`/`dark` class on `` in place and emits `theme`. Restyle from the variables, or listen with `orca.on('theme', …)`, to follow it. +`orca plugin check` (and `dev watch --check`) checks each custom editor without running it or +Orca: from the `entry` it follows local ` + +${e}`}var wI=new TextEncoder;function Np(e,n){return e.length>n?n+1:wI.encode(e).byteLength}function Cy(e,n=Iy,i={}){let o=i.maxObjects??1e4,t=i.maxDepth??100,r=new WeakSet,a=0,s=0,u=l=>{a=Math.min(n+1,a+l)},c=(l,p)=>{if(a>n)return;if(l===null){u(1);return}switch(typeof l){case"undefined":case"boolean":u(1);return;case"number":u(8);return;case"bigint":u(Np(l.toString(),n-a));return;case"string":u(Np(l,n-a));return;case"symbol":case"function":a=n+1;return;case"object":break}let m=l;if(r.has(m)){u(8);return}if(r.add(m),s+=1,s>o||p>t){a=n+1;return}if(m instanceof ArrayBuffer){u(m.byteLength);return}if(typeof SharedArrayBuffer<"u"&&m instanceof SharedArrayBuffer){u(m.byteLength);return}if(ArrayBuffer.isView(m)){u(16),c(m.buffer,p+1);return}if(typeof Blob<"u"&&m instanceof Blob){u(m.size);return}if(m instanceof Date){u(8);return}if(m instanceof RegExp){c(m.source,p+1),c(m.flags,p+1);return}if(m instanceof Map){u(8);for(let[f,g]of m)if(u(4),c(f,p+1),c(g,p+1),a>n)return;return}if(m instanceof Set){u(8);for(let f of m)if(u(4),c(f,p+1),a>n)return;return}if(Array.isArray(m)){u(8);for(let f of m)if(u(4),c(f,p+1),a>n)return;return}try{let f=Object.getPrototypeOf(m);if(f!==Object.prototype&&f!==null){a=n+1;return}for(let g of Object.keys(m))if(u(4),u(Np(g,n-a)),c(m[g],p+1),a>n)return}catch{a=n+1}};return c(e,0),a}var II=e=>typeof e=="object"&&e!==null,Uy=/') + }) + await writeFile(join(root, 'secret.js'), 'export {}') + await symlink(join(root, 'secret.js'), join(root, 'notes', 'editor', 'lib.js')) + const result = await check(root) + expect(result.issues).toEqual([ + expect.objectContaining({ + code: 'editor_import', + message: expect.stringContaining('outside') + }) + ]) + } + ) + + it('reads import maps and skips data blocks, and resolves bare native imports through the map', async () => { + const root = await editorWith({ + 'editor/index.html': page( + [ + '', + '', + '' + ].join('\n') + ), + 'editor/vendor/lib.js': 'export const a = (x) => x\n', + 'editor/vendor/kit/b.js': 'export const b = 1\n' + }) + const result = await check(root) + expect(result.issues).toEqual([]) + expect(result.files).toEqual([ + 'editor/index.html', + 'editor/vendor/kit/b.js', + 'editor/vendor/lib.js' + ]) + }) + + it('refuses a bare native import the import map does not name', async () => { + const root = await editorWith({ + 'editor/index.html': page('') + }) + expect((await check(root)).issues).toEqual([ + expect.objectContaining({ + code: 'editor_import', + message: expect.stringContaining('import map') + }) + ]) + }) + + it('checks JSON imports as JSON and an extensionless entry as a page', async () => { + const root = await editorWith( + { + 'editor/page': page(''), + 'editor/main.ts': "import data from './data.json' with { type: 'json' }\nvoid data\n", + 'editor/data.json': '{ "ok": true, }' + }, + 'editor/page' + ) + expect((await check(root)).issues).toEqual([ + expect.objectContaining({ code: 'editor_syntax', location: 'editor/data.json:1:1' }) + ]) + }) + + it('decodes an escaped script src before looking it up', async () => { + const root = await editorWith({ + 'editor/index.html': page(''), + 'editor/my script.js': 'void 0\n' + }) + const result = await check(root) + expect(result.issues).toEqual([]) + expect(result.files).toContain('editor/my script.js') + }) + + it('lets a dependency reach the 16 MiB the protocol serves, past the 10 MiB entry cap', async () => { + const root = await editorWith({ + 'editor/index.html': page(''), + 'editor/vendor.mjs': `export const x = 1\n//${'x'.repeat(11 * 1024 * 1024)}\n` + }) + expect((await check(root)).issues).toEqual([]) + }) +}) diff --git a/src/cli/plugin-authoring/plugin-editor-check.test.ts b/src/cli/plugin-authoring/plugin-editor-check.test.ts new file mode 100644 index 00000000000..d9bd329eba0 --- /dev/null +++ b/src/cli/plugin-authoring/plugin-editor-check.test.ts @@ -0,0 +1,128 @@ +import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { checkPluginEditors, formatPluginEditorCheck } from './plugin-editor-check' +import { createPluginFromTemplate } from './plugin-create' +import { validatePluginTarget } from './plugin-folder-validation' +import { checkPluginDevFolder } from '../plugin-dev-watch/plugin-dev-watch-check' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +/** The editor template with its script moved to `editor/main.ts`, which imports `./sync.ts`. */ +async function editorPlugin(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-editor-check-')) + roots.push(root) + const dir = join(root, 'notes') + await createPluginFromTemplate({ dir, template: 'editor', publisher: 'acme' }) + const html = join(dir, 'editor', 'index.html') + await writeFile( + html, + (await readFile(html, 'utf8')).replace( + /' + ) + ) + await writeFile( + join(dir, 'editor', 'main.ts'), + "import { start } from './sync'\nconst text: string = 'a'\nstart(text)\n" + ) + await writeFile(join(dir, 'editor', 'sync.ts'), 'export function start(text: string): void {}\n') + return dir +} + +describe('orca plugin check: custom editors', () => { + it('passes the editor template and follows its script and imports', async () => { + const dir = await editorPlugin() + const [check] = await checkPluginEditors(dir) + expect(check.issues).toEqual([]) + expect(check.files).toEqual(['editor/index.html', 'editor/main.ts', 'editor/sync.ts']) + expect(formatPluginEditorCheck(check)[0]).toMatch(/: ok, 3 file\(s\) load$/) + }) + + it('fails on a syntax error that validate lets through', async () => { + const dir = await editorPlugin() + await appendFile(join(dir, 'editor', 'main.ts'), 'this is not valid ts(\n') + expect((await validatePluginTarget(dir)).valid).toBe(true) + const [check] = await checkPluginEditors(dir) + expect(check.issues).toEqual([ + expect.objectContaining({ code: 'editor_syntax', location: 'editor/main.ts:4:6' }) + ]) + }) + + it('reports missing files and refused imports, and warns on string eval', async () => { + const dir = await editorPlugin() + await writeFile( + join(dir, 'editor', 'main.ts'), + [ + "import './gone'", + "import 'left-pad'", + '// eval("comments do not count")', + "const label = 'new Function in a string does not count'", + "eval('1')", + "const f = new Function('return 1')", + '' + ].join('\n') + ) + const [check] = await checkPluginEditors(dir) + expect(check.issues.map(({ code, location }) => [code, location])).toEqual([ + ['editor_import', 'editor/main.ts:1:9'], + ['editor_import', 'editor/main.ts:2:9'] + ]) + // A lexical scan can misread code, so eval is a warning that does not fail the check. + expect(check.warnings.map(({ code, location }) => [code, location])).toEqual([ + ['editor_eval', 'editor/main.ts:5:1'], + ['editor_eval', 'editor/main.ts:6:11'] + ]) + }) + + it('reports a missing script, a broken inline script, and a script over 16 MiB', async () => { + const dir = await editorPlugin() + const html = join(dir, 'editor', 'index.html') + await writeFile( + html, + [ + '', + '', + '', + '', + '' + ].join('\n') + ) + await mkdir(join(dir, 'editor'), { recursive: true }) + await writeFile(join(dir, 'editor', 'big.js'), 'x'.repeat(16 * 1024 * 1024 + 1)) + const [check] = await checkPluginEditors(dir) + expect(check.issues.map(({ code, location }) => [code, location])).toEqual([ + ['editor_syntax', 'editor/index.html:6:1'], + ['editor_missing_file', 'editor/index.html:2:1'], + ['editor_too_large', 'editor/index.html:3:1'] + ]) + }) + + it('caps a transpiled source module at 1 MiB', async () => { + const dir = await editorPlugin() + await writeFile(join(dir, 'editor', 'sync.ts'), `// ${'x'.repeat(1024 * 1024)}\n`) + const [check] = await checkPluginEditors(dir) + expect(check.issues).toEqual([ + expect.objectContaining({ code: 'editor_too_large', location: 'editor/main.ts:1:24' }) + ]) + }) + + it('fails dev watch --check on an editor issue, and passes it with only a warning', async () => { + const dir = await editorPlugin() + await appendFile(join(dir, 'editor', 'main.ts'), "eval('1')\n") + const warned = await checkPluginDevFolder(dir) + expect(warned.ok).toBe(true) + expect(warned.editors[0]?.warnings).toEqual([expect.objectContaining({ code: 'editor_eval' })]) + await appendFile(join(dir, 'editor', 'main.ts'), 'this is not valid ts(\n') + const failed = await checkPluginDevFolder(dir) + expect(failed.ok).toBe(false) + expect(failed.editors[0]?.issues).toEqual([expect.objectContaining({ code: 'editor_syntax' })]) + }) +}) diff --git a/src/cli/plugin-authoring/plugin-editor-check.ts b/src/cli/plugin-authoring/plugin-editor-check.ts new file mode 100644 index 00000000000..db21881764d --- /dev/null +++ b/src/cli/plugin-authoring/plugin-editor-check.ts @@ -0,0 +1,62 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import type { + PluginEditorCheckIssue, + PluginEditorStaticCheck +} from '../../main/plugins/testing/plugin-editor-static-check' +import { parsePluginManifest } from '../../shared/plugins/manifest/plugin-manifest' +import { pluginTestingFiles } from './plugin-test-runner' + +export type PluginEditorCheck = { + editor: string + entry: string + /** Plugin-relative files the entry loads, itself included. */ + files: string[] + issues: PluginEditorCheckIssue[] + /** `editor_eval`: likely refused by the frame, but found by a scan that can misread code. */ + warnings: PluginEditorCheckIssue[] +} + +type EditorCheckModule = { + checkPluginEditorEntry: (dir: string, entry: string) => Promise +} + +/** Every custom editor's files, transpiled and resolved as the frame loads them; runs no code. */ +export async function checkPluginEditors( + dir: string, + appRoot?: string +): Promise { + const parsed = parsePluginManifest( + JSON.parse(await readFile(join(dir, 'orca-plugin.json'), 'utf8')) + ) + const editors = parsed.ok ? parsed.manifest.contributes.customEditors : [] + if (editors.length === 0) { + return [] + } + // The check ships in the hooks bundle, which carries the frame's transpiler in plain Node. + const loaded: Partial = await import( + pathToFileURL(pluginTestingFiles(appRoot).hooks).href + ) + const check = loaded.checkPluginEditorEntry + if (!check) { + throw new Error('this Orca build has no custom editor check') + } + const checks: PluginEditorCheck[] = [] + for (const editor of editors) { + checks.push({ editor: editor.id, entry: editor.entry, ...(await check(dir, editor.entry)) }) + } + return checks +} + +/** Lines for one editor: how many files it loads, or each issue at its plugin location. */ +export function formatPluginEditorCheck(check: PluginEditorCheck): string[] { + const head = `${check.editor} (${check.entry}): ` + const line = (issue: PluginEditorCheckIssue) => + `${issue.location}: ${issue.message} [${issue.code}]` + const warnings = check.warnings.map((warning) => ` warning: ${line(warning)}`) + if (check.issues.length === 0) { + return [`${head}ok, ${check.files.length} file(s) load`, ...warnings] + } + return [`${head}failed`, ...check.issues.map((issue) => ` ${line(issue)}`), ...warnings] +} diff --git a/src/cli/plugin-dev-watch/plugin-dev-watch-check.test.ts b/src/cli/plugin-dev-watch/plugin-dev-watch-check.test.ts index b7aa904e741..3415de2b7d8 100644 --- a/src/cli/plugin-dev-watch/plugin-dev-watch-check.test.ts +++ b/src/cli/plugin-dev-watch/plugin-dev-watch-check.test.ts @@ -45,7 +45,8 @@ const passed: PluginDevWatchCheckResult = { issues: [], warnings: [] } - ] + ], + editors: [] } const failed: PluginDevWatchCheckResult = { ok: false, @@ -59,7 +60,8 @@ const failed: PluginDevWatchCheckResult = { issues: [{ kind: 'plugin', message: 'boom', location: 'src/panel.tsx:4:7' }], warnings: [] } - ] + ], + editors: [] } /** Polls: 1 baseline, 2 status (unchanged), 3 logs, 4 status (one save), then Ctrl-C. */ diff --git a/src/cli/plugin-dev-watch/plugin-dev-watch-check.ts b/src/cli/plugin-dev-watch/plugin-dev-watch-check.ts index d94ba850a67..bc540563b59 100644 --- a/src/cli/plugin-dev-watch/plugin-dev-watch-check.ts +++ b/src/cli/plugin-dev-watch/plugin-dev-watch-check.ts @@ -1,5 +1,6 @@ import { validatePluginTarget } from '../plugin-authoring/plugin-folder-validation' import { checkPluginPanels, type PluginPanelCheck } from '../plugin-authoring/plugin-panel-check' +import { checkPluginEditors, type PluginEditorCheck } from '../plugin-authoring/plugin-editor-check' /** `orca plugin check` for one folder, as data: validation errors, else each panel's render. */ export type PluginDevWatchCheckResult = { @@ -7,16 +8,23 @@ export type PluginDevWatchCheckResult = { /** Manifest problems; panels are not rendered while there are any. */ invalid: string[] panels: PluginPanelCheck[] + /** Each custom editor's static check. */ + editors: PluginEditorCheck[] } export async function checkPluginDevFolder(dir: string): Promise { const report = await validatePluginTarget(dir) if (!report.valid) { - return { ok: false, invalid: report.errors, panels: [] } + return { ok: false, invalid: report.errors, panels: [], editors: [] } } + const [rendered, editors] = await Promise.all([ + checkPluginPanels(report.path), + checkPluginEditors(report.path) + ]) // Why no tree: a watch prints one line per save; the summary says what drew. - const panels = (await checkPluginPanels(report.path)).map(({ tree: _tree, ...panel }) => panel) - return { ok: panels.every((panel) => panel.issues.length === 0), invalid: [], panels } + const panels = rendered.map(({ tree: _tree, ...panel }) => panel) + const ok = [...panels, ...editors].every((check) => check.issues.length === 0) + return { ok, invalid: [], panels, editors } } /** Runs checks one at a time: a request while one runs queues a single rerun of the newest code. */ diff --git a/src/cli/plugin-dev-watch/plugin-dev-watch-events.ts b/src/cli/plugin-dev-watch/plugin-dev-watch-events.ts index d07357ccc76..3f426a405d5 100644 --- a/src/cli/plugin-dev-watch/plugin-dev-watch-events.ts +++ b/src/cli/plugin-dev-watch/plugin-dev-watch-events.ts @@ -1,6 +1,7 @@ import type { PluginHostStatus } from '../../shared/plugins/plugin-list-entry' import { formatPluginPanelCheck } from '../plugin-authoring/plugin-panel-check' import type { PluginDevWatchCheckResult } from './plugin-dev-watch-check' +import { formatPluginEditorCheck } from '../plugin-authoring/plugin-editor-check' import { PLUGIN_DEV_WATCH_SHOOTABLE, type PluginDevWatchShot } from './plugin-dev-watch-shot' /** `missing`: the host lists no plugin for the folder (for example, its id changed). */ @@ -120,7 +121,8 @@ export function formatPluginDevWatchEvent(event: PluginDevWatchEvent): string { ? [` in Orca: ${pluginStatusPhrase(event.pluginStatus, event.pluginError)}`] : []), ...event.invalid.map((error) => ` invalid: ${error}`), - ...event.panels.flatMap(formatPluginPanelCheck).map((line) => ` ${line}`) + ...event.panels.flatMap(formatPluginPanelCheck).map((line) => ` ${line}`), + ...event.editors.flatMap(formatPluginEditorCheck).map((line) => ` ${line}`) ].join('\n') case 'shot': return [ diff --git a/src/cli/plugin-dev-watch/plugin-dev-watch-shot.test.ts b/src/cli/plugin-dev-watch/plugin-dev-watch-shot.test.ts index 4ca8dce9bc2..01a58754a8f 100644 --- a/src/cli/plugin-dev-watch/plugin-dev-watch-shot.test.ts +++ b/src/cli/plugin-dev-watch/plugin-dev-watch-shot.test.ts @@ -113,7 +113,7 @@ describe('plugin dev watch --shot', () => { { check: async () => { order.push('check') - return { ok: true, invalid: [], panels: [] } + return { ok: true, invalid: [], panels: [], editors: [] } }, shot: host }, diff --git a/src/cli/specs/plugin.ts b/src/cli/specs/plugin.ts index 337bc8326b4..2cf279bd54d 100644 --- a/src/cli/specs/plugin.ts +++ b/src/cli/specs/plugin.ts @@ -66,7 +66,7 @@ export const PLUGIN_AUTHORING_COMMAND_SPECS: CommandSpec[] = [ { path: ['plugin', 'check'], summary: - 'Validate a plugin and render each components panel once on this machine, unsandboxed: only for code you trust', + 'Validate a plugin, render each components panel once on this machine (unsandboxed: only for code you trust), and statically check each custom editor', usage: 'orca plugin check [dir] [--json]', allowedFlags: [...LOCAL_COMMAND_FLAGS, 'dir'], positionalArgs: ['dir'], diff --git a/src/main/plugins/panels/plugin-frame-content-types.ts b/src/main/plugins/panels/plugin-frame-content-types.ts new file mode 100644 index 00000000000..c8c5bb99024 --- /dev/null +++ b/src/main/plugins/panels/plugin-frame-content-types.ts @@ -0,0 +1,49 @@ +import { extname } from 'node:path' + +// How the frame protocol types a plugin file; free of the shell so plain-Node checks share it. + +const MIME_TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.htm': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', + '.md': 'text/markdown; charset=utf-8', + '.csv': 'text/csv; charset=utf-8', + '.xml': 'application/xml; charset=utf-8', + '.wasm': 'application/wasm', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.otf': 'font/otf', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', + '.mp4': 'video/mp4', + '.webm': 'video/webm' +} + +export const HTML_CONTENT_TYPE = 'text/html; charset=utf-8' +export const JAVASCRIPT_CONTENT_TYPE = 'text/javascript; charset=utf-8' + +export function pluginFrameContentType(path: string): string { + return MIME_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream' +} + +/** Code the guest executes or renders as a document: read whole and checked byte-for-byte. */ +export function isCheckedPluginFrameType(contentType: string): boolean { + return /^(?:text\/html|text\/javascript|text\/css|application\/json|application\/wasm|image\/svg\+xml|application\/xml)/.test( + contentType + ) +} diff --git a/src/main/plugins/panels/plugin-frame-module-transform.ts b/src/main/plugins/panels/plugin-frame-module-transform.ts index d0c1c770083..8cd939f7c77 100644 --- a/src/main/plugins/panels/plugin-frame-module-transform.ts +++ b/src/main/plugins/panels/plugin-frame-module-transform.ts @@ -14,9 +14,9 @@ import { } from './plugin-source-transpile-protocol' import { parsePluginTranspileFailure } from './plugin-source-transpile-failure' import { pluginSourceMapPrelude } from '../../../shared/plugins/frames/plugin-frame-source-map-prelude' +import { PLUGIN_SOURCE_MODULE_MAX_BYTES } from '../../../shared/plugins/frames/plugin-frame-entry-limits' -/** Source modules are read whole and transpiled per load, so they get a tighter cap. */ -export const PLUGIN_SOURCE_MODULE_MAX_BYTES = 1024 * 1024 +export { PLUGIN_SOURCE_MODULE_MAX_BYTES } const CACHE_MAX_ENTRIES = 512 const CACHE_MAX_CHARS = 64 * 1024 * 1024 diff --git a/src/main/plugins/panels/plugin-frame-protocol.ts b/src/main/plugins/panels/plugin-frame-protocol.ts index c614328f1eb..b8d766c33bb 100644 --- a/src/main/plugins/panels/plugin-frame-protocol.ts +++ b/src/main/plugins/panels/plugin-frame-protocol.ts @@ -1,4 +1,8 @@ import { stat } from 'node:fs/promises' +import { + PLUGIN_FRAME_ASSET_MAX_BYTES, + PLUGIN_FRAME_CHECKED_MAX_BYTES +} from '../../../shared/plugins/frames/plugin-frame-entry-limits' import { parsePluginFrameUrl, PLUGIN_DECLARATIVE_HOST_FILE, @@ -45,10 +49,7 @@ import { export { pluginFrameContentType } from './plugin-frame-responses' -/** Streamed assets (images, fonts, media); the whole plugin tree is capped at 50 MiB anyway. */ -export const PLUGIN_FRAME_ASSET_MAX_BYTES = 32 * 1024 * 1024 -/** Documents, scripts and WASM are read whole to check their digest, so they get a lower cap. */ -export const PLUGIN_FRAME_CHECKED_MAX_BYTES = 16 * 1024 * 1024 +export { PLUGIN_FRAME_ASSET_MAX_BYTES, PLUGIN_FRAME_CHECKED_MAX_BYTES } /** Concurrent requests one guest session may hold open; more get 503 until some finish. */ export const PLUGIN_FRAME_MAX_IN_FLIGHT = 32 diff --git a/src/main/plugins/panels/plugin-frame-responses.ts b/src/main/plugins/panels/plugin-frame-responses.ts index 3377824b082..76ce62fea64 100644 --- a/src/main/plugins/panels/plugin-frame-responses.ts +++ b/src/main/plugins/panels/plugin-frame-responses.ts @@ -1,52 +1,11 @@ import { createReadStream } from 'node:fs' -import { extname } from 'node:path' import { PLUGIN_GUEST_CSP } from '../../../shared/plugins/frames/plugin-panel-shell' - -const MIME_TYPES: Record = { - '.html': 'text/html; charset=utf-8', - '.htm': 'text/html; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.mjs': 'text/javascript; charset=utf-8', - '.css': 'text/css; charset=utf-8', - '.json': 'application/json; charset=utf-8', - '.map': 'application/json; charset=utf-8', - '.txt': 'text/plain; charset=utf-8', - '.md': 'text/markdown; charset=utf-8', - '.csv': 'text/csv; charset=utf-8', - '.xml': 'application/xml; charset=utf-8', - '.wasm': 'application/wasm', - '.svg': 'image/svg+xml', - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.avif': 'image/avif', - '.ico': 'image/x-icon', - '.woff': 'font/woff', - '.woff2': 'font/woff2', - '.ttf': 'font/ttf', - '.otf': 'font/otf', - '.mp3': 'audio/mpeg', - '.wav': 'audio/wav', - '.ogg': 'audio/ogg', - '.mp4': 'video/mp4', - '.webm': 'video/webm' -} - -export const HTML_CONTENT_TYPE = 'text/html; charset=utf-8' -export const JAVASCRIPT_CONTENT_TYPE = 'text/javascript; charset=utf-8' - -export function pluginFrameContentType(path: string): string { - return MIME_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream' -} - -/** Code the guest executes or renders as a document: read whole and checked byte-for-byte. */ -export function isCheckedPluginFrameType(contentType: string): boolean { - return /^(?:text\/html|text\/javascript|text\/css|application\/json|application\/wasm|image\/svg\+xml|application\/xml)/.test( - contentType - ) -} +export { + HTML_CONTENT_TYPE, + isCheckedPluginFrameType, + JAVASCRIPT_CONTENT_TYPE, + pluginFrameContentType +} from './plugin-frame-content-types' const COMMON_HEADERS = { // Why on every response: workers take their CSP from their own script response. diff --git a/src/main/plugins/plugin-artifact-validation.ts b/src/main/plugins/plugin-artifact-validation.ts index 5f0eb8bd82b..5870d780a06 100644 --- a/src/main/plugins/plugin-artifact-validation.ts +++ b/src/main/plugins/plugin-artifact-validation.ts @@ -1,6 +1,5 @@ -import { createReadStream } from 'node:fs' -import { readdir, realpath, stat } from 'node:fs/promises' -import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { readdir, realpath } from 'node:fs/promises' +import { join, resolve } from 'node:path' import type { PluginManifest } from '../../shared/plugins/manifest/plugin-manifest' import { PLUGIN_SYNTAX_CONFIGURATION_MAX_BYTES, @@ -10,11 +9,23 @@ import { parsePluginVmRecipeArtifact } from '../../shared/plugins/artifacts/plug import { PLUGIN_IMAGE_RULES } from '../../shared/plugins/images/plugin-image-fields' import { PLUGIN_NEW_FILE_TEMPLATE_MAX_BYTES } from '../../shared/plugins/contributions/plugin-new-file-contribution' import { validatePluginImageArtifacts } from './plugin-image-artifact-validation' +import { PLUGIN_PANEL_ENTRY_MAX_BYTES } from '../../shared/plugins/frames/plugin-frame-entry-limits' +import { + PLUGIN_WORKER_ENTRY_MAX_BYTES, + readContainedPluginArtifactBytes, + readContainedPluginArtifactText, + resolveContainedPluginArtifact, + resolvePathFromRealRoot +} from './plugin-contained-artifact' export type PluginArtifactValidationResult = { ok: true } | { ok: false; error: string } -export const PLUGIN_PANEL_ENTRY_MAX_BYTES = 10 * 1024 * 1024 -export const PLUGIN_WORKER_ENTRY_MAX_BYTES = 50 * 1024 * 1024 +export { PLUGIN_PANEL_ENTRY_MAX_BYTES, PLUGIN_WORKER_ENTRY_MAX_BYTES } +export { + readContainedPluginArtifactBytes, + readContainedPluginArtifactText, + resolveContainedPluginArtifact +} const PLUGIN_ICON_MAX_BYTES = 2 * 1024 * 1024 export const PLUGIN_LANGUAGE_PACK_MAX_BYTES = 5 * 1024 * 1024 export const PLUGIN_VM_RECIPE_MAX_BYTES = 256 * 1024 @@ -101,72 +112,6 @@ function declaredArtifactPaths(manifest: PluginManifest): DeclaredArtifact[] { ] } -export async function resolveContainedPluginArtifact( - rootDir: string, - relativePath: string, - maxBytes = PLUGIN_WORKER_ENTRY_MAX_BYTES -): Promise { - const rootReal = await realpath(resolve(rootDir)) - return resolvePathFromRealRoot(rootDir, rootReal, relativePath, 'file', maxBytes) -} - -export async function readContainedPluginArtifactText( - rootDir: string, - relativePath: string, - maxBytes: number -): Promise { - return (await readContainedPluginArtifactBytes(rootDir, relativePath, maxBytes)).toString('utf8') -} - -export async function readContainedPluginArtifactBytes( - rootDir: string, - relativePath: string, - maxBytes: number -): Promise { - const artifact = await resolveContainedPluginArtifact(rootDir, relativePath, maxBytes) - const chunks: Buffer[] = [] - let totalBytes = 0 - for await (const chunk of createReadStream(artifact)) { - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) - totalBytes += bytes.byteLength - if (totalBytes > maxBytes) { - throw new Error(`exceeds the ${maxBytes}-byte artifact limit`) - } - chunks.push(bytes) - } - return Buffer.concat(chunks, totalBytes) -} - -async function resolvePathFromRealRoot( - rootDir: string, - rootReal: string, - relativePath: string, - kind: 'file' | 'directory', - maxBytes?: number -): Promise { - const artifactReal = await realpath(resolve(rootDir, ...relativePath.split(/[\\/]/))) - const fromRoot = relative(rootReal, artifactReal) - if ( - fromRoot.length === 0 || - isAbsolute(fromRoot) || - fromRoot === '..' || - fromRoot.startsWith(`..${sep}`) - ) { - throw new Error('resolves outside the plugin directory') - } - const artifactStat = await stat(artifactReal) - if (kind === 'file' && !artifactStat.isFile()) { - throw new Error('is not a regular file') - } - if (kind === 'directory' && !artifactStat.isDirectory()) { - throw new Error('is not a directory') - } - if (kind === 'file' && maxBytes !== undefined && artifactStat.size > maxBytes) { - throw new Error(`exceeds the ${maxBytes}-byte artifact limit`) - } - return artifactReal -} - /** Why: APFS and NTFS resolve `dark.json` to a committed `DARK.json`, but Linux does not. */ async function assertExactPathSpelling( rootDir: string, diff --git a/src/main/plugins/plugin-contained-artifact.ts b/src/main/plugins/plugin-contained-artifact.ts new file mode 100644 index 00000000000..75f00bff270 --- /dev/null +++ b/src/main/plugins/plugin-contained-artifact.ts @@ -0,0 +1,74 @@ +import { createReadStream } from 'node:fs' +import { realpath, stat } from 'node:fs/promises' +import { isAbsolute, relative, resolve, sep } from 'node:path' + +/** Reads plugin files by their real path, refusing any that a symlink takes outside the plugin. + * Free of manifest code so the plain-Node check bundle can share it. */ + +export const PLUGIN_WORKER_ENTRY_MAX_BYTES = 50 * 1024 * 1024 + +export async function resolveContainedPluginArtifact( + rootDir: string, + relativePath: string, + maxBytes = PLUGIN_WORKER_ENTRY_MAX_BYTES +): Promise { + const rootReal = await realpath(resolve(rootDir)) + return resolvePathFromRealRoot(rootDir, rootReal, relativePath, 'file', maxBytes) +} + +export async function readContainedPluginArtifactText( + rootDir: string, + relativePath: string, + maxBytes: number +): Promise { + return (await readContainedPluginArtifactBytes(rootDir, relativePath, maxBytes)).toString('utf8') +} + +export async function readContainedPluginArtifactBytes( + rootDir: string, + relativePath: string, + maxBytes: number +): Promise { + const artifact = await resolveContainedPluginArtifact(rootDir, relativePath, maxBytes) + const chunks: Buffer[] = [] + let totalBytes = 0 + for await (const chunk of createReadStream(artifact)) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > maxBytes) { + throw new Error(`exceeds the ${maxBytes}-byte artifact limit`) + } + chunks.push(bytes) + } + return Buffer.concat(chunks, totalBytes) +} + +export async function resolvePathFromRealRoot( + rootDir: string, + rootReal: string, + relativePath: string, + kind: 'file' | 'directory', + maxBytes?: number +): Promise { + const artifactReal = await realpath(resolve(rootDir, ...relativePath.split(/[\\/]/))) + const fromRoot = relative(rootReal, artifactReal) + if ( + fromRoot.length === 0 || + isAbsolute(fromRoot) || + fromRoot === '..' || + fromRoot.startsWith(`..${sep}`) + ) { + throw new Error('resolves outside the plugin directory') + } + const artifactStat = await stat(artifactReal) + if (kind === 'file' && !artifactStat.isFile()) { + throw new Error('is not a regular file') + } + if (kind === 'directory' && !artifactStat.isDirectory()) { + throw new Error('is not a directory') + } + if (kind === 'file' && maxBytes !== undefined && artifactStat.size > maxBytes) { + throw new Error(`exceeds the ${maxBytes}-byte artifact limit`) + } + return artifactReal +} diff --git a/src/main/plugins/testing/plugin-editor-html-scripts.ts b/src/main/plugins/testing/plugin-editor-html-scripts.ts new file mode 100644 index 00000000000..922903b810e --- /dev/null +++ b/src/main/plugins/testing/plugin-editor-html-scripts.ts @@ -0,0 +1,93 @@ +/** The scripts an editor's HTML document runs, read as Chromium would for `orca plugin check`. */ + +export type HtmlScript = { text: string; module: boolean; lineOffset: number } +export type HtmlScriptSrc = { src: string; module: boolean; line: number; column: number } +/** An import map's `imports`: bare specifier, or a prefix ending in `/`, to a URL. */ +export type HtmlImportMap = Record + +// Chromium's JavaScript MIME types; any other `type` is a data block it never runs. +const JS_TYPES = new Set([ + '', + 'module', + 'text/javascript', + 'application/javascript', + 'application/ecmascript', + 'application/x-ecmascript', + 'application/x-javascript', + 'text/ecmascript', + 'text/jscript', + 'text/livescript', + 'text/x-ecmascript', + 'text/x-javascript' +]) + +export function lineColumn(text: string, offset: number): { line: number; column: number } { + const before = text.slice(0, offset).split('\n') + return { line: before.length, column: (before.at(-1)?.length ?? 0) + 1 } +} + +function attribute(attributes: string, name: string): string | null { + const match = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec( + attributes + ) + return match ? (match[1] ?? match[2] ?? match[3] ?? '') : null +} + +function importsOf(json: string): HtmlImportMap { + try { + const parsed: unknown = JSON.parse(json) + const imports = + typeof parsed === 'object' && parsed !== null && 'imports' in parsed ? parsed.imports : null + if (typeof imports !== 'object' || imports === null) { + return {} + } + return Object.fromEntries( + Object.entries(imports).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ) + } catch { + return {} + } +} + +/** Inline and `src` scripts Chromium runs, and the document's import map. */ +export function readHtmlScripts(html: string): { + inline: HtmlScript[] + srcs: HtmlScriptSrc[] + importMap: HtmlImportMap +} { + const inline: HtmlScript[] = [] + const srcs: HtmlScriptSrc[] = [] + const importMap: HtmlImportMap = {} + for (const match of html.matchAll(/]*)>([\s\S]*?)<\/script\s*>/gi)) { + const type = (attribute(match[1], 'type') ?? '').trim().toLowerCase() + if (type === 'importmap') { + Object.assign(importMap, importsOf(match[2])) + continue + } + if (!JS_TYPES.has(type)) { + continue + } + const module = type === 'module' + const src = attribute(match[1], 'src') + if (src !== null) { + srcs.push({ src, module, ...lineColumn(html, match.index) }) + } else if (match[2].trim()) { + const contentStart = match.index + match[0].indexOf('>') + 1 + inline.push({ text: match[2], module, lineOffset: lineColumn(html, contentStart).line - 1 }) + } + } + return { inline, srcs, importMap } +} + +/** The import map's target for a bare specifier: an exact key, else the longest `/` prefix. */ +export function resolveImportMapSpecifier(map: HtmlImportMap, specifier: string): string | null { + if (map[specifier] !== undefined) { + return map[specifier] + } + const prefix = Object.keys(map) + .filter((key) => key.endsWith('/') && specifier.startsWith(key)) + .sort((a, b) => b.length - a.length)[0] + return prefix === undefined ? null : map[prefix] + specifier.slice(prefix.length) +} diff --git a/src/main/plugins/testing/plugin-editor-static-check.ts b/src/main/plugins/testing/plugin-editor-static-check.ts new file mode 100644 index 00000000000..0573702c651 --- /dev/null +++ b/src/main/plugins/testing/plugin-editor-static-check.ts @@ -0,0 +1,292 @@ +/** + * `orca plugin check` for custom editors, without a running Orca: every file an editor entry + * loads (its local `