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']) // Protect plugin security copy across the whole namespace so extracted/new dialogs cannot become writable. // Exact nonsecurity chrome exemptions live in plugin-translatable-chrome.ts. const PROTECTED_TRANSLATION_ROOT = 'auto.components.settings.' const PROTECTED_TRANSLATION_MODULE = /^plugin/i export type PluginLanguagePackRegistration = { id: `plugin:${string}` resourceLanguage: string pluginKey: string locale: string displayName?: string catalog: Record } export function isPluginLanguagePackRegistration( value: unknown ): value is PluginLanguagePackRegistration { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return false } const pack = value as Record return ( typeof pack.id === 'string' && pack.id.startsWith('plugin:') && typeof pack.resourceLanguage === 'string' && pack.resourceLanguage === pluginLanguageResourceId(pack.id as `plugin:${string}`) && typeof pack.pluginKey === 'string' && typeof pack.locale === 'string' && (pack.displayName === undefined || typeof pack.displayName === 'string') && checkPluginLanguagePackCatalog(pack.catalog).ok ) } // Internal i18next ID only; settings and IPC keep the qualified `plugin:` ID. export function pluginLanguageResourceId(id: `plugin:${string}`): string { // Fixed-width UTF-8 hex keeps IDs collision-free; private-use subtags allow at most 8 chars. const encoded = Array.from(new TextEncoder().encode(id.slice('plugin:'.length)), (byte) => byte.toString(16).padStart(2, '0') ).join('') const subtags = encoded.match(/.{1,8}/g) ?? [] // The real base locale lets Intl and i18next pick the pack's plural rules natively. return `${pluginPackBaseLocale(id.slice(id.lastIndexOf('/') + 1))}-x-orcapk-${subtags.join('-')}` } function pluginPackBaseLocale(locale: string): string { try { const supported = Intl.PluralRules.supportedLocalesOf(locale)[0] // Unsupported tags would get the runtime's plural rules; use English like other unknown locales. return supported ? new Intl.Locale(supported).baseName : 'en' } catch { return 'en' } } export type PluginLanguagePackParseResult = | { ok: true; catalog: Record; entries: number } | { ok: false; error: string } export type PluginLanguagePackValidationResult = | { ok: true; entries: number } | { ok: false; error: string } type PluginLanguagePackWalkResult = | { ok: true; catalog: Record | null; entries: number } | { ok: false; error: string } type CatalogFrame = { source: Record target: Record | null path: string depth: number } export function isCatalogObject(value: unknown): value is Record { return ( typeof value === 'object' && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype ) } 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 { if (key.includes('.')) { return true } for (let index = 0; index < key.length; index += 1) { if (key.charCodeAt(index) <= 31) { return true } } return false } 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' } } return validatePluginLanguagePackCatalog(json) } export function validatePluginLanguagePackCatalog(source: unknown): PluginLanguagePackParseResult { const result = walkPluginLanguagePackCatalog(source, true) if (!result.ok) { return result } return { ok: true, catalog: result.catalog!, entries: result.entries } } export function checkPluginLanguagePackCatalog( source: unknown ): PluginLanguagePackValidationResult { const result = walkPluginLanguagePackCatalog(source, false) return result.ok ? { ok: true, entries: result.entries } : result } function walkPluginLanguagePackCatalog( source: unknown, copyCatalog: boolean ): PluginLanguagePackWalkResult { if (!isCatalogObject(source)) { return { ok: false, error: 'language pack root must be an object' } } const catalog: Record | null = copyCatalog ? {} : null const stack: CatalogFrame[] = [{ source, target: catalog, path: '', depth: 0 }] const seen = new WeakSet([source]) 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 of Object.keys(frame.source)) { const value = frame.source[key] 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 // Walk protected containers only to reach exempt chrome; still check every leaf’s 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` } } if (frame.target) { frame.target[key] = value } continue } if (!isCatalogObject(value)) { return { ok: false, error: `translation at ${path} must be a string or object` } } if (seen.has(value)) { return { ok: false, error: `catalog contains a repeated or cyclic object at ${path}` } } seen.add(value) const child: Record | null = frame.target ? {} : null if (frame.target && child) { frame.target[key] = child } stack.push({ source: value, target: child, path, depth: frame.depth + 1 }) } } return { ok: true, catalog, entries } }