fix: harden malformed renderer state normalization

This commit is contained in:
OrcaWin
2026-08-09 23:13:45 -07:00
parent f25b2bb237
commit 01de449a99
7 changed files with 236 additions and 16 deletions
@@ -144,6 +144,43 @@ describe('profile project transfer', () => {
)
})
it('loads null and malformed project containers without inventing project rows', async () => {
const validProject = {
id: 'folder-project',
displayName: 'Folder Project',
badgeColor: '#737373',
sourceRepoIds: [],
createdAt: 1,
updatedAt: 1
}
const validSetup = {
id: 'folder-project::local',
projectId: 'folder-project',
hostId: 'local' as const,
repoId: '',
path: 'C:\\workspace\\folder-project',
displayName: 'Folder Project',
setupState: 'ready' as const,
setupMethod: 'imported-existing-folder' as const,
createdAt: 1,
updatedAt: 1
}
writeProfileState('personal', makeState({ projects: null as never }))
writeProfileState(
'work',
makeState({
projects: [null, 42, [], validProject, 'project'] as never,
projectHostSetups: [validSetup]
})
)
const { readProfileState: readPersistedProfileState } =
await import('./profile-project-state-file')
expect(readPersistedProfileState('personal', testState.dir).projects).toEqual([])
expect(readPersistedProfileState('work', testState.dir).projects).toEqual([validProject])
})
it('copies a project into another profile with a new repo id and re-keyed metadata', async () => {
const sourceWorktreeId = 'repo-1::/workspace/orca-feature'
writeProfileState(
+37
View File
@@ -590,6 +590,43 @@ describe('Store', () => {
})
})
it('loads a null persisted project container as empty', async () => {
writeDataFile({
...getDefaultPersistedState(testState.dir),
projects: null
})
const store = await createStore()
expect(store.getProjects()).toEqual([])
store.flush()
expect((readDataFile() as PersistedState).projects).toEqual([])
})
it('drops malformed persisted project members before compatibility projection', async () => {
const first = makeProject({ id: 'folder-first', displayName: 'First' })
const second = makeProject({ id: 'folder-second', displayName: 'Second' })
const firstSetup = makeProjectHostSetup({
id: 'folder-first::local',
projectId: first.id,
path: 'C:\\workspace\\first'
})
const secondSetup = makeProjectHostSetup({
id: 'folder-second::local',
projectId: second.id,
path: 'C:\\workspace\\second'
})
writeDataFile({
...getDefaultPersistedState(testState.dir),
projects: [null, first, 42, [], second, 'project'],
projectHostSetups: [firstSetup, secondSetup]
})
const store = await createStore()
expect(store.getProjects()).toEqual([first, second])
})
it('updates and persists a project Windows runtime preference', async () => {
const project = makeProject({
id: 'project-1',
@@ -60,6 +60,56 @@ describe('plugin language pack loading', () => {
expect(usePluginLanguagePackStore.getState().packs).toEqual([first, second])
})
it.each([
['Date', new Date(0)],
['Map', new Map([['greeting', 'Hello']])]
])('rejects a %s language-pack catalog at renderer ingress', async (_name, catalog) => {
const id = 'plugin:malformed' as const
vi.stubGlobal('window', {
api: {
plugins: {
listLanguagePacks: vi.fn().mockResolvedValue([
{
id,
resourceLanguage: pluginLanguageResourceId(id),
pluginKey: 'malformed',
locale: 'en',
catalog
}
])
}
}
})
await usePluginLanguagePackStore.getState().fetchPacks()
expect(usePluginLanguagePackStore.getState().packs).toEqual([])
})
it('rejects a structured-cloned cyclic catalog before i18next resource installation', async () => {
const id = 'plugin:cyclic' as const
const catalog: Record<string, unknown> = { greeting: 'Hello' }
catalog.self = catalog
const response = structuredClone([
{
id,
resourceLanguage: pluginLanguageResourceId(id),
pluginKey: 'cyclic',
locale: 'en',
catalog
}
])
vi.stubGlobal('window', {
api: { plugins: { listLanguagePacks: vi.fn().mockResolvedValue(response) } }
})
await usePluginLanguagePackStore.getState().fetchPacks()
const packs = usePluginLanguagePackStore.getState().packs
expect(() => setRendererPluginLanguagePacks(packs)).not.toThrow()
expect(packs).toEqual([])
})
it('rejects an inconsistent resource language before i18next owns its cleanup', async () => {
vi.stubGlobal('window', {
api: {
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
parsePluginLanguagePackArtifact,
PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH,
PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES,
validatePluginLanguagePackCatalog,
pluginLanguageResourceId
} from './plugin-language-pack-artifact'
@@ -163,4 +165,23 @@ describe('plugin language-pack artifacts', () => {
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`)
})
})
})
@@ -37,9 +37,7 @@ export function isPluginLanguagePackRegistration(
pack.resourceLanguage === pluginLanguageResourceId(pack.id as `plugin:${string}`) &&
typeof pack.pluginKey === 'string' &&
typeof pack.locale === 'string' &&
typeof pack.catalog === 'object' &&
pack.catalog !== null &&
!Array.isArray(pack.catalog)
validatePluginLanguagePackCatalog(pack.catalog).ok
)
}
@@ -65,7 +63,12 @@ type CatalogFrame = {
}
function isCatalogObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
)
}
function protectedTranslation(path: string): boolean {
@@ -89,12 +92,17 @@ export function parsePluginLanguagePackArtifact(raw: string): PluginLanguagePack
} catch {
return { ok: false, error: 'language pack must contain one JSON object' }
}
if (!isCatalogObject(json)) {
return validatePluginLanguagePackCatalog(json)
}
export function validatePluginLanguagePackCatalog(source: unknown): PluginLanguagePackParseResult {
if (!isCatalogObject(source)) {
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 }]
const stack: CatalogFrame[] = [{ source, target: catalog, path: '', depth: 0 }]
const seen = new WeakSet<object>([source])
let entries = 0
while (stack.length > 0) {
const frame = stack.pop()!
@@ -136,6 +144,10 @@ export function parsePluginLanguagePackArtifact(raw: string): PluginLanguagePack
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<string, unknown> = {}
frame.target[key] = child
stack.push({ source: value, target: child, path, depth: frame.depth + 1 })
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import type { Project } from './types'
import { normalizePersistedProjectSourceRepoIds } from './project-source-repo-id-normalization'
function makeProject(id: string, sourceRepoIds: unknown = []): Project {
return {
id,
displayName: id,
badgeColor: '#737373',
sourceRepoIds,
createdAt: 1,
updatedAt: 1
} as Project
}
describe('normalizePersistedProjectSourceRepoIds', () => {
it.each([null, undefined, {}, 'projects'])('treats a non-array container as empty', (input) => {
expect(normalizePersistedProjectSourceRepoIds(input)).toEqual({ projects: [], changed: true })
})
it('drops malformed members while preserving valid row identity and order', () => {
const first = Object.freeze(makeProject('first'))
const second = Object.freeze(makeProject('second', ['repo-2']))
const input = Object.freeze([null, first, 42, [], second, 'project'])
const result = normalizePersistedProjectSourceRepoIds(input)
expect(result).toEqual({ projects: [first, second], changed: true })
expect(result.projects[0]).toBe(first)
expect(result.projects[1]).toBe(second)
})
it('copies frozen rows only when source repo ids need repair', () => {
const missing = Object.freeze(makeProject('missing', null))
const mixedIds = Object.freeze(makeProject('mixed', Object.freeze(['repo-1', null, 7])))
const input = Object.freeze([missing, mixedIds])
const result = normalizePersistedProjectSourceRepoIds(input)
expect(result.projects).toEqual([
{ ...missing, sourceRepoIds: [] },
{ ...mixedIds, sourceRepoIds: ['repo-1'] }
])
expect(result.projects[0]).not.toBe(missing)
expect(result.projects[1]).not.toBe(mixedIds)
})
})
@@ -1,24 +1,40 @@
import type { Project } from './types'
export function normalizePersistedProjectSourceRepoIds(projects: readonly Project[]): {
function isProjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function normalizePersistedProjectSourceRepoIds(projects: unknown): {
projects: Project[]
changed: boolean
} {
if (!Array.isArray(projects)) {
return { projects: [], changed: true }
}
let changed = false
const normalizedProjects = projects.map((project) => {
const normalizedProjects: Project[] = []
for (const candidate of projects) {
if (!isProjectRecord(candidate)) {
changed = true
continue
}
const project = candidate as Project
const sourceRepoIds = candidate.sourceRepoIds
if (
Array.isArray(project.sourceRepoIds) &&
project.sourceRepoIds.every((repoId) => typeof repoId === 'string')
Array.isArray(sourceRepoIds) &&
sourceRepoIds.every((repoId) => typeof repoId === 'string')
) {
return project
normalizedProjects.push(project)
continue
}
changed = true
return {
normalizedProjects.push({
...project,
sourceRepoIds: Array.isArray(project.sourceRepoIds)
? project.sourceRepoIds.filter((repoId): repoId is string => typeof repoId === 'string')
sourceRepoIds: Array.isArray(sourceRepoIds)
? sourceRepoIds.filter((repoId): repoId is string => typeof repoId === 'string')
: []
}
})
})
}
return { projects: normalizedProjects, changed }
}