Skip to content

Commit 1530644

Browse files
committed
feat(xdr): add maxLength/maxDepth options to array codec helpers
1 parent 3917e41 commit 1530644

3 files changed

Lines changed: 127 additions & 7 deletions

File tree

src/xdr/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export {
4040
decodeStream,
4141
encodeArray,
4242
decodeArray,
43+
type XdrArrayOptions,
4344
type XdrFormat,
4445
type JsonValue,
4546
type XdrValueConstructor,

src/xdr/values/xdr-value.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,24 @@ export function decodeStream<Wire, Instance extends XdrValue>(
176176
return out;
177177
}
178178

179+
/** Options shared by {@link encodeArray} and {@link decodeArray}. */
180+
export interface XdrArrayOptions {
181+
/**
182+
* Largest element count to accept, defaulting to the XDR maximum of
183+
* 2^32 - 1. Both encoding and decoding throw `XdrError` past this many
184+
* elements. Pass the bound from the XDR definition, so a field declared
185+
* `TimeSlicedPeerData peers<25>` decodes with `maxLength: 25`, or any cap
186+
* you want to enforce, to reject an oversized array before its elements are
187+
* decoded.
188+
*/
189+
maxLength?: number;
190+
/**
191+
* How many nested schemas may be entered, counting the array itself as the
192+
* first level.
193+
*/
194+
maxDepth?: number;
195+
}
196+
179197
/**
180198
* Encode a list of XDR values as a single length-prefixed XDR variable-length
181199
* array (`T values<>` — a 4-byte count followed by the elements). This is the
@@ -188,25 +206,33 @@ export function decodeStream<Wire, Instance extends XdrValue>(
188206
export function encodeArray<Wire, Instance extends XdrValue>(
189207
type: XdrValueConstructor<Wire, Instance>,
190208
values: readonly Instance[],
209+
options?: XdrArrayOptions,
191210
): Uint8Array;
192211
export function encodeArray<Wire, Instance extends XdrValue>(
193212
type: XdrValueConstructor<Wire, Instance>,
194213
values: readonly Instance[],
195214
format: "raw",
215+
options?: XdrArrayOptions,
196216
): Uint8Array;
197217
export function encodeArray<Wire, Instance extends XdrValue>(
198218
type: XdrValueConstructor<Wire, Instance>,
199219
values: readonly Instance[],
200220
format: "hex" | "base64",
221+
options?: XdrArrayOptions,
201222
): string;
202223
export function encodeArray<Wire, Instance extends XdrValue>(
203224
type: XdrValueConstructor<Wire, Instance>,
204225
values: readonly Instance[],
205-
format: XdrFormat = "raw",
226+
formatOrOptions?: XdrFormat | XdrArrayOptions,
227+
maybeOptions?: XdrArrayOptions,
206228
): Uint8Array | string {
207-
const codec = array(type.schema, UNBOUNDED_MAX_LENGTH);
208-
const bytes = codec.encode(values.map((v) => v.toXdrObject() as Wire));
209-
return encodeBytes(bytes, format);
229+
const [format, options] = splitArrayArgs(formatOrOptions, maybeOptions);
230+
const codec = array(type.schema, options.maxLength ?? UNBOUNDED_MAX_LENGTH);
231+
const bytes = codec.encode(
232+
values.map((v) => v.toXdrObject() as Wire),
233+
{ maxDepth: options.maxDepth },
234+
);
235+
return encodeBytes(bytes, format ?? "raw");
210236
}
211237

212238
/**
@@ -219,22 +245,42 @@ export function encodeArray<Wire, Instance extends XdrValue>(
219245
export function decodeArray<Wire, Instance extends XdrValue>(
220246
type: XdrValueConstructor<Wire, Instance>,
221247
input: Uint8Array,
248+
options?: XdrArrayOptions,
222249
): Instance[];
223250
export function decodeArray<Wire, Instance extends XdrValue>(
224251
type: XdrValueConstructor<Wire, Instance>,
225252
input: string,
226253
format: "hex" | "base64",
254+
options?: XdrArrayOptions,
227255
): Instance[];
228256
export function decodeArray<Wire, Instance extends XdrValue>(
229257
type: XdrValueConstructor<Wire, Instance>,
230258
input: Uint8Array | string,
231-
format?: "hex" | "base64",
259+
formatOrOptions?: "hex" | "base64" | XdrArrayOptions,
260+
maybeOptions?: XdrArrayOptions,
232261
): Instance[] {
233-
const codec = array(type.schema, UNBOUNDED_MAX_LENGTH);
234-
const wires = codec.decode(decodeBytes(input, format));
262+
const [format, options] = splitArrayArgs(formatOrOptions, maybeOptions);
263+
const codec = array(type.schema, options.maxLength ?? UNBOUNDED_MAX_LENGTH);
264+
const wires = codec.decode(
265+
decodeBytes(input, format as "hex" | "base64" | undefined),
266+
{ maxDepth: options.maxDepth },
267+
);
235268
return wires.map((w) => type.fromXdrObject(w));
236269
}
237270

271+
/**
272+
* Sort out the trailing `(format?, options?)` arguments of the array helpers,
273+
* where the format may be omitted and the options object take its place.
274+
*/
275+
function splitArrayArgs(
276+
formatOrOptions?: XdrFormat | XdrArrayOptions,
277+
maybeOptions?: XdrArrayOptions,
278+
): [XdrFormat | undefined, XdrArrayOptions] {
279+
return typeof formatOrOptions === "string"
280+
? [formatOrOptions, maybeOptions ?? {}]
281+
: [undefined, formatOrOptions ?? maybeOptions ?? {}];
282+
}
283+
238284
export function encodeBytes(
239285
bytes: Uint8Array,
240286
format: XdrFormat,

test/unit/xdr/array_codec.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,77 @@ describe("encodeArray / decodeArray", () => {
5353
const padded = new Uint8Array([...bytes, 0, 0, 0, 0]);
5454
expect(() => decodeArray(ScVal, padded)).toThrow(XdrError);
5555
});
56+
57+
describe("maxLength", () => {
58+
it("accepts a list at the limit", () => {
59+
const bytes = encodeArray(ScVal, values, { maxLength: 3 });
60+
const decoded = decodeArray(ScVal, bytes, { maxLength: 3 });
61+
decoded.forEach((v, i) => expect(v.equals(values[i])).toBe(true));
62+
});
63+
64+
it("rejects encoding more elements than the limit", () => {
65+
expect(() => encodeArray(ScVal, values, { maxLength: 2 })).toThrow(
66+
XdrError,
67+
);
68+
expect(() =>
69+
encodeArray(ScVal, values, "base64", { maxLength: 2 }),
70+
).toThrow(XdrError);
71+
});
72+
73+
it("rejects decoding a count past the limit", () => {
74+
const bytes = encodeArray(ScVal, values);
75+
expect(() => decodeArray(ScVal, bytes, { maxLength: 2 })).toThrow(
76+
XdrError,
77+
);
78+
79+
const b64 = encodeArray(ScVal, values, "base64");
80+
expect(() => decodeArray(ScVal, b64, "base64", { maxLength: 2 })).toThrow(
81+
XdrError,
82+
);
83+
});
84+
85+
it("rejects an inflated count before reading any element", () => {
86+
// A 4-byte header claiming 2^31 elements over an otherwise empty body:
87+
// the count check must fire instead of allocating for the claim.
88+
const bogus = new Uint8Array([0x7f, 0xff, 0xff, 0xff]);
89+
expect(() => decodeArray(ScVal, bogus, { maxLength: 16 })).toThrow(
90+
XdrError,
91+
);
92+
});
93+
});
94+
95+
describe("maxDepth", () => {
96+
/** An `ScVal` vector nested `depth` levels deep around a u32. */
97+
const nest = (depth: number): ScVal => {
98+
let v: ScVal = ScVal.scvU32(1);
99+
for (let i = 0; i < depth; i++) v = ScVal.scvVec([v]);
100+
return v;
101+
};
102+
103+
it("rejects encoding past the limit", () => {
104+
expect(() => encodeArray(ScVal, [nest(8)], { maxDepth: 4 })).toThrow(
105+
XdrError,
106+
);
107+
});
108+
109+
it("rejects decoding past the limit", () => {
110+
const bytes = encodeArray(ScVal, [nest(8)]);
111+
expect(() => decodeArray(ScVal, bytes, { maxDepth: 4 })).toThrow(
112+
XdrError,
113+
);
114+
// Same bytes, same helper — only the cap makes them unacceptable.
115+
expect(decodeArray(ScVal, bytes)).toHaveLength(1);
116+
});
117+
118+
it("counts the array itself as a level", () => {
119+
// Flat elements, so a cap of 1 leaves no room past the array itself.
120+
// (How many levels a given element costs is a js-xdr detail — an
121+
// ScVal union arm nests through option/array/lazy wrappers.)
122+
const bytes = encodeArray(ScVal, values);
123+
expect(() => decodeArray(ScVal, bytes, { maxDepth: 1 })).toThrow(
124+
XdrError,
125+
);
126+
expect(decodeArray(ScVal, bytes, { maxDepth: 2 })).toHaveLength(3);
127+
});
128+
});
56129
});

0 commit comments

Comments
 (0)