Skip to content

Commit 22f18d0

Browse files
authored
Add a function to create a decoder that checks the size of the input bytes (#944)
#### Problem When an app has access to many codecs, for example when using generated clients, it is easy to write code that accidentally uses the wrong type of `Decoder` for a given byte array. This is difficult to debug as you will often be able to use the wrong decoder without any errors, but will be getting invalid data. #### Summary of Changes This PR adds a new helper function `createDecoderThatUsesExactByteArray` that can wrap any decoder. The transformed decoder throws if the new offset after decoding a byte array, is not at the end of the input bytes. This means that if you pass a byte array that is longer or shorter than the wrapped decoder expects, the decode will fail. As noted in the issue this is not a strong guarantee that you're using the correct decoder, many decoders are the same size or variable size. But it should catch a lot of difficult to debug cases in practice, such as decoding account data using the wrong decoder. Note that to enable this, the signature of `transformDecoder` is modified to additionally provide the `newOffset` to the callback. We use both the original offset and the new offset in the error context. Fixes #755
1 parent 5b735fe commit 22f18d0

File tree

7 files changed

+173
-0
lines changed

7 files changed

+173
-0
lines changed

.changeset/quick-crabs-cut.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@solana/codecs-core': patch
3+
'@solana/errors': patch
4+
---
5+
6+
Add a function to create a decoder that checks the size of the input bytes
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { SOLANA_ERROR__CODECS__EXPECTED_DECODER_TO_CONSUME_ENTIRE_BYTE_ARRAY, SolanaError } from '@solana/errors';
2+
3+
import { createDecoder, Decoder } from '../codec';
4+
import { createDecoderThatConsumesEntireByteArray } from '../decoder-entire-byte-array';
5+
6+
describe('createDecoderThatConsumesEntireByteArray', () => {
7+
// Given: a decoder that consumes exactly 4 bytes
8+
const outputNumber = 1234;
9+
const innerDecoder: Decoder<number> = createDecoder({
10+
fixedSize: 4,
11+
read: (_bytes, offset) => [outputNumber, offset + 4],
12+
});
13+
14+
describe('decode function', () => {
15+
describe('with no offset', () => {
16+
it('returns the same value as the inner decoder when the entire byte array is consumed', () => {
17+
const decoder = createDecoderThatConsumesEntireByteArray(innerDecoder);
18+
const bytes = new Uint8Array(4);
19+
const value = decoder.decode(bytes);
20+
expect(value).toBe(outputNumber);
21+
});
22+
23+
it('fatals when the inner decoder does not consume the entire byte array', () => {
24+
const decoder = createDecoderThatConsumesEntireByteArray(innerDecoder);
25+
const bytes = new Uint8Array(5);
26+
expect(() => decoder.decode(bytes)).toThrow(
27+
new SolanaError(SOLANA_ERROR__CODECS__EXPECTED_DECODER_TO_CONSUME_ENTIRE_BYTE_ARRAY, {
28+
expectedLength: 4,
29+
numExcessBytes: 1,
30+
}),
31+
);
32+
});
33+
});
34+
35+
describe('with an offset', () => {
36+
it('returns the same value as the inner decoder when the entire byte array is consumed', () => {
37+
const decoder = createDecoderThatConsumesEntireByteArray(innerDecoder);
38+
const bytes = new Uint8Array(6);
39+
const value = decoder.decode(bytes, 2);
40+
expect(value).toBe(outputNumber);
41+
});
42+
43+
it('fatals when the inner decoder does not consume the entire byte array', () => {
44+
const decoder = createDecoderThatConsumesEntireByteArray(innerDecoder);
45+
const bytes = new Uint8Array(7);
46+
expect(() => decoder.decode(bytes, 2)).toThrow(
47+
new SolanaError(SOLANA_ERROR__CODECS__EXPECTED_DECODER_TO_CONSUME_ENTIRE_BYTE_ARRAY, {
48+
expectedLength: 6,
49+
numExcessBytes: 1,
50+
}),
51+
);
52+
});
53+
});
54+
});
55+
56+
describe('read function', () => {
57+
describe('with no offset', () => {
58+
it('returns the same value as the inner decoder when the entire byte array is consumed', () => {
59+
const decoder = createDecoderThatConsumesEntireByteArray(innerDecoder);
60+
const bytes = new Uint8Array(4);
61+
const [value] = decoder.read(bytes, 0);
62+
expect(value).toBe(outputNumber);
63+
});
64+
65+
it('returns the same offset as the inner decoder when the entire byte array is consumed', () => {
66+
const decoder = createDecoderThatConsumesEntireByteArray(innerDecoder);
67+
const bytes = new Uint8Array(4);
68+
const [, offset] = decoder.read(bytes, 0);
69+
expect(offset).toBe(4);
70+
});
71+
72+
it('fatals when the inner decoder does not consume the entire byte array', () => {
73+
const decoder = createDecoderThatConsumesEntireByteArray(innerDecoder);
74+
const bytes = new Uint8Array(5);
75+
expect(() => decoder.read(bytes, 0)).toThrow(
76+
new SolanaError(SOLANA_ERROR__CODECS__EXPECTED_DECODER_TO_CONSUME_ENTIRE_BYTE_ARRAY, {
77+
expectedLength: 4,
78+
numExcessBytes: 1,
79+
}),
80+
);
81+
});
82+
});
83+
84+
describe('with an offset', () => {
85+
it('returns the same value as the inner decoder when the entire byte array is consumed', () => {
86+
const decoder = createDecoderThatConsumesEntireByteArray(innerDecoder);
87+
const bytes = new Uint8Array(6);
88+
const [value] = decoder.read(bytes, 2);
89+
expect(value).toBe(outputNumber);
90+
});
91+
92+
it('returns the same offset as the inner decoder when the entire byte array is consumed', () => {
93+
const decoder = createDecoderThatConsumesEntireByteArray(innerDecoder);
94+
const bytes = new Uint8Array(6);
95+
const [, offset] = decoder.read(bytes, 2);
96+
expect(offset).toBe(6);
97+
});
98+
99+
it('fatals when the inner decoder does not consume the entire byte array', () => {
100+
const decoder = createDecoderThatConsumesEntireByteArray(innerDecoder);
101+
const bytes = new Uint8Array(7);
102+
expect(() => decoder.read(bytes, 2)).toThrow(
103+
new SolanaError(SOLANA_ERROR__CODECS__EXPECTED_DECODER_TO_CONSUME_ENTIRE_BYTE_ARRAY, {
104+
expectedLength: 6,
105+
numExcessBytes: 1,
106+
}),
107+
);
108+
});
109+
});
110+
});
111+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { SOLANA_ERROR__CODECS__EXPECTED_DECODER_TO_CONSUME_ENTIRE_BYTE_ARRAY, SolanaError } from '@solana/errors';
2+
3+
import { createDecoder, Decoder } from './codec';
4+
5+
/**
6+
* Create a {@link Decoder} that asserts that the bytes provided to `decode` or `read` are fully consumed by the inner decoder
7+
* @param decoder A decoder to wrap
8+
* @returns A new decoder that will throw if provided with a byte array that it does not fully consume
9+
*
10+
* @typeParam T - The type of the decoder
11+
*
12+
* @remarks
13+
* Note that this compares the offset after encoding to the length of the input byte array
14+
*
15+
* The `offset` parameter to `decode` and `read` is still considered, and will affect the new offset that is compared to the byte array length
16+
*
17+
* The error that is thrown by the returned decoder is a {@link SolanaError} with the code `SOLANA_ERROR__CODECS__EXPECTED_DECODER_TO_CONSUME_ENTIRE_BYTE_ARRAY`
18+
*
19+
* @example
20+
* Create a decoder that decodes a `u32` (4 bytes) and ensures the entire byte array is consumed
21+
* ```ts
22+
* const decoder = createDecoderThatUsesExactByteArray(getU32Decoder());
23+
* decoder.decode(new Uint8Array([0, 0, 0, 0])); // 0
24+
* decoder.decode(new Uint8Array([0, 0, 0, 0, 0])); // throws
25+
*
26+
* // with an offset
27+
* decoder.decode(new Uint8Array([0, 0, 0, 0, 0]), 1); // 0
28+
* decoder.decode(new Uint8Array([0, 0, 0, 0, 0, 0]), 1); // throws
29+
* ```
30+
*/
31+
export function createDecoderThatConsumesEntireByteArray<T>(decoder: Decoder<T>): Decoder<T> {
32+
return createDecoder({
33+
...decoder,
34+
read(bytes, offset) {
35+
const [value, newOffset] = decoder.read(bytes, offset);
36+
if (bytes.length > newOffset) {
37+
throw new SolanaError(SOLANA_ERROR__CODECS__EXPECTED_DECODER_TO_CONSUME_ENTIRE_BYTE_ARRAY, {
38+
expectedLength: newOffset,
39+
numExcessBytes: bytes.length - newOffset,
40+
});
41+
}
42+
return [value, newOffset];
43+
},
44+
});
45+
}

packages/codecs-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,7 @@ export * from './assertions';
657657
export * from './bytes';
658658
export * from './codec';
659659
export * from './combine-codec';
660+
export * from './decoder-entire-byte-array';
660661
export * from './fix-codec-size';
661662
export * from './offset-codec';
662663
export * from './pad-codec';

packages/errors/src/codes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ export const SOLANA_ERROR__CODECS__EXPECTED_ZERO_VALUE_TO_MATCH_ITEM_FIXED_SIZE
290290
export const SOLANA_ERROR__CODECS__ENCODED_BYTES_MUST_NOT_INCLUDE_SENTINEL = 8078020;
291291
export const SOLANA_ERROR__CODECS__SENTINEL_MISSING_IN_DECODED_BYTES = 8078021;
292292
export const SOLANA_ERROR__CODECS__CANNOT_USE_LEXICAL_VALUES_AS_ENUM_DISCRIMINATORS = 8078022;
293+
export const SOLANA_ERROR__CODECS__EXPECTED_DECODER_TO_CONSUME_ENTIRE_BYTE_ARRAY = 8078023;
293294

294295
// RPC-related errors.
295296
// Reserve error codes in the range [8100000-8100999].
@@ -361,6 +362,7 @@ export type SolanaErrorCode =
361362
| typeof SOLANA_ERROR__CODECS__ENCODER_DECODER_MAX_SIZE_MISMATCH
362363
| typeof SOLANA_ERROR__CODECS__ENCODER_DECODER_SIZE_COMPATIBILITY_MISMATCH
363364
| typeof SOLANA_ERROR__CODECS__ENUM_DISCRIMINATOR_OUT_OF_RANGE
365+
| typeof SOLANA_ERROR__CODECS__EXPECTED_DECODER_TO_CONSUME_ENTIRE_BYTE_ARRAY
364366
| typeof SOLANA_ERROR__CODECS__EXPECTED_FIXED_LENGTH
365367
| typeof SOLANA_ERROR__CODECS__EXPECTED_POSITIVE_BYTE_LENGTH
366368
| typeof SOLANA_ERROR__CODECS__EXPECTED_VARIABLE_LENGTH

packages/errors/src/context.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
SOLANA_ERROR__CODECS__ENCODER_DECODER_FIXED_SIZE_MISMATCH,
2727
SOLANA_ERROR__CODECS__ENCODER_DECODER_MAX_SIZE_MISMATCH,
2828
SOLANA_ERROR__CODECS__ENUM_DISCRIMINATOR_OUT_OF_RANGE,
29+
SOLANA_ERROR__CODECS__EXPECTED_DECODER_TO_CONSUME_ENTIRE_BYTE_ARRAY,
2930
SOLANA_ERROR__CODECS__EXPECTED_POSITIVE_BYTE_LENGTH,
3031
SOLANA_ERROR__CODECS__EXPECTED_ZERO_VALUE_TO_MATCH_ITEM_FIXED_SIZE,
3132
SOLANA_ERROR__CODECS__INVALID_BYTE_LENGTH,
@@ -316,6 +317,10 @@ export type SolanaErrorContext = DefaultUnspecifiedErrorContextToUndefined<
316317
formattedValidDiscriminators: string;
317318
validDiscriminators: number[];
318319
};
320+
[SOLANA_ERROR__CODECS__EXPECTED_DECODER_TO_CONSUME_ENTIRE_BYTE_ARRAY]: {
321+
expectedLength: number;
322+
numExcessBytes: number;
323+
};
319324
[SOLANA_ERROR__CODECS__EXPECTED_POSITIVE_BYTE_LENGTH]: {
320325
bytesLength: number;
321326
codecDescription: string;

packages/errors/src/messages.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
SOLANA_ERROR__CODECS__ENCODER_DECODER_MAX_SIZE_MISMATCH,
3434
SOLANA_ERROR__CODECS__ENCODER_DECODER_SIZE_COMPATIBILITY_MISMATCH,
3535
SOLANA_ERROR__CODECS__ENUM_DISCRIMINATOR_OUT_OF_RANGE,
36+
SOLANA_ERROR__CODECS__EXPECTED_DECODER_TO_CONSUME_ENTIRE_BYTE_ARRAY,
3637
SOLANA_ERROR__CODECS__EXPECTED_FIXED_LENGTH,
3738
SOLANA_ERROR__CODECS__EXPECTED_POSITIVE_BYTE_LENGTH,
3839
SOLANA_ERROR__CODECS__EXPECTED_VARIABLE_LENGTH,
@@ -330,6 +331,8 @@ export const SolanaErrorMessages: Readonly<{
330331
'Expected sentinel [$hexSentinel] to be present in decoded bytes [$hexDecodedBytes].',
331332
[SOLANA_ERROR__CODECS__UNION_VARIANT_OUT_OF_RANGE]:
332333
'Union variant out of range. Expected an index between $minRange and $maxRange, got $variant.',
334+
[SOLANA_ERROR__CODECS__EXPECTED_DECODER_TO_CONSUME_ENTIRE_BYTE_ARRAY]:
335+
'This decoder expected a byte array of exactly $expectedLength bytes, but $numExcessBytes unexpected excess bytes remained after decoding. Are you sure that you have chosen the correct decoder for this data?',
333336
[SOLANA_ERROR__CRYPTO__RANDOM_VALUES_FUNCTION_UNIMPLEMENTED]: 'No random values implementation could be found.',
334337
[SOLANA_ERROR__INSTRUCTION_ERROR__ACCOUNT_ALREADY_INITIALIZED]: 'instruction requires an uninitialized account',
335338
[SOLANA_ERROR__INSTRUCTION_ERROR__ACCOUNT_BORROW_FAILED]:

0 commit comments

Comments
 (0)