Files
orca/src/shared/plugins/plugin-language-pack-artifact.ts
T
Evgenii 439a8c46cf fix(plugins): let language packs translate plugin chrome (#12455)
protectedTranslation refused every language-pack key under
auto.components.settings.plugin*, which caught 104 keys that carry no trust
meaning — section titles, empty states, Refresh, Add path. A 35-path exact
allowlist opens those while consent, provenance, and every *Failed string stay
protected; anything new stays protected until it is added deliberately.

PluginsSettingsSection.experimental is held back from the contributed
allowlist: the "Experimental" chip is a trust badge, which the module's own
boundary comment places out of scope.

Co-authored-by: Evgenii <kumiro@me.com>
2026-08-04 03:56:33 -07:00

126 lines
4.5 KiB
TypeScript

import {
translatablePluginChrome,
translatablePluginChromeContainer
} from './plugin-translatable-chrome'
export const PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES = 20_000
export const PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH = 16
const DANGEROUS_CATALOG_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
// Why: every plugin-facing security surface lives under this namespace, so
// protecting the whole subtree keeps a new dialog from silently becoming
// plugin-writable the way PluginConsentProvenance did when it was extracted.
// The subtree also holds copy with no security meaning; those exact paths are
// listed in plugin-translatable-chrome.ts and stay translatable.
const PROTECTED_TRANSLATION_ROOT = 'auto.components.settings.'
const PROTECTED_TRANSLATION_MODULE = /^plugin/i
export type PluginLanguagePackRegistration = {
id: `plugin:${string}`
resourceLanguage: `plugin${string}`
pluginKey: string
locale: string
catalog: Record<string, unknown>
}
export function pluginLanguageResourceId(id: `plugin:${string}`): `plugin${string}` {
let encoded = ''
for (let index = 0; index < id.length; index += 1) {
encoded += id.charCodeAt(index).toString(16).padStart(4, '0')
}
// Why: i18next parses punctuation in language tags during resource lookup;
// fixed-width UTF-16 hex preserves a collision-free qualified-ID mapping.
return `plugin${encoded}`
}
export type PluginLanguagePackParseResult =
| { ok: true; catalog: Record<string, unknown>; entries: number }
| { ok: false; error: string }
type CatalogFrame = {
source: Record<string, unknown>
target: Record<string, unknown>
path: string
depth: number
}
function isCatalogObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function protectedTranslation(path: string): boolean {
if (!path.startsWith(PROTECTED_TRANSLATION_ROOT)) {
return false
}
if (translatablePluginChrome(path)) {
return false
}
return PROTECTED_TRANSLATION_MODULE.test(path.slice(PROTECTED_TRANSLATION_ROOT.length))
}
function hasUnsafeCatalogKeyCharacter(key: string): boolean {
return key.includes('.') || Array.from(key).some((character) => character.charCodeAt(0) <= 31)
}
export function parsePluginLanguagePackArtifact(raw: string): PluginLanguagePackParseResult {
let json: unknown
try {
json = JSON.parse(raw)
} catch {
return { ok: false, error: 'language pack must contain one JSON object' }
}
if (!isCatalogObject(json)) {
return { ok: false, error: 'language pack root must be an object' }
}
const catalog: Record<string, unknown> = {}
const stack: CatalogFrame[] = [{ source: json, target: catalog, path: '', depth: 0 }]
let entries = 0
while (stack.length > 0) {
const frame = stack.pop()!
if (frame.depth > PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH) {
return { ok: false, error: `catalog exceeds depth ${PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH}` }
}
for (const [key, value] of Object.entries(frame.source)) {
entries += 1
if (entries > PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES) {
return {
ok: false,
error: `catalog exceeds ${PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES} entries`
}
}
if (
key.length === 0 ||
key.length > 128 ||
DANGEROUS_CATALOG_KEYS.has(key) ||
hasUnsafeCatalogKeyCharacter(key)
) {
return { ok: false, error: `catalog key ${key || '(empty)'} is not safe` }
}
const path = frame.path ? `${frame.path}.${key}` : key
// A protected container stays walkable only when exempt chrome sits below
// it; every leaf inside is still matched against its own full path.
if (
protectedTranslation(path) &&
!(isCatalogObject(value) && translatablePluginChromeContainer(path))
) {
return { ok: false, error: `catalog cannot replace protected security copy at ${path}` }
}
if (typeof value === 'string') {
if (value.length > 8192) {
return { ok: false, error: `translation at ${path} exceeds 8192 characters` }
}
frame.target[key] = value
continue
}
if (!isCatalogObject(value)) {
return { ok: false, error: `translation at ${path} must be a string or object` }
}
const child: Record<string, unknown> = {}
frame.target[key] = child
stack.push({ source: value, target: child, path, depth: frame.depth + 1 })
}
}
return { ok: true, catalog, entries }
}