mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 00:02:34 +00:00
* wip: normalize untrusted project catalog rows at load boundaries
* fix(catalog): make ProjectHostSetup field types true at the ingest boundary
Crash 3bcc5be3: a setup row whose repoId arrived null reached Settings'
projectByRepoId memo and threw on .trim(). The type said `string`; persisted
JSON and remote hosts on other versions can disagree.
Normalize project/setup rows where untrusted data enters typed code — the
persisted-state load (marking dirty, which is the migration), profile
transfer reads, the repo-derived projection, and the renderer's IPC/RPC
ingest and adoption steps — instead of re-guarding each consumer. Also
covers `setup.path`, whose identical crash is on the sidebar render path.
Coercion only: never drops a row, never adds or removes an optional key, and
returns input references when a row already conforms, so selector and useMemo
identity is unchanged.
* refactor(catalog): drop the `as` casts the normalizer introduced
A change whose thesis is "stop the declared types from lying" should not use
`as` to paper over types.
The four source casts all came from the row normalizers returning `readonly`
arrays into mutably-owned fields. Take and return mutable arrays instead, and
copy at the one caller that holds a readonly projection — where identity is
not load-bearing, unlike the persistence path, whose dirty check compares it.
Tests built deliberately malformed rows by casting a literal. Build a valid
row and `Reflect.set` the bad value onto it, which says outright that the
fixture violates its type; parse the non-array case from JSON, which is how
it actually arrives. Fixtures that were merely incomplete needed no cast at
all — `Repo` requires only the five fields they already had.
Also narrow normalizeLoadedProjectCatalog to the two fields it reads.
* refactor(profiles): validate untrusted profile JSON instead of asserting it
Removes the last introduced cast and the unsound pre-existing ones in the
file this change already touches.
The test cast is gone because narrowing normalizeLoadedProjectCatalog to the
two fields it reads made `{}` assignable on its own.
arrayOrEmpty and recordOrEmpty checked the shape and then asserted the
element type, which is the same "declared type is a promise the data does not
keep" problem this change exists to fix. Array.isArray already narrows on its
own, and a generic isRecord narrows the value part, so both assertions delete
outright. JSON.parse returns any, so annotating the binding beats asserting
its result.
The two remaining `as const` in project-host-setup-actions are untouched and
deliberate: a literal assertion narrows a type rather than overriding it.
* Make project catalog normalizers handle null rows defensively
Instead of crashing when corrupt or null catalog rows are encountered,
the normalizers now gracefully repair them with default values. Refactored
helper functions for clarity and added type predicates to improve type
narrowing.
208 lines
7.6 KiB
TypeScript
208 lines
7.6 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import type { Project, ProjectHostSetup } from './project-types'
|
|
import {
|
|
normalizeProjectHostSetupRow,
|
|
normalizeProjectHostSetupRows,
|
|
normalizeProjectRow,
|
|
normalizeProjectRows
|
|
} from './project-catalog-row-normalization'
|
|
|
|
// Why Reflect.set and not a cast: these fixtures deliberately violate the declared types, which is
|
|
// the whole point — writing the bad value onto a valid row says that outright.
|
|
function corrupt<T extends object>(row: T, overrides: Record<string, unknown>): T {
|
|
for (const [key, value] of Object.entries(overrides)) {
|
|
Reflect.set(row, key, value)
|
|
}
|
|
return row
|
|
}
|
|
|
|
function makeSetup(overrides: Record<string, unknown> = {}): ProjectHostSetup {
|
|
const setup: ProjectHostSetup = {
|
|
id: 'setup-1',
|
|
projectId: 'project-1',
|
|
hostId: 'local',
|
|
repoId: 'repo-1',
|
|
path: '/Users/alice/orca',
|
|
displayName: 'orca',
|
|
setupState: 'ready',
|
|
setupMethod: 'legacy-repo',
|
|
createdAt: 1,
|
|
updatedAt: 2
|
|
}
|
|
return corrupt(setup, overrides)
|
|
}
|
|
|
|
function makeProject(overrides: Record<string, unknown> = {}): Project {
|
|
const project: Project = {
|
|
id: 'project-1',
|
|
displayName: 'Project',
|
|
badgeColor: '#737373',
|
|
sourceRepoIds: ['repo-1'],
|
|
createdAt: 1,
|
|
updatedAt: 2
|
|
}
|
|
return corrupt(project, overrides)
|
|
}
|
|
|
|
describe('normalizeProjectHostSetupRow', () => {
|
|
it('returns the input reference when every declared type already holds', () => {
|
|
const setup = makeSetup()
|
|
expect(normalizeProjectHostSetupRow(setup)).toBe(setup)
|
|
})
|
|
|
|
it.each([
|
|
['null', null],
|
|
['undefined', undefined],
|
|
['a number', 42],
|
|
['an object', {}]
|
|
])('coerces a %s repoId to an empty string', (_label, repoId) => {
|
|
expect(normalizeProjectHostSetupRow(makeSetup({ repoId })).repoId).toBe('')
|
|
})
|
|
|
|
// Why path: `project-grouping.ts` calls `.trim()` and `parseWslUncPath()` on it during
|
|
// sidebar render, so the same bad row takes down the main window, not one error boundary.
|
|
it.each([
|
|
['null', null],
|
|
['a number', 7]
|
|
])('coerces a %s path to an empty string', (_label, path) => {
|
|
expect(normalizeProjectHostSetupRow(makeSetup({ path })).path).toBe('')
|
|
})
|
|
|
|
it('coerces every other required string field', () => {
|
|
const normalized = normalizeProjectHostSetupRow(
|
|
makeSetup({ id: null, projectId: null, displayName: null })
|
|
)
|
|
expect(normalized.id).toBe('')
|
|
expect(normalized.projectId).toBe('')
|
|
expect(normalized.displayName).toBe('')
|
|
})
|
|
|
|
it('falls back to the local host id for a non-string hostId', () => {
|
|
expect(normalizeProjectHostSetupRow(makeSetup({ hostId: null })).hostId).toBe('local')
|
|
})
|
|
|
|
// Why 0: the catalog merge already reads 0 as "unknown", so it degrades instead of
|
|
// restamping a fake timestamp that would win min()/max() against a real one.
|
|
it.each([
|
|
['null', null],
|
|
['NaN', Number.NaN],
|
|
['a string', '5']
|
|
])('coerces a %s timestamp to 0', (_label, value) => {
|
|
const normalized = normalizeProjectHostSetupRow(
|
|
makeSetup({ createdAt: value, updatedAt: value })
|
|
)
|
|
expect(normalized.createdAt).toBe(0)
|
|
expect(normalized.updatedAt).toBe(0)
|
|
})
|
|
|
|
// Why: these are only ever compared, never string-method'd — inventing a fallback would
|
|
// silently change what a corrupt row claims about itself.
|
|
it('leaves the setupState and setupMethod unions untouched', () => {
|
|
const normalized = normalizeProjectHostSetupRow(
|
|
makeSetup({ repoId: null, setupState: 'bogus', setupMethod: null })
|
|
)
|
|
expect(normalized.setupState).toBe('bogus')
|
|
expect(normalized.setupMethod).toBeNull()
|
|
})
|
|
|
|
// Why: `mergeProjectCompatibilityProject` and the setup projection branch on `in` /
|
|
// `!== undefined`, so materialising an absent optional key changes merge behaviour.
|
|
it('never adds or removes an optional key', () => {
|
|
const withoutOptionals = normalizeProjectHostSetupRow(makeSetup({ repoId: null }))
|
|
expect('connectionId' in withoutOptionals).toBe(false)
|
|
expect('worktreeBasePath' in withoutOptionals).toBe(false)
|
|
const withOptionals = normalizeProjectHostSetupRow(
|
|
makeSetup({ repoId: null, connectionId: null, worktreeBasePath: '/base' })
|
|
)
|
|
expect('connectionId' in withOptionals).toBe(true)
|
|
expect(withOptionals.worktreeBasePath).toBe('/base')
|
|
})
|
|
})
|
|
|
|
describe('normalizeProjectRow', () => {
|
|
it('returns the input reference when every declared type already holds', () => {
|
|
const project = makeProject()
|
|
expect(normalizeProjectRow(project)).toBe(project)
|
|
})
|
|
|
|
// Why: the compatibility merge spreads `sourceRepoIds`, which throws on a non-array.
|
|
it.each([
|
|
['null', null, []],
|
|
['a string', 'repo-1', []],
|
|
['an array with holes', ['repo-1', null, 3], ['repo-1']]
|
|
])('repairs a %s sourceRepoIds', (_label, sourceRepoIds, expected) => {
|
|
expect(normalizeProjectRow(makeProject({ sourceRepoIds })).sourceRepoIds).toEqual(expected)
|
|
})
|
|
|
|
it('coerces the required string fields', () => {
|
|
const normalized = normalizeProjectRow(
|
|
makeProject({ id: null, displayName: null, badgeColor: 42 })
|
|
)
|
|
expect(normalized.id).toBe('')
|
|
expect(normalized.displayName).toBe('')
|
|
expect(normalized.badgeColor).toBe('')
|
|
})
|
|
|
|
// Why: `'localWindowsRuntimePreference' in overlay` decides whether a preference clear wins.
|
|
it('never materialises an absent localWindowsRuntimePreference', () => {
|
|
const normalized = normalizeProjectRow(makeProject({ id: null }))
|
|
expect('localWindowsRuntimePreference' in normalized).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('row array normalization', () => {
|
|
it('repairs null rows without throwing', () => {
|
|
const setups = [null] as unknown as ProjectHostSetup[]
|
|
const projects = [null] as unknown as Project[]
|
|
expect(normalizeProjectHostSetupRows(setups)[0]).toMatchObject({
|
|
id: '',
|
|
repoId: '',
|
|
path: ''
|
|
})
|
|
expect(normalizeProjectRows(projects)[0]).toMatchObject({
|
|
id: '',
|
|
sourceRepoIds: []
|
|
})
|
|
})
|
|
|
|
it('returns the input array when nothing needed repair', () => {
|
|
const setups = [makeSetup(), makeSetup({ id: 'setup-2' })]
|
|
expect(normalizeProjectHostSetupRows(setups)).toBe(setups)
|
|
const projects = [makeProject()]
|
|
expect(normalizeProjectRows(projects)).toBe(projects)
|
|
})
|
|
|
|
// Why: TerminalPane and TabBarQuickCommandsButton use these arrays as useMemo deps, so an
|
|
// untouched row must keep its object identity across normalization.
|
|
it('reuses untouched rows and only reallocates the repaired one', () => {
|
|
const setups = [
|
|
makeSetup(),
|
|
makeSetup({ id: 'setup-2', repoId: null }),
|
|
makeSetup({ id: 's3' })
|
|
]
|
|
const normalized = normalizeProjectHostSetupRows(setups)
|
|
expect(normalized).not.toBe(setups)
|
|
expect(normalized[0]).toBe(setups[0])
|
|
expect(normalized[1]).not.toBe(setups[1])
|
|
expect(normalized[2]).toBe(setups[2])
|
|
})
|
|
|
|
// Why: coercing a field must change one value, never which rows exist — setting a "changed"
|
|
// flag on the old selector normalizer unioned in repo-derived rows and invented phantom rows.
|
|
it('preserves row count and order', () => {
|
|
const setups = [makeSetup({ repoId: null }), makeSetup({ id: 'setup-2' })]
|
|
expect(normalizeProjectHostSetupRows(setups).map((setup) => setup.id)).toEqual([
|
|
'setup-1',
|
|
'setup-2'
|
|
])
|
|
})
|
|
|
|
it('treats a non-array as empty', () => {
|
|
// Why JSON.parse: a persisted catalog really can hold a non-array here, and parsing is how it
|
|
// arrives — the parameter type cannot express it.
|
|
const notAnArray: never[] = JSON.parse('null')
|
|
expect(normalizeProjectHostSetupRows(notAnArray)).toEqual([])
|
|
expect(normalizeProjectRows(notAnArray)).toEqual([])
|
|
})
|
|
})
|