mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 00:02:24 +00:00
## What this changes Two renderer inputs were trusted because their TypeScript types said they were valid. Both are now validated at the boundary that owns them. **Terminal cursor style.** `normalizeTerminalCursorStyleDefault` preserved any non-null migration-stamped value without checking it against the actual enum, so a runtime value outside `bar | block | underline` survived into `terminal.options.cursorStyle`. It now enum-checks and falls back to `block`. Applied on both read paths (desktop `Store` load, web `getStoredSettings`) and both write paths (`Store.updateSettings`, web `settings.set`), so an unsupported value cannot reach persistence, the `settings:changed` publication, or xterm. **Plugin language packs.** The renderer stored the `listLanguagePacks()` IPC result without a runtime check, so a non-array response was assigned to state and later crashed its first array consumer on `.find`. Ingress now accepts only an array, drops members failing `isPluginLanguagePackRegistration`, keeps valid siblings in their original order, and logs which failure mode occurred. The registration guard requires `resourceLanguage` to equal `pluginLanguageResourceId(id)`, which keeps a pack with a missing or inconsistent identity out of i18next — that shape reproduces as `TypeError: Cannot read properties of undefined (reading 'includes')` with the guard removed. Catalog validation is shape-only on this path (`validatePluginLanguagePackCatalogShape`), so revalidating an already-parsed catalog does not allocate a second copy of it. `isCatalogObject` also now requires a plain-object prototype, and the walk rejects repeated or cyclic object references. ## Root cause: partially known Worth stating plainly, because the fix is defensive rather than causal: - The non-array pack container and the out-of-enum cursor value are both reproduced directly by tests. - Two additional field stacks are *consistent with* an invalid `resourceLanguage` and are blocked by this guard, but the reports do not prove that malformed registrations were their source. - No production writer in current code or history emits an out-of-enum cursor style. The Ghostty config importer (`src/main/ghostty/mapper.ts`) already rejects unsupported `cursor-style` values. The original writer is unidentified. Every in-tree producer supplies valid data, so these guards are no-ops on the current happy path. They are boundary hardening against a mutation we have not located, not a repair of a known writer. ## Trade-offs - Malformed registrations are skipped with one warning rather than surfaced in the UI. Valid siblings keep their identity and order. Note the main-process registry already reports per-plugin parse errors, so this path only catches corruption after main has validated. - Renderer ingress walks each catalog once per lazy load or `contentPacksChanged`. It does not run on render or terminal-output paths, but a maximum-size burst still costs tens of milliseconds synchronously. Pack count and plugin-ID length remain uncapped. - An unsupported cursor value now renders and persists as `block`, so anyone relying on an undocumented third-party value loses it. - Loading settings whose cursor style is absent or invalid now marks state dirty and rewrites once, matching the existing `terminalRightClickToPasteDefaultedForPlatform` pattern above it. ## Compatibility `listLanguagePacks` is local `ipcMain`/`ipcRenderer` only and is not implemented in the web build, so nothing here crosses the remote wire — no RPC parameter, publication schema, stream frame, opcode, or capability changed. Validation is receiver-side at existing boundaries. Nothing is platform-, shell-, PTY-, native-module-, SSH-, WSL-, or worktree-dependent, and the web build normalizes on both read and write. Malformed plugin data is rejected before i18next sees it; no new permission, network path, executable input, or persistence schema was added. ## Verification Locally on the final head: the 5 touched test files pass (591 tests), plus node and web typecheck, oxlint, and oxfmt. All GitHub required checks pass, including Windows packaging, static analysis, Git and wire compatibility, shell contracts, and all 32 Node 24/26 shards. The path-gated E2E job is skipped after its detector passed. Scope note: an earlier revision generalized this hardening to `Project.sourceRepoIds` and profile transfer without field evidence. That scope was removed; the diff is cursor and plugin paths only.
215 lines
6.9 KiB
TypeScript
215 lines
6.9 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
isPluginLanguagePackRegistration,
|
|
parsePluginLanguagePackArtifact,
|
|
PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH,
|
|
PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES,
|
|
validatePluginLanguagePackCatalog,
|
|
validatePluginLanguagePackCatalogShape,
|
|
pluginLanguageResourceId
|
|
} from './plugin-language-pack-artifact'
|
|
|
|
describe('plugin language-pack artifacts', () => {
|
|
it('maps qualified IDs to distinct alphanumeric i18next resource languages', () => {
|
|
const first = pluginLanguageResourceId('plugin:a.bc/d-ef')
|
|
const second = pluginLanguageResourceId('plugin:ab.c/de-f')
|
|
|
|
expect(first).toMatch(/^plugin[0-9a-f]+$/)
|
|
expect(second).toMatch(/^plugin[0-9a-f]+$/)
|
|
expect(first).not.toBe(second)
|
|
})
|
|
|
|
it('accepts nested string catalogs and reports their bounded entry count', () => {
|
|
expect(
|
|
parsePluginLanguagePackArtifact(
|
|
JSON.stringify({
|
|
settings: { appearance: { title: 'Aparência' } },
|
|
common: { save: 'Salvar' }
|
|
})
|
|
)
|
|
).toEqual({
|
|
ok: true,
|
|
catalog: {
|
|
settings: { appearance: { title: 'Aparência' } },
|
|
common: { save: 'Salvar' }
|
|
},
|
|
entries: 5
|
|
})
|
|
})
|
|
|
|
it.each([
|
|
'PluginConsentDialog',
|
|
// The provenance badge and install-error copy carry the trust decision.
|
|
'PluginConsentProvenance',
|
|
'pluginError',
|
|
'PluginKeybindingConsentPreview',
|
|
'PluginMarketplaceListingRow',
|
|
'PluginMarketplacePreviewDialog',
|
|
'PluginMarketplaceSourceDialog',
|
|
'PluginVmRecipeConsentPreview'
|
|
])('prevents language packs from rewriting %s security copy', (component) => {
|
|
expect(
|
|
parsePluginLanguagePackArtifact(
|
|
JSON.stringify({
|
|
auto: {
|
|
components: {
|
|
settings: { [component]: { disclaimer: 'This plugin is perfectly safe.' } }
|
|
}
|
|
}
|
|
})
|
|
)
|
|
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
|
|
})
|
|
|
|
it.each([
|
|
['PluginsSettingsSection', 'title', 'Плагины'],
|
|
['PluginMarketplaceBrowser', 'refresh', 'Обновить'],
|
|
['PluginDevelopmentSection', 'title', 'Разработка']
|
|
])('lets a language pack translate %s.%s, which asserts nothing', (component, key, value) => {
|
|
expect(
|
|
parsePluginLanguagePackArtifact(
|
|
JSON.stringify({
|
|
auto: { components: { settings: { [component]: { [key]: value } } } }
|
|
})
|
|
).ok
|
|
).toBe(true)
|
|
})
|
|
|
|
// Why: the chrome exemption must not leak into the copy that carries a claim
|
|
// about what plugins may do — that copy is the whole reason the prefix is broad.
|
|
it.each([
|
|
['description', 'Plugins are sandboxed and cannot read your files.'],
|
|
['systemDescription', 'Every plugin here has been reviewed by Orca.'],
|
|
['featureOff', 'Installed plugins keep running while the system is off.']
|
|
])('still refuses PluginsSettingsSection.%s', (key, value) => {
|
|
expect(
|
|
parsePluginLanguagePackArtifact(
|
|
JSON.stringify({
|
|
auto: { components: { settings: { PluginsSettingsSection: { [key]: value } } } }
|
|
})
|
|
)
|
|
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
|
|
})
|
|
|
|
it('still refuses the development-plugin permission promise', () => {
|
|
expect(
|
|
parsePluginLanguagePackArtifact(
|
|
JSON.stringify({
|
|
auto: {
|
|
components: {
|
|
settings: {
|
|
PluginDevelopmentSection: { help: 'Dev plugins skip permission review.' }
|
|
}
|
|
}
|
|
}
|
|
})
|
|
)
|
|
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
|
|
})
|
|
|
|
it('still refuses install failure copy that reports a trust event', () => {
|
|
expect(
|
|
parsePluginLanguagePackArtifact(
|
|
JSON.stringify({
|
|
auto: {
|
|
components: {
|
|
settings: { PluginMarketplaceBrowser: { installFailed: 'Installed successfully.' } }
|
|
}
|
|
}
|
|
})
|
|
)
|
|
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
|
|
})
|
|
|
|
it('forges no trust badge: the community→Official swap is refused', () => {
|
|
expect(
|
|
parsePluginLanguagePackArtifact(
|
|
JSON.stringify({
|
|
auto: {
|
|
components: {
|
|
settings: { PluginConsentProvenance: { community: 'Official' } }
|
|
}
|
|
}
|
|
})
|
|
)
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: expect.stringContaining('auto.components.settings.PluginConsentProvenance')
|
|
})
|
|
})
|
|
|
|
it('still lets language packs translate non-plugin settings copy', () => {
|
|
expect(
|
|
parsePluginLanguagePackArtifact(
|
|
JSON.stringify({
|
|
auto: { components: { settings: { AppearanceSection: { title: 'Apariencia' } } } }
|
|
})
|
|
).ok
|
|
).toBe(true)
|
|
})
|
|
|
|
it.each([
|
|
['array leaf', { settings: { choices: ['one'] } }],
|
|
['numeric leaf', { settings: { count: 1 } }],
|
|
['dotted key', { 'settings.title': 'Title' }],
|
|
['prototype key', JSON.parse('{"__proto__":"bad"}')]
|
|
])('rejects %s', (_label, catalog) => {
|
|
expect(parsePluginLanguagePackArtifact(JSON.stringify(catalog)).ok).toBe(false)
|
|
})
|
|
|
|
it('rejects excessive nesting without recursive validation', () => {
|
|
let catalog: Record<string, unknown> = { value: 'deep' }
|
|
for (let index = 0; index <= PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH; index += 1) {
|
|
catalog = { nested: catalog }
|
|
}
|
|
expect(parsePluginLanguagePackArtifact(JSON.stringify(catalog))).toMatchObject({
|
|
ok: false,
|
|
error: expect.stringContaining('depth')
|
|
})
|
|
})
|
|
|
|
it('accepts the entry limit and rejects one additional entry', () => {
|
|
const catalog = Object.fromEntries(
|
|
Array.from({ length: PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES }, (_, index) => [
|
|
`key${index}`,
|
|
'value'
|
|
])
|
|
)
|
|
|
|
expect(validatePluginLanguagePackCatalog(catalog)).toMatchObject({
|
|
ok: true,
|
|
entries: PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES
|
|
})
|
|
catalog.overflow = 'value'
|
|
expect(validatePluginLanguagePackCatalog(catalog)).toMatchObject({
|
|
ok: false,
|
|
error: expect.stringContaining(`${PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES} entries`)
|
|
})
|
|
})
|
|
|
|
it('shape-validates 16 maximum-entry registrations without returning catalog copies', () => {
|
|
const catalog = Object.fromEntries(
|
|
Array.from({ length: PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES }, (_, index) => [
|
|
`key${index}`,
|
|
'value'
|
|
])
|
|
)
|
|
const packs = Array.from({ length: 16 }, (_, index) => {
|
|
const id = `plugin:maximum-${index}` as const
|
|
return {
|
|
id,
|
|
resourceLanguage: pluginLanguageResourceId(id),
|
|
pluginKey: `maximum-${index}`,
|
|
locale: 'en',
|
|
catalog
|
|
}
|
|
})
|
|
|
|
expect(validatePluginLanguagePackCatalogShape(catalog)).toEqual({
|
|
ok: true,
|
|
entries: PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES
|
|
})
|
|
expect(packs.filter(isPluginLanguagePackRegistration)).toHaveLength(16)
|
|
})
|
|
})
|