Skip to content

Commit 18a0d68

Browse files
authored
refactor: update snappy frame decompress (#7333)
* refactor: update snappy frame decompress * chore: fix lint errors * chore: separate code paths for ChunkTypes
1 parent 877b1ae commit 18a0d68

File tree

4 files changed

+115
-48
lines changed

4 files changed

+115
-48
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,38 @@
1+
import crc32c from "@chainsafe/fast-crc32c";
2+
13
export enum ChunkType {
24
IDENTIFIER = 0xff,
35
COMPRESSED = 0x00,
46
UNCOMPRESSED = 0x01,
57
PADDING = 0xfe,
8+
SKIPPABLE = 0x80,
69
}
710

811
export const IDENTIFIER = Buffer.from([0x73, 0x4e, 0x61, 0x50, 0x70, 0x59]);
912
export const IDENTIFIER_FRAME = Buffer.from([0xff, 0x06, 0x00, 0x00, 0x73, 0x4e, 0x61, 0x50, 0x70, 0x59]);
13+
14+
/**
15+
* As per the snappy framing format for streams, the size of any uncompressed chunk can be
16+
* no longer than 65536 bytes.
17+
*
18+
* From: https://github.com/google/snappy/blob/main/framing_format.txt#L90:L92
19+
*/
20+
export const UNCOMPRESSED_CHUNK_SIZE = 65536;
21+
22+
export function crc(value: Uint8Array): Buffer {
23+
// this function doesn't actually need a buffer
24+
// see https://github.com/napi-rs/node-rs/blob/main/packages/crc32/index.d.ts
25+
const x = crc32c.calculate(value as Buffer);
26+
const result = Buffer.allocUnsafe?.(4) ?? Buffer.alloc(4);
27+
28+
// As defined in section 3 of https://github.com/google/snappy/blob/master/framing_format.txt
29+
// And other implementations for reference:
30+
// Go: https://github.com/golang/snappy/blob/2e65f85255dbc3072edf28d6b5b8efc472979f5a/snappy.go#L97
31+
// Python: https://github.com/andrix/python-snappy/blob/602e9c10d743f71bef0bac5e4c4dffa17340d7b3/snappy/snappy.py#L70
32+
// Mask the right hand to (32 - 17) = 15 bits -> 0x7fff, to keep correct 32 bit values.
33+
// Shift the left hand with >>> for correct 32 bit intermediate result.
34+
// Then final >>> 0 for 32 bits output
35+
result.writeUInt32LE((((x >>> 15) | ((x & 0x7fff) << 17)) + 0xa282ead8) >>> 0, 0);
36+
37+
return result;
38+
}

packages/reqresp/src/encodingStrategies/sszSnappy/snappyFrames/compress.ts

+3-32
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,8 @@
1-
import crc32c from "@chainsafe/fast-crc32c";
21
import snappy from "snappy";
3-
import {ChunkType, IDENTIFIER_FRAME} from "./common.js";
2+
import {ChunkType, IDENTIFIER_FRAME, UNCOMPRESSED_CHUNK_SIZE, crc} from "./common.js";
43

54
// The logic in this file is largely copied (in simplified form) from https://github.com/ChainSafe/node-snappy-stream/
65

7-
/**
8-
* As per the snappy framing format for streams, the size of any uncompressed chunk can be
9-
* no longer than 65536 bytes.
10-
*
11-
* From: https://github.com/google/snappy/blob/main/framing_format.txt#L90:L92
12-
*/
13-
const UNCOMPRESSED_CHUNK_SIZE = 65536;
14-
15-
function checksum(value: Buffer): Buffer {
16-
const x = crc32c.calculate(value);
17-
const result = Buffer.allocUnsafe?.(4) ?? Buffer.alloc(4);
18-
19-
// As defined in section 3 of https://github.com/google/snappy/blob/master/framing_format.txt
20-
// And other implementations for reference:
21-
// Go: https://github.com/golang/snappy/blob/2e65f85255dbc3072edf28d6b5b8efc472979f5a/snappy.go#L97
22-
// Python: https://github.com/andrix/python-snappy/blob/602e9c10d743f71bef0bac5e4c4dffa17340d7b3/snappy/snappy.py#L70
23-
// Mask the right hand to (32 - 17) = 15 bits -> 0x7fff, to keep correct 32 bit values.
24-
// Shift the left hand with >>> for correct 32 bit intermediate result.
25-
// Then final >>> 0 for 32 bits output
26-
result.writeUInt32LE((((x >>> 15) | ((x & 0x7fff) << 17)) + 0xa282ead8) >>> 0, 0);
27-
28-
return result;
29-
}
30-
316
export async function* encodeSnappy(bytes: Buffer): AsyncGenerator<Buffer> {
327
yield IDENTIFIER_FRAME;
338

@@ -36,17 +11,13 @@ export async function* encodeSnappy(bytes: Buffer): AsyncGenerator<Buffer> {
3611
const compressed = snappy.compressSync(chunk);
3712
if (compressed.length < chunk.length) {
3813
const size = compressed.length + 4;
39-
yield Buffer.concat([
40-
Buffer.from([ChunkType.COMPRESSED, size, size >> 8, size >> 16]),
41-
checksum(chunk),
42-
compressed,
43-
]);
14+
yield Buffer.concat([Buffer.from([ChunkType.COMPRESSED, size, size >> 8, size >> 16]), crc(chunk), compressed]);
4415
} else {
4516
const size = chunk.length + 4;
4617
yield Buffer.concat([
4718
//
4819
Buffer.from([ChunkType.UNCOMPRESSED, size, size >> 8, size >> 16]),
49-
checksum(chunk),
20+
crc(chunk),
5021
chunk,
5122
]);
5223
}

packages/reqresp/src/encodingStrategies/sszSnappy/snappyFrames/uncompress.ts

+43-16
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {uncompress} from "snappyjs";
22
import {Uint8ArrayList} from "uint8arraylist";
3-
import {ChunkType, IDENTIFIER} from "./common.js";
3+
import {ChunkType, IDENTIFIER, UNCOMPRESSED_CHUNK_SIZE, crc} from "./common.js";
44

55
export class SnappyFramesUncompress {
66
private buffer = new Uint8ArrayList();
@@ -21,32 +21,55 @@ export class SnappyFramesUncompress {
2121
if (this.buffer.length < 4) break;
2222

2323
const type = getChunkType(this.buffer.get(0));
24+
25+
if (!this.state.foundIdentifier && type !== ChunkType.IDENTIFIER) {
26+
throw "malformed input: must begin with an identifier";
27+
}
28+
2429
const frameSize = getFrameSize(this.buffer, 1);
2530

2631
if (this.buffer.length - 4 < frameSize) {
2732
break;
2833
}
2934

30-
const data = this.buffer.subarray(4, 4 + frameSize);
35+
const frame = this.buffer.subarray(4, 4 + frameSize);
3136
this.buffer.consume(4 + frameSize);
3237

33-
if (!this.state.foundIdentifier && type !== ChunkType.IDENTIFIER) {
34-
throw "malformed input: must begin with an identifier";
35-
}
38+
switch (type) {
39+
case ChunkType.IDENTIFIER: {
40+
if (!Buffer.prototype.equals.call(frame, IDENTIFIER)) {
41+
throw "malformed input: bad identifier";
42+
}
43+
this.state.foundIdentifier = true;
44+
continue;
45+
}
46+
case ChunkType.PADDING:
47+
case ChunkType.SKIPPABLE:
48+
continue;
49+
case ChunkType.COMPRESSED: {
50+
const checksum = frame.subarray(0, 4);
51+
const data = frame.subarray(4);
3652

37-
if (type === ChunkType.IDENTIFIER) {
38-
if (!Buffer.prototype.equals.call(data, IDENTIFIER)) {
39-
throw "malformed input: bad identifier";
53+
const uncompressed = uncompress(data, UNCOMPRESSED_CHUNK_SIZE);
54+
if (crc(uncompressed).compare(checksum) !== 0) {
55+
throw "malformed input: bad checksum";
56+
}
57+
result.append(uncompressed);
58+
break;
4059
}
41-
this.state.foundIdentifier = true;
42-
continue;
43-
}
60+
case ChunkType.UNCOMPRESSED: {
61+
const checksum = frame.subarray(0, 4);
62+
const uncompressed = frame.subarray(4);
4463

45-
if (type === ChunkType.COMPRESSED) {
46-
result.append(uncompress(data.subarray(4)));
47-
}
48-
if (type === ChunkType.UNCOMPRESSED) {
49-
result.append(data.subarray(4));
64+
if (uncompressed.length > UNCOMPRESSED_CHUNK_SIZE) {
65+
throw "malformed input: too large";
66+
}
67+
if (crc(uncompressed).compare(checksum) !== 0) {
68+
throw "malformed input: bad checksum";
69+
}
70+
result.append(uncompressed);
71+
break;
72+
}
5073
}
5174
}
5275
if (result.length === 0) {
@@ -82,6 +105,10 @@ function getChunkType(value: number): ChunkType {
82105
case ChunkType.PADDING:
83106
return ChunkType.PADDING;
84107
default:
108+
// https://github.com/google/snappy/blob/main/framing_format.txt#L129
109+
if (value >= 0x80 && value <= 0xfd) {
110+
return ChunkType.SKIPPABLE;
111+
}
85112
throw new Error("Unsupported snappy chunk type");
86113
}
87114
}

packages/reqresp/test/unit/encodingStrategies/sszSnappy/snappyFrames/uncompress.test.ts

+40
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {pipe} from "it-pipe";
22
import {Uint8ArrayList} from "uint8arraylist";
33
import {describe, expect, it} from "vitest";
4+
import {ChunkType, IDENTIFIER_FRAME, crc} from "../../../../../src/encodingStrategies/sszSnappy/snappyFrames/common.js";
45
import {encodeSnappy} from "../../../../../src/encodingStrategies/sszSnappy/snappyFrames/compress.js";
56
import {SnappyFramesUncompress} from "../../../../../src/encodingStrategies/sszSnappy/snappyFrames/uncompress.js";
67

@@ -56,4 +57,43 @@ describe("encodingStrategies / sszSnappy / snappy frames / uncompress", () => {
5657

5758
expect(decompress.uncompress(new Uint8ArrayList(Buffer.alloc(3, 1)))).toBe(null);
5859
});
60+
61+
it("should detect invalid checksum", () => {
62+
const chunks = new Uint8ArrayList();
63+
chunks.append(IDENTIFIER_FRAME);
64+
65+
chunks.append(Uint8Array.from([ChunkType.UNCOMPRESSED, 0x80, 0x00, 0x00]));
66+
// first 4 bytes are checksum
67+
// 0xffffffff is clearly an invalid checksum
68+
chunks.append(Uint8Array.from(Array.from({length: 0x80}, () => 0xff)));
69+
70+
const decompress = new SnappyFramesUncompress();
71+
expect(() => decompress.uncompress(chunks)).toThrow(/checksum/);
72+
});
73+
74+
it("should detect skippable frames", () => {
75+
const chunks = new Uint8ArrayList();
76+
chunks.append(IDENTIFIER_FRAME);
77+
78+
chunks.append(Uint8Array.from([ChunkType.SKIPPABLE, 0x80, 0x00, 0x00]));
79+
chunks.append(Uint8Array.from(Array.from({length: 0x80}, () => 0xff)));
80+
81+
const decompress = new SnappyFramesUncompress();
82+
expect(decompress.uncompress(chunks)).toBeNull();
83+
});
84+
85+
it("should detect large data", () => {
86+
const chunks = new Uint8ArrayList();
87+
chunks.append(IDENTIFIER_FRAME);
88+
89+
// add a chunk of size 100000
90+
chunks.append(Uint8Array.from([ChunkType.UNCOMPRESSED, 160, 134, 1]));
91+
const data = Uint8Array.from(Array.from({length: 100000 - 4}, () => 0xff));
92+
const checksum = crc(data);
93+
chunks.append(checksum);
94+
chunks.append(data);
95+
96+
const decompress = new SnappyFramesUncompress();
97+
expect(() => decompress.uncompress(chunks)).toThrow(/large/);
98+
});
5999
});

0 commit comments

Comments
 (0)