fix(session): reject sparse provenance arrays

This commit is contained in:
Hypatia May
2026-07-16 11:08:59 +08:00
parent 8184332918
commit a6c8775f12
3 changed files with 46 additions and 37 deletions

View File

@@ -123,8 +123,8 @@ function isReplaceOp(value: object): value is Extract<SurfaceOp, { op: 'replace'
&& isEventSeq(op['end']) && isEventSeq(op['end'])
} }
/** Validate event-local metadata and narrow a surface-eligible event. */ /** Validate event-local surface eligibility and return its operation. */
function surfaceEventOf(event: SessionEvent): SurfaceEvent | undefined { function surfaceOpOf(event: SessionEvent): SurfaceOp | undefined {
const raw = event as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown } const raw = event as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown }
if (!isSurfaceEligibleType(event.type)) { if (!isSurfaceEligibleType(event.type)) {
if (raw.surfaceOp !== undefined) { if (raw.surfaceOp !== undefined) {
@@ -135,45 +135,50 @@ function surfaceEventOf(event: SessionEvent): SurfaceEvent | undefined {
} }
return return
} }
if (raw.surfaceOp === undefined) { const op = raw.surfaceOp
if (op === undefined) {
throw new Error(`session event "${event.type}" is surface-eligible and requires a surfaceOp marker`) throw new Error(`session event "${event.type}" is surface-eligible and requires a surfaceOp marker`)
} }
if (raw.surfaceOp !== 'append') { if (op === 'append') return op
if (raw.surfaceOp === null || typeof raw.surfaceOp !== 'object' || Array.isArray(raw.surfaceOp)) { if (op === null || typeof op !== 'object' || Array.isArray(op)) {
throw new Error(`session event "${event.type}" carries an invalid surfaceOp`) throw new Error(`session event "${event.type}" carries an invalid surfaceOp`)
}
if (!isReplaceOp(raw.surfaceOp)) {
throw new Error(`session event "${event.type}" carries an invalid replace surfaceOp`)
}
} }
if (raw.sourceEventSeqs !== undefined && !Array.isArray(raw.sourceEventSeqs)) { if (!isReplaceOp(op)) {
throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`) throw new Error(`session event "${event.type}" carries an invalid replace surfaceOp`)
} }
if (Array.isArray(raw.sourceEventSeqs) && !raw.sourceEventSeqs.every(isEventSeq)) { return op
throw new Error(`session event "${event.type}" sourceEventSeqs must contain non-negative safe integers`)
}
return event as SurfaceEvent
} }
/** Validate provenance against prior log entries and the replacement range. */ /** Validate provenance against prior log entries and the replacement range. */
function assertProvenance( function assertProvenance(
event: SurfaceEvent, event: SessionEvent,
shadowedSeqs: readonly number[], shadowedSeqs: readonly number[],
): void { ): void {
const sources = event.sourceEventSeqs const raw = (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs
if (sources !== undefined && sources.length === 0) { const sources = new Set<number>()
throw new Error('sourceEventSeqs must not be empty when present') if (raw !== undefined) {
} if (!Array.isArray(raw)) {
const sourceSet = new Set(sources ?? []) throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`)
if (sources !== undefined && sourceSet.size !== sources.length) { }
throw new Error('sourceEventSeqs must not contain duplicates') if (raw.length === 0) {
} throw new Error('sourceEventSeqs must not be empty when present')
for (const source of sources ?? []) { }
if (source >= event.seq) { let nonEarlierSource: number | undefined
throw new Error(`sourceEventSeqs must reference earlier events: ${source} >= current seq ${event.seq}`) for (const source of raw) {
if (!isEventSeq(source)) {
throw new Error(`session event "${event.type}" sourceEventSeqs must densely contain non-negative safe integers`)
}
sources.add(source)
if (nonEarlierSource === undefined && source >= event.seq) nonEarlierSource = source
}
if (sources.size !== raw.length) {
throw new Error('sourceEventSeqs must not contain duplicates')
}
if (nonEarlierSource !== undefined) {
throw new Error(`sourceEventSeqs must reference earlier events: ${nonEarlierSource} >= current seq ${event.seq}`)
} }
} }
const missing = shadowedSeqs.filter(seq => !sourceSet.has(seq)) const missing = shadowedSeqs.filter(seq => !sources.has(seq))
if (missing.length > 0) { if (missing.length > 0) {
throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`) throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
} }
@@ -213,19 +218,19 @@ function planSurfaceEvent(
if (event.seq !== expectedSeq) { if (event.seq !== expectedSeq) {
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`) throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
} }
const surfaceEvent = surfaceEventOf(event) const surfaceOp = surfaceOpOf(event)
if (surfaceEvent === undefined) return if (surfaceOp === undefined) return
if (surfaceEvent.surfaceOp === 'append') { if (surfaceOp === 'append') {
assertProvenance(surfaceEvent, []) assertProvenance(event, [])
return { kind: 'append', seq: event.seq } return { kind: 'append', seq: event.seq }
} }
const range = replacementRange(state, surfaceEvent.surfaceOp) const range = replacementRange(state, surfaceOp)
assertProvenance(surfaceEvent, range.shadowedSeqs) assertProvenance(event, range.shadowedSeqs)
return { return {
kind: 'replace', kind: 'replace',
seq: event.seq, seq: event.seq,
start: surfaceEvent.surfaceOp.start, start: surfaceOp.start,
end: surfaceEvent.surfaceOp.end, end: surfaceOp.end,
...range, ...range,
} }
} }

View File

@@ -58,6 +58,7 @@ describe('foldSurface provenance', () => {
['a non-array', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: 'invalid' }], /must be an array/], ['a non-array', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: 'invalid' }], /must be an array/],
['an empty array', [provenanceEvent(0, [])], /must not be empty/], ['an empty array', [provenanceEvent(0, [])], /must not be empty/],
['duplicates', [provenanceEvent(0, undefined), provenanceEvent(1, [0, 0])], /must not contain duplicates/], ['duplicates', [provenanceEvent(0, undefined), provenanceEvent(1, [0, 0])], /must not contain duplicates/],
['a sparse array', [provenanceEvent(0, Array<number>(1))], /densely contain/],
['a non-number', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: ['0'] }], /non-negative safe integers/], ['a non-number', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: ['0'] }], /non-negative safe integers/],
['a fractional number', [provenanceEvent(0, [0.5])], /non-negative safe integers/], ['a fractional number', [provenanceEvent(0, [0.5])], /non-negative safe integers/],
['a negative number', [provenanceEvent(0, [-1])], /non-negative safe integers/], ['a negative number', [provenanceEvent(0, [-1])], /non-negative safe integers/],

View File

@@ -358,6 +358,9 @@ describe('session event tracing', () => {
['empty sources', [ ['empty sources', [
appendEvent(0, []), appendEvent(0, []),
]], ]],
['sparse sources', [
appendEvent(0, Array<number>(1)),
]],
['duplicate sources', [ ['duplicate sources', [
appendEvent(0), appendEvent(0),
appendEvent(1, [0, 0]), appendEvent(1, [0, 0]),