Skip to content

Commit ebe0ae9

Browse files
committed
fix: keep record diff dimensions unrestricted, bound only hash work
Sessions may be created or resized to any positive dimensions, so the side schema must not narrow the public contract. Drop the cols/rows cap and instead skip the pre-event blank-hash equality check above a 100k-row work bound, keeping safeParse cost bounded without rejecting contract-valid results.
1 parent 45dfeda commit ebe0ae9

2 files changed

Lines changed: 19 additions & 12 deletions

File tree

src/protocol/schemas.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -501,20 +501,20 @@ export const RecordDiffLineSchema = z.discriminatedUnion('op', [
501501
]);
502502
export type RecordDiffLine = z.infer<typeof RecordDiffLineSchema>;
503503

504-
// Generous ceiling on diffable screen dimensions; real terminals are far
505-
// below it. Bounding rows keeps validation-time work (e.g. hashing the
506-
// blank pre-event screen) proportional to a schema-checked limit instead of
507-
// attacker-controlled input.
508-
const MAX_RECORD_DIFF_DIMENSION = 100_000;
504+
// Work bound for validation-time blank-screen hashing only. Dimensions are
505+
// deliberately NOT capped (the session contract accepts any positive size);
506+
// above this row count the pre-event blank-hash equality check is skipped so
507+
// safeParse never performs unbounded work on attacker-controlled input.
508+
const MAX_BLANK_HASH_ROWS = 100_000;
509509

510510
export const RecordDiffSideSchema = z
511511
.object({
512512
sessionId: NonEmptyStringSchema,
513513
// -1 mirrors ReplayInput.targetSeq for an empty event log: the side is
514514
// the pre-event blank screen and no event sequence was replayed.
515515
capturedAtSeq: z.number().int().gte(-1),
516-
cols: PositiveIntSchema.lte(MAX_RECORD_DIFF_DIMENSION),
517-
rows: PositiveIntSchema.lte(MAX_RECORD_DIFF_DIMENSION),
516+
cols: PositiveIntSchema,
517+
rows: PositiveIntSchema,
518518
screenHash: Sha256HexSchema,
519519
})
520520
.strict();
@@ -558,11 +558,14 @@ export const RecordDiffResultSchema = z
558558
}
559559

560560
// capturedAtSeq -1 marks the pre-event blank screen, so such a side must
561-
// hash to `rows` empty canonical lines.
561+
// hash to `rows` empty canonical lines. Skipped above the work bound so
562+
// validation cost stays bounded for arbitrarily large (but contract-
563+
// valid) dimensions.
562564
for (const sideKey of ['a', 'b'] as const) {
563565
const side = value[sideKey];
564566
if (
565567
side.capturedAtSeq === -1 &&
568+
side.rows <= MAX_BLANK_HASH_ROWS &&
566569
side.screenHash !== sha256Hex('\n'.repeat(side.rows - 1))
567570
) {
568571
ctx.addIssue({

test/unit/protocol/messages.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -846,23 +846,27 @@ describe('RPC message schemas', () => {
846846
).toBe(false);
847847
});
848848

849-
it('bounds record diff side dimensions', () => {
849+
it('accepts huge session dimensions without unbounded validation work', () => {
850+
// Dimensions accepted by the session contract must validate here too;
851+
// above the work bound the blank-hash equality check is skipped, so this
852+
// parses quickly regardless of the declared hash.
850853
const side = {
851854
sessionId: 'session-01',
852855
capturedAtSeq: -1,
853856
cols: 80,
854-
rows: 100_001,
857+
rows: 1_000_000_000,
855858
screenHash: 'a'.repeat(64),
856859
};
857-
// Oversized rows fail schema validation before any blank-screen hashing.
860+
const started = Date.now();
858861
expect(
859862
RecordDiffResultSchema.safeParse({
860863
identical: true,
861864
a: side,
862865
b: side,
863866
diff: [],
864867
}).success,
865-
).toBe(false);
868+
).toBe(true);
869+
expect(Date.now() - started).toBeLessThan(1_000);
866870
});
867871

868872
it('requires pre-event record diff sides to hash to a blank screen', () => {

0 commit comments

Comments
 (0)