From a6c8775f121abb846f1dab0e3b5c09a40f45632f Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 11:08:59 +0800 Subject: [PATCH] fix(session): reject sparse provenance arrays --- packages/core/session/src/surface.ts | 79 ++++++++++--------- packages/core/session/tests/surface.spec.ts | 1 + .../session-query/tests/tracing.spec.ts | 3 + 3 files changed, 46 insertions(+), 37 deletions(-) diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index d585ab9798..1662d2d80d 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -123,8 +123,8 @@ function isReplaceOp(value: object): value is Extract= event.seq) { - throw new Error(`sourceEventSeqs must reference earlier events: ${source} >= current seq ${event.seq}`) + const raw = (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs + const sources = new Set() + if (raw !== undefined) { + if (!Array.isArray(raw)) { + throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`) + } + if (raw.length === 0) { + throw new Error('sourceEventSeqs must not be empty when present') + } + let nonEarlierSource: number | undefined + 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) { 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) { throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`) } - const surfaceEvent = surfaceEventOf(event) - if (surfaceEvent === undefined) return - if (surfaceEvent.surfaceOp === 'append') { - assertProvenance(surfaceEvent, []) + const surfaceOp = surfaceOpOf(event) + if (surfaceOp === undefined) return + if (surfaceOp === 'append') { + assertProvenance(event, []) return { kind: 'append', seq: event.seq } } - const range = replacementRange(state, surfaceEvent.surfaceOp) - assertProvenance(surfaceEvent, range.shadowedSeqs) + const range = replacementRange(state, surfaceOp) + assertProvenance(event, range.shadowedSeqs) return { kind: 'replace', seq: event.seq, - start: surfaceEvent.surfaceOp.start, - end: surfaceEvent.surfaceOp.end, + start: surfaceOp.start, + end: surfaceOp.end, ...range, } } diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 87012b668d..1615e12f4b 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -58,6 +58,7 @@ describe('foldSurface provenance', () => { ['a non-array', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: 'invalid' }], /must be an array/], ['an empty array', [provenanceEvent(0, [])], /must not be empty/], ['duplicates', [provenanceEvent(0, undefined), provenanceEvent(1, [0, 0])], /must not contain duplicates/], + ['a sparse array', [provenanceEvent(0, Array(1))], /densely contain/], ['a non-number', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: ['0'] }], /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/], diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index efc32d2216..eb22a9f95c 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -358,6 +358,9 @@ describe('session event tracing', () => { ['empty sources', [ appendEvent(0, []), ]], + ['sparse sources', [ + appendEvent(0, Array(1)), + ]], ['duplicate sources', [ appendEvent(0), appendEvent(1, [0, 0]),