Files
orca/src/shared/plugins/catalog/plugin-catalog-release.ts
T
Neil 6c86dec334 fix(plugins): harden security dialogs, syntax literals and display names against data plugins (#36220)
- Pin dialog and floating surfaces inside .plugin-security-chrome so a theme's --popover can't wash out trust text
- Limit auto-closing pairs and comment markers to 1-8 printable single-line characters (validate:plugin refuses; hosts drop the entry)
- Strip control, bidi and zero-width characters from plugin names, titles and listing text (validate:plugin refuses; hosts and the plugin list strip)
2026-10-29 21:23:52 -07:00

294 lines
11 KiB
TypeScript

import { z } from 'zod'
import { safePluginDisplayText } from '../manifest/plugin-display-text'
import {
pluginExecutionAllows,
pluginExecutionProfileSchema
} from '../contributions/plugin-execution-profile'
import {
PLUGIN_CATALOG_TYPE_PROFILE,
PLUGIN_CATALOG_TYPES,
pluginCatalogTypes
} from './plugin-catalog-types'
import { PLUGIN_CAPABILITY_KINDS } from '../capabilities/plugin-capabilities'
import {
pluginContributionSummary,
pluginContributionSummaryKey,
pluginContributionSummarySchema
} from '../contributions/plugin-contribution-summary'
import { pluginCatalogReleaseDetailFields } from './plugin-detail-fields'
import { pluginCatalogImagesSchema } from '../images/plugin-image-fields'
import {
pluginEditorFileTypes,
pluginFileTypeSchema,
pluginHandledFileTypes
} from '../contributions/plugin-file-type'
import type { PluginManifest } from '../manifest/plugin-manifest'
import { pluginFieldDoc, PLUGIN_MANIFEST_V2_SINCE } from '../manifest/plugin-schema-field-docs'
export {
PLUGIN_CATALOG_TYPES,
pluginCatalogReleaseTypes,
pluginCatalogTypes,
type PluginCatalogType
} from './plugin-catalog-types'
const NEXT = PLUGIN_MANIFEST_V2_SINCE
// Why hex only: these are painted directly, so anything that could carry url() or var() is out.
const hexColor = (description: string) =>
z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'must be a #rrggbb color')
.meta(pluginFieldDoc(description, NEXT, ['#1b1430']))
/** 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)
.meta(pluginFieldDoc("The manifest's `version` for this release.", NEXT, ['1.2.0'])),
// Why SHA-1 only: plugin materialization rejects SHA-256 repositories.
commit: z
.string()
.regex(/^[0-9a-f]{40}$/, 'must be a full lowercase SHA-1 commit')
.meta(pluginFieldDoc('Full SHA-1 commit Orca checks out for this release.', NEXT)),
contentHash: z
.string()
.regex(/^[0-9a-f]{64}$/, 'must be a lowercase SHA-256 hex digest')
.meta(
pluginFieldDoc(
"SHA-256 of the plugin's installed files, as Orca computes it at install.",
NEXT
)
),
execution: pluginExecutionProfileSchema.meta(
pluginFieldDoc("The manifest's `execution` profile.", NEXT, ['data'])
),
minimumOrca: z
.string()
.regex(/^\d+\.\d+\.\d+$/, 'must be an x.y.z version')
.meta(
pluginFieldDoc(
"The manifest's `engines.orca` without `>=`. Older Orca lists the plugin but will not install it.",
NEXT,
[NEXT]
)
),
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')
.optional()
.meta({
...pluginFieldDoc(
'Optional; Orca derives it from `contributes` and `execution`. When present, every kind of contribution the release carries, matching its manifest exactly. `instructions` needs the `declarative` profile or higher, `panel` and `editor` need `sandboxed-ui`, and `worker` needs `trusted-worker`.',
NEXT,
[['syntax']]
),
uniqueItems: true
}),
name: safePluginDisplayText(z.string().min(1).max(128))
.optional()
.meta(
pluginFieldDoc(
"The manifest's `name`, so Orca can suggest the plugin before downloading it.",
NEXT,
['Gleam']
)
),
...pluginCatalogReleaseDetailFields,
images: pluginCatalogImagesSchema
.optional()
.catch(undefined)
.meta(
pluginFieldDoc(
'Icon, cover and screenshots for the listing, shown before anything is downloaded.',
NEXT
)
),
themePreview: z
.array(
z
.strictObject({
label: safePluginDisplayText(z.string().min(1).max(128)).meta(
pluginFieldDoc('Theme name.', NEXT, ['Dusk'])
),
base: z.enum(['light', 'dark']).meta(pluginFieldDoc('Light or dark theme.', NEXT)),
background: hexColor('Window background.'),
foreground: hexColor('Main text.'),
accent: hexColor('Accent, such as buttons.'),
surface: hexColor('Cards and sidebars.')
})
.meta({
id: 'CatalogThemePreview',
description: 'Colors to draw a theme card before install.'
})
)
.max(4)
.optional()
.catch(undefined)
.meta(
pluginFieldDoc(
'Up to 4 theme palettes for listing art. Display only; the installed theme files decide the real colors.',
NEXT
)
),
contributes: pluginContributionSummarySchema
.optional()
.catch(undefined)
.meta(
pluginFieldDoc(
'What the release adds, as the listing shows it. Generate it with the catalog tooling; Orca refuses an install whose manifest says otherwise.',
NEXT
)
),
capabilities: z
.array(z.enum(PLUGIN_CAPABILITY_KINDS))
.max(PLUGIN_CAPABILITY_KINDS.length)
.optional()
.catch(undefined)
.meta(
pluginFieldDoc(
'Every capability the release asks for. Orca refuses an install whose manifest asks for different ones.',
NEXT,
[['notifications:show']]
)
),
/** Invalid entries are dropped, not fatal, so a newer catalog never hides the plugin. */
fileTypes: z
.array(pluginFileTypeSchema.optional().catch(undefined))
.transform((entries) =>
[...new Set(entries)].filter((entry): entry is string => entry !== undefined).slice(0, 32)
)
.optional()
.meta(
pluginFieldDoc(
'Extensions, without the dot, that this release opens. Orca suggests the plugin when such a file has no viewer.',
NEXT,
[['gleam']]
)
),
/** Invalid entries are dropped, not fatal, so a newer catalog never hides the plugin. */
editorFileTypes: z
.array(pluginFileTypeSchema.optional().catch(undefined))
.transform((entries) =>
[...new Set(entries)].filter((entry): entry is string => entry !== undefined).slice(0, 32)
)
.optional()
.meta(
pluginFieldDoc(
'The `fileTypes` this release opens in a custom editor. Orca suggests the plugin for such a file even when it already opens it as text.',
NEXT,
[['excalidraw']]
)
)
})
.superRefine((release, ctx) => {
for (const [index, type] of (release.types ?? []).entries()) {
const required = PLUGIN_CATALOG_TYPE_PROFILE[type]
if (required && !pluginExecutionAllows(release.execution, required)) {
ctx.addIssue({
code: 'custom',
path: ['types', index],
message: `${release.execution} cannot declare ${type}`
})
}
}
})
.meta({
id: 'CatalogRelease',
description: 'The exact release the catalog vouches for. Orca refuses bytes that differ.'
})
export type PluginCatalogRelease = z.infer<typeof pluginCatalogReleaseSchema>
/** The release entry a catalog should list for these exact bytes, so authors paste it instead of
* hand-copying fields that `pluginCatalogReleaseError` would refuse. */
export function pluginCatalogReleaseEntry(
manifest: PluginManifest,
pinned: { commit: string; contentHash: string }
): z.input<typeof pluginCatalogReleaseSchema> | null {
if (manifest.manifestVersion !== 2) {
return null
}
const fileTypes = pluginHandledFileTypes(manifest.contributes)
const editorFileTypes = pluginEditorFileTypes(manifest.contributes)
const capabilities = [...new Set(manifest.capabilities.map((capability) => capability.kind))]
return {
version: manifest.version,
commit: pinned.commit,
contentHash: pinned.contentHash,
execution: manifest.execution,
minimumOrca: manifest.engines.orca.replace(/^>=/, ''),
name: manifest.name,
contributes: pluginContributionSummary(manifest),
...(capabilities.length > 0 ? { capabilities } : {}),
...(fileTypes.length > 0 ? { fileTypes } : {}),
...(editorFileTypes.length > 0 ? { editorFileTypes } : {})
}
}
/** 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'
}
if (
release.types &&
pluginCatalogTypes(manifest).sort().join() !== [...release.types].sort().join()
) {
return 'contributions do not match the catalog release'
}
// Why: a suggestion must lead to a plugin that really handles the file.
const handled = new Set(pluginHandledFileTypes(manifest.contributes))
if (release.name !== undefined && release.name !== manifest.name) {
return 'plugin name does not match the catalog release'
}
if (release.fileTypes?.some((fileType) => !handled.has(fileType))) {
return 'file types do not match the catalog release'
}
const editorTypes = new Set(pluginEditorFileTypes(manifest.contributes))
if (release.editorFileTypes?.some((fileType) => !editorTypes.has(fileType))) {
return 'editor file types do not match the catalog release'
}
if (
release.contributes &&
pluginContributionSummaryKey(release.contributes) !==
pluginContributionSummaryKey(pluginContributionSummary(manifest))
) {
return 'the listed contributions do not match the catalog release'
}
const asked = [...new Set(manifest.capabilities.map((capability) => capability.kind))]
.sort()
.join()
if (release.capabilities && [...new Set(release.capabilities)].sort().join() !== asked) {
return 'the listed capabilities do not match the catalog release'
}
return null
}