fix(session): reject sparse provenance arrays
This commit is contained in:
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/],
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
|||||||
Reference in New Issue
Block a user